diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37745d6ba..36725d7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,12 @@ jobs: run: python3 scripts/fetch_meta.py - name: Run golangci-lint run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main + # ── errs/ contract guards (Rules B/C/D/E) ── + # Rule A is enforced by the forbidigo entry above (`errs-typed-only`). + # LABEL diagnostics for ad_hoc_* Subtypes pass through; CI workflows can + # grep stderr for `[needs-taxonomy-decision]` to apply the GitHub label. + - name: Run errs/ lint guards (lintcheck) + run: go run -C lint . .. coverage: needs: fast-gate diff --git a/.gitignore b/.gitignore index 437052468..c9baa379a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output /lark-cli +/lint/lint .cache/ dist/ bin/ diff --git a/.golangci.yml b/.golangci.yml index c15ebe084..068756c65 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -45,11 +45,10 @@ linters: - path: _test\.go$ linters: - bodyclose - - bidichk - gocritic - depguard - forbidigo - - path-except: (shortcuts/|internal/) + - path-except: (shortcuts/|internal/|cmd/service/) linters: - forbidigo - path: internal/vfs/ @@ -61,6 +60,14 @@ linters: text: shortcuts-no-raw-http linters: - forbidigo + # The errs-typed-only rule (Rule A, errs/ contract guards) only fires on + # the business path: shortcuts/** and cmd/service/**. cmd/api, cmd/auth, + # cmd/event, internal/output, internal/client and root.go are dispatcher + # / framework paths that may legitimately call the output constructors. + - path-except: (shortcuts/|cmd/service/) + text: errs-typed-only + linters: + - forbidigo settings: depguard: @@ -79,6 +86,25 @@ linters: Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. forbidigo: forbid: + # ── errs/: business path must produce typed *errs.* errors ── + # Path-scoped to shortcuts/** and cmd/service/** via the exclusion rule + # above; dispatcher / framework paths (cmd/api, cmd/auth, cmd/event, + # internal/output, internal/client, cmd/root.go) keep direct access. + # CI runs golangci-lint with --new-from-rev=origin/main so this only + # blocks new diffs — existing unmigrated call sites stay green. + - pattern: output\.(ErrAPI|Errorf|ErrWithHint|ErrBare|ClassifyLarkError)\b + msg: >- + [errs-typed-only] business path must not call legacy output.* error + constructors (ErrAPI / Errorf / ErrWithHint / ErrBare / ClassifyLarkError). + Construct a typed *errs.XxxError directly (see errs/ERROR_CONTRACT.md + Quick reference). The errclass.BuildAPIError classifier is shipped + for stage 2+ — APIClient.CheckResponse will route through it once + the per-domain typed migration lands. + - pattern: output\.(ExitError|ErrDetail)\{ + msg: >- + [errs-typed-only] business path must not construct legacy + *output.ExitError / output.ErrDetail literals. Return a typed + *errs.XxxError instead (see errs/ERROR_CONTRACT.md Quick reference). # ── http: shortcuts must not construct raw HTTP requests ── # Bans request / client construction; constants (http.MethodPost, # http.StatusOK) and pure helpers (http.StatusText, http.Header) are diff --git a/cmd/api/api.go b/cmd/api/api.go index f5676e9b1..4c7712fe6 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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) } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index e81cc984f..8488c84a4 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -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, diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index d52a0e0bd..db609d6c0 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -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{ diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index ae6ad8a2d..51dc9db9d 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -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 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 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 !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") diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 827790b93..fbf72a87c 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -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) diff --git a/cmd/config/init.go b/cmd/config/init.go index 3fc56c725..837c6f511 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -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 diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 0450402f9..8f6d85321 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -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 diff --git a/cmd/event/runtime.go b/cmd/event/runtime.go index 51e3d95db..92bc3b17c 100644 --- a/cmd/event/runtime.go +++ b/cmd/event/runtime.go @@ -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 diff --git a/cmd/platform_guards.go b/cmd/platform_guards.go index 714d147fd..5b167cb3d 100644 --- a/cmd/platform_guards.go +++ b/cmd/platform_guards.go @@ -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 diff --git a/cmd/prune.go b/cmd/prune.go index 1f503517e..2ec66fdaf 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -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{ diff --git a/cmd/root.go b/cmd/root.go index 00d9a24bc..eb0c0a29d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,8 +16,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" @@ -200,22 +200,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) } @@ -223,35 +253,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) @@ -266,8 +282,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} @@ -276,15 +292,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. // @@ -307,6 +337,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() @@ -381,15 +418,20 @@ 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 } - // Extract required scopes from API error detail scopes := extractRequiredScopes(exitErr.Detail.Detail) if len(scopes) == 0 { return @@ -400,7 +442,6 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { return } - // Select the recommended (least-privilege) scope scopeIfaces := make([]interface{}, len(scopes)) for i, s := range scopes { scopeIfaces[i] = s @@ -410,22 +451,20 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { recommended = scopes[0] } - // Build admin console URL with the recommended scope host := "open.feishu.cn" if cfg.Brand == "lark" { host = "open.larksuite.com" } - consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended)) + consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", + host, url.QueryEscape(cfg.AppID), url.QueryEscape(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)" @@ -435,13 +474,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)" @@ -453,7 +490,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { } } -// extractRequiredScopes extracts scope names from the API error's permission_violations field. +// extractRequiredScopes pulls scope names out of an API error detail's +// permission_violations[].subject. Returns nil when the structure is absent. func extractRequiredScopes(detail interface{}) []string { m, ok := detail.(map[string]interface{}) if !ok { diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index a8919d1ce..d19fec034 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -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", diff --git a/cmd/root_test.go b/cmd/root_test.go index 6aac983db..f2d437e96 100644 --- a/cmd/root_test.go +++ b/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,130 +72,169 @@ 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()) +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) } - // 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") + if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") { + t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong) } } -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"}, - }, - }) +func TestConfigureFlagCompletions(t *testing.T) { + t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) }) - 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") + tests := []struct { + name string + args []string + wantDisabled bool + }{ + {"plain command", []string{"im", "+send"}, true}, + {"help flag", []string{"im", "--help"}, true}, + {"no args", []string{}, true}, + {"__complete request", []string{"__complete", "im", "+send", ""}, false}, + {"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false}, + {"completion subcommand", []string{"completion", "bash"}, false}, } - // The message should have been enriched - if !strings.Contains(err.Error(), "App scope not enabled") { - t.Errorf("expected enriched message, got: %s", err.Error()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled) + configureFlagCompletions(tc.args) + if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled { + t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled) + } + }) } } -func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) { +// isCompletionCommand must classify BOTH cobra completion aliases as +// completion requests so the Shutdown emit and update-notice paths skip +// shell-completion invocations. __completeNoDesc is an Alias of +// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and +// dispatches the same RunE; bash/zsh completion typically calls the +// NoDesc variant. +func TestIsCompletionCommand(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 string + args []string + want bool }{ - { - 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", - }, + {"plain command", []string{"im", "+send"}, false}, + {"__complete", []string{"__complete", "im"}, true}, + {"__completeNoDesc", []string{"__completeNoDesc", "im"}, true}, + {"completion subcommand", []string{"completion", "bash"}, true}, + {"completion in tail", []string{"foo", "bar", "completion"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isCompletionCommand(tc.args); got != tc.want { + t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want) + } + }) } +} - 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, - }) +// 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"}, + } - exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": tt.scope}, + 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", + } - handleRootError(f, exitErr) + gotExit := handleRootError(f, spErr) + if gotExit != 1 { + t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit) + } - consoleURL := exitErr.Detail.ConsoleURL - if consoleURL == "" { - t.Fatal("expected console_url to be set") + 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 !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 got := errObj["code"]; got != tc.wantCode { + t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode) } - 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) + 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]) + } } }) } } -func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) { +// 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{ @@ -223,30 +266,23 @@ func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testin resourceCmd.AddCommand(methodCmd) f.CurrentCommand = methodCmd - exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{}) - enrichMissingScopeError(f, exitErr) + authErr := newAuthErrorWithNeedAuthMarker() + applyNeedAuthorizationHint(f, authErr) - 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 authErr.Category != errs.CategoryAuthentication { + t.Errorf("Category = %q, want authentication", authErr.Category) } - 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 !strings.Contains(authErr.Message, "need_user_authorization") { + t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message) } - if exitErr.Detail.Detail != nil { - t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail) + if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") { + t.Errorf("expected declared-scope hint, got %q", authErr.Hint) } } -func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) { +// 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{ @@ -261,30 +297,17 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing. serviceCmd.AddCommand(shortcutCmd) f.CurrentCommand = shortcutCmd - exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{}) - enrichMissingScopeError(f, exitErr) + authErr := newAuthErrorWithNeedAuthMarker() + applyNeedAuthorizationHint(f, authErr) - 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) + if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") { + t.Errorf("expected shortcut scope hint, got %q", authErr.Hint) } } -func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) { +// 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{ @@ -299,18 +322,18 @@ func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) serviceCmd.AddCommand(shortcutCmd) f.CurrentCommand = shortcutCmd - exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{}) - enrichMissingScopeError(f, exitErr) + authErr := newAuthErrorWithNeedAuthMarker() + applyNeedAuthorizationHint(f, authErr) - 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) + 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) } } -func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) { +// 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{ @@ -325,74 +348,12 @@ func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) { serviceCmd.AddCommand(shortcutCmd) f.CurrentCommand = shortcutCmd - exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{}) - exitErr.Detail.Hint = "existing hint" - enrichMissingScopeError(f, exitErr) + authErr := newAuthErrorWithNeedAuthMarker() + authErr.Hint = "existing hint" + applyNeedAuthorizationHint(f, authErr) 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) - } - if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") { - t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong) - } -} - -func TestConfigureFlagCompletions(t *testing.T) { - t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) }) - - tests := []struct { - name string - args []string - wantDisabled bool - }{ - {"plain command", []string{"im", "+send"}, true}, - {"help flag", []string{"im", "--help"}, true}, - {"no args", []string{}, true}, - {"__complete request", []string{"__complete", "im", "+send", ""}, false}, - {"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false}, - {"completion subcommand", []string{"completion", "bash"}, false}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled) - configureFlagCompletions(tc.args) - if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled { - t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled) - } - }) - } -} - -// isCompletionCommand must classify BOTH cobra completion aliases as -// completion requests so the Shutdown emit and update-notice paths skip -// shell-completion invocations. __completeNoDesc is an Alias of -// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and -// dispatches the same RunE; bash/zsh completion typically calls the -// NoDesc variant. -func TestIsCompletionCommand(t *testing.T) { - tests := []struct { - name string - args []string - want bool - }{ - {"plain command", []string{"im", "+send"}, false}, - {"__complete", []string{"__complete", "im"}, true}, - {"__completeNoDesc", []string{"__completeNoDesc", "im"}, true}, - {"completion subcommand", []string{"completion", "bash"}, true}, - {"completion in tail", []string{"foo", "bar", "completion"}, true}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := isCompletionCommand(tc.args); got != tc.want { - t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want) - } - }) + if authErr.Hint != want { + t.Errorf("expected appended hint %q, got %q", want, authErr.Hint) } } diff --git a/cmd/service/service.go b/cmd/service/service.go index 20c5e025f..876247b8b 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -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", //nolint:forbidigo // stage-1 legacy carve-out — see comment above + 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"]) //nolint:forbidigo // stage-1 legacy carve-out — see comment above + } +} + // 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) diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 3a2e5a7be..42377850e 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -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 -} diff --git a/errs/ERROR_CONTRACT.md b/errs/ERROR_CONTRACT.md new file mode 100644 index 000000000..e834be18b --- /dev/null +++ b/errs/ERROR_CONTRACT.md @@ -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: " (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_.go` (service). +2. If it maps from a Lark code, register the mapping in + `internal/errclass/codemeta_.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.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_.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`. diff --git a/errs/category.go b/errs/category.go new file mode 100644 index 000000000..b73df253d --- /dev/null +++ b/errs/category.go @@ -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" +) diff --git a/errs/category_test.go b/errs/category_test.go new file mode 100644 index 000000000..5e92706a1 --- /dev/null +++ b/errs/category_test.go @@ -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) + } + }) + } +} diff --git a/errs/doc.go b/errs/doc.go new file mode 100644 index 000000000..0cfc28999 --- /dev/null +++ b/errs/doc.go @@ -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 diff --git a/errs/internal_carrier.go b/errs/internal_carrier.go new file mode 100644 index 000000000..019be9fed --- /dev/null +++ b/errs/internal_carrier.go @@ -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 +} diff --git a/errs/marshal_test.go b/errs/marshal_test.go new file mode 100644 index 000000000..d9ae9158e --- /dev/null +++ b/errs/marshal_test.go @@ -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) + } + } +} diff --git a/errs/predicates.go b/errs/predicates.go new file mode 100644 index 000000000..d3b1c8e92 --- /dev/null +++ b/errs/predicates.go @@ -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) } diff --git a/errs/predicates_test.go b/errs/predicates_test.go new file mode 100644 index 000000000..82cd6b9fc --- /dev/null +++ b/errs/predicates_test.go @@ -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) + } + }) + } +} diff --git a/errs/problem.go b/errs/problem.go new file mode 100644 index 000000000..f9270f269 --- /dev/null +++ b/errs/problem.go @@ -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 } diff --git a/errs/problem_test.go b/errs/problem_test.go new file mode 100644 index 000000000..08c034520 --- /dev/null +++ b/errs/problem_test.go @@ -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") + } +} diff --git a/errs/subtypes.go b/errs/subtypes.go new file mode 100644 index 000000000..35b2d2788 --- /dev/null +++ b/errs/subtypes.go @@ -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. diff --git a/errs/subtypes_service_task.go b/errs/subtypes_service_task.go new file mode 100644 index 000000000..04464411f --- /dev/null +++ b/errs/subtypes_service_task.go @@ -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_.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" +) diff --git a/errs/types.go b/errs/types.go new file mode 100644 index 000000000..aa5718445 --- /dev/null +++ b/errs/types.go @@ -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"` +} diff --git a/errs/types_test.go b/errs/types_test.go new file mode 100644 index 000000000..8d626f1fc --- /dev/null +++ b/errs/types_test.go @@ -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) + } + }) + } +} diff --git a/errs/wrap.go b/errs/wrap.go new file mode 100644 index 000000000..1da0c0cf9 --- /dev/null +++ b/errs/wrap.go @@ -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, + } +} diff --git a/errs/wrap_test.go b/errs/wrap_test.go new file mode 100644 index 000000000..43f0e3003 --- /dev/null +++ b/errs/wrap_test.go @@ -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) + } +} diff --git a/internal/auth/errors.go b/internal/auth/errors.go index b5186f72c..13b9eeeb1 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -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 diff --git a/internal/auth/transport.go b/internal/auth/transport.go index 567885418..b6d422899 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -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, } } } diff --git a/internal/auth/transport_test.go b/internal/auth/transport_test.go new file mode 100644 index 000000000..73e76b7cf --- /dev/null +++ b/internal/auth/transport_test.go @@ -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) + } +} diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index 35bf4133a..a46609646 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -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 { diff --git a/internal/client/api_errors.go b/internal/client/api_errors.go index 65cf18ac2..613b02da5 100644 --- a/internal/client/api_errors.go +++ b/internal/client/api_errors.go @@ -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 diff --git a/internal/client/api_errors_test.go b/internal/client/api_errors_test.go index a8bb04617..4a3cf5f1b 100644 --- a/internal/client/api_errors_test.go +++ b/internal/client/api_errors_test.go @@ -6,15 +6,17 @@ 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) +func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) { + err := WrapDoAPIError(&json.SyntaxError{Offset: 1}) if err == nil { t.Fatal("expected error") } @@ -23,16 +25,16 @@ func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) { 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 exitErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI, got %d", exitErr.Code) } - if strings.Contains(exitErr.Error(), "invalid JSON response") { - t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error()) + if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { + t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail) } } -func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) { - err := WrapDoAPIError(&json.SyntaxError{Offset: 1}) +func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) { + err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) if err == nil { t.Fatal("expected error") } @@ -45,24 +47,130 @@ func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) { t.Fatalf("expected ExitAPI, got %d", exitErr.Code) } if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail) + t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail) } } -func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) { - err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) - if err == nil { - t.Fatal("expected error") +// 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.ExitAPI { - t.Fatalf("expected ExitAPI, got %d", exitErr.Code) + if exitErr.Code != output.ExitNetwork { + t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork) } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail) + 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 "" + } + 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) } } diff --git a/internal/client/client.go b/internal/client/client.go index 816b637b1..2a043e324 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -91,12 +91,24 @@ 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 wrapped via WrapDoAPIError so every caller (cmd/api, +// RuntimeContext, shortcuts) gets the same typed *errs.InternalError carrying +// the internal/sdk_error contract — without each one remembering to wrap. +// Earlier auth/validation errors (already typed via output.ErrAuth) 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 +119,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 +139,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 +278,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 +442,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 diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 88e991068..a1330c4a9 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -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{} + +func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) { + return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type} +} + +// 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 "), +// 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, &failingTokenResolver{}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } _, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, - ApiPath: "/open-apis/test", - }, core.AsBot) + ApiPath: "/open-apis/contact/v3/users/me", + }, core.AsUser) + if err == nil { - t.Fatal("DoSDKRequest() 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("DoSDKRequest() 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) } } -func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) { - ac := &APIClient{ - HTTP: &http.Client{}, - Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, - } +// 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.DoStream(context.Background(), &larkcore.ApiReq{ + _, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, - ApiPath: "https://example.com/open-apis/test", + 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("DoStream() error = nil, want auth error") + t.Fatal("expected JSON parse 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.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) } } diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 3476db9ad..66b064b46 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -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 } diff --git a/internal/client/response.go b/internal/client/response.go index aec73cb04..67ff98a0d 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -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 diff --git a/internal/client/response_test.go b/internal/client/response_test.go index f0318ed12..4030d1ce2 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -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()) - } -} diff --git a/internal/cmdpolicy/apply.go b/internal/cmdpolicy/apply.go index fead7fd4d..744ad0ee3 100644 --- a/internal/cmdpolicy/apply.go +++ b/internal/cmdpolicy/apply.go @@ -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{ diff --git a/internal/cmdutil/confirm.go b/internal/cmdutil/confirm.go index a7fa0f8fe..45031521b 100644 --- a/internal/cmdutil/confirm.go +++ b/internal/cmdutil/confirm.go @@ -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, diff --git a/internal/core/config.go b/internal/core/config.go index eff57c762..9c566ce50 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -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(), diff --git a/internal/core/errors.go b/internal/core/errors.go index 14d443a06..b5ad13e89 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -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 diff --git a/internal/core/notconfigured.go b/internal/core/notconfigured.go index cf76e60a9..770c898d3 100644 --- a/internal/core/notconfigured.go +++ b/internal/core/notconfigured.go @@ -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, diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go new file mode 100644 index 000000000..3500292e1 --- /dev/null +++ b/internal/errclass/classify.go @@ -0,0 +1,279 @@ +// 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) + // 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 +} diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go new file mode 100644 index 000000000..11a01ae86 --- /dev/null +++ b/internal/errclass/classify_test.go @@ -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) + } +} diff --git a/internal/errclass/codemeta.go b/internal/errclass/codemeta.go new file mode 100644 index 000000000..c3ea20453 --- /dev/null +++ b/internal/errclass/codemeta.go @@ -0,0 +1,86 @@ +// 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}, + 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 + } +} diff --git a/internal/errclass/codemeta_task.go b/internal/errclass/codemeta_task.go new file mode 100644 index 000000000..4d5e3c728 --- /dev/null +++ b/internal/errclass/codemeta_task.go @@ -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") } diff --git a/internal/errclass/codemeta_test.go b/internal/errclass/codemeta_test.go new file mode 100644 index 000000000..ff965ae52 --- /dev/null +++ b/internal/errclass/codemeta_test.go @@ -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") +} diff --git a/internal/errcompat/promote.go b/internal/errcompat/promote.go new file mode 100644 index 000000000..dc4638e24 --- /dev/null +++ b/internal/errcompat/promote.go @@ -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 diff --git a/internal/errcompat/promote_test.go b/internal/errcompat/promote_test.go new file mode 100644 index 000000000..43ffea74e --- /dev/null +++ b/internal/errcompat/promote_test.go @@ -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) + } +} diff --git a/internal/hook/install.go b/internal/hook/install.go index 53fbe6f78..76dad869e 100644 --- a/internal/hook/install.go +++ b/internal/hook/install.go @@ -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 diff --git a/internal/output/envelope.go b/internal/output/envelope.go index 812fe0797..b94e823b9 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -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"` diff --git a/internal/output/errors.go b/internal/output/errors.go index 0a9099232..ee9caa95b 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -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"` } diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index 30662dd05..ab4a50789 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -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"]) + } +} diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go index 6fde811a8..6b8c2310b 100644 --- a/internal/output/exitcode.go +++ b/internal/output/exitcode.go @@ -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 +} diff --git a/internal/output/exitcode_test.go b/internal/output/exitcode_test.go new file mode 100644 index 000000000..e0683a498 --- /dev/null +++ b/internal/output/exitcode_test.go @@ -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) + } +} diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index a2ac2590e..f45312b14 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -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 @@ -54,57 +64,158 @@ 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). +// 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", + + 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", + + 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", + + LarkErrRateLimit: "please try again later", + LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests", + 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) { - 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" - - // permission: scope not granted - case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission, - LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized: - return ExitAPI, "permission", "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" - - // 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 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() + 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 +} - return ExitAPI, "api_error", "" +// 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" } diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index a9af905c5..4580c1c46 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -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", }, { diff --git a/lint/README.md b/lint/README.md new file mode 100644 index 000000000..1fe7ceae2 --- /dev/null +++ b/lint/README.md @@ -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//`. 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 + + 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_.go` with sibling + `rule__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: "", fn: .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. diff --git a/lint/errscontract/rule_adhoc_subtype.go b/lint/errscontract/rule_adhoc_subtype.go new file mode 100644 index 000000000..cb2c5c015 --- /dev/null +++ b/lint/errscontract/rule_adhoc_subtype.go @@ -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 +} diff --git a/lint/errscontract/rule_declared_subtype.go b/lint/errscontract/rule_declared_subtype.go new file mode 100644 index 000000000..92dfe22d4 --- /dev/null +++ b/lint/errscontract/rule_declared_subtype.go @@ -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 `.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: ` — 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 +} diff --git a/lint/errscontract/rule_no_registrar.go b/lint/errscontract/rule_no_registrar.go new file mode 100644 index 000000000..4435ad8a9 --- /dev/null +++ b/lint/errscontract/rule_no_registrar.go @@ -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_.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 +} diff --git a/lint/errscontract/rule_problem_embed.go b/lint/errscontract/rule_problem_embed.go new file mode 100644 index 000000000..4042497a7 --- /dev/null +++ b/lint/errscontract/rule_problem_embed.go @@ -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 +} diff --git a/lint/errscontract/rule_subtype_classifier.go b/lint/errscontract/rule_subtype_classifier.go new file mode 100644 index 000000000..2cf89135e --- /dev/null +++ b/lint/errscontract/rule_subtype_classifier.go @@ -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_ 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 +} diff --git a/lint/errscontract/rule_typed_error_completeness.go b/lint/errscontract/rule_typed_error_completeness.go new file mode 100644 index 000000000..263bdf7fd --- /dev/null +++ b/lint/errscontract/rule_typed_error_completeness.go @@ -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.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.Error` selector, OR +// - the file lives inside the canonical errs package and the type is an +// unqualified `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// — 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 +// `:` 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", + } +} diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go new file mode 100644 index 000000000..ddb77ed28 --- /dev/null +++ b/lint/errscontract/rules_test.go @@ -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) + } +} diff --git a/lint/errscontract/runner.go b/lint/errscontract/runner.go new file mode 100644 index 000000000..5d4945751 --- /dev/null +++ b/lint/errscontract/runner.go @@ -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 +} diff --git a/lint/errscontract/scan.go b/lint/errscontract/scan.go new file mode 100644 index 000000000..2149e45fb --- /dev/null +++ b/lint/errscontract/scan.go @@ -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 +} diff --git a/lint/errscontract/scan_test.go b/lint/errscontract/scan_test.go new file mode 100644 index 000000000..7ffc63cd0 --- /dev/null +++ b/lint/errscontract/scan_test.go @@ -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) + } +} diff --git a/lint/errscontract/typecheck.go b/lint/errscontract/typecheck.go new file mode 100644 index 000000000..892afcc7e --- /dev/null +++ b/lint/errscontract/typecheck.go @@ -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 +} diff --git a/lint/errscontract/typecheck_test.go b/lint/errscontract/typecheck_test.go new file mode 100644 index 000000000..7f28ef444 --- /dev/null +++ b/lint/errscontract/typecheck_test.go @@ -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) + } +} diff --git a/lint/errscontract/violation.go b/lint/errscontract/violation.go new file mode 100644 index 000000000..57fa39a54 --- /dev/null +++ b/lint/errscontract/violation.go @@ -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 +} diff --git a/lint/go.mod b/lint/go.mod new file mode 100644 index 000000000..992975015 --- /dev/null +++ b/lint/go.mod @@ -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 +) diff --git a/lint/go.sum b/lint/go.sum new file mode 100644 index 000000000..e3144c2c5 --- /dev/null +++ b/lint/go.sum @@ -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= diff --git a/lint/lintapi/violation.go b/lint/lintapi/violation.go new file mode 100644 index 000000000..590d142c7 --- /dev/null +++ b/lint/lintapi/violation.go @@ -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 +} diff --git a/lint/main.go b/lint/main.go new file mode 100644 index 000000000..2d98e8163 --- /dev/null +++ b/lint/main.go @@ -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) +} diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 0a9a1d709..d21984000 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -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"]}}}}` diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index ed1d3a3bb..313788237 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -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 // --------------------------------------------------------------------------- diff --git a/shortcuts/calendar/errors.go b/shortcuts/calendar/errors.go index e6d1e905f..f34b07163 100644 --- a/shortcuts/calendar/errors.go +++ b/shortcuts/calendar/errors.go @@ -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 "" diff --git a/shortcuts/common/drive_media_upload.go b/shortcuts/common/drive_media_upload.go index f2e45eb14..7c3a2cec5 100644 --- a/shortcuts/common/drive_media_upload.go +++ b/shortcuts/common/drive_media_upload.go @@ -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) } diff --git a/shortcuts/common/drive_media_upload_test.go b/shortcuts/common/drive_media_upload_test.go index 2846c90b8..fcce15cc5 100644 --- a/shortcuts/common/drive_media_upload_test.go +++ b/shortcuts/common/drive_media_upload_test.go @@ -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 diff --git a/shortcuts/common/mcp_client_test.go b/shortcuts/common/mcp_client_test.go index 2550652b6..6a1b03a19 100644 --- a/shortcuts/common/mcp_client_test.go +++ b/shortcuts/common/mcp_client_test.go @@ -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) } } diff --git a/shortcuts/drive/drive_create_shortcut_test.go b/shortcuts/drive/drive_create_shortcut_test.go index 3d883b964..942e079d8 100644 --- a/shortcuts/drive/drive_create_shortcut_test.go +++ b/shortcuts/drive/drive_create_shortcut_test.go @@ -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", }, diff --git a/shortcuts/drive/drive_search.go b/shortcuts/drive/drive_search.go index f71be3478..e1b618fe6 100644 --- a/shortcuts/drive/drive_search.go +++ b/shortcuts/drive/drive_search.go @@ -643,7 +643,6 @@ func enrichDriveSearchError(err error) error { Code: exitErr.Code, Detail: &detail, Err: exitErr.Err, - Raw: exitErr.Raw, } } diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 303aeac11..b0ea305fe 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -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) - } -} diff --git a/shortcuts/drive/list_remote.go b/shortcuts/drive/list_remote.go index 409ad49de..5f773b02c 100644 --- a/shortcuts/drive/list_remote.go +++ b/shortcuts/drive/list_remote.go @@ -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, diff --git a/shortcuts/mail/mail_shortcut_validation_test.go b/shortcuts/mail/mail_shortcut_validation_test.go index 698130210..ebc92cb20 100644 --- a/shortcuts/mail/mail_shortcut_validation_test.go +++ b/shortcuts/mail/mail_shortcut_validation_test.go @@ -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. } diff --git a/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go index b66cdd28f..96d62dab2 100644 --- a/shortcuts/markdown/helpers.go +++ b/shortcuts/markdown/helpers.go @@ -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) } diff --git a/shortcuts/sheets/lark_sheets_sheet_management.go b/shortcuts/sheets/lark_sheets_sheet_management.go index 67ab6f9d7..bfbd56494 100644 --- a/shortcuts/sheets/lark_sheets_sheet_management.go +++ b/shortcuts/sheets/lark_sheets_sheet_management.go @@ -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, } } diff --git a/tests/cli_e2e/config/bind_test.go b/tests/cli_e2e/config/bind_test.go index e8b64aeca..28e568378 100644 --- a/tests/cli_e2e/config/bind_test.go +++ b/tests/cli_e2e/config/bind_test.go @@ -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`") }