mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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.
This commit is contained in:
@@ -238,7 +238,11 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
|
||||
// raw API error detail (log_id, troubleshooter, permission_violations)
|
||||
// stays on the wire — `lark-cli api` callers explicitly want the raw
|
||||
// envelope.
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -248,9 +252,15 @@ func apiRun(opts *APIOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
||||
// Per-domain migration in stage 2+ will route through
|
||||
// errclass.BuildAPIError to populate identity-aware fields
|
||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
||||
CheckError: ac.CheckResponse,
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
// MarkRaw: see comment above on the DoAPI path. Applies equally to
|
||||
// HandleResponse failures so the raw API error survives to the wire.
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
@@ -262,9 +272,12 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
@@ -277,9 +290,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
@@ -291,9 +304,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -399,154 +397,6 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for permission denied API response")
|
||||
}
|
||||
|
||||
// Error should be marked Raw
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected API error from api command to be marked Raw")
|
||||
}
|
||||
|
||||
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:message:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
// The message should NOT have been enriched (no "App scope not enabled" replacement)
|
||||
if strings.Contains(exitErr.Error(), "App scope not enabled") {
|
||||
t.Error("expected original message, not enriched message")
|
||||
}
|
||||
// Detail should still contain the raw API error detail
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil Detail")
|
||||
}
|
||||
if exitErr.Detail.Detail == nil {
|
||||
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected paginated API error to be marked Raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -176,6 +176,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
// Legacy *output.ExitError producer: this literal predates the typed
|
||||
// error contract introduced by errs/. New code MUST NOT construct
|
||||
// *output.ExitError directly — missing-scope signals should move to
|
||||
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
|
||||
// extension fields) when the login flow migrates to typed errors.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
|
||||
@@ -13,30 +13,45 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertExitError checks the full structured error in one assertion.
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed validation error — they normalize to the same wantDetail fields.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -595,8 +610,10 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
|
||||
@@ -95,8 +95,9 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
|
||||
@@ -352,6 +352,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -14,12 +18,49 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// enrichMissingScopeError preserves the original need_user_authorization
|
||||
// message and appends a scope hint when the current command declares the
|
||||
// required scopes locally.
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
// locally (via shortcut registration or service-method metadata).
|
||||
//
|
||||
// Stage-1: this typed path is dormant — no production code returns a typed
|
||||
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
|
||||
// in without re-architecting. The active stage-1 path is
|
||||
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
|
||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if err == nil || f == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if authErr.Hint == "" {
|
||||
authErr.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
authErr.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||
// need_user_authorization marker AND the current command declares scopes
|
||||
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
||||
// envelope path until per-domain stage-2 typed migration.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
|
||||
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
|
||||
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
@@ -27,12 +68,10 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
|
||||
@@ -36,6 +36,13 @@ import (
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
|
||||
// which is part of the legacy error surface that predates the typed error
|
||||
// contract introduced by errs/. New code MUST NOT add new callers — the
|
||||
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
|
||||
// when the platform-extension framework migrates. This wrapper is retained
|
||||
// only for the existing in-tree call sites; it will be removed once they
|
||||
// have moved to the typed surface.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
@@ -75,6 +82,12 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
// Deprecated: installPluginInstallErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin install failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
@@ -116,6 +129,12 @@ func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
|
||||
// via its internal makeErr lambda. New code MUST NOT add such producers —
|
||||
// plugin conflict failures should surface as a typed *errs.XxxError once the
|
||||
// platform-extension framework migrates. This helper is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
@@ -143,6 +162,12 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin lifecycle failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
@@ -194,6 +219,13 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
||||
// of the legacy error surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT add new callers — the platform-
|
||||
// extension guard plumbing will switch to typed errs.* errors when the
|
||||
// platform-extension framework migrates. This wrapper is retained only for
|
||||
// the existing in-tree call sites; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
if cmd == nil {
|
||||
return
|
||||
|
||||
@@ -105,6 +105,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
// Legacy *output.ExitError producer: this literal predates the
|
||||
// typed error contract introduced by errs/. New denial sites MUST
|
||||
// NOT construct *output.ExitError directly — they should return a
|
||||
// typed *errs.XxxError once the cmdpolicy framework migrates.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
|
||||
127
cmd/root.go
127
cmd/root.go
@@ -15,8 +15,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -199,22 +199,52 @@ func configureFlagCompletions(args []string) {
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
|
||||
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
|
||||
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
|
||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, ...) to the top level. Routed by
|
||||
// errs.CategoryOf via ExitCodeOf.
|
||||
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
|
||||
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
|
||||
// this path so existing wire shapes are preserved byte-for-byte until
|
||||
// per-domain typed migration in stage 2+.
|
||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable)
|
||||
// that differs from the standard ErrDetail, so it's handled separately.
|
||||
var spErr *internalauth.SecurityPolicyError
|
||||
// SecurityPolicyError keeps the legacy custom envelope (string codes,
|
||||
// challenge_url, retryable) and exit code 1 — its wire shape predates the
|
||||
// typed taxonomy and downstream OAuth/policy consumers depend on it.
|
||||
// The taxonomy migration for this category is deferred to a later PR.
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if errors.As(err, &spErr) {
|
||||
writeSecurityPolicyError(errOut, spErr)
|
||||
return 1
|
||||
}
|
||||
|
||||
// All other structured errors normalize to ExitError.
|
||||
// *core.ConfigError flows raw to the legacy envelope path in stage 1
|
||||
// (asExitError → output.ErrWithHint). Typed migration via
|
||||
// errcompat.PromoteConfigError happens in stage 2+.
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return output.ExitCodeOf(err)
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
// preserve the original API error detail; skip enrichment
|
||||
// which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
@@ -222,35 +252,21 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// Cobra errors (required flags, unknown commands, etc.)
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// asExitError converts known structured error types to *output.ExitError.
|
||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
|
||||
// This format intentionally differs from the standard ErrDetail envelope:
|
||||
// it uses string codes ("challenge_required"/"access_denied") and extra fields
|
||||
// (retryable, challenge_url) for machine-readable policy error handling.
|
||||
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
|
||||
// writeSecurityPolicyError writes the security-policy-specific JSON envelope.
|
||||
// This wire format intentionally differs from the typed envelope writer: it
|
||||
// uses string codes ("challenge_required"/"access_denied"), a "auth_error"
|
||||
// type literal, and a top-level "retryable" field — the shape OAuth/policy
|
||||
// consumers have been parsing since before the typed taxonomy existed.
|
||||
func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
|
||||
var codeStr string
|
||||
switch spErr.Code {
|
||||
case internalauth.LarkErrBlockByPolicyTryAuth:
|
||||
switch spErr.Subtype {
|
||||
case errs.SubtypeChallengeRequired:
|
||||
codeStr = "challenge_required"
|
||||
case internalauth.LarkErrBlockByPolicy:
|
||||
case errs.SubtypeAccessDenied:
|
||||
codeStr = "access_denied"
|
||||
default:
|
||||
codeStr = strconv.Itoa(spErr.Code)
|
||||
@@ -265,8 +281,8 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
if spErr.ChallengeURL != "" {
|
||||
errData["challenge_url"] = spErr.ChallengeURL
|
||||
}
|
||||
if spErr.CLIHint != "" {
|
||||
errData["hint"] = spErr.CLIHint
|
||||
if spErr.Hint != "" {
|
||||
errData["hint"] = spErr.Hint
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
@@ -275,15 +291,29 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
err := encoder.Encode(env)
|
||||
|
||||
if err != nil {
|
||||
if encErr := encoder.Encode(env); encErr != nil {
|
||||
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// asExitError converts known structured error types to *output.ExitError.
|
||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError bridge; removed after typed migration.
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
@@ -306,6 +336,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — unknown-subcommand signals should move to
|
||||
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
|
||||
// agent-protocol metadata as typed extension fields. This helper is retained
|
||||
// only while existing dispatch sites are migrated; it will be removed once
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
@@ -380,10 +417,16 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
})
|
||||
}
|
||||
|
||||
// enrichPermissionError adds console_url and improves the hint for permission errors.
|
||||
// It differentiates between:
|
||||
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console
|
||||
// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope
|
||||
// enrichPermissionError adds console_url and improves the hint for legacy
|
||||
// *output.ExitError permission errors. Differentiates between:
|
||||
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope
|
||||
// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized:
|
||||
// user has not authorized the scope → hint to auth login
|
||||
// - default: other permission errors → console + auth-login fallback
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
|
||||
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
|
||||
// + ConsoleURL on the typed envelope and remove this helper.
|
||||
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
|
||||
return
|
||||
@@ -405,15 +448,13 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
// Build admin console URL with the recommended scope
|
||||
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
|
||||
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
||||
exitErr.Detail.Detail = nil
|
||||
|
||||
isBot := f.ResolvedIdentity.IsBot()
|
||||
|
||||
larkCode := exitErr.Detail.Code
|
||||
switch larkCode {
|
||||
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
|
||||
// User has not authorized the scope → re-authorize
|
||||
exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode)
|
||||
if isBot {
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
@@ -423,13 +464,11 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
|
||||
case output.LarkErrAppScopeNotEnabled:
|
||||
// App has not enabled the API scope → admin console
|
||||
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
|
||||
default:
|
||||
// Other permission errors (matched by keyword)
|
||||
exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode)
|
||||
if isBot {
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
|
||||
@@ -161,160 +161,8 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
@@ -524,7 +372,7 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
// shortcut: typed error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
|
||||
499
cmd/root_test.go
499
cmd/root_test.go
@@ -4,19 +4,23 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
@@ -68,273 +72,6 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Create a permission error (would normally be enriched) and mark it Raw
|
||||
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
})
|
||||
err.Raw = true
|
||||
|
||||
code := handleRootError(f, err)
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
|
||||
}
|
||||
// The message should NOT have been enriched by enrichPermissionError
|
||||
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
|
||||
if strings.Contains(err.Error(), "App scope not enabled") {
|
||||
t.Errorf("expected message not enriched, got: %s", err.Error())
|
||||
}
|
||||
// Detail.Detail should be preserved (enrichPermissionError clears it to nil)
|
||||
if err.Detail != nil && err.Detail.Detail == nil {
|
||||
t.Error("expected Detail.Detail to be preserved, but it was cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Create a permission error without Raw — should be enriched
|
||||
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
})
|
||||
|
||||
code := handleRootError(f, err)
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for non-Raw error")
|
||||
}
|
||||
// The message should have been enriched
|
||||
if !strings.Contains(err.Error(), "App scope not enabled") {
|
||||
t.Errorf("expected enriched message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
scope string
|
||||
wantInURL string // substring that must appear in console_url
|
||||
denyInURL string // substring that must NOT appear raw in console_url
|
||||
}{
|
||||
{
|
||||
name: "ampersand in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope&evil=injected",
|
||||
wantInURL: "scopes=scope%26evil%3Dinjected",
|
||||
denyInURL: "scopes=scope&evil=injected",
|
||||
},
|
||||
{
|
||||
name: "hash in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope#fragment",
|
||||
wantInURL: "scopes=scope%23fragment",
|
||||
denyInURL: "scopes=scope#fragment",
|
||||
},
|
||||
{
|
||||
name: "space in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope with spaces",
|
||||
wantInURL: "scopes=scope+with+spaces",
|
||||
},
|
||||
{
|
||||
name: "special chars in appID",
|
||||
appID: "app&id=bad",
|
||||
scope: "calendar:calendar:readonly",
|
||||
wantInURL: "clientID=app%26id%3Dbad",
|
||||
denyInURL: "clientID=app&id=bad",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": tt.scope},
|
||||
},
|
||||
})
|
||||
|
||||
handleRootError(f, exitErr)
|
||||
|
||||
consoleURL := exitErr.Detail.ConsoleURL
|
||||
if consoleURL == "" {
|
||||
t.Fatal("expected console_url to be set")
|
||||
}
|
||||
if !strings.Contains(consoleURL, tt.wantInURL) {
|
||||
t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL)
|
||||
}
|
||||
if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) {
|
||||
t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected error detail")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
exitErr.Detail.Hint = "existing hint"
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if exitErr.Detail.Hint != want {
|
||||
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
@@ -396,3 +133,227 @@ func TestIsCompletionCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_* lives with the implementation in
|
||||
// internal/errcompat/promote_test.go.
|
||||
|
||||
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
|
||||
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
|
||||
// writer. Downstream OAuth/policy consumers parse a wire format that
|
||||
// predates the typed taxonomy and depend on:
|
||||
// - error.type == "auth_error" (not the Category literal "policy")
|
||||
// - error.code is a string ("challenge_required" / "access_denied"), not a number
|
||||
// - error.retryable is present at the top of the error object
|
||||
// - exit code 1 (not ExitContentSafety 6)
|
||||
//
|
||||
// Migration of this category to the typed envelope is deferred to a later PR.
|
||||
func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
subtype errs.Subtype
|
||||
code int
|
||||
wantCode string
|
||||
}{
|
||||
{"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"},
|
||||
{"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
spErr := &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: tc.subtype,
|
||||
Code: tc.code,
|
||||
Message: "blocked by access policy",
|
||||
Hint: "complete challenge in your browser",
|
||||
},
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
|
||||
}
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
||||
}
|
||||
errObj, ok := env["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
||||
}
|
||||
if got := errObj["type"]; got != "auth_error" {
|
||||
t.Errorf("error.type = %v, want %q", got, "auth_error")
|
||||
}
|
||||
if got := errObj["code"]; got != tc.wantCode {
|
||||
t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode)
|
||||
}
|
||||
if got, ok := errObj["retryable"].(bool); !ok || got {
|
||||
t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"])
|
||||
}
|
||||
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
||||
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
||||
}
|
||||
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
||||
t.Errorf("error.hint = %v, want hint message", got)
|
||||
}
|
||||
// And the typed-only fields must NOT appear on this envelope.
|
||||
for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} {
|
||||
if _, exists := errObj[leaked]; exists {
|
||||
t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
|
||||
// contains the need_user_authorization marker — the same shape that
|
||||
// resolveAccessToken now produces when the credential chain returns
|
||||
// *internalauth.NeedAuthorizationError.
|
||||
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
||||
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||
return &errs.AuthenticationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthentication,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: fmt.Sprintf("API call failed: %s", cause),
|
||||
},
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
||||
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
||||
// declared-scopes Hint appended when the current command is a registered
|
||||
// service method.
|
||||
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if authErr.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want authentication", authErr.Category)
|
||||
}
|
||||
if !strings.Contains(authErr.Message, "need_user_authorization") {
|
||||
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
|
||||
}
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
|
||||
// same hint behavior for mounted shortcut commands.
|
||||
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
|
||||
// conditional scopes declared on a shortcut surface in the hint.
|
||||
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
|
||||
// declared-scopes guidance is appended (separated by newline) when the typed
|
||||
// AuthenticationError already carries a Hint from elsewhere.
|
||||
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
authErr.Hint = "existing hint"
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if authErr.Hint != want {
|
||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
|
||||
// with a per-method recommended `--scope` hint, matching the pre-PR
|
||||
// behaviour. Per-domain typed migration in stage 2+ will lift this
|
||||
// into PermissionError.MissingScopes / ConsoleURL on the typed
|
||||
// envelope; until then the legacy ExitError envelope is preserved.
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
@@ -280,7 +285,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -290,10 +295,56 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
// scopeAwareChecker returns an error checker that enriches the
|
||||
// LarkErrUserScopeInsufficient (99991679) business error with a
|
||||
// per-method recommended `--scope` hint. All other non-zero codes fall
|
||||
// through to legacy output.ErrAPI (matching pre-PR behaviour). The
|
||||
// identity parameter is accepted to match the client.ResponseOptions
|
||||
// CheckError signature; isBotMode is captured from the enclosing call so
|
||||
// the recommended scope reflects the caller's identity at request time.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
|
||||
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
|
||||
// + ConsoleURL on the typed envelope and remove this helper.
|
||||
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error {
|
||||
return func(result interface{}, _ core.Identity) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
}
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
larkCode := int(code)
|
||||
msg := registry.GetStrFromMap(resultMap, "msg")
|
||||
|
||||
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
|
||||
identity := "user"
|
||||
if isBotMode {
|
||||
identity = "tenant"
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, identity)
|
||||
// Stage-1 carve-out: this restores the pre-PR scope-insufficient
|
||||
// enrichment (recommended scope + auth-login hint) on the legacy
|
||||
// envelope. The typed migration in stage 2+ will lift this into
|
||||
// PermissionError.MissingScopes / ConsoleURL on the typed wire.
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
|
||||
}
|
||||
|
||||
// Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError +
|
||||
// ClassifyLarkError). Typed migration is stage-2+.
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -339,7 +390,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
|
||||
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
@@ -474,36 +525,10 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints.
|
||||
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error {
|
||||
return func(result interface{}) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
}
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
larkCode := int(code)
|
||||
msg := registry.GetStrFromMap(resultMap, "msg")
|
||||
|
||||
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
|
||||
identity := "user"
|
||||
if isBotMode {
|
||||
identity = "tenant"
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, identity)
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
|
||||
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
|
||||
}
|
||||
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
@@ -516,9 +541,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if !hasItems {
|
||||
@@ -529,9 +554,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -412,39 +411,6 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !isExitError(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got: %T %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
|
||||
}
|
||||
// stdout must be empty on API error — error details belong in stderr envelope only.
|
||||
// This guards against re-introducing duplicate output (see commit 86215a10).
|
||||
if stdout.Len() > 0 {
|
||||
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
@@ -662,73 +628,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for code=0, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker("not a map")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil for non-map result, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_APIError(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API error: [40003]") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
|
||||
scopes := []interface{}{"calendar:read"}
|
||||
checker := scopeAwareChecker(scopes, false)
|
||||
err := checker(map[string]interface{}{
|
||||
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||
"msg": "scope insufficient",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !isExitError(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" {
|
||||
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
|
||||
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
scopes := []interface{}{"calendar:read"}
|
||||
checker := scopeAwareChecker(scopes, true)
|
||||
err := checker(map[string]interface{}{
|
||||
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||
"msg": "scope insufficient",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
// Bot mode should still include the scope hint
|
||||
if !strings.Contains(err.Error(), "insufficient permissions") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
@@ -866,13 +765,3 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
ee, ok := err.(*output.ExitError)
|
||||
if ok && target != nil {
|
||||
*target = ee
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
558
errs/ERROR_CONTRACT.md
Normal file
558
errs/ERROR_CONTRACT.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# lark-cli Error Contract
|
||||
|
||||
`errs/` defines a typed, RFC 7807–aligned error taxonomy for the CLI. Three
|
||||
audiences depend on it: **AI agents and shell scripts** parsing the JSON
|
||||
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
|
||||
OAuth shapes; and **framework + business code** producing errors. This file
|
||||
is the single source of truth for all three.
|
||||
|
||||
This document describes the **typed authoring target**. The refactor lands
|
||||
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
|
||||
legacy shapes today — see **Migration** for what is live in each stage.
|
||||
|
||||
Migrating an `*output.ExitError` call site? See **Migration**. Something off
|
||||
in production? See **Troubleshooting**.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every error belongs to exactly one **Category**. The set is closed
|
||||
(`errs/category.go`); adding a member requires deliberate review.
|
||||
2. Every **newly constructed** typed error has a **Subtype** — a stable
|
||||
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
||||
Undeclared subtypes fail CI. The constraint applies only to typed
|
||||
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
|
||||
dispatcher's `asExitError` → legacy envelope path (not the typed
|
||||
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
|
||||
stage-1 passthrough; its stage-2+ typed migration will subject the
|
||||
promoted typed error to this Subtype constraint at that time.
|
||||
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
|
||||
branch on. Renaming either is a breaking change.
|
||||
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
|
||||
It is `omitempty` and never carries CLI-internal meaning.
|
||||
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
|
||||
exported `*Error` structs that do not.
|
||||
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
||||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
||||
7. For the typed-envelope path, exit codes derive from `Category` only
|
||||
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
|
||||
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
|
||||
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
|
||||
both are retired in the legacy-removal stage.
|
||||
|
||||
## Wire format
|
||||
|
||||
Typed errors render to **stderr** as one JSON object per process exit:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "user",
|
||||
"error": {
|
||||
"type": "authorization",
|
||||
"subtype": "missing_scope",
|
||||
"code": 99991679,
|
||||
"message": "missing scope `calendar:event:create` for app cli_xxx",
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"missing_scopes": ["calendar:event:create"],
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Stability | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `ok` | wire-stable | always `false` for errors |
|
||||
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
|
||||
| `error.type` | **wire-stable** | one of the 9 Categories |
|
||||
| `error.subtype` | **wire-stable** | declared Subtype constant |
|
||||
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
|
||||
| `error.message` | informational | not safe to branch on |
|
||||
| `error.hint` | informational | actionable recovery guidance |
|
||||
| `error.log_id` | informational | upstream request id (server-side trace) |
|
||||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Carve-out: `SecurityPolicyError` keeps the legacy
|
||||
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
|
||||
envelope until its consumers migrate. Removal is staged in **Migration**.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|----------|------|------|--------------|
|
||||
| `validation` | malformed user input | 2 | `ValidationError` |
|
||||
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
|
||||
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
|
||||
| `config` | local config missing / unbound | 3 | `ConfigError` |
|
||||
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
|
||||
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
|
||||
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
||||
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
|
||||
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
|
||||
|
||||
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
||||
|
||||
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
|
||||
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
|
||||
> but the Go type is named `PermissionError`. This is deliberate, following
|
||||
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
|
||||
> `codes.PermissionDenied`): each name is chosen to be **maximally
|
||||
> distinct and readable on its own**, not to be perfectly symmetric.
|
||||
> `AuthenticationError` and `AuthorizationError` differ visually only at
|
||||
> the 5th character and are easy to confuse in code review;
|
||||
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
|
||||
> field stays formal because it is the protocol-level taxonomy; the Go
|
||||
> type favors call-site readability.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
call site
|
||||
│ constructs typed error (e.g. *errs.ValidationError)
|
||||
▼
|
||||
command runE returns err
|
||||
│
|
||||
▼
|
||||
cmd/root.go handleRootError dispatches:
|
||||
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
|
||||
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
||||
├─ *core.ConfigError → asExitError adapts to legacy envelope ↓
|
||||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
||||
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
|
||||
```
|
||||
|
||||
Only the typed and `*output.ExitError` branches emit a JSON envelope on
|
||||
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
||||
subcommand messages) print plain text and exit `1` — consumers must
|
||||
tolerate that fallback.
|
||||
|
||||
## Consumers
|
||||
|
||||
### Go (in-process)
|
||||
|
||||
```go
|
||||
var pe *errs.PermissionError
|
||||
if errors.As(err, &pe) {
|
||||
fmt.Println("missing:", pe.MissingScopes)
|
||||
}
|
||||
```
|
||||
|
||||
Predicates cover the common categories (`errs/predicates.go`):
|
||||
|
||||
```go
|
||||
if errs.IsAuthentication(err) { ... }
|
||||
if errs.IsPermission(err) { ... }
|
||||
if errs.IsValidation(err) { ... }
|
||||
```
|
||||
|
||||
Type-agnostic field access:
|
||||
|
||||
```go
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
||||
}
|
||||
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
|
||||
```
|
||||
|
||||
### Shell / AI
|
||||
|
||||
```bash
|
||||
out=$(lark-cli ... 2>&1)
|
||||
code=$?
|
||||
|
||||
# Untyped / Cobra errors print plain text — guard before jq.
|
||||
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
|
||||
printf '%s\n' "$out" >&2
|
||||
exit "$code"
|
||||
fi
|
||||
|
||||
case "$(jq -r '.error.type // empty' <<<"$out")" in
|
||||
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
|
||||
network) echo "transport failure, safe to retry" ;;
|
||||
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Unknown fields are forward-compatible additions: ignore, don't fail.
|
||||
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
|
||||
extension fields — `message` is human-readable prose that may be
|
||||
reworded without notice.
|
||||
|
||||
## Producers
|
||||
|
||||
### Quick reference
|
||||
|
||||
| Situation | Use |
|
||||
|-----------|-----|
|
||||
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
|
||||
| Login required | `&errs.AuthenticationError{...}` |
|
||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| Local config missing | `&errs.ConfigError{...}` |
|
||||
| Transport failure | `&errs.NetworkError{...}` |
|
||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
|
||||
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
|
||||
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
|
||||
|
||||
### Authoring discipline
|
||||
|
||||
Five rules every producer follows. Some are enforced by `lint/errscontract`
|
||||
AST guards (`go run -C lint . ..`); the rest by code review.
|
||||
|
||||
#### Propagate typed errors unchanged
|
||||
|
||||
A function that receives an error already carrying `errs.Problem`
|
||||
returns it as-is up the stack. Reclassification at non-boundary frames
|
||||
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
|
||||
single-source taxonomy and silently downgrades typed signals.
|
||||
|
||||
Conforming:
|
||||
|
||||
```go
|
||||
_, err := runtime.DoAPI(req, opts)
|
||||
if err != nil {
|
||||
return err // already typed by the framework boundary
|
||||
}
|
||||
```
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
|
||||
return &errs.InternalError{Cause: err} // re-decides category
|
||||
```
|
||||
|
||||
#### Never return a typed-nil pointer
|
||||
|
||||
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
|
||||
a non-nil interface — `errors.As` matches and `.Error()` may panic.
|
||||
Return interface `nil` literally.
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
var e *errs.ValidationError // nil pointer
|
||||
return e // non-nil interface holding nil pointer
|
||||
```
|
||||
|
||||
#### Let `Category` derive the exit code
|
||||
|
||||
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
|
||||
maps `Category` to the shell code. A new exit-code requirement means a
|
||||
new `Category`, not a one-off override at the call site.
|
||||
|
||||
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
|
||||
codes during stage 1.)
|
||||
|
||||
#### Split `Message`, `Hint`, and `Cause`
|
||||
|
||||
Each field carries a distinct role:
|
||||
|
||||
| Field | Carries | Style |
|
||||
|-------|---------|-------|
|
||||
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
|
||||
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
|
||||
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
|
||||
|
||||
`Hint` must not be merged into `Message`. AI agents and humans read them
|
||||
on separate channels; merging defeats both.
|
||||
|
||||
`Cause` must be a real `error`. If the upstream returned an `error`,
|
||||
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
|
||||
do not inline its `.Error()` into `Message`.
|
||||
|
||||
Conforming:
|
||||
|
||||
```go
|
||||
return &errs.NetworkError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryNetwork,
|
||||
Subtype: errs.SubtypeNetworkTransport,
|
||||
Message: "request to /open-apis failed after 3 retries",
|
||||
Hint: "check connectivity and retry; set --log-level debug if it persists",
|
||||
},
|
||||
Cause: ioErr,
|
||||
}
|
||||
```
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
|
||||
// conflates what + what-to-do + cause into one string
|
||||
```
|
||||
|
||||
#### `ValidationError.Param` uses the `--flag` form
|
||||
|
||||
When a `*ValidationError` originates from a flag value, `Param` holds the
|
||||
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
|
||||
agents grep this field literally to surface "the bad flag was `--X`".
|
||||
|
||||
For positional arguments, use the canonical name without dashes
|
||||
(`"target_user_id"`).
|
||||
|
||||
### Constructing typed errors
|
||||
|
||||
The minimal struct literal:
|
||||
|
||||
```go
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
|
||||
},
|
||||
Param: "--data",
|
||||
}
|
||||
```
|
||||
|
||||
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
||||
remain callable during migration; new code should prefer the struct
|
||||
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
|
||||
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
|
||||
|
||||
#### Shortcut `Execute` walkthrough
|
||||
|
||||
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
||||
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
||||
1440")`. The typed migration target:
|
||||
|
||||
```go
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
duration := runtime.Int("duration-minutes")
|
||||
if duration < 1 || duration > 1440 {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
|
||||
Hint: "pass a value in [1, 1440]",
|
||||
},
|
||||
Param: "--duration-minutes",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := runtime.DoAPI(req, opts)
|
||||
if err != nil {
|
||||
return err // already typed by the framework boundary; propagate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Two patterns visible: a producer site (the typed `*errs.ValidationError`
|
||||
above) and a propagation site (the `return err` after `runtime.DoAPI`,
|
||||
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
|
||||
|
||||
When the validation logic outgrows a single range check — multiple
|
||||
flags, format parsing, conditional rules — extract it into a helper that
|
||||
also returns the typed `*errs.ValidationError`. The helper, not
|
||||
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
|
||||
this codebase; see `parseTimeRange` in
|
||||
`shortcuts/calendar/calendar_agenda.go:144`).
|
||||
|
||||
### Wrapping upstream errors
|
||||
|
||||
When a producer receives an error from a function it called, four cases
|
||||
cover the decision:
|
||||
|
||||
| Source | Decision | Example |
|
||||
|--------|----------|---------|
|
||||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
||||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` |
|
||||
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
|
||||
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
|
||||
|
||||
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
|
||||
attaching an upstream error to a typed one. `Cause` is the chain
|
||||
`errs.UnwrapTypedError` walks and the chain consumer code expects;
|
||||
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
|
||||
wire envelope does not surface.
|
||||
|
||||
#### Boundary helpers (framework-internal)
|
||||
|
||||
These helpers are called from framework boundaries, not from domain
|
||||
code:
|
||||
|
||||
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
|
||||
already-typed errors pass through unchanged.
|
||||
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
|
||||
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
|
||||
boundary.
|
||||
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
|
||||
JSON parse failures to `*errs.InternalError`.
|
||||
|
||||
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
|
||||
package, you are probably calling the SDK at the wrong layer — go
|
||||
through `runtime.DoAPI`.
|
||||
|
||||
### Extending the taxonomy
|
||||
|
||||
#### Add a Subtype
|
||||
|
||||
1. Add a constant in `errs/subtypes.go` (framework) or
|
||||
`errs/subtypes_service_<name>.go` (service).
|
||||
2. If it maps from a Lark code, register the mapping in
|
||||
`internal/errclass/codemeta_<service>.go`.
|
||||
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
||||
4. Reference the constant from a producer.
|
||||
5. `go run -C lint . ..` — `CheckDeclaredSubtype` fails until the
|
||||
constant is wired through.
|
||||
|
||||
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
|
||||
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
|
||||
declared constant within one week of introduction; `CheckAdHocSubtype`
|
||||
emits a warning to keep them visible.
|
||||
|
||||
#### Add a typed Error struct
|
||||
|
||||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
||||
|
||||
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
|
||||
nil-receiver-safe `Unwrap()` if it carries `Cause`.
|
||||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
||||
3. Add a wire-format pin in `errs/marshal_test.go`.
|
||||
|
||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||
typed struct as a documented extension field, not into the envelope's
|
||||
top level.
|
||||
|
||||
## CI guards
|
||||
|
||||
| Check | Enforces | Where |
|
||||
|-------|----------|-------|
|
||||
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
|
||||
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
|
||||
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
|
||||
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
|
||||
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
|
||||
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
|
||||
|
||||
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
|
||||
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
|
||||
dependency stays out of the shipped `lark-cli` binary's module graph;
|
||||
see `lint/README.md` for how to add a new lint domain.
|
||||
|
||||
## Stability
|
||||
|
||||
| Tier | Surface | Change policy |
|
||||
|------|---------|---------------|
|
||||
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
|
||||
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
|
||||
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
|
||||
|
||||
The deprecated `*output.ExitError` surface is outside these tiers — it
|
||||
will be removed once business migration completes.
|
||||
|
||||
## Migration
|
||||
|
||||
The error-contract refactor lands in stages. This PR is **stage 1**, and
|
||||
its scope is **strictly framework-only**: every production wire shape
|
||||
matches pre-PR byte-for-byte (additive fields only where the legacy slot
|
||||
had no subtype emission). Stage 1 ships infrastructure; behavioural
|
||||
migration of any specific path lives in later stages.
|
||||
|
||||
Stages:
|
||||
|
||||
1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy,
|
||||
classifier (`internal/errclass`), promotion stub (`internal/errcompat`,
|
||||
passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`),
|
||||
and six lint guards (forbidigo + five AST checks). Wire shapes
|
||||
preserved byte-for-byte versus pre-PR, with **one intentional semantic
|
||||
fix**: config-class errors (`*core.ConfigError`) now exit `3` instead
|
||||
of `2`, aligning with `ExitCodeForCategory` (config errors share the
|
||||
auth exit slot per the taxonomy). The classifier and promote helpers
|
||||
are *shipped but unused* in production paths — they exist so stage 2+
|
||||
migrations can plug in without re-architecting.
|
||||
2. **`SecurityPolicyError` typed envelope** — replace the legacy
|
||||
`type: "auth_error"` carve-out with the typed shape.
|
||||
3. **Business-domain migration**, one PR per domain in declared order:
|
||||
`task → drive → calendar → im → mail → whiteboard → contact`. Each PR
|
||||
moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` /
|
||||
`output.ErrWithHint(...)` call sites to typed constructors or
|
||||
`BuildAPIError`, removes its Deprecated annotations, and announces the
|
||||
wire change explicitly.
|
||||
4. **Framework-boundary migration**: `client.WrapDoAPIError` and
|
||||
`client.WrapJSONResponseParseError` flip to typed wrap;
|
||||
`client.CheckResponse` adopts `errclass.BuildAPIError`;
|
||||
`internal/client/client.go resolveAccessToken` adopts the typed
|
||||
`NeedAuthorizationError → *errs.AuthenticationError` recognition;
|
||||
`cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed
|
||||
`*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the
|
||||
`Type="config"` (and later `Type="auth"`) branches to typed.
|
||||
5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no
|
||||
production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`,
|
||||
`ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and
|
||||
`ErrorEnvelope`.
|
||||
|
||||
During migration, helper assertions accept both shapes (see
|
||||
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
|
||||
so the build stays green domain-by-domain.
|
||||
|
||||
Before / after at a call site (illustrative — actually performed in
|
||||
stage 3):
|
||||
|
||||
```go
|
||||
// before (legacy)
|
||||
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
||||
|
||||
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
|
||||
return errclass.BuildAPIError(parsedResp, cc)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
||||
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
|
||||
through to the generic bucket (`internal/errclass/classify.go`). Add the
|
||||
code to `internal/errclass/codemeta_<service>.go` with the right Category
|
||||
and Subtype, plus a dispatch test in `classify_test.go`.
|
||||
|
||||
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
|
||||
`client.WrapDoAPIError` taking the non-transport branch
|
||||
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
|
||||
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
|
||||
transport detection too narrow for this error (e.g. a `*url.Error` with an
|
||||
inner that does not satisfy `net.Error`)? Either widen the transport
|
||||
predicate or add an explicit typed wrap upstream.
|
||||
|
||||
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
|
||||
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
|
||||
Bare string literals trip `CheckDeclaredSubtype` unless they match the
|
||||
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
|
||||
follow-up warning.
|
||||
|
||||
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
|
||||
slipped through. All typed errors define nil-safe `Unwrap()`, but
|
||||
returning a typed-nil pointer up the stack still defeats `errors.As`.
|
||||
Return interface `nil` from constructors, never a typed-nil pointer.
|
||||
|
||||
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
|
||||
typed before reaching `handleRootError`. Wrap at the boundary
|
||||
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
|
||||
string cannot be classified retroactively.
|
||||
|
||||
## Security & privacy
|
||||
|
||||
- `log_id` is a server-side trace token. Safe to surface; it does not
|
||||
carry user content.
|
||||
- `missing_scopes` is app configuration, not user data.
|
||||
- `Message` and `Hint` must not contain tokens, JWTs, or personally
|
||||
identifying values. CI does not catch this — producer responsibility.
|
||||
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
|
||||
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
|
||||
optional debug logging only.
|
||||
|
||||
## Pointers (task-driven)
|
||||
|
||||
- *Which struct to construct?* → **Producers / Quick reference**
|
||||
- *Add a new condition?* → **Add a Subtype**
|
||||
- *Consume from a shell script?* → **Consumers / Shell / AI**
|
||||
- *Understand or fix a CI failure?* → **CI guards**
|
||||
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
|
||||
Deprecated note on the symbol being replaced.
|
||||
- *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go`
|
||||
→ `errs/predicates.go` → `internal/errclass/` →
|
||||
`cmd/root.go` `handleRootError`.
|
||||
19
errs/category.go
Normal file
19
errs/category.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Category is the top-level taxonomy axis. Wire JSON: "type".
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryValidation Category = "validation"
|
||||
CategoryAuthentication Category = "authentication"
|
||||
CategoryAuthorization Category = "authorization"
|
||||
CategoryConfig Category = "config"
|
||||
CategoryNetwork Category = "network"
|
||||
CategoryAPI Category = "api"
|
||||
CategoryPolicy Category = "policy"
|
||||
CategoryInternal Category = "internal"
|
||||
CategoryConfirmation Category = "confirmation"
|
||||
)
|
||||
31
errs/category_test.go
Normal file
31
errs/category_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCategoryWireValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Category
|
||||
want string
|
||||
}{
|
||||
{"validation", CategoryValidation, "validation"},
|
||||
{"authentication", CategoryAuthentication, "authentication"},
|
||||
{"authorization", CategoryAuthorization, "authorization"},
|
||||
{"config", CategoryConfig, "config"},
|
||||
{"network", CategoryNetwork, "network"},
|
||||
{"api", CategoryAPI, "api"},
|
||||
{"policy", CategoryPolicy, "policy"},
|
||||
{"internal", CategoryInternal, "internal"},
|
||||
{"confirmation", CategoryConfirmation, "confirmation"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("category %s = %q, want %q", tt.name, string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
errs/doc.go
Normal file
37
errs/doc.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errs is the public error-contract surface for lark-cli.
|
||||
//
|
||||
// It defines a closed taxonomy (9 Categories) and a small set of typed
|
||||
// errors that embed Problem — an RFC 7807-aligned shared shape. External
|
||||
// consumers (AI agents, shell scripts, integrating SDKs) read structured
|
||||
// fields instead of regex-parsing free-string error messages.
|
||||
//
|
||||
// # The Problem shape
|
||||
//
|
||||
// Every typed error embeds Problem so the JSON wire shape (`type`,
|
||||
// `subtype`, `code`, `message`, `hint`, `log_id`, `retryable`) is uniform
|
||||
// across categories. Typed extensions (PermissionError.MissingScopes,
|
||||
// SecurityPolicyError.ChallengeURL, etc.) appear at the top level of the
|
||||
// envelope alongside the shared fields, not nested under a `detail` key.
|
||||
//
|
||||
// # Working with typed errors
|
||||
//
|
||||
// Use ProblemOf to read shared fields polymorphically:
|
||||
//
|
||||
// if p, ok := errs.ProblemOf(err); ok {
|
||||
// log.Printf("category=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
||||
// }
|
||||
//
|
||||
// Use the IsXxx predicates or stdlib errors.As to branch on concrete type:
|
||||
//
|
||||
// if errs.IsPermission(err) {
|
||||
// var pe *errs.PermissionError
|
||||
// _ = errors.As(err, &pe)
|
||||
// fmt.Println("missing scopes:", pe.MissingScopes)
|
||||
// }
|
||||
//
|
||||
// Use WrapInternal at boundaries to lift any non-typed error to
|
||||
// *InternalError; typed errors pass through unchanged.
|
||||
package errs
|
||||
11
errs/internal_carrier.go
Normal file
11
errs/internal_carrier.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// problemCarrier is the non-exported extraction interface.
|
||||
// Used by ProblemOf via errors.As, working around the Go embed semantic where
|
||||
// *Problem cannot match *PermissionError directly.
|
||||
type problemCarrier interface {
|
||||
ProblemDetail() *Problem
|
||||
}
|
||||
235
errs/marshal_test.go
Normal file
235
errs/marshal_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Per-type marshal tests pin each typed error's wire shape against its
|
||||
// canonical fields. They guard against future refactors that change struct
|
||||
// layout from accidentally altering the externally visible JSON contract.
|
||||
//
|
||||
// Each test asserts (a) Problem fields surface at the top level via embed
|
||||
// promotion, (b) extension fields sit alongside as siblings (NOT under a
|
||||
// `detail` sub-object), and (c) omitempty is honored on optional fields.
|
||||
|
||||
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
||||
pe := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
|
||||
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
Identity: "user",
|
||||
ConsoleURL: "https://example",
|
||||
}
|
||||
b, err := json.Marshal(pe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"authorization"`,
|
||||
`"subtype":"missing_scope"`,
|
||||
`"code":99991679`,
|
||||
`"message":"x"`,
|
||||
`"hint":"y"`,
|
||||
`"log_id":"lg"`,
|
||||
`"missing_scopes":["docx:document"]`,
|
||||
`"identity":"user"`,
|
||||
`"console_url":"https://example"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, `"retryable"`) {
|
||||
t.Errorf("retryable should be omitted when false; got %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"detail"`) {
|
||||
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_MarshalJSON(t *testing.T) {
|
||||
ve := &ValidationError{
|
||||
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
||||
Param: "--scope",
|
||||
}
|
||||
b, _ := json.Marshal(ve)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"invalid_argument"`,
|
||||
`"message":"bad"`,
|
||||
`"param":"--scope"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Param omitempty when ""
|
||||
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
|
||||
b2, _ := json.Marshal(ve2)
|
||||
if strings.Contains(string(b2), `"param"`) {
|
||||
t.Errorf("param should be omitted when empty; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthError_MarshalJSON(t *testing.T) {
|
||||
ae := &AuthenticationError{
|
||||
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
|
||||
UserOpenID: "ou_x",
|
||||
}
|
||||
b, _ := json.Marshal(ae)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"authentication"`,
|
||||
`"subtype":"token_expired"`,
|
||||
`"message":"expired"`,
|
||||
`"user_open_id":"ou_x"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
ce := &ConfigError{
|
||||
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
|
||||
Field: "app_id",
|
||||
}
|
||||
b, _ := json.Marshal(ce)
|
||||
s := string(b)
|
||||
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
|
||||
CauseKind: "timeout",
|
||||
}
|
||||
b, _ := json.Marshal(ne)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"network"`,
|
||||
`"subtype":"transport"`,
|
||||
`"cause":"timeout"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// CauseKind omitempty when ""
|
||||
ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}}
|
||||
b2, _ := json.Marshal(ne2)
|
||||
if strings.Contains(string(b2), `"cause"`) {
|
||||
t.Errorf("cause should be omitted when empty; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_MarshalJSON(t *testing.T) {
|
||||
ae := &APIError{
|
||||
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
||||
Detail: map[string]any{"raw": "value"},
|
||||
}
|
||||
b, _ := json.Marshal(ae)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"api"`,
|
||||
`"subtype":"rate_limit"`,
|
||||
`"code":99991400`,
|
||||
`"retryable":true`,
|
||||
`"detail":{`,
|
||||
`"raw":"value"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Detail omitempty when nil
|
||||
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
||||
b2, _ := json.Marshal(ae2)
|
||||
if strings.Contains(string(b2), `"detail"`) {
|
||||
t.Errorf("detail should be omitted when nil; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
|
||||
spe := &SecurityPolicyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
|
||||
ChallengeURL: "https://chal.example",
|
||||
}
|
||||
b, _ := json.Marshal(spe)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"policy"`,
|
||||
`"subtype":"challenge_required"`,
|
||||
`"challenge_url":"https://chal.example"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
||||
cse := &ContentSafetyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
||||
Rules: []string{"pii", "violence"},
|
||||
}
|
||||
b, _ := json.Marshal(cse)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"policy"`,
|
||||
`"rules":["pii","violence"]`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError_MarshalJSON(t *testing.T) {
|
||||
ie := &InternalError{
|
||||
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
|
||||
}
|
||||
b, _ := json.Marshal(ie)
|
||||
s := string(b)
|
||||
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
|
||||
cre := &ConfirmationRequiredError{
|
||||
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
|
||||
Risk: "write",
|
||||
Action: "mail +send",
|
||||
}
|
||||
b, _ := json.Marshal(cre)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"confirmation"`,
|
||||
`"risk":"write"`,
|
||||
`"action":"mail +send"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
errs/predicates.go
Normal file
88
errs/predicates.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ProblemOf extracts the embedded Problem via the non-exported problemCarrier interface.
|
||||
// This is the supported way to read shared fields without depending on a specific typed error.
|
||||
//
|
||||
// A typed error whose embedded *Problem is nil is treated as "not a problem
|
||||
// carrier" — returning (nil, true) here would cause CategoryOf / IsRetryable
|
||||
// and other downstream readers to dereference nil.
|
||||
func ProblemOf(err error) (*Problem, bool) {
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
if p := c.ProblemDetail(); p != nil {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// UnwrapTypedError walks the wrap chain and returns the first error that
|
||||
// embeds Problem (i.e. any typed error in this package). Returns the typed
|
||||
// error itself (as error) so callers — notably JSON marshaling — see the
|
||||
// concrete value's own struct tags rather than an opaque wrapper.
|
||||
func UnwrapTypedError(err error) (error, bool) {
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
if e, ok := c.(error); ok {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CategoryOf returns the error's Category for metrics/logging/dispatch routing.
|
||||
// Falls back to CategoryInternal for non-typed errors.
|
||||
func CategoryOf(err error) Category {
|
||||
if p, ok := ProblemOf(err); ok {
|
||||
return p.Category
|
||||
}
|
||||
return CategoryInternal
|
||||
}
|
||||
|
||||
// IsRetryable reads Problem.Retryable; non-typed errors are non-retryable by default.
|
||||
func IsRetryable(err error) bool {
|
||||
if p, ok := ProblemOf(err); ok {
|
||||
return p.Retryable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidation reports whether err is a *ValidationError.
|
||||
func IsValidation(err error) bool { var x *ValidationError; return errors.As(err, &x) }
|
||||
|
||||
// IsPermission reports whether err is a *PermissionError.
|
||||
func IsPermission(err error) bool { var x *PermissionError; return errors.As(err, &x) }
|
||||
|
||||
// IsNetwork reports whether err is a *NetworkError.
|
||||
func IsNetwork(err error) bool { var x *NetworkError; return errors.As(err, &x) }
|
||||
|
||||
// IsAPI reports whether err is an *APIError.
|
||||
func IsAPI(err error) bool { var x *APIError; return errors.As(err, &x) }
|
||||
|
||||
// IsSecurityPolicy reports whether err is a *SecurityPolicyError.
|
||||
func IsSecurityPolicy(err error) bool { var x *SecurityPolicyError; return errors.As(err, &x) }
|
||||
|
||||
// IsContentSafety reports whether err is a *ContentSafetyError.
|
||||
func IsContentSafety(err error) bool { var x *ContentSafetyError; return errors.As(err, &x) }
|
||||
|
||||
// IsInternal reports whether err is an *InternalError.
|
||||
func IsInternal(err error) bool { var x *InternalError; return errors.As(err, &x) }
|
||||
|
||||
// IsConfirmationRequired reports whether err is a *ConfirmationRequiredError.
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
var x *ConfirmationRequiredError
|
||||
return errors.As(err, &x)
|
||||
}
|
||||
|
||||
// IsAuthentication reports whether err is an *AuthenticationError.
|
||||
func IsAuthentication(err error) bool { var x *AuthenticationError; return errors.As(err, &x) }
|
||||
|
||||
// IsConfig reports whether err is a *ConfigError.
|
||||
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }
|
||||
203
errs/predicates_test.go
Normal file
203
errs/predicates_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestIsRetryable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "api error with retryable=true",
|
||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI, Retryable: true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "api error with retryable=false (zero)",
|
||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsRetryable(tt.err); got != tt.want {
|
||||
t.Errorf("IsRetryable(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthTypedOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "errs.AuthenticationError",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "errs.ConfigError",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsAuthentication(tt.err); got != tt.want {
|
||||
t.Errorf("IsAuthentication(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConfigTypedOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "errs.ConfigError",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "errs.AuthenticationError",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsConfig(tt.err); got != tt.want {
|
||||
t.Errorf("IsConfig(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryOf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want errs.Category
|
||||
}{
|
||||
{
|
||||
name: "typed validation error",
|
||||
err: &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation}},
|
||||
want: errs.CategoryValidation,
|
||||
},
|
||||
{
|
||||
name: "typed permission error",
|
||||
err: &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}},
|
||||
want: errs.CategoryAuthorization,
|
||||
},
|
||||
{
|
||||
name: "typed config error",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: errs.CategoryConfig,
|
||||
},
|
||||
{
|
||||
name: "typed auth error",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: errs.CategoryAuthentication,
|
||||
},
|
||||
{
|
||||
name: "plain error falls back to internal",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: errs.CategoryInternal,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.CategoryOf(tt.err); got != tt.want {
|
||||
t.Errorf("CategoryOf(%v) = %q, want %q", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemOf_NilProblemReturnsFalse pins that a problemCarrier whose
|
||||
// ProblemDetail() returns nil does NOT satisfy ProblemOf — otherwise
|
||||
// CategoryOf / IsRetryable and other downstream readers would dereference
|
||||
// nil and panic. *Problem(nil) is a directly constructable trigger: its
|
||||
// ProblemDetail method `return p` is nil-safe and yields nil.
|
||||
func TestProblemOf_NilProblemReturnsFalse(t *testing.T) {
|
||||
var nilP *errs.Problem
|
||||
var err error = nilP // *Problem implements error via Error() (nil-receiver safe)
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if ok {
|
||||
t.Fatalf("ProblemOf(*Problem(nil)) = (%v, true); want (nil, false)", p)
|
||||
}
|
||||
if p != nil {
|
||||
t.Errorf("ProblemOf(*Problem(nil)).p = %v; want nil", p)
|
||||
}
|
||||
|
||||
// Downstream readers must not panic on the same input.
|
||||
if cat := errs.CategoryOf(err); cat != errs.CategoryInternal {
|
||||
t.Errorf("CategoryOf(*Problem(nil)) = %q, want fallback %q", cat, errs.CategoryInternal)
|
||||
}
|
||||
if retryable := errs.IsRetryable(err); retryable {
|
||||
t.Errorf("IsRetryable(*Problem(nil)) = true; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedPredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
pred func(error) bool
|
||||
want bool
|
||||
}{
|
||||
{"IsValidation+", &errs.ValidationError{}, errs.IsValidation, true},
|
||||
{"IsValidation-", &errs.APIError{}, errs.IsValidation, false},
|
||||
{"IsPermission+", &errs.PermissionError{}, errs.IsPermission, true},
|
||||
{"IsPermission-", &errs.APIError{}, errs.IsPermission, false},
|
||||
{"IsNetwork+", &errs.NetworkError{}, errs.IsNetwork, true},
|
||||
{"IsAPI+", &errs.APIError{}, errs.IsAPI, true},
|
||||
{"IsSecurityPolicy+", &errs.SecurityPolicyError{}, errs.IsSecurityPolicy, true},
|
||||
{"IsContentSafety+", &errs.ContentSafetyError{}, errs.IsContentSafety, true},
|
||||
{"IsInternal+", &errs.InternalError{}, errs.IsInternal, true},
|
||||
{"IsConfirmationRequired+", &errs.ConfirmationRequiredError{}, errs.IsConfirmationRequired, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.pred(tc.err); got != tc.want {
|
||||
t.Errorf("%s: predicate = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
errs/problem.go
Normal file
38
errs/problem.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
|
||||
//
|
||||
// Message is REQUIRED. Producers must populate it; an empty Message will make
|
||||
// Error() return "" — a known Go footgun for fmt.Errorf("...: %v", err).
|
||||
//
|
||||
// Wire-format notes:
|
||||
// - No Component field. Service / shortcut component is metric-only
|
||||
// enrichment derived by the dispatcher from the cobra command path; it
|
||||
// never appears on the wire.
|
||||
// - No DocURL field. PermissionError carries the same intent via its typed
|
||||
// ConsoleURL extension; other typed errors do not link out.
|
||||
// - Retryable uses omitempty so only `true` is emitted; consumers treat
|
||||
// absence as false.
|
||||
type Problem struct {
|
||||
Category Category `json:"type"`
|
||||
Subtype Subtype `json:"subtype,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
LogID string `json:"log_id,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
// Error satisfies the standard `error` interface. A nil receiver is treated
|
||||
// as the empty string so a stray nil *Problem stored in an error interface
|
||||
// cannot panic the dispatcher.
|
||||
func (p *Problem) Error() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.Message
|
||||
}
|
||||
func (p *Problem) ProblemDetail() *Problem { return p }
|
||||
72
errs/problem_test.go
Normal file
72
errs/problem_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProblemError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
p Problem
|
||||
want string
|
||||
}{
|
||||
{"empty message", Problem{}, ""},
|
||||
{"plain message", Problem{Message: "boom"}, "boom"},
|
||||
{"message ignores hint", Problem{Message: "msg", Hint: "do x"}, "msg"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := (&tt.p).Error(); got != tt.want {
|
||||
t.Errorf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemError_NilReceiverDoesNotPanic pins the nil-receiver guard on
|
||||
// (*Problem).Error(). Without it, a nil *Problem stored in an error interface
|
||||
// would panic when the root dispatcher calls err.Error() for logging.
|
||||
func TestProblemError_NilReceiverDoesNotPanic(t *testing.T) {
|
||||
var p *Problem // nil
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("(*Problem)(nil).Error() panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if got := p.Error(); got != "" {
|
||||
t.Errorf("(*Problem)(nil).Error() = %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemDetailReturnsReceiver(t *testing.T) {
|
||||
p := &Problem{Message: "x"}
|
||||
if got := p.ProblemDetail(); got != p {
|
||||
t.Errorf("ProblemDetail() = %p, want receiver %p", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemHasNoComponentField(t *testing.T) {
|
||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("Component"); ok {
|
||||
t.Errorf("Problem.Component must not exist; got field %#v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemHasNoDocURLField(t *testing.T) {
|
||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("DocURL"); ok {
|
||||
t.Errorf("Problem.DocURL must not exist on the base Problem (PermissionError carries ConsoleURL instead); got field %#v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemCategoryTagIsType(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(Problem{}).FieldByName("Category")
|
||||
if !ok {
|
||||
t.Fatalf("Problem.Category must exist")
|
||||
}
|
||||
if got := f.Tag.Get("json"); got != "type" {
|
||||
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
|
||||
}
|
||||
}
|
||||
75
errs/subtypes.go
Normal file
75
errs/subtypes.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtype is the second-level taxonomy axis. Wire JSON: "subtype".
|
||||
type Subtype string
|
||||
|
||||
const (
|
||||
SubtypeUnknown Subtype = "unknown" // catch-all fallback; producers must prefer a specific subtype
|
||||
)
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
const (
|
||||
SubtypeTokenMissing Subtype = "token_missing" // no token in request (Authorization header absent / no local token cache)
|
||||
SubtypeTokenInvalid Subtype = "token_invalid" // token present but content/format wrong
|
||||
SubtypeTokenExpired Subtype = "token_expired" // token explicitly expired
|
||||
SubtypeRefreshTokenInvalid Subtype = "refresh_token_invalid" // refresh_token is v1 legacy format, unusable
|
||||
SubtypeRefreshTokenExpired Subtype = "refresh_token_expired" // refresh_token expired
|
||||
SubtypeRefreshTokenRevoked Subtype = "refresh_token_revoked" // refresh_token revoked (user logout / admin action)
|
||||
SubtypeRefreshTokenReused Subtype = "refresh_token_reused" // refresh_token already used (single-use rotation triggered)
|
||||
SubtypeRefreshServerError Subtype = "refresh_server_error" // refresh endpoint transient error (retryable)
|
||||
)
|
||||
|
||||
// CategoryAuthorization subtypes
|
||||
const (
|
||||
SubtypeMissingScope Subtype = "missing_scope" // user authorized app but did not grant this scope
|
||||
SubtypeUserUnauthorized Subtype = "user_unauthorized" // user never authorized the app
|
||||
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
|
||||
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
|
||||
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
|
||||
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
|
||||
)
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
const (
|
||||
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
|
||||
)
|
||||
|
||||
// CategoryAPI subtypes
|
||||
const (
|
||||
SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded
|
||||
SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification)
|
||||
SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported)
|
||||
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
|
||||
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
|
||||
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
|
||||
)
|
||||
|
||||
// CategoryPolicy subtypes (security-policy envelope shape)
|
||||
const (
|
||||
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
|
||||
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
|
||||
)
|
||||
|
||||
// CategoryInternal subtypes
|
||||
const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
// CategoryConfirmation subtypes intentionally have no declarations yet.
|
||||
21
errs/subtypes_service_task.go
Normal file
21
errs/subtypes_service_task.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Service-specific Subtype declarations. Per-service files follow the
|
||||
// naming pattern subtypes_service_<name>.go so the framework's closed
|
||||
// Subtype enum stays readable while service taxonomies remain visible.
|
||||
|
||||
// Task service subtypes — consumed by internal/errclass/codemeta_task.go.
|
||||
const (
|
||||
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
|
||||
SubtypeTaskPermissionDenied Subtype = "task_permission_denied"
|
||||
SubtypeTaskNotFound Subtype = "task_not_found"
|
||||
SubtypeTaskConflict Subtype = "task_conflict"
|
||||
SubtypeTaskServerError Subtype = "task_server_error"
|
||||
SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit"
|
||||
SubtypeTaskFollowerLimit Subtype = "task_follower_limit"
|
||||
SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit"
|
||||
SubtypeTaskReminderExists Subtype = "task_reminder_exists"
|
||||
)
|
||||
136
errs/types.go
Normal file
136
errs/types.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// ValidationError is the typed error for CategoryValidation.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
// it. A nil typed-pointer held inside an error interface is treated as
|
||||
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
|
||||
func (e *ValidationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type AuthenticationError struct {
|
||||
Problem
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *AuthenticationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// PermissionError is the typed error for CategoryAuthorization.
|
||||
type PermissionError struct {
|
||||
Problem
|
||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
ConsoleURL string `json:"console_url,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigError is the typed error for CategoryConfig.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *ConfigError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NetworkError is the typed error for CategoryNetwork.
|
||||
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
|
||||
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
|
||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
|
||||
// not serialized.
|
||||
type NetworkError struct {
|
||||
Problem
|
||||
CauseKind string `json:"cause,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *NetworkError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
|
||||
// business errors). Detail preserves the raw Lark error map for diagnostics.
|
||||
type APIError struct {
|
||||
Problem
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
|
||||
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
|
||||
type SecurityPolicyError struct {
|
||||
Problem
|
||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
|
||||
type ContentSafetyError struct {
|
||||
Problem
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// InternalError is the typed error for CategoryInternal.
|
||||
// Cause is preserved for logging but not emitted on the wire.
|
||||
type InternalError struct {
|
||||
Problem
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *InternalError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
|
||||
// Risk is one of: "read" | "write" | "high-risk-write".
|
||||
type ConfirmationRequiredError struct {
|
||||
Problem
|
||||
Risk string `json:"risk"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
154
errs/types_test.go
Normal file
154
errs/types_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissionErrorJSONShape(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
Message: "x",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
}
|
||||
b, err := json.Marshal(perm)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
|
||||
mustContain := []string{
|
||||
`"type":"authorization"`,
|
||||
`"subtype":"missing_scope"`,
|
||||
`"missing_scopes":["docx:document"]`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("json output missing %q\nfull output: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":false`,
|
||||
}
|
||||
for _, bad := range mustNotContain {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("json output unexpectedly contains %q\nfull output: %s", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedSemanticChasm proves the documented Go embed limitation:
|
||||
// errors.As(*PermissionError, &p *Problem) returns false even though
|
||||
// PermissionError embeds Problem. ProblemOf works around this by routing
|
||||
// via the unexported problemCarrier interface.
|
||||
func TestEmbedSemanticChasm(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
Message: "missing",
|
||||
},
|
||||
}
|
||||
|
||||
var p *Problem
|
||||
if errors.As(perm, &p) {
|
||||
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
||||
}
|
||||
|
||||
got, ok := ProblemOf(perm)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
|
||||
}
|
||||
if got != &perm.Problem {
|
||||
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
|
||||
}
|
||||
if got.Category != CategoryAuthorization {
|
||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
||||
orig := errors.New("transport stalled")
|
||||
spe := &SecurityPolicyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
|
||||
Cause: orig,
|
||||
}
|
||||
if got := errors.Unwrap(spe); got != orig {
|
||||
t.Fatalf("errors.Unwrap(spe) = %v, want %v", got, orig)
|
||||
}
|
||||
if !errors.Is(spe, orig) {
|
||||
t.Fatal("errors.Is(spe, orig) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedErrors_UnwrapNilReceiver pins the nil-receiver guard on every typed
|
||||
// error's Unwrap. Without these, a typed-nil pointer stored in an error
|
||||
// interface would panic when the root dispatcher or any caller walks the
|
||||
// errors.Is / errors.Unwrap chain.
|
||||
//
|
||||
// The doc comments on these types claim "nil-receiver safe" but until this
|
||||
// test landed nothing actually pinned that claim — exactly the
|
||||
// behavioral-comment-without-test footgun caught in PR #984 review.
|
||||
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
||||
t.Helper()
|
||||
checks := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
|
||||
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
|
||||
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
|
||||
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
|
||||
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
|
||||
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
|
||||
}
|
||||
for _, c := range checks {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("(*%s)(nil).Unwrap() panicked: %v", c.name, r)
|
||||
}
|
||||
}()
|
||||
if got := c.call(); got != nil {
|
||||
t.Errorf("(*%s)(nil).Unwrap() = %v, want nil", c.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
|
||||
// nil-safety guard above does not silently drop a real Cause on non-nil
|
||||
// receivers. Without this, a buggy refactor could change `return e.Cause` to
|
||||
// `return nil` and the test suite would still pass.
|
||||
func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
||||
cause := errors.New("upstream cause")
|
||||
cases := []struct {
|
||||
name string
|
||||
err interface{ Unwrap() error }
|
||||
}{
|
||||
{"ValidationError", &ValidationError{Cause: cause}},
|
||||
{"AuthenticationError", &AuthenticationError{Cause: cause}},
|
||||
{"ConfigError", &ConfigError{Cause: cause}},
|
||||
{"NetworkError", &NetworkError{Cause: cause}},
|
||||
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
|
||||
{"InternalError", &InternalError{Cause: cause}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.err.Unwrap(); got != cause {
|
||||
t.Errorf("(*%s).Unwrap() = %v, want %v", c.name, got, cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
errs/wrap.go
Normal file
27
errs/wrap.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "errors"
|
||||
|
||||
// WrapInternal wraps a non-typed error into *InternalError.
|
||||
// Typed errors (anything implementing problemCarrier) pass through unchanged.
|
||||
// Component is metric-only and derived by the dispatcher, so it is not a parameter here.
|
||||
func WrapInternal(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
return err
|
||||
}
|
||||
return &InternalError{
|
||||
Problem: Problem{
|
||||
Category: CategoryInternal,
|
||||
Subtype: SubtypeUnknown,
|
||||
Message: err.Error(),
|
||||
},
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
49
errs/wrap_test.go
Normal file
49
errs/wrap_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapInternalPlainError(t *testing.T) {
|
||||
orig := fmt.Errorf("boom")
|
||||
wrapped := WrapInternal(orig)
|
||||
|
||||
var ie *InternalError
|
||||
if !errors.As(wrapped, &ie) {
|
||||
t.Fatalf("WrapInternal did not produce *InternalError; got %T", wrapped)
|
||||
}
|
||||
if ie.Category != CategoryInternal {
|
||||
t.Errorf("Category = %q, want %q", ie.Category, CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != SubtypeUnknown {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, SubtypeUnknown)
|
||||
}
|
||||
if ie.Message != "boom" {
|
||||
t.Errorf("Message = %q, want %q", ie.Message, "boom")
|
||||
}
|
||||
if ie.Cause != orig {
|
||||
t.Errorf("Cause = %v, want original error %v", ie.Cause, orig)
|
||||
}
|
||||
if got := errors.Unwrap(wrapped); got != orig {
|
||||
t.Errorf("errors.Unwrap = %v, want original %v", got, orig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInternalPassesThroughTyped(t *testing.T) {
|
||||
apiErr := &APIError{Problem: Problem{Category: CategoryAPI, Message: "api boom"}}
|
||||
got := WrapInternal(apiErr)
|
||||
if got != apiErr {
|
||||
t.Errorf("WrapInternal should pass through typed errors unchanged; got %#v want %#v", got, apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInternalNil(t *testing.T) {
|
||||
if got := WrapInternal(nil); got != nil {
|
||||
t.Errorf("WrapInternal(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -8,21 +8,14 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
LarkErrBlockByPolicy = 21001 // access denied by access control policy
|
||||
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
|
||||
needUserAuthorizationMarker = "need_user_authorization"
|
||||
)
|
||||
|
||||
// RefreshTokenRetryable contains error codes that allow one immediate retry.
|
||||
// All other refresh errors clear the token immediately.
|
||||
var RefreshTokenRetryable = map[int]bool{
|
||||
output.LarkErrRefreshServerError: true,
|
||||
}
|
||||
|
||||
// TokenRetryCodes contains error codes that allow retry after token refresh.
|
||||
var TokenRetryCodes = map[int]bool{
|
||||
output.LarkErrTokenInvalid: true,
|
||||
@@ -51,6 +44,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
|
||||
@@ -58,24 +52,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return strings.Contains(err.Error(), needUserAuthorizationMarker)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is returned when a request is blocked by access control policies.
|
||||
type SecurityPolicyError struct {
|
||||
Code int
|
||||
Message string
|
||||
ChallengeURL string
|
||||
CLIHint string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the error message for SecurityPolicyError.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
// SecurityPolicyError is preserved as a Go type alias so existing
|
||||
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
|
||||
// The concrete struct lives in errs/types.go.
|
||||
type SecurityPolicyError = errs.SecurityPolicyError
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -85,34 +87,56 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
|
||||
// response coming back from a remote server (this transport is installed on
|
||||
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
|
||||
// remote, not by lark-cli itself).
|
||||
//
|
||||
// Observed production shape from the MCP gateway — Lark code in the outer
|
||||
// `error.code` slot, hint under `data.cli_hint`:
|
||||
//
|
||||
// {"jsonrpc": "2.0", "id": 1,
|
||||
// "error": {"code": 21000, "message": "...",
|
||||
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
|
||||
//
|
||||
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
|
||||
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
|
||||
// hint under `data.hint`) so a future server-side migration to that layout
|
||||
// would not silently drop policy detection. The Lark code is looked up in the
|
||||
// central code registry; the hint key is read from `data.hint` first and
|
||||
// falls back to `data.cli_hint`.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
// MCP (JSON-RPC) response format:
|
||||
// {
|
||||
// "error": {
|
||||
// "code": 21000,
|
||||
// "message": "...",
|
||||
// "data": { "challenge_url": "...", "cli_hint": "..." }
|
||||
// }
|
||||
// }
|
||||
errMap, ok := result["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
code := getInt(errMap, "code", 0)
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
dataMap, _ := errMap["data"].(map[string]interface{})
|
||||
|
||||
// Try data.code first (shape B); fall back to outer error.code (shape A).
|
||||
code := 0
|
||||
if dataMap != nil {
|
||||
code = getInt(dataMap, "code", 0)
|
||||
}
|
||||
if code == 0 {
|
||||
code = getInt(errMap, "code", 0)
|
||||
}
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataMap, ok := errMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
if dataMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean up backticks and spaces from challenge_url
|
||||
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
|
||||
cliHint := getStr(dataMap, "cli_hint")
|
||||
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
|
||||
cliHint := getStr(dataMap, "hint")
|
||||
if cliHint == "" {
|
||||
cliHint = getStr(dataMap, "cli_hint")
|
||||
}
|
||||
msg := getStr(errMap, "message")
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
@@ -122,11 +146,15 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
|
||||
}
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +174,9 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if it's a security policy error
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
// 2. Check if it's a security policy error (consult central code registry)
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,11 +202,15 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
|
||||
if msg != "" || challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
internal/auth/transport_test.go
Normal file
114
internal/auth/transport_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
|
||||
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
|
||||
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
|
||||
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
|
||||
// This shape is forward-compat for a future server-side migration to the
|
||||
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
|
||||
// for the shape observed in production today.
|
||||
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603, // JSON-RPC internal error
|
||||
"message": "challenge required",
|
||||
"data": map[string]interface{}{
|
||||
"code": 21000, // Lark code for challenge_required
|
||||
"type": "policy",
|
||||
"subtype": "challenge_required",
|
||||
"challenge_url": "https://example.com/challenge",
|
||||
"hint": "please complete the challenge in your browser",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Code != 21000 {
|
||||
t.Errorf("Code = %d, want 21000", spErr.Code)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeChallengeRequired {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
|
||||
}
|
||||
if spErr.ChallengeURL != "https://example.com/challenge" {
|
||||
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
|
||||
}
|
||||
if spErr.Hint != "please complete the challenge in your browser" {
|
||||
t.Errorf("Hint = %q", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
|
||||
// in production from the MCP gateway: the Lark code sits in the outer
|
||||
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
|
||||
// The transport's outer-code fallback path must recognise the policy code and
|
||||
// surface the typed error with the hint promoted.
|
||||
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": 21001, // outer slot carries the Lark code
|
||||
"message": "access denied",
|
||||
"data": map[string]interface{}{
|
||||
"challenge_url": "https://example.com/c",
|
||||
"cli_hint": "contact admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
|
||||
}
|
||||
// `cli_hint` must surface when `hint` is absent.
|
||||
if spErr.Hint != "contact admin" {
|
||||
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
|
||||
// nil (passes through) when the Lark code does not classify as
|
||||
// CategoryPolicy — keeps regular API errors out of the security-policy path.
|
||||
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603,
|
||||
"message": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
|
||||
"type": "authorization",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := transport.tryHandleMCPResponse(result); err != nil {
|
||||
t.Fatalf("expected nil (non-policy code), got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -223,16 +225,21 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
}
|
||||
|
||||
code := getInt(data, "code", -1)
|
||||
if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth {
|
||||
meta, metaOK := errclass.LookupCodeMeta(code)
|
||||
if metaOK && meta.Category == errs.CategoryPolicy {
|
||||
challengeUrl := getStr(data, "challenge_url")
|
||||
cliHint := getStr(data, "cli_hint")
|
||||
msg := getStr(data, "error_description")
|
||||
|
||||
return nil, &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return nil, &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +247,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
|
||||
if (code != -1 && code != 0) || errStr != "" {
|
||||
// Retryable server error: retry once, then clear token on second failure.
|
||||
if RefreshTokenRetryable[code] {
|
||||
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
|
||||
data, err = callEndpoint()
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -19,10 +20,31 @@ const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard
|
||||
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
|
||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
||||
// remain network errors.
|
||||
//
|
||||
// Already-classified errors pass through unchanged: any *output.ExitError
|
||||
// (legacy envelope from output.ErrAuth / output.ErrAPI / output.ErrWithHint)
|
||||
// and any typed *errs.* error (carries an embedded Problem) keeps its own
|
||||
// category and exit code. This is what makes the wrap idempotent on the
|
||||
// auth/credential chain — resolveAccessToken returns output.ErrAuth for
|
||||
// missing tokens, and that classification must survive the SDK boundary.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + rawAPIJSONHint
|
||||
// on JSON-decode, network otherwise) for the wrap-from-untyped branch.
|
||||
// Preserved so SDK Do() callers keep the original envelope until per-domain
|
||||
// migration to typed errors. New code should route through
|
||||
// APIClient.CheckResponse (typed *errs.APIError) or construct
|
||||
// *errs.NetworkError / *errs.InternalError directly.
|
||||
func WrapDoAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var existing *output.ExitError
|
||||
if errors.As(err, &existing) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
@@ -32,6 +54,11 @@ func WrapDoAPIError(err error) error {
|
||||
|
||||
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
|
||||
// into API errors with hints instead of generic parse failures.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + ExitAPI +
|
||||
// rawAPIJSONHint). The 3-branch behaviour is preserved so existing callers
|
||||
// of internal/client/response.go keep emitting the same envelope until
|
||||
// per-domain migration to typed errors.
|
||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,31 +6,15 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
|
||||
err := WrapDoAPIError(io.EOF)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
|
||||
}
|
||||
if strings.Contains(exitErr.Error(), "invalid JSON response") {
|
||||
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
@@ -66,3 +50,127 @@ func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
|
||||
// the documented 3-branch behaviour: empty (or whitespace-only) response
|
||||
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
|
||||
// only "\n" must not be reclassified as transport failures.
|
||||
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
|
||||
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") {
|
||||
t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
|
||||
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
|
||||
// (the SDK delivered something but the read itself failed mid-flight).
|
||||
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
|
||||
raw := errors.New("connection reset by peer")
|
||||
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
|
||||
// already-classified *output.ExitError (e.g. output.ErrAuth from
|
||||
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
|
||||
// intact. Without this, missing-token errors regress from exit 3/auth to
|
||||
// exit 4/network at the SDK boundary.
|
||||
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in error
|
||||
want int
|
||||
wantType string
|
||||
}{
|
||||
{"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"},
|
||||
{"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"},
|
||||
{"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := WrapDoAPIError(tc.in)
|
||||
if got != tc.in {
|
||||
t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != tc.want {
|
||||
t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType {
|
||||
t.Fatalf("Detail.Type = %q, want %q (detail=%#v)",
|
||||
func() string {
|
||||
if exitErr.Detail == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return exitErr.Detail.Type
|
||||
}(),
|
||||
tc.wantType, exitErr.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
|
||||
// (carries an embedded Problem) passes through unchanged. Forward-compat for
|
||||
// stage-4 credential chain migration that will return *errs.AuthenticationError
|
||||
// directly instead of legacy output.ErrAuth.
|
||||
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
|
||||
cases := []error{
|
||||
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
|
||||
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}},
|
||||
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}},
|
||||
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
|
||||
}
|
||||
for _, in := range cases {
|
||||
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
|
||||
got := WrapDoAPIError(in)
|
||||
if got != in {
|
||||
t.Fatalf("expected identity passthrough, got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
|
||||
// error wraps a JSON decode error somewhere in its chain, the outer
|
||||
// classification takes precedence — we never re-classify an already-typed error
|
||||
// as a JSON parse error.
|
||||
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
|
||||
jsonErr := &json.SyntaxError{Offset: 1}
|
||||
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
|
||||
|
||||
got := WrapDoAPIError(authWrappingJSON)
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +91,28 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
||||
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
||||
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||
//
|
||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
||||
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
||||
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
||||
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
||||
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
||||
// Errors that arrive already-classified (legacy *output.ExitError from
|
||||
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
||||
// future stages) flow through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// WrapDoAPIError is idempotent on already-classified errors:
|
||||
// the *output.ExitError that resolveAccessToken returns for missing
|
||||
// tokens (via output.ErrAuth) passes through with its auth category
|
||||
// and exit 3 intact, and any future typed *errs.* error from the
|
||||
// credential chain survives the same way. Only stray untyped errors
|
||||
// (raw fmt.Errorf) get the transport-or-internal fallback.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
@@ -107,7 +123,11 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
}
|
||||
|
||||
opts = append(opts, extraOpts...)
|
||||
return c.SDK.Do(ctx, req, opts...)
|
||||
resp, err := c.SDK.Do(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
@@ -123,7 +143,10 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// See DoSDKRequest comment on the same wrap pattern; the typed
|
||||
// auth-error pass-through plus untyped fallback applies equally to
|
||||
// streaming requests.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
@@ -259,14 +282,27 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
|
||||
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
||||
}
|
||||
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
|
||||
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
|
||||
// directly when the response may not be JSON (e.g. file downloads).
|
||||
//
|
||||
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
||||
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
|
||||
// or malformed page body would surface to the root handler as a plain-text
|
||||
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
||||
// framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError / *errs.NetworkError.
|
||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||
resp, err := c.DoAPI(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseJSONResponse(resp)
|
||||
result, parseErr := ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
||||
@@ -410,10 +446,14 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
|
||||
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
||||
}
|
||||
|
||||
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
|
||||
// Returns nil if result is not a map, map is nil, or code is 0.
|
||||
func CheckLarkResponse(result interface{}) error {
|
||||
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
|
||||
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
|
||||
// existing callers keep emitting the same envelope until per-domain
|
||||
// migration to typed errors. The identity parameter is reserved for the
|
||||
// stage-2 typed path; stage-1 ignores it.
|
||||
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
|
||||
@@ -45,12 +45,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -434,42 +428,118 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}))
|
||||
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoSDKRequest() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
|
||||
}
|
||||
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||
}
|
||||
|
||||
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
|
||||
// invariant codex caught the day this PR landed: when resolveAccessToken
|
||||
// produces output.ErrAuth ("no access token available for <identity>"),
|
||||
// DoSDKRequest must surface it with the original auth classification —
|
||||
// not silently downgrade it to a network error via the SDK-failure wrap.
|
||||
//
|
||||
// Regression scenario: shortcut path
|
||||
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
|
||||
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
|
||||
// and routed agents into "check your connection" instead of "log in".
|
||||
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "https://example.com/open-apis/test",
|
||||
}, core.AsBot)
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsUser)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("DoStream() error = nil, want auth error")
|
||||
t.Fatal("expected auth error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoStream() error = %v, want auth error", err)
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
|
||||
// SDK transport errors get the network classification via WrapDoAPIError.
|
||||
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
|
||||
// *url.Error, which the wrap classifier recognises as a transport error.
|
||||
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from broken transport, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
|
||||
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
|
||||
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
|
||||
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError; until then this test pins the legacy shape so we do
|
||||
// not regress envelope coverage.
|
||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
|
||||
}, nil
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.CallAPI(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/me",
|
||||
As: "bot",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON parse error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,25 +8,38 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PaginationOptions contains pagination control options.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}) error) error {
|
||||
checkErr func(interface{}, core.Identity) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
// Identity resolution honors pagOpts.Identity first, then the request's
|
||||
// own identity, and only falls back to AsUser when neither caller
|
||||
// supplied one. Without checking request.As, bot/auto requests would
|
||||
// always be classified as user identity for checkErr.
|
||||
identity := pagOpts.Identity
|
||||
if identity == "" {
|
||||
identity = request.As
|
||||
}
|
||||
if identity == "" || identity == core.AsAuto {
|
||||
identity = core.AsUser
|
||||
}
|
||||
if apiErr := checkErr(result, identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -30,8 +31,13 @@ type ResponseOptions struct {
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
CommandPath string // raw cobra CommandPath() for content safety scanning
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
CheckError func(interface{}) error
|
||||
// Identity is forwarded to CheckError (default or caller-supplied) so the
|
||||
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
|
||||
// Defaults to core.AsUser when empty.
|
||||
Identity core.Identity
|
||||
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
|
||||
// with the Identity field (or AsUser when unset).
|
||||
CheckError func(result interface{}, identity core.Identity) error
|
||||
}
|
||||
|
||||
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
|
||||
@@ -40,9 +46,21 @@ type ResponseOptions struct {
|
||||
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
|
||||
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
identity := opts.Identity
|
||||
if identity == "" {
|
||||
identity = core.AsUser
|
||||
}
|
||||
check := opts.CheckError
|
||||
if check == nil {
|
||||
check = CheckLarkResponse
|
||||
// Stage 1: default check routes through legacy CheckResponse
|
||||
// (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will
|
||||
// switch this to errclass.BuildAPIError so PermissionError carries
|
||||
// MissingScopes / ConsoleURL — at that point a zero-value
|
||||
// *APIClient still works because BuildAPIError short-circuits on
|
||||
// empty AppID, gracefully degrading identity-aware fields.
|
||||
check = func(r interface{}, id core.Identity) error {
|
||||
return (&APIClient{}).CheckResponse(r, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||
@@ -58,7 +76,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if err != nil {
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result); apiErr != nil {
|
||||
if apiErr := check(result, identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
// Content safety scanning
|
||||
|
||||
@@ -234,37 +234,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty JSON body")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if exitErr.Detail.Message != "API returned an empty JSON response body" {
|
||||
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
@@ -424,17 +393,3 @@ func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 JSON with non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "99991400") {
|
||||
t.Errorf("expected lark error code in message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,13 @@ func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
|
||||
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
|
||||
// need a custom Message or an independent Hint (strict-mode) should
|
||||
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
|
||||
//
|
||||
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
|
||||
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
|
||||
// This helper is retained only while existing call sites are migrated; it
|
||||
// will be removed once they have moved to the typed surface.
|
||||
func BuildDenialError(path string, d Denial) *output.ExitError {
|
||||
cd := CommandDeniedFromDenial(path, d)
|
||||
return &output.ExitError{
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// Deprecated: RequireConfirmation produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — confirmation-required signals should move to typed
|
||||
// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata
|
||||
// (level/action) as typed extension fields. This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func RequireConfirmation(action string) error {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitConfirmationRequired,
|
||||
|
||||
@@ -236,7 +236,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
@@ -244,20 +244,19 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, &ConfigError{Code: 2, Type: "config",
|
||||
return nil, &ConfigError{Code: 3, Type: "config",
|
||||
Message: "appId and appSecret keychain key are out of sync",
|
||||
Hint: err.Error()}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import "fmt"
|
||||
// ConfigError is a structured error from config resolution.
|
||||
// It carries enough information for main.go to convert it into an output.ExitError.
|
||||
type ConfigError struct {
|
||||
Code int // exit code: 2=validation, 3=auth
|
||||
Code int // exit code: 3 (config errors share the auth exit code)
|
||||
Type string // "config" or "auth"
|
||||
Message string
|
||||
Hint string
|
||||
|
||||
@@ -31,7 +31,7 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
@@ -71,14 +71,14 @@ func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
@@ -105,14 +105,14 @@ func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
|
||||
284
internal/errclass/classify.go
Normal file
284
internal/errclass/classify.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
||||
}
|
||||
|
||||
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||
// Returns nil when resp is nil or resp["code"] is 0.
|
||||
//
|
||||
// Routing by Category:
|
||||
//
|
||||
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
||||
// Authentication → *errs.AuthenticationError
|
||||
// Config → *errs.ConfigError
|
||||
// Policy → *errs.SecurityPolicyError
|
||||
// Validation → *errs.ValidationError
|
||||
// Network → *errs.NetworkError
|
||||
// Internal → *errs.InternalError
|
||||
// Confirmation → *errs.ConfirmationRequiredError
|
||||
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
|
||||
//
|
||||
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||
// CategoryAPI + SubtypeUnknown.
|
||||
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
code := intFromAny(resp["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
if msg == "" {
|
||||
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
||||
// so the typed wire envelope still carries a human-readable signal.
|
||||
msg = fmt.Sprintf("API error: [%d]", code)
|
||||
}
|
||||
// Lark API responses sometimes carry log_id at the top level
|
||||
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
||||
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
||||
// back to the nested location so log_id always surfaces on the typed
|
||||
// envelope.
|
||||
logID, _ := resp["log_id"].(string)
|
||||
if logID == "" {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if nested, ok := errBlock["log_id"].(string); ok {
|
||||
logID = nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta, ok := LookupCodeMeta(code)
|
||||
if !ok {
|
||||
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
||||
}
|
||||
|
||||
base := errs.Problem{
|
||||
Category: meta.Category,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
LogID: logID,
|
||||
Retryable: meta.Retryable,
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
return buildPermissionError(base, resp, cc)
|
||||
case errs.CategoryAuthentication:
|
||||
return &errs.AuthenticationError{Problem: base}
|
||||
case errs.CategoryConfig:
|
||||
return &errs.ConfigError{Problem: base}
|
||||
case errs.CategoryPolicy:
|
||||
return buildSecurityPolicyError(base, resp)
|
||||
case errs.CategoryValidation:
|
||||
return &errs.ValidationError{Problem: base}
|
||||
case errs.CategoryNetwork:
|
||||
return &errs.NetworkError{Problem: base}
|
||||
case errs.CategoryInternal:
|
||||
return &errs.InternalError{Problem: base}
|
||||
case errs.CategoryConfirmation:
|
||||
return &errs.ConfirmationRequiredError{Problem: base}
|
||||
default:
|
||||
return &errs.APIError{Problem: base, Detail: resp}
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
||||
// response's data block, so the typed SecurityPolicyError carries the same
|
||||
// browser-challenge information that internal/auth/transport.go surfaces at
|
||||
// the HTTP layer.
|
||||
//
|
||||
// Data shapes accepted (whichever the upstream sends):
|
||||
//
|
||||
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
||||
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
||||
//
|
||||
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
||||
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
||||
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
||||
// either spelling surfaces, matching the transport layer.
|
||||
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
||||
dataMap, _ := resp["data"].(map[string]any)
|
||||
if dataMap == nil {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
dataMap, _ = errBlock["data"].(map[string]any)
|
||||
}
|
||||
}
|
||||
if dataMap == nil {
|
||||
return &errs.SecurityPolicyError{Problem: p}
|
||||
}
|
||||
|
||||
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
||||
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
||||
challengeURL = ""
|
||||
}
|
||||
|
||||
hint := stringFromAny(dataMap["hint"])
|
||||
if hint == "" {
|
||||
hint = stringFromAny(dataMap["cli_hint"])
|
||||
}
|
||||
if hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: p,
|
||||
ChallengeURL: challengeURL,
|
||||
}
|
||||
}
|
||||
|
||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
||||
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
|
||||
func isHTTPSURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
||||
func stringFromAny(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
||||
return &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionHint returns an actionable next-step string for a permission
|
||||
// error. User identity with a missing user-scope is recovered by re-running
|
||||
// `auth login --scope ...`; bot identity or app-level scope errors are
|
||||
// recovered by enabling scopes in the open-platform console. The subtype
|
||||
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
||||
// where re-authentication will not help regardless of the caller identity.
|
||||
//
|
||||
// Exported so direct construction sites (cmd/service/service.go's
|
||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||
// byte-for-byte instead of hand-rolling divergent strings.
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
||||
// app_scope_not_enabled means the scope has not been granted at the
|
||||
// app (developer console) level — re-authenticating cannot fix it,
|
||||
// so route every caller identity to the console hint.
|
||||
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
||||
if len(missing) == 0 {
|
||||
if useConsole {
|
||||
return "check the app's scope grant in the Lark open platform console"
|
||||
}
|
||||
return "ensure the calling identity has been granted the required scopes"
|
||||
}
|
||||
scopes := strings.Join(missing, " ")
|
||||
if useConsole {
|
||||
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
|
||||
}
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := errBlock["permission_violations"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := m["subject"].(string)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return int(i)
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
747
internal/errclass/classify_test.go
Normal file
747
internal/errclass/classify_test.go
Normal file
@@ -0,0 +1,747 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// missingScopeResp builds a minimal Lark missing-scope response with one
|
||||
// violation. Shared across the envelope-shape and brand-switch tests.
|
||||
func missingScopeResp(scope string) map[string]any {
|
||||
return map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": scope},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
||||
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("nil resp should return nil error, got %v", got)
|
||||
}
|
||||
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("code=0 should return nil error, got %v", got)
|
||||
}
|
||||
// json.Number 0 path (real-world SDK decodes with UseNumber)
|
||||
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
|
||||
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("json.Number(0) should return nil error, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// matchesTypedError reports whether err is the typed-error variant identified by
|
||||
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
|
||||
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
|
||||
// through to *APIError) fails loudly instead of passing on Category alone.
|
||||
func matchesTypedError(err error, wantTyped string) bool {
|
||||
switch wantTyped {
|
||||
case "PermissionError":
|
||||
var x *errs.PermissionError
|
||||
return errors.As(err, &x)
|
||||
case "AuthenticationError":
|
||||
var x *errs.AuthenticationError
|
||||
return errors.As(err, &x)
|
||||
case "ValidationError":
|
||||
var x *errs.ValidationError
|
||||
return errors.As(err, &x)
|
||||
case "NetworkError":
|
||||
var x *errs.NetworkError
|
||||
return errors.As(err, &x)
|
||||
case "ConfigError":
|
||||
var x *errs.ConfigError
|
||||
return errors.As(err, &x)
|
||||
case "InternalError":
|
||||
var x *errs.InternalError
|
||||
return errors.As(err, &x)
|
||||
case "ConfirmationRequiredError":
|
||||
var x *errs.ConfirmationRequiredError
|
||||
return errors.As(err, &x)
|
||||
case "SecurityPolicyError":
|
||||
var x *errs.SecurityPolicyError
|
||||
return errors.As(err, &x)
|
||||
case "APIError":
|
||||
// APIError is the default fallback; use a direct type assertion to avoid
|
||||
// matching against typed subclasses that also satisfy IsAPI.
|
||||
_, ok := err.(*errs.APIError)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantExit int
|
||||
wantTyped string
|
||||
}{
|
||||
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
|
||||
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
||||
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := map[string]any{"code": tc.code, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for code %d, got nil", tc.code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
|
||||
}
|
||||
if p.Category != tc.wantCat {
|
||||
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
|
||||
}
|
||||
if p.Subtype != tc.wantSubtype {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != tc.wantExit {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
|
||||
}
|
||||
if !matchesTypedError(err, tc.wantTyped) {
|
||||
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
|
||||
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
|
||||
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
|
||||
// route accordingly so the embedded Problem.Category matches the wire type.
|
||||
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
|
||||
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 1470400")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if _, isAPI := err.(*errs.APIError); isAPI {
|
||||
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-1",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
|
||||
if !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// positive assertions
|
||||
for _, want := range []string{
|
||||
`"type": "authorization"`,
|
||||
`"subtype": "missing_scope"`,
|
||||
`"code": 99991679`,
|
||||
`"missing_scopes":`,
|
||||
`"docx:document"`,
|
||||
`"console_url":`,
|
||||
`open.feishu.cn/app/cli_a123/auth`,
|
||||
`"identity": "user"`,
|
||||
`"log_id": "lg-1"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("envelope missing %q\nfull: %s", want, out)
|
||||
}
|
||||
}
|
||||
// negative assertions on the wire format
|
||||
for _, mustNot := range []string{
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
||||
} {
|
||||
if strings.Contains(out, mustNot) {
|
||||
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
|
||||
// Test 1: Retryable:true → key present
|
||||
apiErr := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
|
||||
if !strings.Contains(buf.String(), `"retryable": true`) {
|
||||
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
|
||||
}
|
||||
|
||||
// Test 2: Retryable:false → key absent
|
||||
buf.Reset()
|
||||
apiErr2 := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Message: "x", Retryable: false,
|
||||
}}
|
||||
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
|
||||
}
|
||||
if strings.Contains(buf.String(), `"retryable"`) {
|
||||
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
||||
// scope values so a hostile value cannot break out of the URL framing
|
||||
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
||||
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
scopes []string
|
||||
wantInURL []string // substrings that MUST appear
|
||||
denyInURL []string // substrings that MUST NOT appear
|
||||
}{
|
||||
{
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
|
||||
for _, want := range tt.wantInURL {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, deny := range tt.denyInURL {
|
||||
if strings.Contains(got, deny) {
|
||||
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_DefaultIdentity(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.Identity != "user" {
|
||||
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_NoViolations(t *testing.T) {
|
||||
// permission error without a permission_violations array → MissingScopes nil,
|
||||
// ConsoleURL falls back to the no-scope form.
|
||||
resp := map[string]any{"code": 99991679, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMissingScopes_Dedup(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "x",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
map[string]any{"subject": "docx:document"}, // dup
|
||||
map[string]any{"subject": ""}, // ignored
|
||||
map[string]any{"subject": "im:message"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if got, want := len(pe.MissingScopes), 2; got != want {
|
||||
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
|
||||
// identical whether produced via the dispatcher (BuildAPIError — the normal
|
||||
// service / shortcut path) or constructed directly at the call site (the
|
||||
// cmd/service permission path).
|
||||
//
|
||||
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
|
||||
// exported PermissionHint and ConsoleURL helpers — the same helpers
|
||||
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
|
||||
// service.go line-by-line so a future drift on either side (e.g. a new
|
||||
// extension field on PermissionError that only BuildAPIError populates) fails
|
||||
// loudly here. The remaining limitation is that this test invokes the helpers
|
||||
// directly rather than driving checkServiceScopes (which requires a credential
|
||||
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
|
||||
// mock harness lands.
|
||||
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
const (
|
||||
brand = "feishu"
|
||||
appID = "cli_a123"
|
||||
identity = "user"
|
||||
)
|
||||
missing := []string{"docx:document"}
|
||||
|
||||
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
||||
resp := missingScopeResp(missing[0])
|
||||
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
||||
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||
}
|
||||
|
||||
// Path B: direct construction — exactly mirrors cmd/service/service.go's
|
||||
// checkServiceScopes (same helpers, same field-fill order). Code
|
||||
// and Message are copied from Path A so the byte-comparison below isolates
|
||||
// the contract under test (Hint + Identity + ConsoleURL convergence).
|
||||
directErr := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Code: dispatcherPE.Code,
|
||||
Message: dispatcherPE.Message,
|
||||
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
|
||||
},
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
|
||||
}
|
||||
|
||||
var bufA, bufB bytes.Buffer
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
||||
t.Fatal("dispatcher path failed to emit typed envelope")
|
||||
}
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
|
||||
t.Fatal("direct path failed to emit typed envelope")
|
||||
}
|
||||
|
||||
if bufA.String() != bufB.String() {
|
||||
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
||||
// Mirrors what the cmd/service direct-construction path produces.
|
||||
pe := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Message: "missing required scope(s): docx:document",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
Identity: "user",
|
||||
}
|
||||
if got := output.ExitCodeOf(pe); got != 3 {
|
||||
t.Errorf("ExitCodeOf = %d, want 3", got)
|
||||
}
|
||||
if !errs.IsPermission(pe) {
|
||||
t.Error("expected IsPermission(pe) == true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
|
||||
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("expected no output for untyped error, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
|
||||
// Some Lark API responses carry log_id nested under "error" rather than
|
||||
// at the top level. BuildAPIError must surface either location.
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"error": map[string]any{
|
||||
"log_id": "lg-nested-123",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-nested-123" {
|
||||
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-top-456",
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-top-456" {
|
||||
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||
t.Errorf("user hint should include missing scopes; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot hint should mention the open-platform console; got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
|
||||
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
|
||||
}
|
||||
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// 99991672 / app_scope_not_enabled means the scope has not been granted
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
||||
// Regression: code 99991672 with user identity previously emitted
|
||||
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
||||
// loop because the missing scope is not yet enabled at the app level.
|
||||
resp := map[string]any{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not enabled",
|
||||
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "open platform console") {
|
||||
t.Errorf("Hint should route to console; got %q", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_HintPopulated(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Hint == "" {
|
||||
t.Error("PermissionError.Hint should be populated by BuildAPIError")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "docx:document") {
|
||||
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
|
||||
// SDK parses with json.Number; verify intFromAny handles it.
|
||||
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for json.Number-encoded code")
|
||||
}
|
||||
if _, ok := err.(*errs.PermissionError); !ok {
|
||||
t.Errorf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
|
||||
// passing through BuildAPIError keep the browser-challenge URL and hint —
|
||||
// agents need challenge_url to drive the user through MFA / device-trust
|
||||
// flows. Without extraction, the typed envelope is degenerate vs. what the
|
||||
// internal/auth/transport.go HTTP-layer interceptor already produces.
|
||||
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
|
||||
"hint": "complete MFA in the browser, then retry",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "complete MFA in the browser, then retry" {
|
||||
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
|
||||
// using data.cli_hint still surface via Hint when data.hint is absent.
|
||||
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21001,
|
||||
"msg": "access denied",
|
||||
"data": map[string]any{
|
||||
"cli_hint": "ask your admin for elevated approval",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.Hint != "ask your admin for elevated approval" {
|
||||
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
|
||||
// untrusted challenge_url (non-https) is dropped — same policy as
|
||||
// internal/auth/transport.go isValidChallengeURL.
|
||||
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://attacker.example.com/challenge",
|
||||
"javascript:alert(1)",
|
||||
"ftp://example.com/challenge",
|
||||
"not a url at all",
|
||||
}
|
||||
for _, bad := range cases {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{"challenge_url": bad, "hint": "h"},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
|
||||
// envelope still routes correctly with empty extension fields when the
|
||||
// upstream response carries only code+msg.
|
||||
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
|
||||
resp := map[string]any{"code": 21000, "msg": "challenge required"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Message != "challenge required" {
|
||||
t.Errorf("Message = %q, want challenge required", spe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
|
||||
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
|
||||
// extension fields stay empty, no panic. Server-side bugs or transitional
|
||||
// API shapes must never crash the CLI dispatcher.
|
||||
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
|
||||
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
|
||||
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
|
||||
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
|
||||
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
|
||||
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
|
||||
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
|
||||
}
|
||||
}()
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
|
||||
// {"error": {"data": {...}}} envelope variant — same lookup paths the
|
||||
// transport-layer interceptor uses on inbound responses.
|
||||
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"error": map[string]any{
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/c/abc",
|
||||
"hint": "wrapped variant",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "wrapped variant" {
|
||||
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
|
||||
}
|
||||
}
|
||||
87
internal/errclass/codemeta.go
Normal file
87
internal/errclass/codemeta.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||
// (see BuildAPIError).
|
||||
type CodeMeta struct {
|
||||
Category errs.Category
|
||||
Subtype errs.Subtype
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
|
||||
// policy/config codes shared across services) live here; service-specific
|
||||
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
|
||||
// merge into this map via init().
|
||||
//
|
||||
// Go language guarantees package-level vars initialize before init() functions,
|
||||
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||
var codeMeta = map[int]CodeMeta{
|
||||
// CategoryAuthentication
|
||||
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
|
||||
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
|
||||
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
|
||||
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
|
||||
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
|
||||
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
|
||||
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
|
||||
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
|
||||
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
|
||||
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
|
||||
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
|
||||
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
|
||||
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
|
||||
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
|
||||
|
||||
// CategoryAPI
|
||||
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
|
||||
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
|
||||
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
|
||||
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
|
||||
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
|
||||
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
|
||||
}
|
||||
|
||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||
// the caller (BuildAPIError) is responsible for falling back to
|
||||
// CategoryAPI/SubtypeUnknown.
|
||||
func LookupCodeMeta(code int) (CodeMeta, bool) {
|
||||
m, ok := codeMeta[code]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
|
||||
// codes into the central registry. Panics on duplicate code so a misregistration
|
||||
// fails fast at startup rather than producing silently-inconsistent classification.
|
||||
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
|
||||
for code, meta := range src {
|
||||
if existing, dup := codeMeta[code]; dup {
|
||||
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
|
||||
code, existing, owner, meta))
|
||||
}
|
||||
codeMeta[code] = meta
|
||||
}
|
||||
}
|
||||
24
internal/errclass/codemeta_task.go
Normal file
24
internal/errclass/codemeta_task.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// taskCodeMeta holds the task-service-specific Lark code classifications.
|
||||
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
|
||||
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
|
||||
// map via mergeCodeMeta + LookupCodeMeta.
|
||||
var taskCodeMeta = map[int]CodeMeta{
|
||||
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
|
||||
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
|
||||
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
|
||||
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
|
||||
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
|
||||
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
|
||||
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
|
||||
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
|
||||
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||
105
internal/errclass/codemeta_test.go
Normal file
105
internal/errclass/codemeta_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991679)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
|
||||
}
|
||||
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
|
||||
if got != want {
|
||||
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(1470403)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("task_permission_denied") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Errorf("Retryable = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991400)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
|
||||
}
|
||||
if got.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(21000)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
|
||||
}
|
||||
if got.Category != errs.CategoryPolicy {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("challenge_required") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
|
||||
}
|
||||
msg, ok := r.(string)
|
||||
if !ok {
|
||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
||||
}
|
||||
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
mergeCodeMeta(map[int]CodeMeta{
|
||||
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
|
||||
}, "test")
|
||||
}
|
||||
32
internal/errcompat/promote.go
Normal file
32
internal/errcompat/promote.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errcompat bridges the legacy *core.ConfigError shape into the
|
||||
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
|
||||
// placed in its own package so it can import both core (for the legacy
|
||||
// type) and errs (for the typed targets) without creating an import cycle
|
||||
// with internal/errclass, which intentionally avoids depending on
|
||||
// internal/core.
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// PromoteConfigError is the stage-2 boundary helper that will convert a
|
||||
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
|
||||
// is a passthrough — the dispatcher continues to render *core.ConfigError
|
||||
// via the legacy envelope path (cmd/root.go asExitError) so the wire
|
||||
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
|
||||
// will fill in the actual promotion logic alongside its corresponding
|
||||
// wire-change announcement.
|
||||
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
||||
if cfgErr == nil {
|
||||
return nil
|
||||
}
|
||||
return cfgErr
|
||||
}
|
||||
|
||||
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
|
||||
var _ = errs.CategoryConfig
|
||||
37
internal/errcompat/promote_test.go
Normal file
37
internal/errcompat/promote_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errcompat_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
)
|
||||
|
||||
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
|
||||
// behaviour: every input *core.ConfigError flows out unchanged so the
|
||||
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
|
||||
// Per-domain typed migration will replace this in stage 2+.
|
||||
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
|
||||
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
|
||||
t.Run(cfgType, func(t *testing.T) {
|
||||
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
|
||||
out := errcompat.PromoteConfigError(src)
|
||||
var got *core.ConfigError
|
||||
if !errors.As(out, &got) || got != src {
|
||||
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
|
||||
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
|
||||
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
|
||||
if got := errcompat.PromoteConfigError(nil); got != nil {
|
||||
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,13 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca
|
||||
// *output.ExitError so cmd/root.go's envelope writer emits the right
|
||||
// JSON structure (type="hook"). Non-AbortError values pass through
|
||||
// unchanged.
|
||||
//
|
||||
// Deprecated: wrapAbortError converts to a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — hook abort signals should move to a typed
|
||||
// *errs.XxxError (typed hook error is tracked for the hook framework
|
||||
// migration PR). This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func wrapAbortError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,14 @@ type Envelope struct {
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the standard error response wrapper.
|
||||
//
|
||||
// Deprecated: ErrorEnvelope belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — the typed envelope shape is owned by
|
||||
// internal/output.WriteTypedErrorEnvelope which marshals typed errs.* errors
|
||||
// directly via JSON reflection (no wrapper struct needed). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
@@ -23,6 +31,13 @@ type ErrorEnvelope struct {
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error.
|
||||
//
|
||||
// Deprecated: ErrDetail belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — typed errs.* structs embed errs.Problem and own their wire shape
|
||||
// via JSON tags (Category, Subtype, Hint, etc. promote to the top level).
|
||||
// This struct is retained only while existing *ExitError call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
type ErrDetail struct {
|
||||
Type string `json:"type"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -37,6 +52,14 @@ type ErrDetail struct {
|
||||
// confirmation_required errors. Level is one of "read" | "write" |
|
||||
// "high-risk-write". Action identifies the command for the agent (e.g.
|
||||
// "mail +send", "drive.files.delete").
|
||||
//
|
||||
// Deprecated: RiskDetail is reachable only via *output.ExitError.Detail.Risk,
|
||||
// part of the legacy envelope surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT use it — confirmation-required
|
||||
// signals belong on *errs.ConfirmationRequiredError (its own typed extension
|
||||
// fields can carry agent-protocol metadata directly). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type RiskDetail struct {
|
||||
Level string `json:"level"`
|
||||
Action string `json:"action"`
|
||||
|
||||
@@ -9,16 +9,26 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ExitError is a structured error that carries an exit code and optional detail.
|
||||
// It is propagated up the call chain and handled by main.go to produce
|
||||
// a JSON error envelope on stderr and the correct exit code.
|
||||
//
|
||||
// Deprecated: *output.ExitError is the legacy error type that predates the
|
||||
// typed error contract introduced by errs/. New code MUST NOT instantiate it
|
||||
// — return a typed *errs.XxxError (see errs/ for the available categories:
|
||||
// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError /
|
||||
// *APIError / *InternalError / etc.). This type is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Detail *ErrDetail
|
||||
Err error
|
||||
Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error
|
||||
Raw bool // when true, the dispatcher skips enrichment (e.g. enrichPermissionError) and preserves the original error detail
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
@@ -35,7 +45,31 @@ func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
|
||||
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
|
||||
// preserves the original API error detail. Returns the original error
|
||||
// unchanged if it is not (or does not wrap) an ExitError.
|
||||
//
|
||||
// Used by `cmd/api` and other "passthrough" call sites where the caller
|
||||
// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.)
|
||||
// on the wire rather than the enriched message/hint variant.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
|
||||
//
|
||||
// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with
|
||||
// *output.ExitError, which predates the typed error contract introduced by
|
||||
// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError
|
||||
// from the command, and cmd/root.go handleRootError will dispatch through
|
||||
// WriteTypedErrorEnvelope. This writer is retained only while existing
|
||||
// *ExitError producers are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
if err.Detail == nil {
|
||||
return
|
||||
@@ -60,6 +94,13 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
// --- Convenience constructors ---
|
||||
|
||||
// Errorf creates an ExitError with the given code, type, and formatted message.
|
||||
//
|
||||
// Deprecated: Errorf belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — construct a typed *errs.XxxError directly (e.g.
|
||||
// *errs.ValidationError, *errs.InternalError). This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
var err error
|
||||
for _, arg := range args {
|
||||
@@ -75,23 +116,58 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrValidation creates a validation ExitError (exit 2).
|
||||
// ErrValidation creates a validation ExitError (exit 2, wire type
|
||||
// "validation"). The legacy *output.ExitError envelope emits only
|
||||
// `type`+`message` — no `subtype`/`param` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype, Param, etc.)
|
||||
// on the wire, construct `&errs.ValidationError{...}` directly so
|
||||
// cmd/root.go routes it through the typed envelope writer. Per-domain
|
||||
// typed migration in stage 2+ will migrate existing call sites and
|
||||
// remove this helper.
|
||||
func ErrValidation(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitValidation, "validation", format, args...)
|
||||
}
|
||||
|
||||
// ErrAuth creates an auth ExitError (exit 3).
|
||||
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
|
||||
//
|
||||
// Stage-1 status: kept as the canonical helper for token-missing /
|
||||
// login-required errors, so the 19 existing call sites in cmd/auth,
|
||||
// cmd/config, cmd/event, internal/client, and shortcuts/common keep
|
||||
// emitting `type: "auth"`. To migrate a single call site to the typed
|
||||
// taxonomy (`type: "authentication"` on the wire), construct
|
||||
// `&errs.AuthenticationError{...}` directly — but note that flips a
|
||||
// user-visible wire field and belongs in the per-domain stage-2 PR for
|
||||
// that area, not in unrelated new code.
|
||||
func ErrAuth(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitAuth, "auth", format, args...)
|
||||
}
|
||||
|
||||
// ErrNetwork creates a network ExitError (exit 4).
|
||||
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
|
||||
// The legacy *output.ExitError envelope emits only `type`+`message` — no
|
||||
// `subtype`/`cause` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype "transport" /
|
||||
// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct
|
||||
// `&errs.NetworkError{...}` directly. Per-domain typed migration in
|
||||
// stage 2+ will migrate existing call sites and remove this helper.
|
||||
func ErrNetwork(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitNetwork, "network", format, args...)
|
||||
}
|
||||
|
||||
// ErrAPI creates an API ExitError using ClassifyLarkError.
|
||||
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
|
||||
//
|
||||
// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code SHOULD
|
||||
// construct a typed *errs.XxxError directly. The stage-2+ migration will
|
||||
// route classification through internal/errclass.BuildAPIError (shipped
|
||||
// but not yet invoked from production paths) so the typed envelope carries
|
||||
// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
|
||||
if errType == "permission" {
|
||||
@@ -110,6 +186,13 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
}
|
||||
|
||||
// ErrWithHint creates an ExitError with a hint string.
|
||||
//
|
||||
// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — construct a typed *errs.XxxError directly and set its Hint
|
||||
// field (the typed envelope promotes Problem.Hint to the wire). This helper
|
||||
// is retained only while existing call sites are migrated; it will be
|
||||
// removed once they have moved to the typed surface.
|
||||
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
return &ExitError{
|
||||
Code: code,
|
||||
@@ -119,17 +202,62 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
|
||||
// ErrBare creates an ExitError with only an exit code and no envelope.
|
||||
// Used for cases like `auth check` where the JSON output is already written to stdout.
|
||||
//
|
||||
// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — express the "exit with code, emit no envelope" semantics
|
||||
// explicitly at the call site (e.g. return a typed *errs.XxxError or call
|
||||
// os.Exit directly from RunE). This helper is retained only while existing
|
||||
// call sites are migrated; it will be removed once they have moved to the
|
||||
// typed surface.
|
||||
func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError)
|
||||
// is skipped and the original API error is preserved. Returns the original error unchanged
|
||||
// if it is not an ExitError.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
||||
// a `detail` sub-object.
|
||||
//
|
||||
// Returns true when err was a typed error (envelope written) and false when
|
||||
// err had no Problem (caller should fall back to WriteErrorEnvelope).
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return err
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here and let the dispatcher fall
|
||||
// back to the legacy envelope writer so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
if _, writeErr := buf.WriteTo(w); writeErr != nil {
|
||||
// Write failed mid-envelope. Return false so the dispatcher does
|
||||
// not silently treat a half-written stderr as a successful emit
|
||||
// and skip every other fallback.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
|
||||
// underlying typed error's own json tags determine the inner shape via
|
||||
// encoding/json reflection; Notice mirrors the existing ErrorEnvelope (see
|
||||
// GetNotice in envelope.go).
|
||||
type typedEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error error `json:"error"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,40 +6,10 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkRaw_ExitError(t *testing.T) {
|
||||
err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil)
|
||||
if err.Raw {
|
||||
t.Fatal("expected Raw=false before MarkRaw")
|
||||
}
|
||||
|
||||
result := MarkRaw(err)
|
||||
if result != err {
|
||||
t.Error("expected MarkRaw to return the same error")
|
||||
}
|
||||
if !err.Raw {
|
||||
t.Error("expected Raw=true after MarkRaw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_NonExitError(t *testing.T) {
|
||||
plain := fmt.Errorf("some plain error")
|
||||
result := MarkRaw(plain)
|
||||
if result != plain {
|
||||
t.Error("expected MarkRaw to return the same error for non-ExitError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_Nil(t *testing.T) {
|
||||
result := MarkRaw(nil)
|
||||
if result != nil {
|
||||
t.Error("expected MarkRaw(nil) to return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
||||
// Set up PendingNotice
|
||||
origNotice := PendingNotice
|
||||
@@ -148,3 +118,89 @@ func TestGetNotice(t *testing.T) {
|
||||
|
||||
PendingNotice = origNotice
|
||||
}
|
||||
|
||||
// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrValidation: the helper MUST return *output.ExitError (so callers
|
||||
// using errors.As(&exitErr) continue to work), with wire fields restricted
|
||||
// to type+message — no `subtype` emission. The typed envelope shape (which
|
||||
// adds subtype, param, etc.) is reserved for stage-2 per-domain migration.
|
||||
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrValidation("bad arg: %s", "x")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrValidation must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitValidation {
|
||||
t.Errorf("Code = %d, want ExitValidation (%d)", exitErr.Code, ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "validation")
|
||||
}
|
||||
if exitErr.Detail.Message != "bad arg: x" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "bad arg: x")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrValidation envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "validation" {
|
||||
t.Errorf("envelope error.type = %v, want \"validation\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
|
||||
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
|
||||
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrNetwork("conn refused: %s", "10.0.0.1")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrNetwork must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitNetwork {
|
||||
t.Errorf("Code = %d, want ExitNetwork (%d)", exitErr.Code, ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "network")
|
||||
}
|
||||
if exitErr.Detail.Message != "conn refused: 10.0.0.1" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "conn refused: 10.0.0.1")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrNetwork envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "network" {
|
||||
t.Errorf("envelope error.type = %v, want \"network\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Fine-grained error types (permission, not_found, rate_limit, etc.)
|
||||
// are communicated via the JSON error envelope's "type" field,
|
||||
// not via exit codes.
|
||||
@@ -16,3 +22,48 @@ const (
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认(agent 协议信号)
|
||||
)
|
||||
|
||||
// ExitCodeForCategory maps an errs.Category to the shell exit code.
|
||||
// Multiple categories may share an exit code (Authentication / Authorization /
|
||||
// Config all map to 3), so the relationship is many-to-one.
|
||||
func ExitCodeForCategory(cat errs.Category) int {
|
||||
switch cat {
|
||||
case errs.CategoryValidation:
|
||||
return ExitValidation
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
case errs.CategoryNetwork:
|
||||
return ExitNetwork
|
||||
case errs.CategoryAPI:
|
||||
return ExitAPI
|
||||
case errs.CategoryPolicy:
|
||||
return ExitContentSafety
|
||||
case errs.CategoryInternal:
|
||||
return ExitInternal
|
||||
case errs.CategoryConfirmation:
|
||||
return ExitConfirmationRequired
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// ExitCodeOf returns the shell exit code for any error.
|
||||
// - typed errors (*errs.PermissionError, *errs.APIError, ...) → routed by Category
|
||||
// - legacy *output.ExitError → uses its own Code field
|
||||
// - *core.ConfigError → reaches the dispatcher as a legacy
|
||||
// *output.ExitError via cmd/root asExitError (stage 1); the typed
|
||||
// promotion path through internal/errcompat.PromoteConfigError is
|
||||
// reserved for stage 2+.
|
||||
// - untyped → ExitInternal
|
||||
func ExitCodeOf(err error) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
68
internal/output/exitcode_test.go
Normal file
68
internal/output/exitcode_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestExitCodeForCategory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cat errs.Category
|
||||
want int
|
||||
}{
|
||||
{"validation", errs.CategoryValidation, 2},
|
||||
{"authentication", errs.CategoryAuthentication, 3},
|
||||
{"authorization", errs.CategoryAuthorization, 3},
|
||||
{"config", errs.CategoryConfig, 3},
|
||||
{"network", errs.CategoryNetwork, 4},
|
||||
{"api", errs.CategoryAPI, 1},
|
||||
{"policy", errs.CategoryPolicy, 6},
|
||||
{"internal", errs.CategoryInternal, 5},
|
||||
{"confirmation", errs.CategoryConfirmation, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ExitCodeForCategory(tc.cat); got != tc.want {
|
||||
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
|
||||
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
|
||||
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_Nil(t *testing.T) {
|
||||
if got := ExitCodeOf(nil); got != 0 {
|
||||
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_PermissionError(t *testing.T) {
|
||||
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
|
||||
if got := ExitCodeOf(err); got != 3 {
|
||||
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_APIError(t *testing.T) {
|
||||
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
|
||||
if got := ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
|
||||
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
|
||||
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,19 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
// Lark API generic error code constants.
|
||||
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
|
||||
//
|
||||
// Kept as exported identifiers because external shortcut packages reference
|
||||
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
|
||||
// Subtype / Retryable metadata for each code lives in internal/errclass and
|
||||
// must remain the single source of truth — ClassifyLarkError below resolves
|
||||
// classification through errclass.LookupCodeMeta.
|
||||
const (
|
||||
// Auth: token missing / invalid / expired.
|
||||
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
|
||||
@@ -32,7 +43,6 @@ const (
|
||||
LarkErrRefreshExpired = 20037 // refresh_token expired
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
@@ -58,59 +68,159 @@ const (
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
// errType provides fine-grained classification in the JSON envelope;
|
||||
// exitCode is kept coarse (ExitAuth or ExitAPI).
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
switch code {
|
||||
// auth: token missing / invalid / expired
|
||||
case LarkErrTokenMissing, LarkErrTokenBadFmt:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
|
||||
// composition is not yet centralized in errclass (the canonical
|
||||
// PermissionHint lives there but the long-form per-code hints below are
|
||||
// still wire-stable strings), so this small lookup remains here. Codes
|
||||
// absent from this map fall back to "".
|
||||
var legacyHints = map[int]string{
|
||||
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
|
||||
|
||||
// permission: scope not granted
|
||||
case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission,
|
||||
LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized:
|
||||
return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login"
|
||||
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
|
||||
|
||||
// app credential / status
|
||||
case LarkErrAppCredInvalid:
|
||||
return ExitAuth, "config", "check app_id / app_secret: lark-cli config set"
|
||||
case LarkErrAppNotInUse, LarkErrAppUnauthorized:
|
||||
return ExitAuth, "app_status", "app is disabled or not installed — check developer console"
|
||||
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
|
||||
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
|
||||
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
|
||||
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrWikiLockContention:
|
||||
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
|
||||
// drive permission-apply specific guidance
|
||||
case LarkErrDrivePermApplyRateLimit:
|
||||
return ExitAPI, "rate_limit",
|
||||
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
|
||||
case LarkErrDrivePermApplyNotApplicable:
|
||||
return ExitAPI, "invalid_params",
|
||||
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
|
||||
|
||||
case LarkErrOwnershipMismatch:
|
||||
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
LarkErrRateLimit: "please try again later",
|
||||
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
|
||||
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
|
||||
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
|
||||
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
|
||||
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
|
||||
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
|
||||
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
|
||||
}
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to the legacy
|
||||
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
|
||||
//
|
||||
// Classification (Category / Subtype) is sourced from
|
||||
// errclass.LookupCodeMeta — the single source of truth shipped for both
|
||||
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
|
||||
// not yet invoked in production). This function adapts that result back to
|
||||
// the legacy tuple shape for callers that still go through *ExitError:
|
||||
//
|
||||
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
|
||||
// Note this differs from the typed pipeline's ExitCodeForCategory in
|
||||
// two preserved-legacy-quirks: Authorization+permission subtypes return
|
||||
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
|
||||
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
|
||||
// - errType: legacy short string per (Category, Subtype), mapped by
|
||||
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
|
||||
// to "api_error".
|
||||
// - hint: per-code lookup in legacyHints; "" when absent.
|
||||
//
|
||||
// Unknown codes (LookupCodeMeta returns false) classify as
|
||||
// (ExitAPI, "api_error", "") — matching the prior default.
|
||||
//
|
||||
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
|
||||
// surface that predates the typed error contract introduced by errs/. New
|
||||
// code MUST NOT use it — classify Lark API responses via
|
||||
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
|
||||
// Category, Subtype, and identity-aware extension fields populated at the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok {
|
||||
return ExitAPI, "api_error", ""
|
||||
}
|
||||
exitCode := legacyExitCode(meta.Category, meta.Subtype)
|
||||
errType := legacyErrType(meta.Category, meta.Subtype)
|
||||
hint := legacyHints[code]
|
||||
// IM ownership mismatch keeps its dynamic recovery hint.
|
||||
if code == LarkErrOwnershipMismatch {
|
||||
hint = buildOwnershipRecoveryHint()
|
||||
}
|
||||
return exitCode, errType, hint
|
||||
}
|
||||
|
||||
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
|
||||
// code. It diverges from ExitCodeForCategory in two places to preserve the
|
||||
// historic wire:
|
||||
//
|
||||
// - CategoryAuthorization with a "permission" subtype (missing_scope,
|
||||
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
|
||||
// ExitAuth (3). Legacy considered permission failures a generic API
|
||||
// refusal.
|
||||
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
|
||||
// under the auth bucket.
|
||||
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return ExitAuth
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return ExitAPI
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
case errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
}
|
||||
|
||||
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
|
||||
// string (e.g. "permission", "rate_limit"). Subtypes outside the
|
||||
// historically-classified set fall back to "api_error", matching the prior
|
||||
// default-case behavior.
|
||||
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return "auth"
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return "permission"
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return "app_status"
|
||||
}
|
||||
return "permission"
|
||||
case errs.CategoryConfig:
|
||||
switch sub {
|
||||
case errs.SubtypeInvalidClient,
|
||||
errs.SubtypeNotConfigured,
|
||||
errs.SubtypeInvalidConfig:
|
||||
return "config"
|
||||
}
|
||||
return "config"
|
||||
case errs.CategoryAPI:
|
||||
switch sub {
|
||||
case errs.SubtypeRateLimit:
|
||||
return "rate_limit"
|
||||
case errs.SubtypeConflict:
|
||||
return "conflict"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "cross_tenant"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "cross_brand"
|
||||
case errs.SubtypeInvalidParameters:
|
||||
return "invalid_parameters"
|
||||
case errs.SubtypeOwnershipMismatch:
|
||||
return "ownership_mismatch"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantType: "cross_tenant",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
{
|
||||
@@ -58,7 +58,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "drive permission apply not applicable",
|
||||
code: LarkErrDrivePermApplyNotApplicable,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "does not accept a permission-apply request",
|
||||
},
|
||||
{
|
||||
|
||||
109
lint/README.md
Normal file
109
lint/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# lint/
|
||||
|
||||
Source-level static checks that guard lark-cli conventions golangci-lint
|
||||
cannot express. Each lint domain is a sibling Go package under `lint/`;
|
||||
the top-level `lint/main.go` aggregates results and emits a single
|
||||
exit code.
|
||||
|
||||
`lint/` is its own Go module so its `golang.org/x/tools/go/packages`
|
||||
dependency does not leak into the shipped `lark-cli` binary's module
|
||||
graph.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
lint/
|
||||
├── go.mod # module github.com/larksuite/cli/lint
|
||||
├── go.sum
|
||||
├── main.go # package main — dispatches to every registered domain
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
└── errscontract/ # first domain: typed-error contract guards
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
├── runner.go
|
||||
├── typecheck.go
|
||||
├── violation.go # local type aliases to lintapi
|
||||
├── rule_problem_embed.go
|
||||
├── rule_no_registrar.go
|
||||
├── rule_adhoc_subtype.go
|
||||
├── rule_declared_subtype.go
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# from the repo root (one level above lint/)
|
||||
go run -C lint . ..
|
||||
```
|
||||
|
||||
`-C lint` switches Go's working directory to `lint/`; the `..` argument
|
||||
is the repo root to scan (relative to `lint/`).
|
||||
|
||||
CI: `.github/workflows/ci.yml` step `Run errs/ lint guards (lintcheck)`.
|
||||
|
||||
Exit codes follow `lint/main.go`:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | no REJECT diagnostics (LABEL / WARNING are advisory) |
|
||||
| 1 | one or more REJECT diagnostics |
|
||||
| 2 | a domain's `ScanRepo` returned an error |
|
||||
|
||||
## Adding a new lint domain
|
||||
|
||||
1. Create a sibling package: `lint/<domain>/`. Pick a name that reads
|
||||
like a category, not a list of rules (`errscontract/` covers many
|
||||
error-contract rules; `flagnaming/` would cover many flag-related
|
||||
rules).
|
||||
|
||||
2. Inside the new package, expose one public entry:
|
||||
|
||||
```go
|
||||
package <domain>
|
||||
|
||||
import "github.com/larksuite/cli/lint/lintapi"
|
||||
|
||||
// ScanRepo walks root and returns every violation produced by this
|
||||
// domain's checks. Domains MUST return []lintapi.Violation so the
|
||||
// top-level dispatcher can aggregate uniformly.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
|
||||
```
|
||||
|
||||
3. Per-rule files are named `rule_<name>.go` with sibling
|
||||
`rule_<name>_test.go`. Each rule function returns
|
||||
`[]lintapi.Violation`. `runner.go` (or `scan.go`) composes the rules.
|
||||
|
||||
4. Register the domain in `lint/main.go`:
|
||||
|
||||
```go
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
||||
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
|
||||
}
|
||||
```
|
||||
|
||||
5. Verify locally:
|
||||
|
||||
```bash
|
||||
go test -C lint ./... # all domains' tests
|
||||
go run -C lint . .. # full scan against the repo
|
||||
```
|
||||
|
||||
6. Document the rules. If they enforce a contract that already has a
|
||||
spec (e.g. `errs/ERROR_CONTRACT.md`), add the lint entry to that
|
||||
contract's "CI guards" table. Otherwise create a short spec
|
||||
alongside the package.
|
||||
|
||||
## Rule severity conventions (`lintapi.Action`)
|
||||
|
||||
| Action | Effect | When to use |
|
||||
|--------|--------|-------------|
|
||||
| `ActionReject` | exit 1, fails CI | a contract violation that must be fixed before merge |
|
||||
| `ActionLabel` | stderr only; CI can grep for `[needs-taxonomy-decision]` and label the PR | governance signal that asks a human to choose (e.g. `ad_hoc_*` subtype needs a taxonomy decision) |
|
||||
| `ActionWarning`| stderr only | advisory hint surfaced to reviewers (typed scope unavailable, fallback to AST-only, etc.) — never gates merges |
|
||||
|
||||
Only `ActionReject` contributes to a nonzero exit code; `ActionLabel`
|
||||
and `ActionWarning` are reviewer signal only.
|
||||
20
lint/errscontract/rule_adhoc_subtype.go
Normal file
20
lint/errscontract/rule_adhoc_subtype.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
// CheckAdHocSubtype detects `Subtype: "ad_hoc_*"` literals (and the
|
||||
// errs.Subtype("ad_hoc_*") cast form) and emits a LABEL diagnostic so a CI
|
||||
// workflow can tag the PR with `needs-taxonomy-decision`. This is a
|
||||
// governance signal, NOT a hard rejection — ad_hoc_* is the reserved
|
||||
// temporary namespace and is allowed for ≤1 week while taxonomy is finalized.
|
||||
func CheckAdHocSubtype(path, src string) []Violation {
|
||||
v, _ := scanSubtype(path, src, nil, nil, nil, "")
|
||||
out := v[:0]
|
||||
for _, vv := range v {
|
||||
if vv.Action == ActionLabel {
|
||||
out = append(out, vv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
189
lint/errscontract/rule_declared_subtype.go
Normal file
189
lint/errscontract/rule_declared_subtype.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// CheckDeclaredSubtype enforces that `Subtype:` literals resolve to a
|
||||
// declared constant value (allowlist), match the ad_hoc_* namespace (deferred
|
||||
// to CheckAdHocSubtype), or are dynamic (WARNING). Undeclared static literals are
|
||||
// rejected.
|
||||
//
|
||||
// allowlist holds declared Subtype const values (e.g. "missing_scope"). The
|
||||
// production CLI derives this from errs/subtypes*.go via the AST; unit tests
|
||||
// pass in a fixture map. Passing nil disables CheckDeclaredSubtype entirely.
|
||||
//
|
||||
// Use CheckDeclaredSubtypeWithNames to additionally reject typo'd selector
|
||||
// references like `errs.SubtypeBogus` that pass the "Subtype*" prefix gate but
|
||||
// reference no declared constant.
|
||||
func CheckDeclaredSubtype(path, src string, allowlist map[string]struct{}) []Violation {
|
||||
return CheckDeclaredSubtypeWithNames(path, src, allowlist, nil)
|
||||
}
|
||||
|
||||
// CheckDeclaredSubtypeWithNames is the strengthened entry point. When
|
||||
// nameset is non-nil, selector references with the form `<pkg>.SubtypeFoo`
|
||||
// must resolve to a declared name in the set; otherwise they emit REJECT.
|
||||
// Passing nil for nameset preserves the legacy prefix-only behaviour.
|
||||
func CheckDeclaredSubtypeWithNames(path, src string, allowlist, nameset map[string]struct{}) []Violation {
|
||||
if allowlist == nil {
|
||||
return nil
|
||||
}
|
||||
v, _ := scanSubtype(path, src, allowlist, nameset, nil, "")
|
||||
out := v[:0]
|
||||
for _, vv := range v {
|
||||
if vv.Action == ActionReject || vv.Action == ActionWarning {
|
||||
out = append(out, vv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkDeclaredSubtypeWithTypedScope is the production walker invoked by ScanRepo. When
|
||||
// scope is enabled, every Subtype-shaped selector is resolved via type
|
||||
// information first: a confirmed errs.Subtype constant skips the AST
|
||||
// nameset check, and a foreign-package Subtype constant is rejected even
|
||||
// when its name matches the nameset. Scope can be nil — in which case
|
||||
// behaviour collapses to CheckDeclaredSubtypeWithNames.
|
||||
//
|
||||
// absPath is the absolute path used during go/packages loading so the
|
||||
// typed scope can locate per-file *types.Info; rel is the human-readable
|
||||
// path embedded in violation reports.
|
||||
func checkDeclaredSubtypeWithTypedScope(rel, absPath, src string, allowlist, nameset map[string]struct{}, scope *TypedScope) []Violation {
|
||||
if allowlist == nil {
|
||||
return nil
|
||||
}
|
||||
v, _ := scanSubtype(rel, src, allowlist, nameset, scope, absPath)
|
||||
out := v[:0]
|
||||
for _, vv := range v {
|
||||
if vv.Action == ActionReject || vv.Action == ActionWarning {
|
||||
out = append(out, vv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scanSubtype walks the file AST and classifies every `Subtype:` key-value
|
||||
// assignment in a composite literal. It returns the full classified list; the
|
||||
// two callers (CheckAdHocSubtype / CheckDeclaredSubtype) filter by Action.
|
||||
//
|
||||
// nameset, when non-nil, lets the classifier reject selector references that
|
||||
// pass the "Subtype*" prefix gate but resolve to no declared constant.
|
||||
//
|
||||
// scope+absPath, when set, enable typed resolution: every Subtype-shaped
|
||||
// identifier is first resolved through go/types to verify it references a
|
||||
// constant declared in the canonical errs package. A foreign-package
|
||||
// Subtype-named constant is rejected even when nameset permits it (because
|
||||
// selector-name matching alone cannot distinguish packages).
|
||||
func scanSubtype(path, src string, allowlist, nameset map[string]struct{}, scope *TypedScope, absPath string) ([]Violation, error) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adHoc := regexp.MustCompile(`^ad_hoc_[a-z0-9_]+$`)
|
||||
var out []Violation
|
||||
emit := func(pos token.Pos, c subtypeClassification) {
|
||||
if c.action == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, Violation{
|
||||
Rule: c.rule,
|
||||
Action: c.action,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: c.message,
|
||||
Suggestion: c.suggestion,
|
||||
})
|
||||
}
|
||||
// Track CompositeLit nodes whose Type elides to CodeMeta (map/slice
|
||||
// elements where the outer Type already names CodeMeta). We populate this
|
||||
// set on the outer pass so the inner pass can recognise positional
|
||||
// `{cat, subtype, retryable}` entries that don't carry their own Type
|
||||
// expression.
|
||||
codeMetaElided := map[*ast.CompositeLit]bool{}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
outer, ok := n.(*ast.CompositeLit)
|
||||
if !ok || !typeYieldsCodeMeta(outer.Type) {
|
||||
return true
|
||||
}
|
||||
for _, el := range outer.Elts {
|
||||
// `key: {cat, subtype, retryable}` — map literal
|
||||
if kv, ok := el.(*ast.KeyValueExpr); ok {
|
||||
if inner, ok := kv.Value.(*ast.CompositeLit); ok && inner.Type == nil {
|
||||
codeMetaElided[inner] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
// `{cat, subtype, retryable}` — slice/array element
|
||||
if inner, ok := el.(*ast.CompositeLit); ok && inner.Type == nil {
|
||||
codeMetaElided[inner] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
cl, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
// Keyed form: `Subtype: <expr>` — covered for every struct literal.
|
||||
for _, el := range cl.Elts {
|
||||
kv, ok := el.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyIdent, ok := kv.Key.(*ast.Ident)
|
||||
if !ok || keyIdent.Name != "Subtype" {
|
||||
continue
|
||||
}
|
||||
emit(kv.Pos(), classifySubtypeExpr(kv.Value, allowlist, nameset, adHoc, scope, absPath))
|
||||
}
|
||||
// Positional form: `{cat, subtype, retryable}` used by
|
||||
// internal/errclass/codemeta*.go for CodeMeta entries. Subtype is
|
||||
// element [1] by positional convention. We inspect when the
|
||||
// composite literal's Type expression directly names CodeMeta OR
|
||||
// when the Type was elided because the enclosing map/slice already
|
||||
// declared CodeMeta as its value type.
|
||||
if (isCodeMetaType(cl.Type) || codeMetaElided[cl]) && len(cl.Elts) >= 2 {
|
||||
// Don't double-emit if element [1] is itself a KeyValueExpr (handled above).
|
||||
if _, isKV := cl.Elts[1].(*ast.KeyValueExpr); !isKV {
|
||||
emit(cl.Elts[1].Pos(), classifySubtypeExpr(cl.Elts[1], allowlist, nameset, adHoc, scope, absPath))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isCodeMetaType reports whether a composite-literal Type expression directly
|
||||
// names the CodeMeta struct (bare or qualified).
|
||||
func isCodeMetaType(expr ast.Expr) bool {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name == "CodeMeta"
|
||||
case *ast.SelectorExpr:
|
||||
return t.Sel != nil && t.Sel.Name == "CodeMeta"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// typeYieldsCodeMeta reports whether a Type expression for a map/slice/array
|
||||
// composite literal has CodeMeta as its element/value type. Used so we can
|
||||
// recognise that the elided `{cat, subtype, retryable}` entries inside such a
|
||||
// literal are positional CodeMeta values whose Subtype lives at element [1].
|
||||
func typeYieldsCodeMeta(expr ast.Expr) bool {
|
||||
switch t := expr.(type) {
|
||||
case *ast.MapType:
|
||||
return isCodeMetaType(t.Value)
|
||||
case *ast.ArrayType:
|
||||
return isCodeMetaType(t.Elt)
|
||||
}
|
||||
return false
|
||||
}
|
||||
100
lint/errscontract/rule_no_registrar.go
Normal file
100
lint/errscontract/rule_no_registrar.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckNoRegistrar forbids the registrar anti-pattern in service / internal
|
||||
// packages (excluding internal/errclass, which legitimately owns codeMeta).
|
||||
//
|
||||
// Detects two registrar anti-patterns:
|
||||
// 1. Direct call to mergeCodeMeta from outside internal/output
|
||||
// (mergeCodeMeta is the central registry's panic-on-dup ingress)
|
||||
// 2. Calls to functions matching the (*)RegisterServiceMap(*) pattern,
|
||||
// a registrar antipattern that broke production/test parity
|
||||
// (the registered service map wouldn't fire in test binaries that
|
||||
// didn't transitively import the registering service).
|
||||
func CheckNoRegistrar(path, src string) []Violation {
|
||||
if !isServiceScope(path) {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
callee := calleeName(call.Fun)
|
||||
if callee == "" {
|
||||
return true
|
||||
}
|
||||
// The registrar antipattern can hide behind middle affixes too
|
||||
// (e.g. FooRegisterServiceMapBar). strings.Contains catches all
|
||||
// shapes that the prefix/suffix pair missed.
|
||||
if callee == "mergeCodeMeta" || strings.Contains(callee, "RegisterServiceMap") {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_registrar",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "registrar pattern forbidden: " + callee + " must not be called from service / internal code",
|
||||
Suggestion: "add CodeMeta entries in internal/errclass/codemeta_<service>.go (same-package init()); " +
|
||||
"registries fail silently when the service is not transitively imported by the test binary",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// calleeName returns the function name for a call expression, supporting
|
||||
// bare Ident calls (e.g. "mergeCodeMeta(...)") and SelectorExpr forms
|
||||
// (e.g. "output.RegisterServiceMap(...)").
|
||||
func calleeName(expr ast.Expr) string {
|
||||
switch f := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return f.Name
|
||||
case *ast.SelectorExpr:
|
||||
if f.Sel != nil {
|
||||
return f.Sel.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isServiceScope reports whether a path is subject to CheckNoRegistrar. Matches paths
|
||||
// under shortcuts/ or internal/ but excludes internal/errclass (which
|
||||
// legitimately owns codeMeta) and test files.
|
||||
func isServiceScope(path string) bool {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return false
|
||||
}
|
||||
// Normalize separators for cross-platform paths.
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
switch {
|
||||
case strings.HasPrefix(p, "shortcuts/") || strings.Contains(p, "/shortcuts/"):
|
||||
return true
|
||||
case strings.HasPrefix(p, "internal/errclass/") || strings.Contains(p, "/internal/errclass/"):
|
||||
return false
|
||||
case strings.HasPrefix(p, "internal/output/") || strings.Contains(p, "/internal/output/"):
|
||||
// CheckNoRegistrar carves out internal/output: it is the typed-envelope writer
|
||||
// and legacy ExitError producer, not a service. Without this guard
|
||||
// any legitimate registrar-shaped symbol there would trigger a
|
||||
// false-positive REJECT.
|
||||
return false
|
||||
case strings.HasPrefix(p, "internal/") || strings.Contains(p, "/internal/"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
76
lint/errscontract/rule_problem_embed.go
Normal file
76
lint/errscontract/rule_problem_embed.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckProblemEmbed enforces the errs/ typed-error contract on a single
|
||||
// source file: every exported struct whose name ends in "Error" must embed the
|
||||
// package-local Problem (or errs.Problem when imported).
|
||||
//
|
||||
// Predicate + test-coverage parity are checked at the directory level by
|
||||
// CheckErrsContract; this AST-only entry is the unit-testable core.
|
||||
func CheckProblemEmbed(path, src string) []Violation {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
typeSpec, ok := n.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := typeSpec.Name.Name
|
||||
// Only enforce CheckProblemEmbed on EXPORTED *Error types — unexported helper
|
||||
// structs that happen to end in "Error" are internal scratch types,
|
||||
// not part of the typed taxonomy.
|
||||
if !ast.IsExported(name) || !strings.HasSuffix(name, "Error") {
|
||||
return true
|
||||
}
|
||||
if !embedsProblem(structType) {
|
||||
out = append(out, Violation{
|
||||
Rule: "problem_embed",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(typeSpec.Pos()).Line,
|
||||
Message: "typed error " + name + " must embed errs.Problem (RFC 7807-aligned canonical shape)",
|
||||
Suggestion: "add `errs.Problem` (or `Problem` if in errs package) as the first embedded field",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// embedsProblem reports whether the struct embeds the canonical Problem type
|
||||
// (bare `Problem` when defined in errs, or `errs.Problem` when imported).
|
||||
func embedsProblem(s *ast.StructType) bool {
|
||||
for _, f := range s.Fields.List {
|
||||
if len(f.Names) != 0 {
|
||||
continue // not embedded
|
||||
}
|
||||
switch t := f.Type.(type) {
|
||||
case *ast.Ident:
|
||||
if t.Name == "Problem" {
|
||||
return true
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
if x, ok := t.X.(*ast.Ident); ok && x.Name == "errs" && t.Sel != nil && t.Sel.Name == "Problem" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
233
lint/errscontract/rule_subtype_classifier.go
Normal file
233
lint/errscontract/rule_subtype_classifier.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// classifySubtypeExpr inspects a single expression sitting in a `Subtype:`
|
||||
// slot and returns the lint verdict. Used by scanSubtype to drive both
|
||||
// CheckAdHocSubtype (ad_hoc_*) and CheckDeclaredSubtype (declared / undeclared / dynamic) signals.
|
||||
func classifySubtypeExpr(expr ast.Expr, allowlist, nameset map[string]struct{}, adHoc *regexp.Regexp, scope *TypedScope, absPath string) subtypeClassification {
|
||||
return subtypeExprClassifier{
|
||||
allowlist: allowlist,
|
||||
nameset: nameset,
|
||||
adHoc: adHoc,
|
||||
scope: scope,
|
||||
absPath: absPath,
|
||||
}.classify(expr)
|
||||
}
|
||||
|
||||
// subtypeExprClassifier is the strategy object for classifying a single
|
||||
// expression assigned to a Subtype slot. The public-ish wrapper above keeps the
|
||||
// scanSubtype callsite simple, while these methods keep each AST expression
|
||||
// shape isolated enough to change independently.
|
||||
type subtypeExprClassifier struct {
|
||||
allowlist map[string]struct{}
|
||||
nameset map[string]struct{}
|
||||
adHoc *regexp.Regexp
|
||||
scope *TypedScope
|
||||
absPath string
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) classify(expr ast.Expr) subtypeClassification {
|
||||
switch v := expr.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
return c.classifySelector(v)
|
||||
case *ast.Ident:
|
||||
return c.classifyIdent(v)
|
||||
case *ast.BasicLit:
|
||||
return c.classifyLiteral(v)
|
||||
case *ast.CallExpr:
|
||||
return c.classifyCall(v)
|
||||
}
|
||||
return subtypeClassification{}
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) classifySelector(sel *ast.SelectorExpr) subtypeClassification {
|
||||
if sel == nil || sel.Sel == nil {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
// Typed-first: route every selector through type resolution, regardless
|
||||
// of naming. This catches `foreign.MyKind` assigned to a Subtype slot,
|
||||
// which the AST fallback intentionally cannot prove.
|
||||
if result, handled := classifyConstViaTypes(sel.Sel, c.absPath, c.scope); handled {
|
||||
return result
|
||||
}
|
||||
// AST fallback: only Subtype-prefixed selector names are treated as
|
||||
// constant references. Bare `Subtype` is usually a struct-field selector
|
||||
// such as `meta.Subtype`, not a constant.
|
||||
if !isSubtypeConstName(sel.Sel.Name) {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
if !c.declaredName(sel.Sel.Name) {
|
||||
return undeclaredSubtypeConst("selector", sel.Sel.Name)
|
||||
}
|
||||
return subtypeClassification{}
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) classifyIdent(id *ast.Ident) subtypeClassification {
|
||||
if id == nil {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
// Typed-first: every identifier in a Subtype slot is type-resolved when
|
||||
// scope is available, regardless of its name.
|
||||
if result, handled := classifyConstViaTypes(id, c.absPath, c.scope); handled {
|
||||
return result
|
||||
}
|
||||
// AST fallback: in-package const form `SubtypeMissingScope`. The bare
|
||||
// `Subtype` identifier is the type name, not a constant reference.
|
||||
if isSubtypeConstName(id.Name) {
|
||||
if !c.declaredName(id.Name) {
|
||||
return undeclaredSubtypeConst("identifier", id.Name)
|
||||
}
|
||||
return subtypeClassification{}
|
||||
}
|
||||
// Local identifier — unresolved value, surface as WARNING for review.
|
||||
return dynamicSubtypeIdentifier(id.Name)
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) classifyLiteral(lit *ast.BasicLit) subtypeClassification {
|
||||
if lit == nil || lit.Kind != token.STRING {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
return classifyStringValue(unquoteSimple(lit.Value), c.allowlist, c.adHoc)
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) classifyCall(call *ast.CallExpr) subtypeClassification {
|
||||
if call == nil || !isSubtypeCast(call.Fun) || len(call.Args) != 1 {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return dynamicSubtypeCast()
|
||||
}
|
||||
return c.classifyLiteral(lit)
|
||||
}
|
||||
|
||||
func (c subtypeExprClassifier) declaredName(name string) bool {
|
||||
if c.nameset == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := c.nameset[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isSubtypeConstName(name string) bool {
|
||||
return strings.HasPrefix(name, "Subtype") && name != "Subtype"
|
||||
}
|
||||
|
||||
func undeclaredSubtypeConst(kind, name string) subtypeClassification {
|
||||
return subtypeClassification{
|
||||
rule: "declared_subtype",
|
||||
action: ActionReject,
|
||||
message: "Subtype " + kind + " " + name + " is not declared in any errs/subtypes*.go file",
|
||||
suggestion: "use a declared const from errs/subtypes*.go (or add one) — typo'd " + kind + " names are silently treated as the zero Subtype",
|
||||
}
|
||||
}
|
||||
|
||||
func dynamicSubtypeIdentifier(name string) subtypeClassification {
|
||||
return subtypeClassification{
|
||||
rule: "declared_subtype",
|
||||
action: ActionWarning,
|
||||
message: "Subtype assigned from identifier " + name + " — value resolution requires manual review",
|
||||
suggestion: "prefer named constants from errs/subtypes.go (e.g. errs.SubtypeMissingScope); if dynamic, justify in PR description",
|
||||
}
|
||||
}
|
||||
|
||||
func dynamicSubtypeCast() subtypeClassification {
|
||||
return subtypeClassification{
|
||||
rule: "declared_subtype",
|
||||
action: ActionWarning,
|
||||
message: "errs.Subtype(...) cast from non-literal expression — value resolution requires manual review",
|
||||
suggestion: "prefer named constants from errs/subtypes.go",
|
||||
}
|
||||
}
|
||||
|
||||
// classifyStringValue is the inner classifier for unquoted Subtype string
|
||||
// literals: ad_hoc_* → CheckAdHocSubtype LABEL, declared → silent accept, anything
|
||||
// else → CheckDeclaredSubtype REJECT.
|
||||
func classifyStringValue(value string, allowlist map[string]struct{}, adHoc *regexp.Regexp) subtypeClassification {
|
||||
if adHoc.MatchString(value) {
|
||||
return subtypeClassification{
|
||||
rule: "adhoc_subtype",
|
||||
action: ActionLabel,
|
||||
message: `Subtype "` + value + `" matches ad_hoc_* temporary namespace — add label "needs-taxonomy-decision" [needs-taxonomy-decision]`,
|
||||
suggestion: "promote ad_hoc_* to a declared Subtype constant within 1 week",
|
||||
}
|
||||
}
|
||||
if allowlist == nil {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
if _, ok := allowlist[value]; ok {
|
||||
return subtypeClassification{}
|
||||
}
|
||||
return subtypeClassification{
|
||||
rule: "declared_subtype",
|
||||
action: ActionReject,
|
||||
message: `Subtype "` + value + `" is not declared in errs/subtypes.go and does not match ad_hoc_* namespace`,
|
||||
suggestion: "use a declared const from errs/subtypes.go (e.g. errs.SubtypeMissingScope), " +
|
||||
"or use ad_hoc_<name> temporarily and file a taxonomy issue",
|
||||
}
|
||||
}
|
||||
|
||||
// classifyConstViaTypes is the typed-resolution gate used by CheckDeclaredSubtype for
|
||||
// every selector or identifier appearing in a `Subtype:` slot. Unlike the
|
||||
// AST path it does NOT pre-filter by name prefix — a foreign constant
|
||||
// named `MyKind` (or any other shape) assigned to `Subtype:` is still sent
|
||||
// through resolution. Return values:
|
||||
//
|
||||
// - handled=true, classification.action == "" : resolved to a
|
||||
// declared errs.Subtype constant; accept without further AST checks.
|
||||
// - handled=true, classification.action == ActionReject : resolved to a
|
||||
// non-errs / non-Subtype constant; reject end-to-end.
|
||||
// - handled=false : nothing to say
|
||||
// (scope disabled, file not in typed load, identifier resolves to a
|
||||
// non-const such as a struct field or type); caller falls back to AST.
|
||||
func classifyConstViaTypes(ident *ast.Ident, absPath string, scope *TypedScope) (subtypeClassification, bool) {
|
||||
if ident == nil || !scope.Enabled() {
|
||||
return subtypeClassification{}, false
|
||||
}
|
||||
resolved, ok := scope.ResolveSubtypeIdent(absPath, ident)
|
||||
if !ok {
|
||||
return subtypeClassification{}, false
|
||||
}
|
||||
if resolved {
|
||||
return subtypeClassification{}, true
|
||||
}
|
||||
// Resolved via type info, but the object is not a canonical errs.Subtype
|
||||
// constant — either it lives in a foreign package or it is an errs
|
||||
// constant that is not in the Subtype set.
|
||||
return subtypeClassification{
|
||||
rule: "declared_subtype",
|
||||
action: ActionReject,
|
||||
message: "Subtype value " + ident.Name + " resolves to a constant outside the canonical errs.Subtype declarations",
|
||||
suggestion: "use a declared const from errs/subtypes*.go — typed Subtype values must originate from " + errsPkgPath,
|
||||
}, true
|
||||
}
|
||||
|
||||
// isSubtypeCast reports whether a call-expression callee is the
|
||||
// `errs.Subtype` (or local `Subtype`) type-cast form.
|
||||
func isSubtypeCast(fun ast.Expr) bool {
|
||||
switch f := fun.(type) {
|
||||
case *ast.Ident:
|
||||
return f.Name == "Subtype"
|
||||
case *ast.SelectorExpr:
|
||||
return f.Sel != nil && f.Sel.Name == "Subtype"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unquoteSimple strips one layer of surrounding double or back quotes.
|
||||
// Sufficient for Go string literals as they appear in the AST.
|
||||
func unquoteSimple(quoted string) string {
|
||||
if len(quoted) >= 2 && (quoted[0] == '"' || quoted[0] == '`') {
|
||||
return quoted[1 : len(quoted)-1]
|
||||
}
|
||||
return quoted
|
||||
}
|
||||
172
lint/errscontract/rule_typed_error_completeness.go
Normal file
172
lint/errscontract/rule_typed_error_completeness.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckTypedErrorCompleteness rejects typed `*errs.<X>Error` composite
|
||||
// literals whose embedded Problem is missing any of the three required
|
||||
// fields: Category, Subtype, Message. Without this check, new code can
|
||||
// silently introduce typed errors that emit empty `type` / `subtype` on
|
||||
// the wire and confuse downstream consumers.
|
||||
//
|
||||
// Fires only when:
|
||||
// - the type is a qualified `errs.<X>Error` selector, OR
|
||||
// - the file lives inside the canonical errs package and the type is an
|
||||
// unqualified `<X>Error` ident.
|
||||
//
|
||||
// This intentionally excludes legacy *Error types in other packages
|
||||
// (core.ConfigError, internal/auth.NeedAuthorizationError, etc.) which
|
||||
// are not part of the typed taxonomy.
|
||||
//
|
||||
// When the inner `Problem:` value is a variable reference (e.g.
|
||||
// `Problem: base`) instead of a composite literal, the check trusts that
|
||||
// the variable was populated elsewhere and skips field-by-field
|
||||
// verification — only literal Problem composites are inspected.
|
||||
//
|
||||
// Returns REJECT violations.
|
||||
func CheckTypedErrorCompleteness(path, src string) []Violation {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
inErrsPackage := isErrsPackagePath(path)
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
errorName, isErrsType := typedErrorTypeName(lit.Type, inErrsPackage)
|
||||
if !isErrsType {
|
||||
return true
|
||||
}
|
||||
problemLit, kind := findProblemLiteral(lit)
|
||||
switch kind {
|
||||
case problemMissing:
|
||||
out = append(out, completenessReject(path, fset.Position(lit.Pos()).Line, errorName, "Problem"))
|
||||
case problemLiteral:
|
||||
for _, required := range []string{"Category", "Subtype", "Message"} {
|
||||
if !hasKeyedEntry(problemLit, required) {
|
||||
out = append(out, completenessReject(path, fset.Position(problemLit.Pos()).Line, errorName, required))
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// typedErrorTypeName reports whether a composite-literal Type names a
|
||||
// typed *errs.XxxError struct, and returns the bare type name for the
|
||||
// diagnostic. Qualified `errs.XxxError` is always recognised; unqualified
|
||||
// `XxxError` only when the file itself is in the errs package.
|
||||
func typedErrorTypeName(expr ast.Expr, inErrsPackage bool) (string, bool) {
|
||||
switch t := expr.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
x, ok := t.X.(*ast.Ident)
|
||||
if !ok || x.Name != "errs" || t.Sel == nil {
|
||||
return "", false
|
||||
}
|
||||
return t.Sel.Name, strings.HasSuffix(t.Sel.Name, "Error") && t.Sel.Name != "Error"
|
||||
case *ast.Ident:
|
||||
if !inErrsPackage {
|
||||
return "", false
|
||||
}
|
||||
return t.Name, strings.HasSuffix(t.Name, "Error") && t.Name != "Error"
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isErrsPackagePath reports whether the given file path is inside the
|
||||
// canonical errs/ package (top-level errs/ files, not sub-packages like
|
||||
// errs/projection/).
|
||||
func isErrsPackagePath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(p, "errs/") && !strings.Contains(p, "/errs/") {
|
||||
return false
|
||||
}
|
||||
// Exclude errs/<subpkg>/ — only direct errs/*.go files count.
|
||||
var rest string
|
||||
if i := strings.Index(p, "/errs/"); i >= 0 {
|
||||
rest = p[i+len("/errs/"):]
|
||||
} else {
|
||||
rest = p[len("errs/"):]
|
||||
}
|
||||
return !strings.Contains(rest, "/")
|
||||
}
|
||||
|
||||
// problemKind is the verdict of findProblemLiteral.
|
||||
type problemKind int
|
||||
|
||||
const (
|
||||
problemMissing problemKind = iota // no Problem key in the outer literal — REJECT
|
||||
problemVariable // Problem value is a variable / call expr — trust the caller
|
||||
problemLiteral // Problem value is itself a composite literal — inspect fields
|
||||
)
|
||||
|
||||
// findProblemLiteral returns the inner Problem composite literal and a
|
||||
// problemKind verdict:
|
||||
//
|
||||
// - problemMissing: outer literal has no Problem key at all (REJECT).
|
||||
// - problemVariable: Problem value is a variable / call expr; caller
|
||||
// populated it elsewhere so this check can't see the fields. Skip.
|
||||
// - problemLiteral: Problem value is an in-place composite literal —
|
||||
// inspect its keys for Category / Subtype / Message.
|
||||
func findProblemLiteral(outer *ast.CompositeLit) (*ast.CompositeLit, problemKind) {
|
||||
for _, el := range outer.Elts {
|
||||
kv, ok := el.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := kv.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "Problem" {
|
||||
continue
|
||||
}
|
||||
inner, ok := kv.Value.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return nil, problemVariable
|
||||
}
|
||||
return inner, problemLiteral
|
||||
}
|
||||
return nil, problemMissing
|
||||
}
|
||||
|
||||
// hasKeyedEntry reports whether a composite literal contains a
|
||||
// `<key>:` keyed entry. Used to verify Problem.Category / Subtype /
|
||||
// Message are present.
|
||||
func hasKeyedEntry(lit *ast.CompositeLit, key string) bool {
|
||||
for _, el := range lit.Elts {
|
||||
kv, ok := el.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ident, ok := kv.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ident.Name == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func completenessReject(path string, line int, errorName, missing string) Violation {
|
||||
return Violation{
|
||||
Rule: "typed_error_completeness",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: line,
|
||||
Message: "typed *" + errorName + " literal is missing required Problem." + missing + " field",
|
||||
Suggestion: "every typed *errs.XxxError must set Problem.Category, Problem.Subtype, and Problem.Message — " +
|
||||
"missing fields emit an empty `type` / `subtype` / `message` on the wire and confuse consumers",
|
||||
}
|
||||
}
|
||||
595
lint/errscontract/rules_test.go
Normal file
595
lint/errscontract/rules_test.go
Normal file
@@ -0,0 +1,595 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
32
lint/errscontract/runner.go
Normal file
32
lint/errscontract/runner.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import "strings"
|
||||
|
||||
// RunAll executes all four checks on the given source. allowlist controls CheckDeclaredSubtype;
|
||||
// pass nil to skip it. Use RunAllWithNames to enable strengthened CheckDeclaredSubtype name
|
||||
// resolution.
|
||||
func RunAll(path, src string, allowlist map[string]struct{}) []Violation {
|
||||
return RunAllWithNames(path, src, allowlist, nil)
|
||||
}
|
||||
|
||||
// RunAllWithNames is RunAll with the strengthened CheckDeclaredSubtype. nameset, when
|
||||
// non-nil, lets CheckDeclaredSubtype reject typo'd `errs.SubtypeBogus` selectors that
|
||||
// reference no declared constant.
|
||||
func RunAllWithNames(path, src string, allowlist, nameset map[string]struct{}) []Violation {
|
||||
var out []Violation
|
||||
if strings.HasPrefix(path, "errs/") || strings.Contains(path, "/errs/") {
|
||||
// CheckProblemEmbed fires on errs/ files only (caller may also enforce parity
|
||||
// across directory via CheckErrsContract).
|
||||
out = append(out, CheckProblemEmbed(path, src)...)
|
||||
}
|
||||
out = append(out, CheckNoRegistrar(path, src)...)
|
||||
out = append(out, CheckAdHocSubtype(path, src)...)
|
||||
out = append(out, CheckTypedErrorCompleteness(path, src)...)
|
||||
if allowlist != nil {
|
||||
out = append(out, CheckDeclaredSubtypeWithNames(path, src, allowlist, nameset)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
385
lint/errscontract/scan.go
Normal file
385
lint/errscontract/scan.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanRepo is the production entry point for the lintcheck CLI. It walks
|
||||
// the repo rooted at root and emits violations covering all four checks.
|
||||
//
|
||||
// root should be the repo root (the directory containing go.mod). The CheckDeclaredSubtype
|
||||
// allowlist (values + declared names) is derived from every errs/subtypes*.go
|
||||
// file; if no subtypes file is found, CheckDeclaredSubtype is silently skipped (CheckAdHocSubtype
|
||||
// still runs).
|
||||
//
|
||||
// Returns the violations sorted by File/Line for stable diff against expected
|
||||
// output in tests.
|
||||
func ScanRepo(root string) ([]Violation, error) {
|
||||
allowlist, nameset, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
|
||||
if err != nil {
|
||||
// "Subtype allowlist file missing" → skip CheckDeclaredSubtype; CheckAdHocSubtype still
|
||||
// catches ad_hoc_*. Any other error (permission, malformed source)
|
||||
// must propagate — otherwise a real taxonomy regression silently
|
||||
// disables CheckDeclaredSubtype in CI.
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("load subtype allowlists: %w", err)
|
||||
}
|
||||
allowlist = nil
|
||||
nameset = nil
|
||||
}
|
||||
|
||||
var all []Violation
|
||||
|
||||
// CheckProblemEmbed: errs/ contract parity (types ↔ predicates ↔ tests ↔ docs).
|
||||
if contractViols, err := CheckErrsContract(root); err == nil {
|
||||
all = append(all, contractViols...)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("rule B: %w", err)
|
||||
}
|
||||
|
||||
// CheckDeclaredSubtype typed resolution: load the workspace's type info once so we
|
||||
// can verify Subtype selectors resolve into the canonical errs package.
|
||||
// A loader failure or empty result falls back to the AST-only pass —
|
||||
// the unit-test API path that ScanRepo shares with
|
||||
// CheckDeclaredSubtypeWithNames already enforces nameset matching.
|
||||
// When the fallback is taken on a workspace that LOOKS like a Go repo
|
||||
// (has a go.mod), we emit a single advisory diagnostic so reviewers
|
||||
// know CheckDeclaredSubtype ran in a less-strict mode this run. ActionWarning is
|
||||
// print-only per Action semantics; it does not fail CI.
|
||||
typedScope, typedErr := LoadTypedScope(root)
|
||||
if typedErr != nil {
|
||||
typedScope = nil
|
||||
}
|
||||
if !typedScope.Enabled() && hasGoMod(root) {
|
||||
all = append(all, Violation{
|
||||
Rule: "declared_subtype",
|
||||
Action: ActionWarning,
|
||||
File: "lint",
|
||||
Line: 0,
|
||||
Message: "CheckDeclaredSubtype typed resolution unavailable; falling back to AST name matching. " +
|
||||
"Workspace was loadable as a Go repo, but errs.Subtype constants could not be resolved via go/types. " +
|
||||
"CheckDeclaredSubtype will be less strict on Subtype: selectors this run.",
|
||||
Suggestion: "ensure errs/subtypes*.go compile and contain typed Subtype consts; " +
|
||||
"re-run with `go run -C lint . ..` after verifying.",
|
||||
})
|
||||
}
|
||||
|
||||
// Walk source tree and apply Rules C/D/E to each .go file.
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Skip well-known noise directories.
|
||||
name := d.Name()
|
||||
if name == ".git" || name == "node_modules" || name == "vendor" ||
|
||||
name == "tests_e2e" || name == "skill-template" || name == "skills" ||
|
||||
name == "docs" || name == "specs" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
// CheckNoRegistrar / D / E do not fire in test files: fixtures may legitimately
|
||||
// exercise edge values, and CheckNoRegistrar's scope is production code only.
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
src, err := os.ReadFile(path) //nolint:gosec // CLI tool; root is operator-provided.
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
||||
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
if allowlist != nil && !isErrsScope(rel) {
|
||||
// CheckDeclaredSubtype does not fire inside the errs/ package itself — that
|
||||
// package defines the Subtype type and its constructors take
|
||||
// Subtype as a parameter, which would otherwise emit a stream
|
||||
// of dynamic-identifier WARNINGs.
|
||||
abs, _ := filepath.Abs(path)
|
||||
all = append(all, checkDeclaredSubtypeWithTypedScope(rel, abs, string(src), allowlist, nameset, typedScope)...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, walkErr
|
||||
}
|
||||
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
if all[i].File != all[j].File {
|
||||
return all[i].File < all[j].File
|
||||
}
|
||||
return all[i].Line < all[j].Line
|
||||
})
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// hasGoMod reports whether the given directory contains a go.mod file at
|
||||
// its root. Used to scope the typed-resolution advisory to repos that look
|
||||
// like Go workspaces; unit-test fixtures without go.mod stay silent.
|
||||
func hasGoMod(root string) bool {
|
||||
_, err := os.Stat(filepath.Join(root, "go.mod"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isErrsScope reports whether a path is inside the errs/ package (including
|
||||
// any subpackage). Used to scope-out CheckDeclaredSubtype from the package
|
||||
// that owns the Subtype type itself.
|
||||
func isErrsScope(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
return strings.HasPrefix(p, "errs/") || strings.Contains(p, "/errs/")
|
||||
}
|
||||
|
||||
// LoadSubtypeAllowlist parses errs/subtypes.go and returns the set of declared
|
||||
// Subtype constant VALUES (not names). Used by CheckDeclaredSubtype.
|
||||
//
|
||||
// Deprecated: prefer LoadSubtypeAllowlists, which also captures the constant
|
||||
// names across every errs/subtypes*.go file. Retained for the unit-test entry
|
||||
// point that targets a single fixture file.
|
||||
func LoadSubtypeAllowlist(subtypesGo string) (map[string]struct{}, error) {
|
||||
values, _, err := loadSubtypeAllowlistFile(subtypesGo)
|
||||
return values, err
|
||||
}
|
||||
|
||||
// LoadSubtypeAllowlists scans every errs/subtypes*.go file under the given
|
||||
// directory and returns (declared VALUES, declared NAMES). The name set lets
|
||||
// CheckDeclaredSubtype reject typo'd selectors like `errs.SubtypeBogus` that satisfy the
|
||||
// "Subtype*" prefix but reference no actual constant. Returns the os.Stat
|
||||
// error if the directory does not exist.
|
||||
func LoadSubtypeAllowlists(errsDir string) (values, names map[string]struct{}, err error) {
|
||||
if _, statErr := os.Stat(errsDir); statErr != nil {
|
||||
return nil, nil, statErr
|
||||
}
|
||||
entries, readErr := os.ReadDir(errsDir)
|
||||
if readErr != nil {
|
||||
return nil, nil, readErr
|
||||
}
|
||||
values = make(map[string]struct{})
|
||||
names = make(map[string]struct{})
|
||||
found := 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "subtypes") || !strings.HasSuffix(name, ".go") ||
|
||||
strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(errsDir, name)
|
||||
v, n, perr := loadSubtypeAllowlistFile(full)
|
||||
if perr != nil {
|
||||
return nil, nil, perr
|
||||
}
|
||||
for k := range v {
|
||||
values[k] = struct{}{}
|
||||
}
|
||||
for k := range n {
|
||||
names[k] = struct{}{}
|
||||
}
|
||||
found++
|
||||
}
|
||||
if found == 0 {
|
||||
// Treat absence like a missing file — caller silently skips CheckDeclaredSubtype
|
||||
// via os.IsNotExist on the wrapped sentinel.
|
||||
return nil, nil, fmt.Errorf("%w: no subtypes*.go found under %s", os.ErrNotExist, errsDir)
|
||||
}
|
||||
return values, names, nil
|
||||
}
|
||||
|
||||
func loadSubtypeAllowlistFile(subtypesGo string) (values, names map[string]struct{}, err error) {
|
||||
src, err := os.ReadFile(subtypesGo) //nolint:gosec // operator-provided path.
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, subtypesGo, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse %s: %w", subtypesGo, err)
|
||||
}
|
||||
values = make(map[string]struct{})
|
||||
names = make(map[string]struct{})
|
||||
for _, decl := range file.Decls {
|
||||
gd, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gd.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// We only care about const blocks whose type is Subtype (the type
|
||||
// declared in this same file). Untyped/iota constants are ignored.
|
||||
if !isSubtypeTypeRef(vs.Type) {
|
||||
continue
|
||||
}
|
||||
for _, n := range vs.Names {
|
||||
if n.Name != "_" {
|
||||
names[n.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, v := range vs.Values {
|
||||
lit, ok := v.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
continue
|
||||
}
|
||||
values[unquoteSimple(lit.Value)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return values, names, nil
|
||||
}
|
||||
|
||||
func isSubtypeTypeRef(expr ast.Expr) bool {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name == "Subtype"
|
||||
case *ast.SelectorExpr:
|
||||
return t.Sel != nil && t.Sel.Name == "Subtype"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckErrsContract enforces CheckProblemEmbed at the directory level. It collects all
|
||||
// exported `*Error` types defined in errs/, then verifies:
|
||||
//
|
||||
// 1. each type embeds Problem (delegated to CheckProblemEmbed per file);
|
||||
// 2. each non-whitelisted type has a matching IsXxx predicate in errs/;
|
||||
// 3. each type is mentioned in at least one errs/*_test.go file.
|
||||
//
|
||||
// Missing predicates and missing tests each emit one diagnostic per type.
|
||||
//
|
||||
// Also walks internal/errclass/codemeta*.go for code-meta parity; absence of
|
||||
// the directory is tolerated (older repo layouts).
|
||||
func CheckErrsContract(root string) ([]Violation, error) {
|
||||
errsDir := filepath.Join(root, "errs")
|
||||
if _, err := os.Stat(errsDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
out []Violation
|
||||
typedErrors = make(map[string]token.Position) // name → first decl position
|
||||
predicateOf = make(map[string]struct{}) // type names with matching IsXxx
|
||||
testMentions = make(map[string]struct{})
|
||||
)
|
||||
|
||||
fset := token.NewFileSet()
|
||||
entries, err := os.ReadDir(errsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First pass: parse every .go in errs/ (no recursion — projection/ is
|
||||
// covered separately if/when we extend the rule).
|
||||
var testSources []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(errsDir, e.Name())
|
||||
src, readErr := os.ReadFile(full) //nolint:gosec // operator-provided path.
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
rel = filepath.ToSlash(rel)
|
||||
file, parseErr := parser.ParseFile(fset, full, src, parser.ParseComments)
|
||||
if parseErr != nil {
|
||||
continue // parse errors aren't this lint's concern; vet/compile will catch them.
|
||||
}
|
||||
if strings.HasSuffix(e.Name(), "_test.go") {
|
||||
testSources = append(testSources, string(src))
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-file CheckProblemEmbed AST check (embeds Problem).
|
||||
out = append(out, CheckProblemEmbed(rel, string(src))...)
|
||||
|
||||
// Collect typed error names and predicate names.
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch d := n.(type) {
|
||||
case *ast.TypeSpec:
|
||||
// Only consider EXPORTED *Error structs — unexported helper
|
||||
// types ending in "Error" are not part of the typed
|
||||
// taxonomy and would create false-positive missing-
|
||||
// predicate violations.
|
||||
if _, ok := d.Type.(*ast.StructType); ok && ast.IsExported(d.Name.Name) && strings.HasSuffix(d.Name.Name, "Error") {
|
||||
if _, dup := typedErrors[d.Name.Name]; !dup {
|
||||
typedErrors[d.Name.Name] = fset.Position(d.Pos())
|
||||
}
|
||||
}
|
||||
case *ast.FuncDecl:
|
||||
if d.Recv != nil {
|
||||
return true // method, not predicate
|
||||
}
|
||||
name := d.Name.Name
|
||||
if !strings.HasPrefix(name, "Is") {
|
||||
return true
|
||||
}
|
||||
// Predicate convention: IsValidation → ValidationError.
|
||||
typeName := name[2:] + "Error"
|
||||
predicateOf[typeName] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Test-file mentions of typed error names.
|
||||
for _, src := range testSources {
|
||||
for name := range typedErrors {
|
||||
if strings.Contains(src, name) {
|
||||
testMentions[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the typed errors and emit diagnostics for missing predicate / test.
|
||||
for name, pos := range typedErrors {
|
||||
relFile := pos.Filename
|
||||
if r, relErr := filepath.Rel(root, pos.Filename); relErr == nil {
|
||||
relFile = filepath.ToSlash(r)
|
||||
}
|
||||
// Predicate (e.g. ValidationError needs IsValidation).
|
||||
if _, ok := predicateOf[name]; !ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "problem_embed",
|
||||
Action: ActionReject,
|
||||
File: relFile,
|
||||
Line: pos.Line,
|
||||
Message: "typed error " + name + " has no matching Is" + strings.TrimSuffix(name, "Error") + " predicate in errs/predicates.go",
|
||||
Suggestion: "add `func Is" + strings.TrimSuffix(name, "Error") +
|
||||
"(err error) bool { var x *" + name + "; return errors.As(err, &x) }` to errs/predicates.go",
|
||||
})
|
||||
}
|
||||
// Test mention.
|
||||
if _, ok := testMentions[name]; !ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "problem_embed",
|
||||
Action: ActionReject,
|
||||
File: relFile,
|
||||
Line: pos.Line,
|
||||
Message: "typed error " + name + " has no test exercising it in errs/*_test.go",
|
||||
Suggestion: "add at least one test in errs/ that references " + name + " (smoke construct + predicate assertion is enough)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
385
lint/errscontract/scan_test.go
Normal file
385
lint/errscontract/scan_test.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
190
lint/errscontract/typecheck.go
Normal file
190
lint/errscontract/typecheck.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/types"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
// errsPkgPath is the canonical import path of the typed-errors package.
|
||||
// CheckDeclaredSubtype's typed-resolution pass verifies that a `pkg.SubtypeXxx` selector's
|
||||
// resolved object belongs to this exact package — selector-name matching
|
||||
// alone would have falsely accepted an identically-named constant from a
|
||||
// foreign package.
|
||||
const errsPkgPath = "github.com/larksuite/cli/errs"
|
||||
|
||||
// TypedScope captures the workspace-wide type information used by CheckDeclaredSubtype's
|
||||
// typed-resolution pass. The zero value is a no-op (typed pass disabled);
|
||||
// LoadTypedScope populates it.
|
||||
//
|
||||
// Once populated:
|
||||
// - typedFiles maps an absolute Go file path to the *types.Info of its
|
||||
// package. The walker uses it to resolve selector / ident references on
|
||||
// a per-file basis: Info.Uses[ident] yields the *types.Object pointed
|
||||
// at by that identifier, including the originating package.
|
||||
// - errsSubtypeConsts holds the typed Subtype constants declared in the
|
||||
// errs package. A resolved object is a "real" Subtype only when it
|
||||
// appears in this set.
|
||||
type TypedScope struct {
|
||||
typedFiles map[string]*types.Info
|
||||
errsSubtypeConsts map[string]*types.Const
|
||||
}
|
||||
|
||||
// Enabled reports whether the typed-resolution pass can answer questions
|
||||
// about errs.Subtype references. It requires both:
|
||||
//
|
||||
// - typedFiles non-empty (go/packages.Load produced usable type info);
|
||||
// - errsSubtypeConsts non-empty (the canonical errs.Subtype const set
|
||||
// was actually discovered).
|
||||
//
|
||||
// Requiring both avoids the half-loaded failure mode where typed-file
|
||||
// indexing succeeded but the errs package was not visited — every
|
||||
// resolution attempt would then claim "foreign const" and over-reject.
|
||||
// Callers fall back to AST-only resolution when Enabled returns false.
|
||||
func (s *TypedScope) Enabled() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return len(s.typedFiles) > 0 && len(s.errsSubtypeConsts) > 0
|
||||
}
|
||||
|
||||
// LookupFileInfo returns the per-package types.Info covering the given Go
|
||||
// file (path matching the absolute path used during the load). Callers use
|
||||
// it to resolve *ast.Ident → *types.Object via Info.Uses.
|
||||
func (s *TypedScope) LookupFileInfo(absPath string) (*types.Info, bool) {
|
||||
if s == nil {
|
||||
return nil, false
|
||||
}
|
||||
info, ok := s.typedFiles[filepath.Clean(absPath)]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
// LoadTypedScope loads the workspace rooted at root with full type
|
||||
// information and returns a scope ready for CheckDeclaredSubtype typed resolution. A
|
||||
// non-nil error reports an unrecoverable failure (the loader could not
|
||||
// even start); a successful return with Enabled() == false indicates the
|
||||
// loader ran but produced no usable type info (e.g. the errs package was
|
||||
// missing) — in which case the caller should fall back silently to the
|
||||
// AST-only path.
|
||||
func LoadTypedScope(root string) (*TypedScope, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName |
|
||||
packages.NeedFiles |
|
||||
packages.NeedCompiledGoFiles |
|
||||
packages.NeedImports |
|
||||
packages.NeedDeps |
|
||||
packages.NeedTypes |
|
||||
packages.NeedSyntax |
|
||||
packages.NeedTypesInfo,
|
||||
Dir: root,
|
||||
Tests: false,
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scope := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{},
|
||||
errsSubtypeConsts: map[string]*types.Const{},
|
||||
}
|
||||
|
||||
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
||||
if p == nil || p.TypesInfo == nil {
|
||||
return
|
||||
}
|
||||
// Index file → TypesInfo for the walker.
|
||||
for _, f := range p.CompiledGoFiles {
|
||||
scope.typedFiles[filepath.Clean(f)] = p.TypesInfo
|
||||
}
|
||||
// Capture declared Subtype constants from the canonical errs package
|
||||
// so CheckDeclaredSubtype can reject selectors that resolve to a foreign-package
|
||||
// const sharing the same name.
|
||||
if p.PkgPath == errsPkgPath && p.Types != nil {
|
||||
collectSubtypeConsts(p.Types, scope.errsSubtypeConsts)
|
||||
}
|
||||
})
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// collectSubtypeConsts scans a *types.Package for exported constants of
|
||||
// type errs.Subtype whose name starts with "Subtype" and records them by
|
||||
// name. The "Subtype" name prefix is enforced so the helper aligns with
|
||||
// the CheckDeclaredSubtype AST pass and avoids matching the underlying `Subtype` type
|
||||
// definition itself.
|
||||
func collectSubtypeConsts(pkg *types.Package, into map[string]*types.Const) {
|
||||
if pkg == nil || pkg.Scope() == nil {
|
||||
return
|
||||
}
|
||||
for _, name := range pkg.Scope().Names() {
|
||||
if !strings.HasPrefix(name, "Subtype") || name == "Subtype" {
|
||||
continue
|
||||
}
|
||||
obj := pkg.Scope().Lookup(name)
|
||||
c, ok := obj.(*types.Const)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Verify the constant's type is errs.Subtype (not e.g. a foreign
|
||||
// "Subtype"-named string alias re-exported from this package).
|
||||
named, ok := c.Type().(*types.Named)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if named.Obj() == nil || named.Obj().Name() != "Subtype" ||
|
||||
named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != errsPkgPath {
|
||||
continue
|
||||
}
|
||||
into[name] = c
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveSubtypeIdent inspects the identifier used as the value of a
|
||||
// `Subtype:` composite-literal field and reports the typed-scope verdict
|
||||
// via the (resolved, ok) tuple:
|
||||
//
|
||||
// - (true, true): the identifier is a declared errs.Subtype constant.
|
||||
// The AST pass may skip its nameset check for this site.
|
||||
// - (false, true): definitive rejection — the identifier resolved to a
|
||||
// constant in a non-errs package, or to a non-Subtype constant inside
|
||||
// errs. Caller MUST NOT fall back to AST resolution; CheckDeclaredSubtype should
|
||||
// reject this site.
|
||||
// - (false, false): typed scope cannot decide (scope disabled, no file
|
||||
// info, sel==nil, no type info for the identifier, or the resolved
|
||||
// object is not a constant). Caller defers to AST-only resolution.
|
||||
func (s *TypedScope) ResolveSubtypeIdent(absPath string, sel *ast.Ident) (resolved, ok bool) {
|
||||
if !s.Enabled() {
|
||||
return false, false
|
||||
}
|
||||
info, found := s.LookupFileInfo(absPath)
|
||||
if !found || info == nil || sel == nil {
|
||||
return false, false
|
||||
}
|
||||
obj, found := info.Uses[sel]
|
||||
if !found || obj == nil {
|
||||
// No type info for this identifier — caller falls back to AST.
|
||||
return false, false
|
||||
}
|
||||
c, isConst := obj.(*types.Const)
|
||||
if !isConst {
|
||||
return false, false
|
||||
}
|
||||
if c.Pkg() == nil || c.Pkg().Path() != errsPkgPath {
|
||||
// Foreign-package constant assigned to a Subtype: slot. Reject —
|
||||
// the caller routes ALL selectors through this path regardless of
|
||||
// name shape, so this branch fires for both `foreign.SubtypeFoo`
|
||||
// and `foreign.MyKind`.
|
||||
return false, true
|
||||
}
|
||||
if _, declared := s.errsSubtypeConsts[c.Name()]; !declared {
|
||||
// In the errs package but not a Subtype const (defense-in-depth).
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
312
lint/errscontract/typecheck_test.go
Normal file
312
lint/errscontract/typecheck_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/importer"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTypedScope_RejectsForeignSubtypeConst proves that the typed-resolution
|
||||
// pass rejects a Subtype-named constant declared in a non-errs package, even
|
||||
// when the constant's NAME matches a declared errs Subtype. This is the
|
||||
// behavior selector-name matching alone could not deliver.
|
||||
//
|
||||
// The test exercises collectSubtypeConsts and ResolveSubtypeIdent directly
|
||||
// against a synthetic types.Package. A full ScanRepo integration test would
|
||||
// need a synthetic go.mod whose module path happens to be
|
||||
// github.com/larksuite/cli — which would conflict with the real repo — so
|
||||
// we exercise the resolution helpers directly here.
|
||||
func TestTypedScope_RejectsForeignSubtypeConst(t *testing.T) {
|
||||
// Synthesize what go/packages would have produced: an errs package
|
||||
// holding the canonical Subtype type plus SubtypeMissingScope const,
|
||||
// and a foreign consumer package that re-defines its own Subtype type
|
||||
// with an identically-named SubtypeMissingScope const.
|
||||
src := `package fakeerrs
|
||||
|
||||
type Subtype string
|
||||
|
||||
const SubtypeMissingScope Subtype = "missing_scope"
|
||||
`
|
||||
fset := token.NewFileSet()
|
||||
errsFile, err := parser.ParseFile(fset, "fakeerrs/subtypes.go", src, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fakeerrs: %v", err)
|
||||
}
|
||||
conf := &types.Config{Importer: importer.Default()}
|
||||
errsPkg, err := conf.Check(errsPkgPath, fset, []*ast.File{errsFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check fakeerrs: %v", err)
|
||||
}
|
||||
|
||||
foreignSrc := `package foreign
|
||||
|
||||
type Subtype string
|
||||
|
||||
const SubtypeMissingScope Subtype = "fraudulent"
|
||||
`
|
||||
foreignFile, err := parser.ParseFile(fset, "foreign/foreign.go", foreignSrc, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse foreign: %v", err)
|
||||
}
|
||||
foreignPkg, err := conf.Check("example.com/foreign", fset, []*ast.File{foreignFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check foreign: %v", err)
|
||||
}
|
||||
|
||||
scope := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{},
|
||||
errsSubtypeConsts: map[string]*types.Const{},
|
||||
}
|
||||
|
||||
// collectSubtypeConsts should pick up SubtypeMissingScope from the
|
||||
// canonical errs package but NOT from the foreign one (different pkg).
|
||||
collectSubtypeConsts(errsPkg, scope.errsSubtypeConsts)
|
||||
collectSubtypeConsts(foreignPkg, scope.errsSubtypeConsts)
|
||||
if _, ok := scope.errsSubtypeConsts["SubtypeMissingScope"]; !ok {
|
||||
t.Fatalf("expected SubtypeMissingScope to be captured from errs")
|
||||
}
|
||||
if got := scope.errsSubtypeConsts["SubtypeMissingScope"].Pkg().Path(); got != errsPkgPath {
|
||||
t.Fatalf("captured const came from %q, want %q", got, errsPkgPath)
|
||||
}
|
||||
|
||||
// Now type-check a consumer file that uses BOTH constants, and verify
|
||||
// ResolveSubtypeIdent accepts the errs reference and rejects the foreign
|
||||
// one with the (resolved=false, ok=true) pair CheckDeclaredSubtype treats as REJECT.
|
||||
consumerSrc := `package consumer
|
||||
|
||||
import (
|
||||
errs "` + errsPkgPath + `"
|
||||
foreign "example.com/foreign"
|
||||
)
|
||||
|
||||
var _ errs.Subtype = errs.SubtypeMissingScope
|
||||
var _ foreign.Subtype = foreign.SubtypeMissingScope
|
||||
`
|
||||
consumerFile, err := parser.ParseFile(fset, "consumer/x.go", consumerSrc, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse consumer: %v", err)
|
||||
}
|
||||
imp := &fakeImporter{m: map[string]*types.Package{
|
||||
errsPkgPath: errsPkg,
|
||||
"example.com/foreign": foreignPkg,
|
||||
}}
|
||||
conf2 := &types.Config{Importer: imp}
|
||||
info := &types.Info{
|
||||
Uses: map[*ast.Ident]types.Object{},
|
||||
}
|
||||
if _, err := conf2.Check("example.com/consumer", fset, []*ast.File{consumerFile}, info); err != nil {
|
||||
t.Fatalf("type-check consumer: %v", err)
|
||||
}
|
||||
scope.typedFiles["consumer/x.go"] = info
|
||||
|
||||
// Walk the consumer file to find the SubtypeMissingScope selectors and
|
||||
// drive ResolveSubtypeIdent against each one.
|
||||
var goodIdent, foreignIdent *ast.Ident
|
||||
ast.Inspect(consumerFile, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "SubtypeMissingScope" {
|
||||
return true
|
||||
}
|
||||
obj := info.Uses[sel.Sel]
|
||||
if obj == nil {
|
||||
return true
|
||||
}
|
||||
switch obj.Pkg().Path() {
|
||||
case errsPkgPath:
|
||||
goodIdent = sel.Sel
|
||||
case "example.com/foreign":
|
||||
foreignIdent = sel.Sel
|
||||
}
|
||||
return true
|
||||
})
|
||||
if goodIdent == nil || foreignIdent == nil {
|
||||
t.Fatalf("did not find both selector idents in consumer source")
|
||||
}
|
||||
|
||||
resolved, ok := scope.ResolveSubtypeIdent("consumer/x.go", goodIdent)
|
||||
if !ok {
|
||||
t.Fatalf("errs reference should resolve via type info")
|
||||
}
|
||||
if !resolved {
|
||||
t.Errorf("errs.SubtypeMissingScope should resolve=true; got resolved=false")
|
||||
}
|
||||
|
||||
resolved, ok = scope.ResolveSubtypeIdent("consumer/x.go", foreignIdent)
|
||||
if !ok {
|
||||
t.Fatalf("foreign reference should still produce ok=true (so CheckDeclaredSubtype can reject)")
|
||||
}
|
||||
if resolved {
|
||||
t.Errorf("foreign.SubtypeMissingScope must NOT resolve=true; selector-name matching alone would have falsely accepted it")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeImporter is a minimal types.Importer used by the test to satisfy
|
||||
// cross-package imports without going through go/packages.
|
||||
type fakeImporter struct {
|
||||
m map[string]*types.Package
|
||||
}
|
||||
|
||||
func (f *fakeImporter) Import(path string) (*types.Package, error) {
|
||||
if p, ok := f.m[path]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return importer.Default().Import(path)
|
||||
}
|
||||
|
||||
// TestTypedScope_FallsBackWhenDisabled documents the no-op contract: when
|
||||
// the scope is empty (loader failed or the unit-test API was used), the
|
||||
// production walker falls back to AST-only resolution. ResolveSubtypeIdent
|
||||
// must signal ok=false so the caller knows to consult the nameset path.
|
||||
func TestTypedScope_FallsBackWhenDisabled(t *testing.T) {
|
||||
var scope *TypedScope
|
||||
if scope.Enabled() {
|
||||
t.Fatalf("nil scope must report Enabled()=false")
|
||||
}
|
||||
if resolved, ok := scope.ResolveSubtypeIdent("x.go", &ast.Ident{Name: "SubtypeFoo"}); resolved || ok {
|
||||
t.Fatalf("disabled scope must return (false,false); got (%v,%v)", resolved, ok)
|
||||
}
|
||||
|
||||
empty := &TypedScope{}
|
||||
if empty.Enabled() {
|
||||
t.Fatalf("empty scope must report Enabled()=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedScope_EnabledRequiresBothTypedFilesAndSubtypeConsts pins the
|
||||
// tightened Enabled() contract: half-loaded scopes (typed files indexed
|
||||
// but errs.Subtype const set empty, or vice versa) must report disabled
|
||||
// so callers fall back to AST instead of over-rejecting every selector.
|
||||
func TestTypedScope_EnabledRequiresBothTypedFilesAndSubtypeConsts(t *testing.T) {
|
||||
onlyFiles := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{"x.go": {Uses: map[*ast.Ident]types.Object{}}},
|
||||
errsSubtypeConsts: map[string]*types.Const{},
|
||||
}
|
||||
if onlyFiles.Enabled() {
|
||||
t.Errorf("scope with files but no errs subtype consts must be disabled — typed pass would over-reject everything")
|
||||
}
|
||||
|
||||
onlyConsts := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{},
|
||||
errsSubtypeConsts: map[string]*types.Const{"SubtypeFoo": nil},
|
||||
}
|
||||
if onlyConsts.Enabled() {
|
||||
t.Errorf("scope with consts but no typed files must be disabled — no per-file lookup is possible")
|
||||
}
|
||||
|
||||
both := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{"x.go": {Uses: map[*ast.Ident]types.Object{}}},
|
||||
errsSubtypeConsts: map[string]*types.Const{"SubtypeFoo": nil},
|
||||
}
|
||||
if !both.Enabled() {
|
||||
t.Errorf("scope with both populated must be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedScope_RejectsForeignNonPrefixedConst pins the A+ behavior of
|
||||
// Refinement 1: even a constant whose name does NOT begin with "Subtype"
|
||||
// is rejected when assigned to a Subtype: slot, because it does not
|
||||
// resolve to a declared errs.Subtype constant. The legacy AST path was
|
||||
// name-gated on the "Subtype" prefix and silently accepted such
|
||||
// references.
|
||||
func TestTypedScope_RejectsForeignNonPrefixedConst(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
|
||||
// Canonical errs package with a real Subtype const.
|
||||
errsSrc := `package fakeerrs
|
||||
|
||||
type Subtype string
|
||||
|
||||
const SubtypeMissingScope Subtype = "missing_scope"
|
||||
`
|
||||
errsFile, err := parser.ParseFile(fset, "fakeerrs/subtypes.go", errsSrc, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse errs: %v", err)
|
||||
}
|
||||
conf := &types.Config{Importer: importer.Default()}
|
||||
errsPkg, err := conf.Check(errsPkgPath, fset, []*ast.File{errsFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check errs: %v", err)
|
||||
}
|
||||
|
||||
// Foreign package declaring a constant named MyKind (NOT Subtype-prefixed).
|
||||
// Under the legacy AST gate this would have been ignored entirely.
|
||||
foreignSrc := `package foreign
|
||||
|
||||
type Kind string
|
||||
|
||||
const MyKind Kind = "wrong"
|
||||
`
|
||||
foreignFile, err := parser.ParseFile(fset, "foreign/foreign.go", foreignSrc, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse foreign: %v", err)
|
||||
}
|
||||
foreignPkg, err := conf.Check("example.com/foreign", fset, []*ast.File{foreignFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check foreign: %v", err)
|
||||
}
|
||||
|
||||
scope := &TypedScope{
|
||||
typedFiles: map[string]*types.Info{},
|
||||
errsSubtypeConsts: map[string]*types.Const{},
|
||||
}
|
||||
collectSubtypeConsts(errsPkg, scope.errsSubtypeConsts)
|
||||
|
||||
// Consumer references foreign.MyKind so the type-checker records it
|
||||
// in Info.Uses; we then drive ResolveSubtypeIdent against that ident.
|
||||
consumerSrc := `package consumer
|
||||
|
||||
import foreign "example.com/foreign"
|
||||
|
||||
var _ foreign.Kind = foreign.MyKind
|
||||
`
|
||||
consumerFile, err := parser.ParseFile(fset, "consumer/x.go", consumerSrc, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse consumer: %v", err)
|
||||
}
|
||||
imp := &fakeImporter{m: map[string]*types.Package{
|
||||
errsPkgPath: errsPkg,
|
||||
"example.com/foreign": foreignPkg,
|
||||
}}
|
||||
conf2 := &types.Config{Importer: imp}
|
||||
info := &types.Info{Uses: map[*ast.Ident]types.Object{}}
|
||||
if _, err := conf2.Check("example.com/consumer", fset, []*ast.File{consumerFile}, info); err != nil {
|
||||
t.Fatalf("type-check consumer: %v", err)
|
||||
}
|
||||
scope.typedFiles["consumer/x.go"] = info
|
||||
|
||||
var foreignIdent *ast.Ident
|
||||
ast.Inspect(consumerFile, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "MyKind" {
|
||||
return true
|
||||
}
|
||||
foreignIdent = sel.Sel
|
||||
return true
|
||||
})
|
||||
if foreignIdent == nil {
|
||||
t.Fatalf("did not find foreign.MyKind selector in consumer source")
|
||||
}
|
||||
|
||||
resolved, ok := scope.ResolveSubtypeIdent("consumer/x.go", foreignIdent)
|
||||
if !ok {
|
||||
t.Fatalf("foreign non-prefixed const must produce ok=true so CheckDeclaredSubtype can reject; got ok=false")
|
||||
}
|
||||
if resolved {
|
||||
t.Errorf("foreign.MyKind (non-Subtype-prefixed) must NOT resolve=true; legacy AST gate would have skipped it silently")
|
||||
}
|
||||
|
||||
// Drive the classifier directly to prove end-to-end rejection.
|
||||
c, handled := classifyConstViaTypes(foreignIdent, "consumer/x.go", scope)
|
||||
if !handled {
|
||||
t.Fatalf("typed classifier must handle resolved foreign const; got handled=false")
|
||||
}
|
||||
if c.action != ActionReject {
|
||||
t.Errorf("classifier action = %q, want REJECT", c.action)
|
||||
}
|
||||
}
|
||||
27
lint/errscontract/violation.go
Normal file
27
lint/errscontract/violation.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import "github.com/larksuite/cli/lint/lintapi"
|
||||
|
||||
// Re-export the shared types so existing rule code reads Action /
|
||||
// Violation locally. The canonical declarations live in lintapi.
|
||||
type (
|
||||
Action = lintapi.Action
|
||||
Violation = lintapi.Violation
|
||||
)
|
||||
|
||||
const (
|
||||
ActionReject = lintapi.ActionReject
|
||||
ActionLabel = lintapi.ActionLabel
|
||||
ActionWarning = lintapi.ActionWarning
|
||||
)
|
||||
|
||||
// subtypeClassification is the package-internal verdict produced by the
|
||||
// CheckDeclaredSubtype classifier for a single Subtype: expression. Empty
|
||||
// action means "accept silently".
|
||||
type subtypeClassification struct {
|
||||
rule, message, suggestion string
|
||||
action Action
|
||||
}
|
||||
10
lint/go.mod
Normal file
10
lint/go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module github.com/larksuite/cli/lint
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require golang.org/x/tools v0.28.0
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
)
|
||||
8
lint/go.sum
Normal file
8
lint/go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
33
lint/lintapi/violation.go
Normal file
33
lint/lintapi/violation.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package lintapi defines the shared types every lint domain returns from
|
||||
// its scan entry point. New lint domains (sibling packages under lint/)
|
||||
// MUST return []lintapi.Violation so cmd/main can aggregate and report
|
||||
// uniformly. The domain may add its own private types for internal use.
|
||||
package lintapi
|
||||
|
||||
// Action enumerates the response modes for a violation.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
// ActionReject hard-fails CI. Only REJECT contributes to a nonzero
|
||||
// lintcheck exit code.
|
||||
ActionReject Action = "REJECT"
|
||||
// ActionLabel emits a diagnostic so CI can label the PR but does not fail.
|
||||
ActionLabel Action = "LABEL"
|
||||
// ActionWarning surfaces a reviewer-attention note without failing CI.
|
||||
// CI does NOT exit nonzero on warnings; they are reviewer signal only.
|
||||
ActionWarning Action = "WARNING"
|
||||
)
|
||||
|
||||
// Violation describes a single lint hit. Rule identifies which check
|
||||
// produced it; the domain package owns the rule namespace.
|
||||
type Violation struct {
|
||||
Rule string
|
||||
Action Action
|
||||
File string
|
||||
Line int
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
87
lint/main.go
Normal file
87
lint/main.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command lintcheck runs the source-level errs/ contract guards (all four checks).
|
||||
// The fifth contract rule (business path must use typed errors) lives in
|
||||
// .golangci.yml as a forbidigo entry; the four checks here are AST-level
|
||||
// guards that golangci-lint cannot express.
|
||||
//
|
||||
// lintcheck lives in its own Go module under lint/ so its build-time
|
||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||
// shipped lark-cli binary's module graph.
|
||||
//
|
||||
// Usage (from repo root):
|
||||
//
|
||||
// go run -C lint . . # scan the lark-cli repo
|
||||
// go run -C lint . /path/to/repo # scan another path
|
||||
//
|
||||
// Exit codes:
|
||||
//
|
||||
// 0 no REJECT violations (LABEL and WARNING diagnostics are advisory)
|
||||
// 1 one or more REJECT violations
|
||||
//
|
||||
// WARNING and LABEL diagnostics are still printed so a CI workflow can grep
|
||||
// for the prefixes — LABEL emits `[needs-taxonomy-decision]` for an
|
||||
// auto-labeler — but neither severity fails CI. Only REJECT does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
// scanner is the contract every lint domain implements. New domains drop in
|
||||
// as sibling packages under lint/ (see README.md) and are added below.
|
||||
type scanner struct {
|
||||
name string
|
||||
fn func(root string) ([]lintapi.Violation, error)
|
||||
}
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Usage: lintcheck [repo-root]\n"+
|
||||
"Runs every registered lint domain against repo-root (default: current directory).\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
// `./...` is a common Go-toolchain idiom; map it to the working dir.
|
||||
if root == "./..." {
|
||||
root = "."
|
||||
}
|
||||
}
|
||||
|
||||
var all []lintapi.Violation
|
||||
for _, s := range scanners {
|
||||
violations, err := s.fn(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "lintcheck %s: %v\n", s.name, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
all = append(all, violations...)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
for _, v := range all {
|
||||
fmt.Fprintf(os.Stderr, "%s:%d: [%s/%s] %s\n", v.File, v.Line, v.Action, v.Rule, v.Message)
|
||||
if v.Suggestion != "" {
|
||||
fmt.Fprintf(os.Stderr, " hint: %s\n", v.Suggestion)
|
||||
}
|
||||
if v.Action == lintapi.ActionReject {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
@@ -1909,7 +1909,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download attachment includes extra query parameter", func(t *testing.T) {
|
||||
t.Run("download attachment uses extra info", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -90,20 +89,6 @@ func noLoginBotDefaultConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (r *missingTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: credential.TokenTypeUAT}
|
||||
}
|
||||
|
||||
type staticAccountResolver struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
func (r *staticAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return credential.AccountFromCliConfig(r.config), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarCreate tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1889,67 +1874,6 @@ func TestRoomFind_RejectsInvertedOrZeroLengthSlots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestion_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarSuggestion, []string{
|
||||
"+suggestion",
|
||||
"--start", "2026-03-27T14:00:00+08:00",
|
||||
"--end", "2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,13 @@ const (
|
||||
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
|
||||
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
|
||||
// Returns an empty string if the structure doesn't match or the array is empty.
|
||||
//
|
||||
// Deprecated: getErrorDetailValue reads from the legacy *output.ErrDetail
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — typed errs.* errors expose Message, Hint, and extension
|
||||
// fields directly on the typed struct via errors.As / errs.ProblemOf. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func getErrorDetailValue(e *output.ErrDetail) string {
|
||||
if e == nil || e.Detail == nil {
|
||||
return ""
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -149,10 +150,15 @@ func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMe
|
||||
}
|
||||
|
||||
func WrapDriveMediaUploadRequestError(err error, action string) error {
|
||||
// Preserve any already-classified error: legacy *output.ExitError or any
|
||||
// typed errs.* error. Only un-classified errors get wrapped as network.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("%s: %v", action, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -22,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var commonDriveMediaUploadTestSeq atomic.Int64
|
||||
@@ -472,36 +470,6 @@ func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDriveMediaUploadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves exit error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := output.ErrValidation("bad input")
|
||||
got := WrapDriveMediaUploadRequestError(original, "upload media failed")
|
||||
if got != original {
|
||||
t.Fatalf("expected same exit error pointer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps generic error as network", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := WrapDriveMediaUploadRequestError(io.EOF, "upload media failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "upload media failed") {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type capturedDriveMediaMultipartBody struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
|
||||
@@ -20,25 +20,6 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestDoMCPCallTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: timeout")
|
||||
}),
|
||||
}
|
||||
|
||||
_, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected network exit code, got %d", exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoMCPCallUnauthorizedHTTPError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -53,12 +34,8 @@ func TestDoMCPCallUnauthorizedHTTPError(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected auth exit code, got %d", exitErr.Code)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("expected auth exit code (%d), got %d", output.ExitAuth, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
name: "cross tenant and unit",
|
||||
code: output.LarkErrDriveCrossTenantUnit,
|
||||
msg: "cross tenant and unit not support",
|
||||
wantType: "cross_tenant_unit",
|
||||
wantType: "cross_tenant",
|
||||
wantHint: "same tenant and region/unit",
|
||||
wantMsgPart: "cross tenant and unit not support",
|
||||
},
|
||||
|
||||
@@ -643,7 +643,6 @@ func enrichDriveSearchError(err error) error {
|
||||
Code: exitErr.Code,
|
||||
Detail: &detail,
|
||||
Err: exitErr.Err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -840,25 +840,3 @@ func TestHashLocalForStatusWrapsOpenError(t *testing.T) {
|
||||
t.Fatalf("expected error to mention the missing file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) {
|
||||
config := driveTestConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, config)
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config)
|
||||
runtime.Factory = f
|
||||
|
||||
_, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "download") {
|
||||
t.Fatalf("expected download-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,13 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote
|
||||
return duplicates
|
||||
}
|
||||
|
||||
// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — duplicate-path signals should move to a typed
|
||||
// *errs.ValidationError (with duplicates metadata as a typed extension
|
||||
// field) when the drive shortcut migrates to typed errors. This helper is
|
||||
// retained only while existing call sites are migrated; it will be removed
|
||||
// once they have moved to the typed surface.
|
||||
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
|
||||
@@ -4,32 +4,34 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertValidationError fails the test unless err is a *output.ExitError with
|
||||
// ExitValidation code whose message contains wantSubstr.
|
||||
// assertValidationError fails the test unless err carries the validation
|
||||
// category with ExitValidation exit code and a message containing wantSubstr.
|
||||
// Accepts both typed *errs.ValidationError and legacy *output.ExitError so
|
||||
// the helper survives the error-contract migration.
|
||||
func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
// Accept both typed *errs.ValidationError and legacy *output.ExitError —
|
||||
// the helper's purpose is to assert "this is a validation-category
|
||||
// error" via either contract, so the dual-path matches the docstring.
|
||||
code := output.ExitCodeOf(err)
|
||||
if !errs.IsValidation(err) && code != output.ExitValidation {
|
||||
t.Fatalf("expected a validation-category error, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code)
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail)
|
||||
}
|
||||
if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) {
|
||||
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error())
|
||||
if wantSubstr != "" && !strings.Contains(err.Error(), wantSubstr) {
|
||||
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +43,8 @@ func assertValidatePasses(t *testing.T, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation {
|
||||
t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr)
|
||||
if errs.IsValidation(err) || output.ExitCodeOf(err) == output.ExitValidation {
|
||||
t.Fatalf("Validate callback should have passed but returned validation error: %v", err)
|
||||
}
|
||||
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -190,10 +191,15 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
// Preserve any already-classified error: legacy *output.ExitError or any
|
||||
// typed errs.* error. Only un-classified errors get wrapped as network.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +355,6 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
|
||||
},
|
||||
Err: err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,13 @@ func TestBind_ConfigShow_UnboundWorkspace(t *testing.T) {
|
||||
Args: []string{"config", "show"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertStderrError(t, result, 2, "openclaw",
|
||||
// Stage-1 wire shape: legacy *output.ExitError envelope (free-string Type
|
||||
// from ws.Display()). Exit code 3 — config errors share the auth slot per
|
||||
// ExitCodeForCategory (pre-PR was 2, corrected as part of this PR's
|
||||
// taxonomy semantics; the per-domain typed migration in stage 2+ will
|
||||
// land the wire-type rename ("openclaw" → "config") alongside the typed
|
||||
// envelope shape (subtype, etc.).
|
||||
assertStderrError(t, result, 3, "openclaw",
|
||||
"openclaw context detected but lark-cli is not bound to it",
|
||||
"read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user