feat(errs): add structured CLI error contract (#984)

Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.

Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
  embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift

Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.

Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.

At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.

First PR in the feat/error-contract-* series.
This commit is contained in:
evandance
2026-05-26 11:42:33 +08:00
committed by GitHub
parent 877fbe6d47
commit fe72e41fb2
94 changed files with 7703 additions and 1250 deletions

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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{

View File

@@ -13,30 +13,45 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion.
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed validation error — they normalize to the same wantDetail fields.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -595,8 +610,10 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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{

View File

@@ -15,8 +15,8 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -199,22 +199,52 @@ func configureFlagCompletions(args []string) {
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
//
// Dispatch order:
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
// this path so existing wire shapes are preserved byte-for-byte until
// per-domain typed migration in stage 2+.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable)
// that differs from the standard ErrDetail, so it's handled separately.
var spErr *internalauth.SecurityPolicyError
// SecurityPolicyError keeps the legacy custom envelope (string codes,
// challenge_url, retryable) and exit code 1 — its wire shape predates the
// typed taxonomy and downstream OAuth/policy consumers depend on it.
// The taxonomy migration for this category is deferred to a later PR.
var spErr *errs.SecurityPolicyError
if errors.As(err, &spErr) {
writeSecurityPolicyError(errOut, spErr)
return 1
}
// All other structured errors normalize to ExitError.
// *core.ConfigError flows raw to the legacy envelope path in stage 1
// (asExitError → output.ErrWithHint). Typed migration via
// errcompat.PromoteConfigError happens in stage 2+.
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return output.ExitCodeOf(err)
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command) preserve the original API
// error detail; skip enrichment which would clear it.
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
@@ -222,35 +252,21 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// Cobra errors (required flags, unknown commands, etc.)
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
// This format intentionally differs from the standard ErrDetail envelope:
// it uses string codes ("challenge_required"/"access_denied") and extra fields
// (retryable, challenge_url) for machine-readable policy error handling.
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
// writeSecurityPolicyError writes the security-policy-specific JSON envelope.
// This wire format intentionally differs from the typed envelope writer: it
// uses string codes ("challenge_required"/"access_denied"), a "auth_error"
// type literal, and a top-level "retryable" field — the shape OAuth/policy
// consumers have been parsing since before the typed taxonomy existed.
func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
var codeStr string
switch spErr.Code {
case internalauth.LarkErrBlockByPolicyTryAuth:
switch spErr.Subtype {
case errs.SubtypeChallengeRequired:
codeStr = "challenge_required"
case internalauth.LarkErrBlockByPolicy:
case errs.SubtypeAccessDenied:
codeStr = "access_denied"
default:
codeStr = strconv.Itoa(spErr.Code)
@@ -265,8 +281,8 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
if spErr.ChallengeURL != "" {
errData["challenge_url"] = spErr.ChallengeURL
}
if spErr.CLIHint != "" {
errData["hint"] = spErr.CLIHint
if spErr.Hint != "" {
errData["hint"] = spErr.Hint
}
env := map[string]interface{}{"ok": false, "error": errData}
@@ -275,15 +291,29 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
err := encoder.Encode(env)
if err != nil {
if encErr := encoder.Encode(env); encErr != nil {
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
return
}
fmt.Fprint(w, buffer.String())
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge; removed after typed migration.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
// group commands (no Run/RunE) with an unknown_subcommand error.
//
@@ -306,6 +336,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
@@ -380,10 +417,16 @@ func installTipsHelpFunc(root *cobra.Command) {
})
}
// enrichPermissionError adds console_url and improves the hint for permission errors.
// It differentiates between:
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console
// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope
// enrichPermissionError adds console_url and improves the hint for legacy
// *output.ExitError permission errors. Differentiates between:
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope
// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized:
// user has not authorized the scope → hint to auth login
// - default: other permission errors → console + auth-login fallback
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
// + ConsoleURL on the typed envelope and remove this helper.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
@@ -405,15 +448,13 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
// Build admin console URL with the recommended scope
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
// Clear raw API detail — useful info is now in message/hint/console_url
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
isBot := f.ResolvedIdentity.IsBot()
larkCode := exitErr.Detail.Code
switch larkCode {
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
// User has not authorized the scope → re-authorize
exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode)
if isBot {
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
@@ -423,13 +464,11 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
case output.LarkErrAppScopeNotEnabled:
// App has not enabled the API scope → admin console
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
exitErr.Detail.ConsoleURL = consoleURL
default:
// Other permission errors (matched by keyword)
exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode)
if isBot {
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"

View File

@@ -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",

View File

@@ -4,19 +4,23 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/spf13/cobra"
)
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
@@ -68,273 +72,6 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
}
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Create a permission error (would normally be enriched) and mark it Raw
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
})
err.Raw = true
code := handleRootError(f, err)
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should contain the error envelope
if stderr.Len() == 0 {
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
}
// The message should NOT have been enriched by enrichPermissionError
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
if strings.Contains(err.Error(), "App scope not enabled") {
t.Errorf("expected message not enriched, got: %s", err.Error())
}
// Detail.Detail should be preserved (enrichPermissionError clears it to nil)
if err.Detail != nil && err.Detail.Detail == nil {
t.Error("expected Detail.Detail to be preserved, but it was cleared")
}
}
func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Create a permission error without Raw — should be enriched
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
})
code := handleRootError(f, err)
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should contain the error envelope
if stderr.Len() == 0 {
t.Error("expected non-empty stderr for non-Raw error")
}
// The message should have been enriched
if !strings.Contains(err.Error(), "App scope not enabled") {
t.Errorf("expected enriched message, got: %s", err.Error())
}
}
func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
tests := []struct {
name string
appID string
scope string
wantInURL string // substring that must appear in console_url
denyInURL string // substring that must NOT appear raw in console_url
}{
{
name: "ampersand in scope",
appID: "cli_good",
scope: "scope&evil=injected",
wantInURL: "scopes=scope%26evil%3Dinjected",
denyInURL: "scopes=scope&evil=injected",
},
{
name: "hash in scope",
appID: "cli_good",
scope: "scope#fragment",
wantInURL: "scopes=scope%23fragment",
denyInURL: "scopes=scope#fragment",
},
{
name: "space in scope",
appID: "cli_good",
scope: "scope with spaces",
wantInURL: "scopes=scope+with+spaces",
},
{
name: "special chars in appID",
appID: "app&id=bad",
scope: "calendar:calendar:readonly",
wantInURL: "clientID=app%26id%3Dbad",
denyInURL: "clientID=app&id=bad",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
})
exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": tt.scope},
},
})
handleRootError(f, exitErr)
consoleURL := exitErr.Detail.ConsoleURL
if consoleURL == "" {
t.Fatal("expected console_url to be set")
}
if !strings.Contains(consoleURL, tt.wantInURL) {
t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL)
}
if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) {
t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL)
}
})
}
}
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
target = entry
break
}
}
if target.Command == "" {
t.Fatal("failed to locate a calendar create command in local registry metadata")
}
parts := strings.Split(target.Command, " ")
if len(parts) != 2 {
t.Fatalf("expected resource/method command, got %q", target.Command)
}
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "calendar"}
resourceCmd := &cobra.Command{Use: parts[0]}
methodCmd := &cobra.Command{Use: parts[1]}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(resourceCmd)
resourceCmd.AddCommand(methodCmd)
f.CurrentCommand = methodCmd
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
shortcutCmd := &cobra.Command{Use: "+status"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Detail == nil {
t.Fatal("expected error detail")
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
}
}
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
exitErr.Detail.Hint = "existing hint"
enrichMissingScopeError(f, exitErr)
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
if exitErr.Detail.Hint != want {
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
@@ -396,3 +133,227 @@ func TestIsCompletionCommand(t *testing.T) {
})
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
// writer. Downstream OAuth/policy consumers parse a wire format that
// predates the typed taxonomy and depend on:
// - error.type == "auth_error" (not the Category literal "policy")
// - error.code is a string ("challenge_required" / "access_denied"), not a number
// - error.retryable is present at the top of the error object
// - exit code 1 (not ExitContentSafety 6)
//
// Migration of this category to the typed envelope is deferred to a later PR.
func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
subtype errs.Subtype
code int
wantCode string
}{
{"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"},
{"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
spErr := &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: tc.subtype,
Code: tc.code,
Message: "blocked by access policy",
Hint: "complete challenge in your browser",
},
ChallengeURL: "https://example.com/challenge",
}
gotExit := handleRootError(f, spErr)
if gotExit != 1 {
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
}
var env map[string]any
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
}
if got := errObj["type"]; got != "auth_error" {
t.Errorf("error.type = %v, want %q", got, "auth_error")
}
if got := errObj["code"]; got != tc.wantCode {
t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode)
}
if got, ok := errObj["retryable"].(bool); !ok || got {
t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"])
}
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
t.Errorf("error.challenge_url = %v, want challenge url", got)
}
if got := errObj["hint"]; got != "complete challenge in your browser" {
t.Errorf("error.hint = %v, want hint message", got)
}
// And the typed-only fields must NOT appear on this envelope.
for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} {
if _, exists := errObj[leaked]; exists {
t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked])
}
}
})
}
}
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
// contains the need_user_authorization marker — the same shape that
// resolveAccessToken now produces when the credential chain returns
// *internalauth.NeedAuthorizationError.
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
return &errs.AuthenticationError{
Problem: errs.Problem{
Category: errs.CategoryAuthentication,
Subtype: errs.SubtypeUnknown,
Message: fmt.Sprintf("API call failed: %s", cause),
},
Cause: cause,
}
}
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
// declared-scopes Hint appended when the current command is a registered
// service method.
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
target = entry
break
}
}
if target.Command == "" {
t.Fatal("failed to locate a calendar create command in local registry metadata")
}
parts := strings.Split(target.Command, " ")
if len(parts) != 2 {
t.Fatalf("expected resource/method command, got %q", target.Command)
}
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "calendar"}
resourceCmd := &cobra.Command{Use: parts[0]}
methodCmd := &cobra.Command{Use: parts[1]}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(resourceCmd)
resourceCmd.AddCommand(methodCmd)
f.CurrentCommand = methodCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
if authErr.Category != errs.CategoryAuthentication {
t.Errorf("Category = %q, want authentication", authErr.Category)
}
if !strings.Contains(authErr.Message, "need_user_authorization") {
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
}
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
}
}
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
// same hint behavior for mounted shortcut commands.
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
}
}
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
// conditional scopes declared on a shortcut surface in the hint.
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
shortcutCmd := &cobra.Command{Use: "+status"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
}
}
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
// declared-scopes guidance is appended (separated by newline) when the typed
// AuthenticationError already carries a Hint from elsewhere.
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
authErr.Hint = "existing hint"
applyNeedAuthorizationHint(f, authErr)
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
if authErr.Hint != want {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}

View File

@@ -271,6 +271,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
// with a per-method recommended `--scope` hint, matching the pre-PR
// behaviour. Per-domain typed migration in stage 2+ will lift this
// into PermissionError.MissingScopes / ConsoleURL on the typed
// envelope; until then the legacy ExitError envelope is preserved.
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
if opts.PageAll {
@@ -280,7 +285,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
return err
}
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -290,10 +295,56 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
CheckError: checkErr,
})
}
// scopeAwareChecker returns an error checker that enriches the
// LarkErrUserScopeInsufficient (99991679) business error with a
// per-method recommended `--scope` hint. All other non-zero codes fall
// through to legacy output.ErrAPI (matching pre-PR behaviour). The
// identity parameter is accepted to match the client.ResponseOptions
// CheckError signature; isBotMode is captured from the enclosing call so
// the recommended scope reflects the caller's identity at request time.
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
// + ConsoleURL on the typed envelope and remove this helper.
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error {
return func(result interface{}, _ core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return nil
}
larkCode := int(code)
msg := registry.GetStrFromMap(resultMap, "msg")
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
identity := "user"
if isBotMode {
identity = "tenant"
}
recommended := registry.SelectRecommendedScope(scopes, identity)
// Stage-1 carve-out: this restores the pre-PR scope-insufficient
// enrichment (recommended scope + auth-login hint) on the legacy
// envelope. The typed migration in stage 2+ will lift this into
// PermissionError.MissingScopes / ConsoleURL on the typed wire.
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
}
// Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError +
// ClassifyLarkError). Typed migration is stage-2+.
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
}
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
if ctx.Err() != nil {
@@ -339,7 +390,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
recommended := registry.SelectRecommendedScope(scopes, "user")
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
@@ -474,36 +525,10 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints.
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error {
return func(result interface{}) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return nil
}
larkCode := int(code)
msg := registry.GetStrFromMap(resultMap, "msg")
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
identity := "user"
if isBotMode {
identity = "tenant"
}
recommended := registry.SelectRecommendedScope(scopes, identity)
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
}
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
@@ -516,9 +541,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
return err
}
if apiErr := checkErr(result); apiErr != nil {
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return apiErr
}
if !hasItems {
@@ -529,9 +554,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
return err
}
if apiErr := checkErr(result); apiErr != nil {
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return apiErr
}
output.FormatValue(out, result, format)

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -412,39 +411,6 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
}
func TestServiceMethod_BotMode_APIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected API error")
}
var exitErr *output.ExitError
if !isExitError(err, &exitErr) {
t.Fatalf("expected ExitError, got: %T %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
}
// stdout must be empty on API error — error details belong in stderr envelope only.
// This guards against re-introducing duplicate output (see commit 86215a10).
if stdout.Len() > 0 {
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
}
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
@@ -662,73 +628,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
// ── scopeAwareChecker ──
func TestScopeAwareChecker_Success(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
if err != nil {
t.Errorf("expected nil error for code=0, got: %v", err)
}
}
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker("not a map")
if err != nil {
t.Errorf("expected nil for non-map result, got: %v", err)
}
}
func TestScopeAwareChecker_APIError(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
if err == nil {
t.Fatal("expected error for non-zero code")
}
if !strings.Contains(err.Error(), "API error: [40003]") {
t.Errorf("unexpected error: %v", err)
}
}
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
scopes := []interface{}{"calendar:read"}
checker := scopeAwareChecker(scopes, false)
err := checker(map[string]interface{}{
"code": float64(output.LarkErrUserScopeInsufficient),
"msg": "scope insufficient",
})
if err == nil {
t.Fatal("expected permission error")
}
var exitErr *output.ExitError
if !isExitError(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Detail.Type != "permission" {
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
}
}
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
scopes := []interface{}{"calendar:read"}
checker := scopeAwareChecker(scopes, true)
err := checker(map[string]interface{}{
"code": float64(output.LarkErrUserScopeInsufficient),
"msg": "scope insufficient",
})
if err == nil {
t.Fatal("expected permission error")
}
// Bot mode should still include the scope hint
if !strings.Contains(err.Error(), "insufficient permissions") {
t.Errorf("unexpected error: %v", err)
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
@@ -866,13 +765,3 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
// ── helpers ──
func isExitError(err error, target **output.ExitError) bool {
ee, ok := err.(*output.ExitError)
if ok && target != nil {
*target = ee
}
return ok
}

558
errs/ERROR_CONTRACT.md Normal file
View File

@@ -0,0 +1,558 @@
# lark-cli Error Contract
`errs/` defines a typed, RFC 7807aligned error taxonomy for the CLI. Three
audiences depend on it: **AI agents and shell scripts** parsing the JSON
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every **newly constructed** typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
It is `omitempty` and never carries CLI-internal meaning.
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
exported `*Error` structs that do not.
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
both are retired in the legacy-removal stage.
## Wire format
Typed errors render to **stderr** as one JSON object per process exit:
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "authorization",
"subtype": "missing_scope",
"code": 99991679,
"message": "missing scope `calendar:event:create` for app cli_xxx",
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"missing_scopes": ["calendar:event:create"],
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
}
}
```
| Field | Stability | Notes |
|-------|-----------|-------|
| `ok` | wire-stable | always `false` for errors |
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
| `error.type` | **wire-stable** | one of the 9 Categories |
| `error.subtype` | **wire-stable** | declared Subtype constant |
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
Carve-out: `SecurityPolicyError` keeps the legacy
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
envelope until its consumers migrate. Removal is staged in **Migration**.
## Categories
| Category | When | Exit | Typed struct |
|----------|------|------|--------------|
| `validation` | malformed user input | 2 | `ValidationError` |
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
> but the Go type is named `PermissionError`. This is deliberate, following
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
> `codes.PermissionDenied`): each name is chosen to be **maximally
> distinct and readable on its own**, not to be perfectly symmetric.
> `AuthenticationError` and `AuthorizationError` differ visually only at
> the 5th character and are easy to confuse in code review;
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
> field stays formal because it is the protocol-level taxonomy; the Go
> type favors call-site readability.
## Flow
```
call site
│ constructs typed error (e.g. *errs.ValidationError)
command runE returns err
cmd/root.go handleRootError dispatches:
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
├─ *core.ConfigError → asExitError adapts to legacy envelope ↓
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
```
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
## Consumers
### Go (in-process)
```go
var pe *errs.PermissionError
if errors.As(err, &pe) {
fmt.Println("missing:", pe.MissingScopes)
}
```
Predicates cover the common categories (`errs/predicates.go`):
```go
if errs.IsAuthentication(err) { ... }
if errs.IsPermission(err) { ... }
if errs.IsValidation(err) { ... }
```
Type-agnostic field access:
```go
if p, ok := errs.ProblemOf(err); ok {
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
}
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
```
### Shell / AI
```bash
out=$(lark-cli ... 2>&1)
code=$?
# Untyped / Cobra errors print plain text — guard before jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
fi
case "$(jq -r '.error.type // empty' <<<"$out")" in
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
network) echo "transport failure, safe to retry" ;;
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
esac
```
Unknown fields are forward-compatible additions: ignore, don't fail.
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
extension fields — `message` is human-readable prose that may be
reworded without notice.
## Producers
### Quick reference
| Situation | Use |
|-----------|-----|
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
| Login required | `&errs.AuthenticationError{...}` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `&errs.ConfigError{...}` |
| Transport failure | `&errs.NetworkError{...}` |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
### Authoring discipline
Five rules every producer follows. Some are enforced by `lint/errscontract`
AST guards (`go run -C lint . ..`); the rest by code review.
#### Propagate typed errors unchanged
A function that receives an error already carrying `errs.Problem`
returns it as-is up the stack. Reclassification at non-boundary frames
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
single-source taxonomy and silently downgrades typed signals.
Conforming:
```go
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary
}
```
Non-conforming:
```go
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
return &errs.InternalError{Cause: err} // re-decides category
```
#### Never return a typed-nil pointer
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
a non-nil interface — `errors.As` matches and `.Error()` may panic.
Return interface `nil` literally.
Non-conforming:
```go
var e *errs.ValidationError // nil pointer
return e // non-nil interface holding nil pointer
```
#### Let `Category` derive the exit code
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
codes during stage 1.)
#### Split `Message`, `Hint`, and `Cause`
Each field carries a distinct role:
| Field | Carries | Style |
|-------|---------|-------|
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
`Hint` must not be merged into `Message`. AI agents and humans read them
on separate channels; merging defeats both.
`Cause` must be a real `error`. If the upstream returned an `error`,
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
do not inline its `.Error()` into `Message`.
Conforming:
```go
return &errs.NetworkError{
Problem: errs.Problem{
Category: errs.CategoryNetwork,
Subtype: errs.SubtypeNetworkTransport,
Message: "request to /open-apis failed after 3 retries",
Hint: "check connectivity and retry; set --log-level debug if it persists",
},
Cause: ioErr,
}
```
Non-conforming:
```go
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### `ValidationError.Param` uses the `--flag` form
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
For positional arguments, use the canonical name without dashes
(`"target_user_id"`).
### Constructing typed errors
The minimal struct literal:
```go
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
},
Param: "--data",
}
```
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration; new code should prefer the struct
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target:
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
Hint: "pass a value in [1, 1440]",
},
Param: "--duration-minutes",
}
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
### Wrapping upstream errors
When a producer receives an error from a function it called, four cases
cover the decision:
| Source | Decision | Example |
|--------|----------|---------|
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` |
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
attaching an upstream error to a typed one. `Cause` is the chain
`errs.UnwrapTypedError` walks and the chain consumer code expects;
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
wire envelope does not surface.
#### Boundary helpers (framework-internal)
These helpers are called from framework boundaries, not from domain
code:
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
already-typed errors pass through unchanged.
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
boundary.
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
JSON parse failures to `*errs.InternalError`.
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
package, you are probably calling the SDK at the wrong layer — go
through `runtime.DoAPI`.
### Extending the taxonomy
#### Add a Subtype
1. Add a constant in `errs/subtypes.go` (framework) or
`errs/subtypes_service_<name>.go` (service).
2. If it maps from a Lark code, register the mapping in
`internal/errclass/codemeta_<service>.go`.
3. Add a dispatch test in `internal/errclass/classify_test.go`.
4. Reference the constant from a producer.
5. `go run -C lint . ..``CheckDeclaredSubtype` fails until the
constant is wired through.
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
declared constant within one week of introduction; `CheckAdHocSubtype`
emits a warning to keep them visible.
#### Add a typed Error struct
Rare; the existing structs cover the 9 Categories with room. If you must:
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
nil-receiver-safe `Unwrap()` if it carries `Cause`.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level.
## CI guards
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
## Stability
| Tier | Surface | Change policy |
|------|---------|---------------|
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
The error-contract refactor lands in stages. This PR is **stage 1**, and
its scope is **strictly framework-only**: every production wire shape
matches pre-PR byte-for-byte (additive fields only where the legacy slot
had no subtype emission). Stage 1 ships infrastructure; behavioural
migration of any specific path lives in later stages.
Stages:
1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy,
classifier (`internal/errclass`), promotion stub (`internal/errcompat`,
passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`),
and six lint guards (forbidigo + five AST checks). Wire shapes
preserved byte-for-byte versus pre-PR, with **one intentional semantic
fix**: config-class errors (`*core.ConfigError`) now exit `3` instead
of `2`, aligning with `ExitCodeForCategory` (config errors share the
auth exit slot per the taxonomy). The classifier and promote helpers
are *shipped but unused* in production paths — they exist so stage 2+
migrations can plug in without re-architecting.
2. **`SecurityPolicyError` typed envelope** — replace the legacy
`type: "auth_error"` carve-out with the typed shape.
3. **Business-domain migration**, one PR per domain in declared order:
`task → drive → calendar → im → mail → whiteboard → contact`. Each PR
moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` /
`output.ErrWithHint(...)` call sites to typed constructors or
`BuildAPIError`, removes its Deprecated annotations, and announces the
wire change explicitly.
4. **Framework-boundary migration**: `client.WrapDoAPIError` and
`client.WrapJSONResponseParseError` flip to typed wrap;
`client.CheckResponse` adopts `errclass.BuildAPIError`;
`internal/client/client.go resolveAccessToken` adopts the typed
`NeedAuthorizationError → *errs.AuthenticationError` recognition;
`cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed
`*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the
`Type="config"` (and later `Type="auth"`) branches to typed.
5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no
production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`,
`ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and
`ErrorEnvelope`.
During migration, helper assertions accept both shapes (see
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
so the build stays green domain-by-domain.
Before / after at a call site (illustrative — actually performed in
stage 3):
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
transport detection too narrow for this error (e.g. a `*url.Error` with an
inner that does not satisfy `net.Error`)? Either widen the transport
predicate or add an explicit typed wrap upstream.
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
Bare string literals trip `CheckDeclaredSubtype` unless they match the
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
follow-up warning.
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
slipped through. All typed errors define nil-safe `Unwrap()`, but
returning a typed-nil pointer up the stack still defeats `errors.As`.
Return interface `nil` from constructors, never a typed-nil pointer.
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
typed before reaching `handleRootError`. Wrap at the boundary
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
string cannot be classified retroactively.
## Security & privacy
- `log_id` is a server-side trace token. Safe to surface; it does not
carry user content.
- `missing_scopes` is app configuration, not user data.
- `Message` and `Hint` must not contain tokens, JWTs, or personally
identifying values. CI does not catch this — producer responsibility.
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
optional debug logging only.
## Pointers (task-driven)
- *Which struct to construct?* → **Producers / Quick reference**
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

19
errs/category.go Normal file
View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Category is the top-level taxonomy axis. Wire JSON: "type".
type Category string
const (
CategoryValidation Category = "validation"
CategoryAuthentication Category = "authentication"
CategoryAuthorization Category = "authorization"
CategoryConfig Category = "config"
CategoryNetwork Category = "network"
CategoryAPI Category = "api"
CategoryPolicy Category = "policy"
CategoryInternal Category = "internal"
CategoryConfirmation Category = "confirmation"
)

31
errs/category_test.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "testing"
func TestCategoryWireValues(t *testing.T) {
tests := []struct {
name string
got Category
want string
}{
{"validation", CategoryValidation, "validation"},
{"authentication", CategoryAuthentication, "authentication"},
{"authorization", CategoryAuthorization, "authorization"},
{"config", CategoryConfig, "config"},
{"network", CategoryNetwork, "network"},
{"api", CategoryAPI, "api"},
{"policy", CategoryPolicy, "policy"},
{"internal", CategoryInternal, "internal"},
{"confirmation", CategoryConfirmation, "confirmation"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.got) != tt.want {
t.Errorf("category %s = %q, want %q", tt.name, string(tt.got), tt.want)
}
})
}
}

37
errs/doc.go Normal file
View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package errs is the public error-contract surface for lark-cli.
//
// It defines a closed taxonomy (9 Categories) and a small set of typed
// errors that embed Problem — an RFC 7807-aligned shared shape. External
// consumers (AI agents, shell scripts, integrating SDKs) read structured
// fields instead of regex-parsing free-string error messages.
//
// # The Problem shape
//
// Every typed error embeds Problem so the JSON wire shape (`type`,
// `subtype`, `code`, `message`, `hint`, `log_id`, `retryable`) is uniform
// across categories. Typed extensions (PermissionError.MissingScopes,
// SecurityPolicyError.ChallengeURL, etc.) appear at the top level of the
// envelope alongside the shared fields, not nested under a `detail` key.
//
// # Working with typed errors
//
// Use ProblemOf to read shared fields polymorphically:
//
// if p, ok := errs.ProblemOf(err); ok {
// log.Printf("category=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
// }
//
// Use the IsXxx predicates or stdlib errors.As to branch on concrete type:
//
// if errs.IsPermission(err) {
// var pe *errs.PermissionError
// _ = errors.As(err, &pe)
// fmt.Println("missing scopes:", pe.MissingScopes)
// }
//
// Use WrapInternal at boundaries to lift any non-typed error to
// *InternalError; typed errors pass through unchanged.
package errs

11
errs/internal_carrier.go Normal file
View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// problemCarrier is the non-exported extraction interface.
// Used by ProblemOf via errors.As, working around the Go embed semantic where
// *Problem cannot match *PermissionError directly.
type problemCarrier interface {
ProblemDetail() *Problem
}

235
errs/marshal_test.go Normal file
View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"strings"
"testing"
)
// Per-type marshal tests pin each typed error's wire shape against its
// canonical fields. They guard against future refactors that change struct
// layout from accidentally altering the externally visible JSON contract.
//
// Each test asserts (a) Problem fields surface at the top level via embed
// promotion, (b) extension fields sit alongside as siblings (NOT under a
// `detail` sub-object), and (c) omitempty is honored on optional fields.
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
pe := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
},
MissingScopes: []string{"docx:document"},
Identity: "user",
ConsoleURL: "https://example",
}
b, err := json.Marshal(pe)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"authorization"`,
`"subtype":"missing_scope"`,
`"code":99991679`,
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
if strings.Contains(s, `"retryable"`) {
t.Errorf("retryable should be omitted when false; got %s", s)
}
if strings.Contains(s, `"detail"`) {
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
}
}
func TestValidationError_MarshalJSON(t *testing.T) {
ve := &ValidationError{
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
Param: "--scope",
}
b, _ := json.Marshal(ve)
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"invalid_argument"`,
`"message":"bad"`,
`"param":"--scope"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// Param omitempty when ""
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
b2, _ := json.Marshal(ve2)
if strings.Contains(string(b2), `"param"`) {
t.Errorf("param should be omitted when empty; got %s", b2)
}
}
func TestAuthError_MarshalJSON(t *testing.T) {
ae := &AuthenticationError{
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
UserOpenID: "ou_x",
}
b, _ := json.Marshal(ae)
s := string(b)
for _, want := range []string{
`"type":"authentication"`,
`"subtype":"token_expired"`,
`"message":"expired"`,
`"user_open_id":"ou_x"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestConfigError_MarshalJSON(t *testing.T) {
ce := &ConfigError{
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
Field: "app_id",
}
b, _ := json.Marshal(ce)
s := string(b)
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
CauseKind: "timeout",
}
b, _ := json.Marshal(ne)
s := string(b)
for _, want := range []string{
`"type":"network"`,
`"subtype":"transport"`,
`"cause":"timeout"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// CauseKind omitempty when ""
ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}}
b2, _ := json.Marshal(ne2)
if strings.Contains(string(b2), `"cause"`) {
t.Errorf("cause should be omitted when empty; got %s", b2)
}
}
func TestAPIError_MarshalJSON(t *testing.T) {
ae := &APIError{
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
Detail: map[string]any{"raw": "value"},
}
b, _ := json.Marshal(ae)
s := string(b)
for _, want := range []string{
`"type":"api"`,
`"subtype":"rate_limit"`,
`"code":99991400`,
`"retryable":true`,
`"detail":{`,
`"raw":"value"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// Detail omitempty when nil
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
b2, _ := json.Marshal(ae2)
if strings.Contains(string(b2), `"detail"`) {
t.Errorf("detail should be omitted when nil; got %s", b2)
}
}
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
spe := &SecurityPolicyError{
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
ChallengeURL: "https://chal.example",
}
b, _ := json.Marshal(spe)
s := string(b)
for _, want := range []string{
`"type":"policy"`,
`"subtype":"challenge_required"`,
`"challenge_url":"https://chal.example"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestContentSafetyError_MarshalJSON(t *testing.T) {
cse := &ContentSafetyError{
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
Rules: []string{"pii", "violence"},
}
b, _ := json.Marshal(cse)
s := string(b)
for _, want := range []string{
`"type":"policy"`,
`"rules":["pii","violence"]`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestInternalError_MarshalJSON(t *testing.T) {
ie := &InternalError{
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
}
b, _ := json.Marshal(ie)
s := string(b)
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
cre := &ConfirmationRequiredError{
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
Risk: "write",
Action: "mail +send",
}
b, _ := json.Marshal(cre)
s := string(b)
for _, want := range []string{
`"type":"confirmation"`,
`"risk":"write"`,
`"action":"mail +send"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}

88
errs/predicates.go Normal file
View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"errors"
)
// ProblemOf extracts the embedded Problem via the non-exported problemCarrier interface.
// This is the supported way to read shared fields without depending on a specific typed error.
//
// A typed error whose embedded *Problem is nil is treated as "not a problem
// carrier" — returning (nil, true) here would cause CategoryOf / IsRetryable
// and other downstream readers to dereference nil.
func ProblemOf(err error) (*Problem, bool) {
var c problemCarrier
if errors.As(err, &c) {
if p := c.ProblemDetail(); p != nil {
return p, true
}
}
return nil, false
}
// UnwrapTypedError walks the wrap chain and returns the first error that
// embeds Problem (i.e. any typed error in this package). Returns the typed
// error itself (as error) so callers — notably JSON marshaling — see the
// concrete value's own struct tags rather than an opaque wrapper.
func UnwrapTypedError(err error) (error, bool) {
var c problemCarrier
if errors.As(err, &c) {
if e, ok := c.(error); ok {
return e, true
}
}
return nil, false
}
// CategoryOf returns the error's Category for metrics/logging/dispatch routing.
// Falls back to CategoryInternal for non-typed errors.
func CategoryOf(err error) Category {
if p, ok := ProblemOf(err); ok {
return p.Category
}
return CategoryInternal
}
// IsRetryable reads Problem.Retryable; non-typed errors are non-retryable by default.
func IsRetryable(err error) bool {
if p, ok := ProblemOf(err); ok {
return p.Retryable
}
return false
}
// IsValidation reports whether err is a *ValidationError.
func IsValidation(err error) bool { var x *ValidationError; return errors.As(err, &x) }
// IsPermission reports whether err is a *PermissionError.
func IsPermission(err error) bool { var x *PermissionError; return errors.As(err, &x) }
// IsNetwork reports whether err is a *NetworkError.
func IsNetwork(err error) bool { var x *NetworkError; return errors.As(err, &x) }
// IsAPI reports whether err is an *APIError.
func IsAPI(err error) bool { var x *APIError; return errors.As(err, &x) }
// IsSecurityPolicy reports whether err is a *SecurityPolicyError.
func IsSecurityPolicy(err error) bool { var x *SecurityPolicyError; return errors.As(err, &x) }
// IsContentSafety reports whether err is a *ContentSafetyError.
func IsContentSafety(err error) bool { var x *ContentSafetyError; return errors.As(err, &x) }
// IsInternal reports whether err is an *InternalError.
func IsInternal(err error) bool { var x *InternalError; return errors.As(err, &x) }
// IsConfirmationRequired reports whether err is a *ConfirmationRequiredError.
func IsConfirmationRequired(err error) bool {
var x *ConfirmationRequiredError
return errors.As(err, &x)
}
// IsAuthentication reports whether err is an *AuthenticationError.
func IsAuthentication(err error) bool { var x *AuthenticationError; return errors.As(err, &x) }
// IsConfig reports whether err is a *ConfigError.
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }

203
errs/predicates_test.go Normal file
View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestIsRetryable(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "api error with retryable=true",
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI, Retryable: true}},
want: true,
},
{
name: "api error with retryable=false (zero)",
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsRetryable(tt.err); got != tt.want {
t.Errorf("IsRetryable(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestIsAuthTypedOnly(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "errs.AuthenticationError",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: true,
},
{
name: "errs.ConfigError",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsAuthentication(tt.err); got != tt.want {
t.Errorf("IsAuthentication(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestIsConfigTypedOnly(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "errs.ConfigError",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: true,
},
{
name: "errs.AuthenticationError",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsConfig(tt.err); got != tt.want {
t.Errorf("IsConfig(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestCategoryOf(t *testing.T) {
tests := []struct {
name string
err error
want errs.Category
}{
{
name: "typed validation error",
err: &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation}},
want: errs.CategoryValidation,
},
{
name: "typed permission error",
err: &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}},
want: errs.CategoryAuthorization,
},
{
name: "typed config error",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: errs.CategoryConfig,
},
{
name: "typed auth error",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: errs.CategoryAuthentication,
},
{
name: "plain error falls back to internal",
err: fmt.Errorf("plain"),
want: errs.CategoryInternal,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.CategoryOf(tt.err); got != tt.want {
t.Errorf("CategoryOf(%v) = %q, want %q", tt.err, got, tt.want)
}
})
}
}
// TestProblemOf_NilProblemReturnsFalse pins that a problemCarrier whose
// ProblemDetail() returns nil does NOT satisfy ProblemOf — otherwise
// CategoryOf / IsRetryable and other downstream readers would dereference
// nil and panic. *Problem(nil) is a directly constructable trigger: its
// ProblemDetail method `return p` is nil-safe and yields nil.
func TestProblemOf_NilProblemReturnsFalse(t *testing.T) {
var nilP *errs.Problem
var err error = nilP // *Problem implements error via Error() (nil-receiver safe)
p, ok := errs.ProblemOf(err)
if ok {
t.Fatalf("ProblemOf(*Problem(nil)) = (%v, true); want (nil, false)", p)
}
if p != nil {
t.Errorf("ProblemOf(*Problem(nil)).p = %v; want nil", p)
}
// Downstream readers must not panic on the same input.
if cat := errs.CategoryOf(err); cat != errs.CategoryInternal {
t.Errorf("CategoryOf(*Problem(nil)) = %q, want fallback %q", cat, errs.CategoryInternal)
}
if retryable := errs.IsRetryable(err); retryable {
t.Errorf("IsRetryable(*Problem(nil)) = true; want false")
}
}
func TestTypedPredicates(t *testing.T) {
cases := []struct {
name string
err error
pred func(error) bool
want bool
}{
{"IsValidation+", &errs.ValidationError{}, errs.IsValidation, true},
{"IsValidation-", &errs.APIError{}, errs.IsValidation, false},
{"IsPermission+", &errs.PermissionError{}, errs.IsPermission, true},
{"IsPermission-", &errs.APIError{}, errs.IsPermission, false},
{"IsNetwork+", &errs.NetworkError{}, errs.IsNetwork, true},
{"IsAPI+", &errs.APIError{}, errs.IsAPI, true},
{"IsSecurityPolicy+", &errs.SecurityPolicyError{}, errs.IsSecurityPolicy, true},
{"IsContentSafety+", &errs.ContentSafetyError{}, errs.IsContentSafety, true},
{"IsInternal+", &errs.InternalError{}, errs.IsInternal, true},
{"IsConfirmationRequired+", &errs.ConfirmationRequiredError{}, errs.IsConfirmationRequired, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.pred(tc.err); got != tc.want {
t.Errorf("%s: predicate = %v, want %v", tc.name, got, tc.want)
}
})
}
}

38
errs/problem.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
//
// Message is REQUIRED. Producers must populate it; an empty Message will make
// Error() return "" — a known Go footgun for fmt.Errorf("...: %v", err).
//
// Wire-format notes:
// - No Component field. Service / shortcut component is metric-only
// enrichment derived by the dispatcher from the cobra command path; it
// never appears on the wire.
// - No DocURL field. PermissionError carries the same intent via its typed
// ConsoleURL extension; other typed errors do not link out.
// - Retryable uses omitempty so only `true` is emitted; consumers treat
// absence as false.
type Problem struct {
Category Category `json:"type"`
Subtype Subtype `json:"subtype,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
LogID string `json:"log_id,omitempty"`
Retryable bool `json:"retryable,omitempty"`
}
// Error satisfies the standard `error` interface. A nil receiver is treated
// as the empty string so a stray nil *Problem stored in an error interface
// cannot panic the dispatcher.
func (p *Problem) Error() string {
if p == nil {
return ""
}
return p.Message
}
func (p *Problem) ProblemDetail() *Problem { return p }

72
errs/problem_test.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"reflect"
"testing"
)
func TestProblemError(t *testing.T) {
tests := []struct {
name string
p Problem
want string
}{
{"empty message", Problem{}, ""},
{"plain message", Problem{Message: "boom"}, "boom"},
{"message ignores hint", Problem{Message: "msg", Hint: "do x"}, "msg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (&tt.p).Error(); got != tt.want {
t.Errorf("Error() = %q, want %q", got, tt.want)
}
})
}
}
// TestProblemError_NilReceiverDoesNotPanic pins the nil-receiver guard on
// (*Problem).Error(). Without it, a nil *Problem stored in an error interface
// would panic when the root dispatcher calls err.Error() for logging.
func TestProblemError_NilReceiverDoesNotPanic(t *testing.T) {
var p *Problem // nil
defer func() {
if r := recover(); r != nil {
t.Fatalf("(*Problem)(nil).Error() panicked: %v", r)
}
}()
if got := p.Error(); got != "" {
t.Errorf("(*Problem)(nil).Error() = %q, want \"\"", got)
}
}
func TestProblemDetailReturnsReceiver(t *testing.T) {
p := &Problem{Message: "x"}
if got := p.ProblemDetail(); got != p {
t.Errorf("ProblemDetail() = %p, want receiver %p", got, p)
}
}
func TestProblemHasNoComponentField(t *testing.T) {
if f, ok := reflect.TypeOf(Problem{}).FieldByName("Component"); ok {
t.Errorf("Problem.Component must not exist; got field %#v", f)
}
}
func TestProblemHasNoDocURLField(t *testing.T) {
if f, ok := reflect.TypeOf(Problem{}).FieldByName("DocURL"); ok {
t.Errorf("Problem.DocURL must not exist on the base Problem (PermissionError carries ConsoleURL instead); got field %#v", f)
}
}
func TestProblemCategoryTagIsType(t *testing.T) {
f, ok := reflect.TypeOf(Problem{}).FieldByName("Category")
if !ok {
t.Fatalf("Problem.Category must exist")
}
if got := f.Tag.Get("json"); got != "type" {
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
}
}

75
errs/subtypes.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Subtype is the second-level taxonomy axis. Wire JSON: "subtype".
type Subtype string
const (
SubtypeUnknown Subtype = "unknown" // catch-all fallback; producers must prefer a specific subtype
)
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
)
// CategoryAuthentication subtypes
const (
SubtypeTokenMissing Subtype = "token_missing" // no token in request (Authorization header absent / no local token cache)
SubtypeTokenInvalid Subtype = "token_invalid" // token present but content/format wrong
SubtypeTokenExpired Subtype = "token_expired" // token explicitly expired
SubtypeRefreshTokenInvalid Subtype = "refresh_token_invalid" // refresh_token is v1 legacy format, unusable
SubtypeRefreshTokenExpired Subtype = "refresh_token_expired" // refresh_token expired
SubtypeRefreshTokenRevoked Subtype = "refresh_token_revoked" // refresh_token revoked (user logout / admin action)
SubtypeRefreshTokenReused Subtype = "refresh_token_reused" // refresh_token already used (single-use rotation triggered)
SubtypeRefreshServerError Subtype = "refresh_server_error" // refresh endpoint transient error (retryable)
)
// CategoryAuthorization subtypes
const (
SubtypeMissingScope Subtype = "missing_scope" // user authorized app but did not grant this scope
SubtypeUserUnauthorized Subtype = "user_unauthorized" // user never authorized the app
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
)
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
)
// CategoryNetwork subtypes
const (
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
)
// CategoryAPI subtypes
const (
SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded
SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification)
SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported)
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
)
// CategoryPolicy subtypes (security-policy envelope shape)
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
)
// CategoryInternal subtypes
const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
)
// CategoryConfirmation subtypes intentionally have no declarations yet.

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Service-specific Subtype declarations. Per-service files follow the
// naming pattern subtypes_service_<name>.go so the framework's closed
// Subtype enum stays readable while service taxonomies remain visible.
// Task service subtypes — consumed by internal/errclass/codemeta_task.go.
const (
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
SubtypeTaskPermissionDenied Subtype = "task_permission_denied"
SubtypeTaskNotFound Subtype = "task_not_found"
SubtypeTaskConflict Subtype = "task_conflict"
SubtypeTaskServerError Subtype = "task_server_error"
SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit"
SubtypeTaskFollowerLimit Subtype = "task_follower_limit"
SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit"
SubtypeTaskReminderExists Subtype = "task_reminder_exists"
)

136
errs/types.go Normal file
View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// ValidationError is the typed error for CategoryValidation.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Cause error `json:"-"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
// it. A nil typed-pointer held inside an error interface is treated as
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
func (e *ValidationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// AuthenticationError is the typed error for CategoryAuthentication.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type AuthenticationError struct {
Problem
UserOpenID string `json:"user_open_id,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *AuthenticationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// PermissionError is the typed error for CategoryAuthorization.
type PermissionError struct {
Problem
MissingScopes []string `json:"missing_scopes,omitempty"`
Identity string `json:"identity,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
}
// ConfigError is the typed error for CategoryConfig.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *ConfigError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// NetworkError is the typed error for CategoryNetwork.
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
// not serialized.
type NetworkError struct {
Problem
CauseKind string `json:"cause,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *NetworkError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
// business errors). Detail preserves the raw Lark error map for diagnostics.
type APIError struct {
Problem
Detail map[string]any `json:"detail,omitempty"`
}
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
type SecurityPolicyError struct {
Problem
ChallengeURL string `json:"challenge_url,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *SecurityPolicyError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
type ContentSafetyError struct {
Problem
Rules []string `json:"rules,omitempty"`
}
// InternalError is the typed error for CategoryInternal.
// Cause is preserved for logging but not emitted on the wire.
type InternalError struct {
Problem
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *InternalError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
// Risk is one of: "read" | "write" | "high-risk-write".
type ConfirmationRequiredError struct {
Problem
Risk string `json:"risk"`
Action string `json:"action"`
}

154
errs/types_test.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestPermissionErrorJSONShape(t *testing.T) {
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "x",
},
MissingScopes: []string{"docx:document"},
}
b, err := json.Marshal(perm)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
mustContain := []string{
`"type":"authorization"`,
`"subtype":"missing_scope"`,
`"missing_scopes":["docx:document"]`,
}
for _, want := range mustContain {
if !strings.Contains(got, want) {
t.Errorf("json output missing %q\nfull output: %s", want, got)
}
}
mustNotContain := []string{
`"component"`,
`"doc_url"`,
`"retryable":false`,
}
for _, bad := range mustNotContain {
if strings.Contains(got, bad) {
t.Errorf("json output unexpectedly contains %q\nfull output: %s", bad, got)
}
}
}
// TestEmbedSemanticChasm proves the documented Go embed limitation:
// errors.As(*PermissionError, &p *Problem) returns false even though
// PermissionError embeds Problem. ProblemOf works around this by routing
// via the unexported problemCarrier interface.
func TestEmbedSemanticChasm(t *testing.T) {
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "missing",
},
}
var p *Problem
if errors.As(perm, &p) {
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
}
got, ok := ProblemOf(perm)
if !ok {
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
}
if got != &perm.Problem {
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
}
if got.Category != CategoryAuthorization {
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
}
}
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
orig := errors.New("transport stalled")
spe := &SecurityPolicyError{
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
Cause: orig,
}
if got := errors.Unwrap(spe); got != orig {
t.Fatalf("errors.Unwrap(spe) = %v, want %v", got, orig)
}
if !errors.Is(spe, orig) {
t.Fatal("errors.Is(spe, orig) = false, want true")
}
}
// TestTypedErrors_UnwrapNilReceiver pins the nil-receiver guard on every typed
// error's Unwrap. Without these, a typed-nil pointer stored in an error
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {
name string
call func() error
}{
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("(*%s)(nil).Unwrap() panicked: %v", c.name, r)
}
}()
if got := c.call(); got != nil {
t.Errorf("(*%s)(nil).Unwrap() = %v, want nil", c.name, got)
}
})
}
}
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
// nil-safety guard above does not silently drop a real Cause on non-nil
// receivers. Without this, a buggy refactor could change `return e.Cause` to
// `return nil` and the test suite would still pass.
func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
cause := errors.New("upstream cause")
cases := []struct {
name string
err interface{ Unwrap() error }
}{
{"ValidationError", &ValidationError{Cause: cause}},
{"AuthenticationError", &AuthenticationError{Cause: cause}},
{"ConfigError", &ConfigError{Cause: cause}},
{"NetworkError", &NetworkError{Cause: cause}},
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
{"InternalError", &InternalError{Cause: cause}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.err.Unwrap(); got != cause {
t.Errorf("(*%s).Unwrap() = %v, want %v", c.name, got, cause)
}
})
}
}

27
errs/wrap.go Normal file
View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// WrapInternal wraps a non-typed error into *InternalError.
// Typed errors (anything implementing problemCarrier) pass through unchanged.
// Component is metric-only and derived by the dispatcher, so it is not a parameter here.
func WrapInternal(err error) error {
if err == nil {
return nil
}
var c problemCarrier
if errors.As(err, &c) {
return err
}
return &InternalError{
Problem: Problem{
Category: CategoryInternal,
Subtype: SubtypeUnknown,
Message: err.Error(),
},
Cause: err,
}
}

49
errs/wrap_test.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"errors"
"fmt"
"testing"
)
func TestWrapInternalPlainError(t *testing.T) {
orig := fmt.Errorf("boom")
wrapped := WrapInternal(orig)
var ie *InternalError
if !errors.As(wrapped, &ie) {
t.Fatalf("WrapInternal did not produce *InternalError; got %T", wrapped)
}
if ie.Category != CategoryInternal {
t.Errorf("Category = %q, want %q", ie.Category, CategoryInternal)
}
if ie.Subtype != SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", ie.Subtype, SubtypeUnknown)
}
if ie.Message != "boom" {
t.Errorf("Message = %q, want %q", ie.Message, "boom")
}
if ie.Cause != orig {
t.Errorf("Cause = %v, want original error %v", ie.Cause, orig)
}
if got := errors.Unwrap(wrapped); got != orig {
t.Errorf("errors.Unwrap = %v, want original %v", got, orig)
}
}
func TestWrapInternalPassesThroughTyped(t *testing.T) {
apiErr := &APIError{Problem: Problem{Category: CategoryAPI, Message: "api boom"}}
got := WrapInternal(apiErr)
if got != apiErr {
t.Errorf("WrapInternal should pass through typed errors unchanged; got %#v want %#v", got, apiErr)
}
}
func TestWrapInternalNil(t *testing.T) {
if got := WrapInternal(nil); got != nil {
t.Errorf("WrapInternal(nil) = %v, want nil", got)
}
}

View File

@@ -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

View File

@@ -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,
}
}
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
// This shape is forward-compat for a future server-side migration to the
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
// for the shape observed in production today.
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32603, // JSON-RPC internal error
"message": "challenge required",
"data": map[string]interface{}{
"code": 21000, // Lark code for challenge_required
"type": "policy",
"subtype": "challenge_required",
"challenge_url": "https://example.com/challenge",
"hint": "please complete the challenge in your browser",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Code != 21000 {
t.Errorf("Code = %d, want 21000", spErr.Code)
}
if spErr.Subtype != errs.SubtypeChallengeRequired {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
}
if spErr.ChallengeURL != "https://example.com/challenge" {
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
}
if spErr.Hint != "please complete the challenge in your browser" {
t.Errorf("Hint = %q", spErr.Hint)
}
}
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
// in production from the MCP gateway: the Lark code sits in the outer
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
// The transport's outer-code fallback path must recognise the policy code and
// surface the typed error with the hint promoted.
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": 21001, // outer slot carries the Lark code
"message": "access denied",
"data": map[string]interface{}{
"challenge_url": "https://example.com/c",
"cli_hint": "contact admin",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Subtype != errs.SubtypeAccessDenied {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
}
// `cli_hint` must surface when `hint` is absent.
if spErr.Hint != "contact admin" {
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
}
}
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
// nil (passes through) when the Lark code does not classify as
// CategoryPolicy — keeps regular API errors out of the security-policy path.
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": -32603,
"message": "permission denied",
"data": map[string]interface{}{
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
"type": "authorization",
},
},
}
if err := transport.tryHandleMCPResponse(result); err != nil {
t.Fatalf("expected nil (non-policy code), got %v", err)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -6,31 +6,15 @@ package client
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
@@ -66,3 +50,127 @@ func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
// the documented 3-branch behaviour: empty (or whitespace-only) response
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
// only "\n" must not be reclassified as transport failures.
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") {
t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail)
}
}
}
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
// (the SDK delivered something but the read itself failed mid-flight).
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
raw := errors.New("connection reset by peer")
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
}
}
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
// already-classified *output.ExitError (e.g. output.ErrAuth from
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
// intact. Without this, missing-token errors regress from exit 3/auth to
// exit 4/network at the SDK boundary.
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
cases := []struct {
name string
in error
want int
wantType string
}{
{"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"},
{"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"},
{"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := WrapDoAPIError(tc.in)
if got != tc.in {
t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in)
}
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", got)
}
if exitErr.Code != tc.want {
t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want)
}
if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType {
t.Fatalf("Detail.Type = %q, want %q (detail=%#v)",
func() string {
if exitErr.Detail == nil {
return "<nil>"
}
return exitErr.Detail.Type
}(),
tc.wantType, exitErr.Detail)
}
})
}
}
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
// (carries an embedded Problem) passes through unchanged. Forward-compat for
// stage-4 credential chain migration that will return *errs.AuthenticationError
// directly instead of legacy output.ErrAuth.
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
cases := []error{
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}},
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}},
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
}
for _, in := range cases {
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
got := WrapDoAPIError(in)
if got != in {
t.Fatalf("expected identity passthrough, got %T %v", got, got)
}
})
}
}
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
// error wraps a JSON decode error somewhere in its chain, the outer
// classification takes precedence — we never re-classify an already-typed error
// as a JSON parse error.
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
jsonErr := &json.SyntaxError{Offset: 1}
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
got := WrapDoAPIError(authWrappingJSON)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", got)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
}
}

View File

@@ -91,12 +91,28 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
//
// SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
// one remembering to wrap. In stage 1 that wire shape is still the legacy
// *output.ExitError envelope (network / api_error) — the stage-4 framework
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
// Errors that arrive already-classified (legacy *output.ExitError from
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
// future stages) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
return nil, err
// WrapDoAPIError is idempotent on already-classified errors:
// the *output.ExitError that resolveAccessToken returns for missing
// tokens (via output.ErrAuth) passes through with its auth category
// and exit 3 intact, and any future typed *errs.* error from the
// credential chain survives the same way. Only stray untyped errors
// (raw fmt.Errorf) get the transport-or-internal fallback.
return nil, WrapDoAPIError(err)
}
if as.IsBot() {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
@@ -107,7 +123,11 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
}
opts = append(opts, extraOpts...)
return c.SDK.Do(ctx, req, opts...)
resp, err := c.SDK.Do(ctx, req, opts...)
if err != nil {
return nil, WrapDoAPIError(err)
}
return resp, nil
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
@@ -123,7 +143,10 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
// Resolve auth
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
return nil, err
// See DoSDKRequest comment on the same wrap pattern; the typed
// auth-error pass-through plus untyped fallback applies equally to
// streaming requests.
return nil, WrapDoAPIError(err)
}
// Build URL
@@ -259,14 +282,27 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
}
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
// directly when the response may not be JSON (e.g. file downloads).
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see an *output.ExitError envelope (api_error for malformed JSON, network
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
// framework-boundary migration will flip this wrapper to typed
// *errs.InternalError / *errs.NetworkError.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
return nil, err
}
return ParseJSONResponse(resp)
result, parseErr := ParseJSONResponse(resp)
if parseErr != nil {
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
}
return result, nil
}
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
@@ -410,10 +446,14 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
}
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
// Returns nil if result is not a map, map is nil, or code is 0.
func CheckLarkResponse(result interface{}) error {
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
//
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
// existing callers keep emitting the same envelope until per-domain
// migration to typed errors. The identity parameter is reserved for the
// stage-2 typed path; stage-1 ignores it.
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil

View File

@@ -45,12 +45,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
return &credential.TokenResult{Token: "test-token"}, nil
}
type missingTokenResolver struct{}
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
@@ -434,42 +428,118 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
}
}
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
return nil, nil
}))
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/test",
}, core.AsBot)
if err == nil {
t.Fatal("DoSDKRequest() error = nil, want auth error")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
}
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
}
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
// invariant codex caught the day this PR landed: when resolveAccessToken
// produces output.ErrAuth ("no access token available for <identity>"),
// DoSDKRequest must surface it with the original auth classification —
// not silently downgrade it to a network error via the SDK-failure wrap.
//
// Regression scenario: shortcut path
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
// and routed agents into "check your connection" instead of "log in".
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "https://example.com/open-apis/test",
}, core.AsBot)
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsUser)
if err == nil {
t.Fatal("DoStream() error = nil, want auth error")
t.Fatal("expected auth error, got nil")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoStream() error = %v, want auth error", err)
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
}
}
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
// SDK transport errors get the network classification via WrapDoAPIError.
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
// *url.Error, which the wrap classifier recognises as a transport error.
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsBot)
if err == nil {
t.Fatal("expected error from broken transport, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
}
}
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
// *errs.InternalError; until then this test pins the legacy shape so we do
// not regress envelope coverage.
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
}, nil
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.CallAPI(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users/me",
As: "bot",
})
if err == nil {
t.Fatal("expected JSON parse error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
}
if exitErr.Detail.Hint != rawAPIJSONHint {
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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{

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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

View File

@@ -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,

View File

@@ -0,0 +1,284 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
}
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
// Returns nil when resp is nil or resp["code"] is 0.
//
// Routing by Category:
//
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
// Authentication → *errs.AuthenticationError
// Config → *errs.ConfigError
// Policy → *errs.SecurityPolicyError
// Validation → *errs.ValidationError
// Network → *errs.NetworkError
// Internal → *errs.InternalError
// Confirmation → *errs.ConfirmationRequiredError
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
//
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
// CategoryAPI + SubtypeUnknown.
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if resp == nil {
return nil
}
code := intFromAny(resp["code"])
if code == 0 {
return nil
}
msg, _ := resp["msg"].(string)
if msg == "" {
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
// so the typed wire envelope still carries a human-readable signal.
msg = fmt.Sprintf("API error: [%d]", code)
}
// Lark API responses sometimes carry log_id at the top level
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
// back to the nested location so log_id always surfaces on the typed
// envelope.
logID, _ := resp["log_id"].(string)
if logID == "" {
if errBlock, ok := resp["error"].(map[string]any); ok {
if nested, ok := errBlock["log_id"].(string); ok {
logID = nested
}
}
}
meta, ok := LookupCodeMeta(code)
if !ok {
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
}
base := errs.Problem{
Category: meta.Category,
Subtype: meta.Subtype,
Code: code,
Message: msg,
LogID: logID,
Retryable: meta.Retryable,
}
switch meta.Category {
case errs.CategoryAuthorization:
return buildPermissionError(base, resp, cc)
case errs.CategoryAuthentication:
return &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
return &errs.ConfigError{Problem: base}
case errs.CategoryPolicy:
return buildSecurityPolicyError(base, resp)
case errs.CategoryValidation:
return &errs.ValidationError{Problem: base}
case errs.CategoryNetwork:
return &errs.NetworkError{Problem: base}
case errs.CategoryInternal:
return &errs.InternalError{Problem: base}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{Problem: base}
default:
return &errs.APIError{Problem: base, Detail: resp}
}
}
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
// response's data block, so the typed SecurityPolicyError carries the same
// browser-challenge information that internal/auth/transport.go surfaces at
// the HTTP layer.
//
// Data shapes accepted (whichever the upstream sends):
//
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
//
// challenge_url is dropped (set to "") if it is not an https:// URL — same
// validation policy as internal/auth/transport.go.isValidChallengeURL.
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
// either spelling surfaces, matching the transport layer.
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
dataMap, _ := resp["data"].(map[string]any)
if dataMap == nil {
if errBlock, ok := resp["error"].(map[string]any); ok {
dataMap, _ = errBlock["data"].(map[string]any)
}
}
if dataMap == nil {
return &errs.SecurityPolicyError{Problem: p}
}
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
if challengeURL != "" && !isHTTPSURL(challengeURL) {
challengeURL = ""
}
hint := stringFromAny(dataMap["hint"])
if hint == "" {
hint = stringFromAny(dataMap["cli_hint"])
}
if hint != "" {
p.Hint = hint
}
return &errs.SecurityPolicyError{
Problem: p,
ChallengeURL: challengeURL,
}
}
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
func isHTTPSURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Scheme == "https"
}
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
func stringFromAny(v any) string {
s, _ := v.(string)
return s
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity
if identity == "" {
identity = "user"
}
p.Hint = PermissionHint(missing, identity, p.Subtype)
return &errs.PermissionError{
Problem: p,
MissingScopes: missing,
Identity: identity,
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
}
}
// PermissionHint returns an actionable next-step string for a permission
// error. User identity with a missing user-scope is recovered by re-running
// `auth login --scope ...`; bot identity or app-level scope errors are
// recovered by enabling scopes in the open-platform console. The subtype
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
// where re-authentication will not help regardless of the caller identity.
//
// Exported so direct construction sites (cmd/service/service.go's
// checkServiceScopes) can produce hints that match the dispatcher path
// byte-for-byte instead of hand-rolling divergent strings.
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
// app_scope_not_enabled means the scope has not been granted at the
// app (developer console) level — re-authenticating cannot fix it,
// so route every caller identity to the console hint.
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
if len(missing) == 0 {
if useConsole {
return "check the app's scope grant in the Lark open platform console"
}
return "ensure the calling identity has been granted the required scopes"
}
scopes := strings.Join(missing, " ")
if useConsole {
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
}
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return nil
}
raw, ok := errBlock["permission_violations"].([]any)
if !ok || len(raw) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, v := range raw {
m, ok := v.(map[string]any)
if !ok {
continue
}
s, _ := m["subject"].(string)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return int(i)
}
f, err := n.Float64()
if err == nil {
return int(f)
}
}
return 0
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass_test
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
// missingScopeResp builds a minimal Lark missing-scope response with one
// violation. Shared across the envelope-shape and brand-switch tests.
func missingScopeResp(scope string) map[string]any {
return map[string]any{
"code": 99991679,
"msg": "scope missing",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": scope},
},
},
}
}
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
t.Errorf("nil resp should return nil error, got %v", got)
}
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
t.Errorf("code=0 should return nil error, got %v", got)
}
// json.Number 0 path (real-world SDK decodes with UseNumber)
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
t.Errorf("json.Number(0) should return nil error, got %v", got)
}
}
// matchesTypedError reports whether err is the typed-error variant identified by
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
// through to *APIError) fails loudly instead of passing on Category alone.
func matchesTypedError(err error, wantTyped string) bool {
switch wantTyped {
case "PermissionError":
var x *errs.PermissionError
return errors.As(err, &x)
case "AuthenticationError":
var x *errs.AuthenticationError
return errors.As(err, &x)
case "ValidationError":
var x *errs.ValidationError
return errors.As(err, &x)
case "NetworkError":
var x *errs.NetworkError
return errors.As(err, &x)
case "ConfigError":
var x *errs.ConfigError
return errors.As(err, &x)
case "InternalError":
var x *errs.InternalError
return errors.As(err, &x)
case "ConfirmationRequiredError":
var x *errs.ConfirmationRequiredError
return errors.As(err, &x)
case "SecurityPolicyError":
var x *errs.SecurityPolicyError
return errors.As(err, &x)
case "APIError":
// APIError is the default fallback; use a direct type assertion to avoid
// matching against typed subclasses that also satisfy IsAPI.
_, ok := err.(*errs.APIError)
return ok
}
return false
}
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
cases := []struct {
name string
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantExit int
wantTyped string
}{
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := map[string]any{"code": tc.code, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
if err == nil {
t.Fatalf("expected error for code %d, got nil", tc.code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
}
if p.Category != tc.wantCat {
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
}
if p.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
}
if got := output.ExitCodeOf(err); got != tc.wantExit {
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
}
if !matchesTypedError(err, tc.wantTyped) {
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
}
})
}
}
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
// route accordingly so the embedded Problem.Category matches the wire type.
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
resp := map[string]any{"code": 1470400, "msg": "bad params"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected error for code 1470400")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if _, isAPI := err.(*errs.APIError); isAPI {
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
}
func TestPermissionErrorEnvelopeShape(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"log_id": "lg-1",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": "docx:document"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
var buf bytes.Buffer
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
if !ok {
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
}
out := buf.String()
// positive assertions
for _, want := range []string{
`"type": "authorization"`,
`"subtype": "missing_scope"`,
`"code": 99991679`,
`"missing_scopes":`,
`"docx:document"`,
`"console_url":`,
`open.feishu.cn/app/cli_a123/auth`,
`"identity": "user"`,
`"log_id": "lg-1"`,
} {
if !strings.Contains(out, want) {
t.Errorf("envelope missing %q\nfull: %s", want, out)
}
}
// negative assertions on the wire format
for _, mustNot := range []string{
`"component"`,
`"doc_url"`,
`"retryable":`, // Retryable defaults false, omitempty → key absent
} {
if strings.Contains(out, mustNot) {
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
}
}
}
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
// Test 1: Retryable:true → key present
apiErr := &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
}}
var buf bytes.Buffer
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
if !strings.Contains(buf.String(), `"retryable": true`) {
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
}
// Test 2: Retryable:false → key absent
buf.Reset()
apiErr2 := &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI, Message: "x", Retryable: false,
}}
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
}
if strings.Contains(buf.String(), `"retryable"`) {
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
}
}
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
pe := err.(*errs.PermissionError)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
}
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
// scope values so a hostile value cannot break out of the URL framing
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
tests := []struct {
name string
appID string
scopes []string
wantInURL []string // substrings that MUST appear
denyInURL []string // substrings that MUST NOT appear
}{
{
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
for _, want := range tt.wantInURL {
if !strings.Contains(got, want) {
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
}
}
for _, deny := range tt.denyInURL {
if strings.Contains(got, deny) {
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
}
}
})
}
}
func TestPermissionError_DefaultIdentity(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
pe := err.(*errs.PermissionError)
if pe.Identity != "user" {
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
}
}
func TestPermissionError_NoViolations(t *testing.T) {
// permission error without a permission_violations array → MissingScopes nil,
// ConsoleURL falls back to the no-scope form.
resp := map[string]any{"code": 99991679, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := err.(*errs.PermissionError)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
func TestExtractMissingScopes_Dedup(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "x",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": "docx:document"},
map[string]any{"subject": "docx:document"}, // dup
map[string]any{"subject": ""}, // ignored
map[string]any{"subject": "im:message"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := err.(*errs.PermissionError)
if got, want := len(pe.MissingScopes), 2; got != want {
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
}
}
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
// identical whether produced via the dispatcher (BuildAPIError — the normal
// service / shortcut path) or constructed directly at the call site (the
// cmd/service permission path).
//
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
// exported PermissionHint and ConsoleURL helpers — the same helpers
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
// service.go line-by-line so a future drift on either side (e.g. a new
// extension field on PermissionError that only BuildAPIError populates) fails
// loudly here. The remaining limitation is that this test invokes the helpers
// directly rather than driving checkServiceScopes (which requires a credential
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
// mock harness lands.
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
const (
brand = "feishu"
appID = "cli_a123"
identity = "user"
)
missing := []string{"docx:document"}
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
resp := missingScopeResp(missing[0])
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
if !ok {
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
}
// Path B: direct construction — exactly mirrors cmd/service/service.go's
// checkServiceScopes (same helpers, same field-fill order). Code
// and Message are copied from Path A so the byte-comparison below isolates
// the contract under test (Hint + Identity + ConsoleURL convergence).
directErr := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
Code: dispatcherPE.Code,
Message: dispatcherPE.Message,
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
},
MissingScopes: missing,
Identity: identity,
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
}
var bufA, bufB bytes.Buffer
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
t.Fatal("dispatcher path failed to emit typed envelope")
}
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
t.Fatal("direct path failed to emit typed envelope")
}
if bufA.String() != bufB.String() {
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
}
}
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
// Mirrors what the cmd/service direct-construction path produces.
pe := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
Message: "missing required scope(s): docx:document",
},
MissingScopes: []string{"docx:document"},
Identity: "user",
}
if got := output.ExitCodeOf(pe); got != 3 {
t.Errorf("ExitCodeOf = %d, want 3", got)
}
if !errs.IsPermission(pe) {
t.Error("expected IsPermission(pe) == true")
}
}
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
var buf bytes.Buffer
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
}
if buf.Len() > 0 {
t.Errorf("expected no output for untyped error, got: %s", buf.String())
}
}
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
// Some Lark API responses carry log_id nested under "error" rather than
// at the top level. BuildAPIError must surface either location.
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"error": map[string]any{
"log_id": "lg-nested-123",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.LogID != "lg-nested-123" {
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
}
}
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"log_id": "lg-top-456",
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.LogID != "lg-top-456" {
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
}
}
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
if !strings.Contains(got, "lark-cli auth login") {
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
}
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
t.Errorf("user hint should include missing scopes; got %q", got)
}
}
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
if !strings.Contains(got, "open platform console") {
t.Errorf("bot hint should mention the open-platform console; got %q", got)
}
if strings.Contains(got, "auth login") {
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
}
}
func TestBuildPermissionHint_NoScopes(t *testing.T) {
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
}
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
}
}
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// 99991672 / app_scope_not_enabled means the scope has not been granted
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
if !strings.Contains(got, "open platform console") {
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
}
if strings.Contains(got, "auth login") {
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
}
}
}
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
// Regression: code 99991672 with user identity previously emitted
// `lark-cli auth login --scope ...` which sends agents into a re-auth
// loop because the missing scope is not yet enabled at the app level.
resp := map[string]any{
"code": 99991672,
"msg": "app scope not enabled",
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
}
if !strings.Contains(p.Hint, "open platform console") {
t.Errorf("Hint should route to console; got %q", p.Hint)
}
if strings.Contains(p.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
}
}
func TestPermissionError_HintPopulated(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.Hint == "" {
t.Error("PermissionError.Hint should be populated by BuildAPIError")
}
if !strings.Contains(p.Hint, "docx:document") {
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
}
}
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
// SDK parses with json.Number; verify intFromAny handles it.
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
if err == nil {
t.Fatal("expected error for json.Number-encoded code")
}
if _, ok := err.(*errs.PermissionError); !ok {
t.Errorf("expected *errs.PermissionError, got %T", err)
}
}
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
// passing through BuildAPIError keep the browser-challenge URL and hint —
// agents need challenge_url to drive the user through MFA / device-trust
// flows. Without extraction, the typed envelope is degenerate vs. what the
// internal/auth/transport.go HTTP-layer interceptor already produces.
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"data": map[string]any{
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
"hint": "complete MFA in the browser, then retry",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
}
if spe.Hint != "complete MFA in the browser, then retry" {
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
}
}
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
// using data.cli_hint still surface via Hint when data.hint is absent.
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
resp := map[string]any{
"code": 21001,
"msg": "access denied",
"data": map[string]any{
"cli_hint": "ask your admin for elevated approval",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.Hint != "ask your admin for elevated approval" {
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
}
}
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
// untrusted challenge_url (non-https) is dropped — same policy as
// internal/auth/transport.go isValidChallengeURL.
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
cases := []string{
"http://attacker.example.com/challenge",
"javascript:alert(1)",
"ftp://example.com/challenge",
"not a url at all",
}
for _, bad := range cases {
t.Run(bad, func(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"data": map[string]any{"challenge_url": bad, "hint": "h"},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
}
})
}
}
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
// envelope still routes correctly with empty extension fields when the
// upstream response carries only code+msg.
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
resp := map[string]any{"code": 21000, "msg": "challenge required"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
}
if spe.Message != "challenge required" {
t.Errorf("Message = %q, want challenge required", spe.Message)
}
}
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
// extension fields stay empty, no panic. Server-side bugs or transitional
// API shapes must never crash the CLI dispatcher.
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
}
}()
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
}
})
}
}
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
// {"error": {"data": {...}}} envelope variant — same lookup paths the
// transport-layer interceptor uses on inbound responses.
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"error": map[string]any{
"data": map[string]any{
"challenge_url": "https://passport.feishu.cn/c/abc",
"hint": "wrapped variant",
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
}
if spe.Hint != "wrapped variant" {
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"github.com/larksuite/cli/errs"
)
// CodeMeta is the classification metadata attached to a Lark numeric code.
// It does NOT carry Message or Hint — those are derived at the dispatcher
// (see BuildAPIError).
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
}
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
// policy/config codes shared across services) live here; service-specific
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
// merge into this map via init().
//
// Go language guarantees package-level vars initialize before init() functions,
// so sub-tables registering via init() can always assume codeMeta is non-nil.
var codeMeta = map[int]CodeMeta{
// CategoryAuthentication
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
// CategoryAuthorization
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
// CategoryAPI
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
// CategoryConfig
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
// CategoryPolicy
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
}
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
// the caller (BuildAPIError) is responsible for falling back to
// CategoryAPI/SubtypeUnknown.
func LookupCodeMeta(code int) (CodeMeta, bool) {
m, ok := codeMeta[code]
return m, ok
}
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
// codes into the central registry. Panics on duplicate code so a misregistration
// fails fast at startup rather than producing silently-inconsistent classification.
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
for code, meta := range src {
if existing, dup := codeMeta[code]; dup {
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
code, existing, owner, meta))
}
codeMeta[code] = meta
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// taskCodeMeta holds the task-service-specific Lark code classifications.
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
// map via mergeCodeMeta + LookupCodeMeta.
var taskCodeMeta = map[int]CodeMeta{
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
}
func init() { mergeCodeMeta(taskCodeMeta, "task") }

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMeta_MissingScope(t *testing.T) {
got, ok := LookupCodeMeta(99991679)
if !ok {
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
}
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
if got != want {
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
}
}
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
got, ok := LookupCodeMeta(1470403)
if !ok {
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
}
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
}
if got.Subtype != errs.Subtype("task_permission_denied") {
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
}
if got.Retryable {
t.Errorf("Retryable = true, want false")
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
}
if !got.Retryable {
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
}
if got.Category != errs.CategoryAuthentication {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
}
}
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
got, ok := LookupCodeMeta(99991400)
if !ok {
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
}
if !got.Retryable {
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
}
if got.Subtype != errs.SubtypeRateLimit {
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
}
}
func TestLookupCodeMeta_Unknown(t *testing.T) {
_, ok := LookupCodeMeta(999999)
if ok {
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
}
}
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
got, ok := LookupCodeMeta(21000)
if !ok {
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
}
if got.Category != errs.CategoryPolicy {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
}
if got.Subtype != errs.Subtype("challenge_required") {
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
}
}
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
defer func() {
r := recover()
if r == nil {
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
}
msg, ok := r.(string)
if !ok {
t.Fatalf("panic value is not a string: %T (%v)", r, r)
}
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
if !strings.Contains(msg, needle) {
t.Errorf("panic message %q missing substring %q", msg, needle)
}
}
}()
mergeCodeMeta(map[int]CodeMeta{
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
}, "test")
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package errcompat bridges the legacy *core.ConfigError shape into the
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
// placed in its own package so it can import both core (for the legacy
// type) and errs (for the typed targets) without creating an import cycle
// with internal/errclass, which intentionally avoids depending on
// internal/core.
package errcompat
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// PromoteConfigError is the stage-2 boundary helper that will convert a
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
// is a passthrough — the dispatcher continues to render *core.ConfigError
// via the legacy envelope path (cmd/root.go asExitError) so the wire
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
// will fill in the actual promotion logic alongside its corresponding
// wire-change announcement.
func PromoteConfigError(cfgErr *core.ConfigError) error {
if cfgErr == nil {
return nil
}
return cfgErr
}
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
var _ = errs.CategoryConfig

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat_test
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errcompat"
)
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
// behaviour: every input *core.ConfigError flows out unchanged so the
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
// Per-domain typed migration will replace this in stage 2+.
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
t.Run(cfgType, func(t *testing.T) {
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
out := errcompat.PromoteConfigError(src)
var got *core.ConfigError
if !errors.As(out, &got) || got != src {
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
}
})
}
}
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
if got := errcompat.PromoteConfigError(nil); got != nil {
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
}
}

View File

@@ -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

View File

@@ -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"`

View File

@@ -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"`
}

View File

@@ -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"])
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestExitCodeForCategory(t *testing.T) {
cases := []struct {
name string
cat errs.Category
want int
}{
{"validation", errs.CategoryValidation, 2},
{"authentication", errs.CategoryAuthentication, 3},
{"authorization", errs.CategoryAuthorization, 3},
{"config", errs.CategoryConfig, 3},
{"network", errs.CategoryNetwork, 4},
{"api", errs.CategoryAPI, 1},
{"policy", errs.CategoryPolicy, 6},
{"internal", errs.CategoryInternal, 5},
{"confirmation", errs.CategoryConfirmation, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ExitCodeForCategory(tc.cat); got != tc.want {
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
}
})
}
}
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
}
}
func TestExitCodeOf_Nil(t *testing.T) {
if got := ExitCodeOf(nil); got != 0 {
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
}
}
func TestExitCodeOf_PermissionError(t *testing.T) {
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
if got := ExitCodeOf(err); got != 3 {
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
}
}
func TestExitCodeOf_APIError(t *testing.T) {
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
if got := ExitCodeOf(err); got != 1 {
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
}
}
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
}
}

View File

@@ -3,8 +3,19 @@
package output
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
)
// Lark API generic error code constants.
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
//
// Kept as exported identifiers because external shortcut packages reference
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
// Subtype / Retryable metadata for each code lives in internal/errclass and
// must remain the single source of truth — ClassifyLarkError below resolves
// classification through errclass.LookupCodeMeta.
const (
// Auth: token missing / invalid / expired.
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
@@ -32,7 +43,6 @@ const (
LarkErrRefreshExpired = 20037 // refresh_token expired
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
@@ -58,59 +68,159 @@ const (
LarkErrOwnershipMismatch = 231205
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
// errType provides fine-grained classification in the JSON envelope;
// exitCode is kept coarse (ExitAuth or ExitAPI).
func ClassifyLarkError(code int, msg string) (int, string, string) {
switch code {
// auth: token missing / invalid / expired
case LarkErrTokenMissing, LarkErrTokenBadFmt:
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired:
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
// legacyHints supplies the per-code actionable hint string for the legacy
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
// composition is not yet centralized in errclass (the canonical
// PermissionHint lives there but the long-form per-code hints below are
// still wire-stable strings), so this small lookup remains here. Codes
// absent from this map fall back to "".
var legacyHints = map[int]string{
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
// permission: scope not granted
case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission,
LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized:
return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login"
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
// app credential / status
case LarkErrAppCredInvalid:
return ExitAuth, "config", "check app_id / app_secret: lark-cli config set"
case LarkErrAppNotInUse, LarkErrAppUnauthorized:
return ExitAuth, "app_status", "app is disabled or not installed — check developer console"
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrWikiLockContention:
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
// sheets-specific constraints that benefit from actionable hints
case LarkErrSheetsFloatImageInvalidDims:
return ExitAPI, "invalid_params",
"check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
// drive permission-apply specific guidance
case LarkErrDrivePermApplyRateLimit:
return ExitAPI, "rate_limit",
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
case LarkErrDrivePermApplyNotApplicable:
return ExitAPI, "invalid_params",
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
case LarkErrOwnershipMismatch:
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
}
return ExitAPI, "api_error", ""
LarkErrRateLimit: "please try again later",
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
}
// ClassifyLarkError maps a Lark API error code + message to the legacy
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
//
// Classification (Category / Subtype) is sourced from
// errclass.LookupCodeMeta — the single source of truth shipped for both
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
// not yet invoked in production). This function adapts that result back to
// the legacy tuple shape for callers that still go through *ExitError:
//
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
// Note this differs from the typed pipeline's ExitCodeForCategory in
// two preserved-legacy-quirks: Authorization+permission subtypes return
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
// - errType: legacy short string per (Category, Subtype), mapped by
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
// to "api_error".
// - hint: per-code lookup in legacyHints; "" when absent.
//
// Unknown codes (LookupCodeMeta returns false) classify as
// (ExitAPI, "api_error", "") — matching the prior default.
//
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
// surface that predates the typed error contract introduced by errs/. New
// code MUST NOT use it — classify Lark API responses via
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
// Category, Subtype, and identity-aware extension fields populated at the
// source. This helper is retained only while existing call sites are
// migrated; it will be removed once they have moved to the typed surface.
func ClassifyLarkError(code int, msg string) (int, string, string) {
meta, ok := errclass.LookupCodeMeta(code)
if !ok {
return ExitAPI, "api_error", ""
}
exitCode := legacyExitCode(meta.Category, meta.Subtype)
errType := legacyErrType(meta.Category, meta.Subtype)
hint := legacyHints[code]
// IM ownership mismatch keeps its dynamic recovery hint.
if code == LarkErrOwnershipMismatch {
hint = buildOwnershipRecoveryHint()
}
return exitCode, errType, hint
}
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
// code. It diverges from ExitCodeForCategory in two places to preserve the
// historic wire:
//
// - CategoryAuthorization with a "permission" subtype (missing_scope,
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
// ExitAuth (3). Legacy considered permission failures a generic API
// refusal.
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
// under the auth bucket.
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
switch cat {
case errs.CategoryAuthentication:
return ExitAuth
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return ExitAPI
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
return ExitAuth
}
return ExitAPI
case errs.CategoryConfig:
return ExitAuth
}
return ExitAPI
}
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
// string (e.g. "permission", "rate_limit"). Subtypes outside the
// historically-classified set fall back to "api_error", matching the prior
// default-case behavior.
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
switch cat {
case errs.CategoryAuthentication:
return "auth"
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return "permission"
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
return "app_status"
}
return "permission"
case errs.CategoryConfig:
switch sub {
case errs.SubtypeInvalidClient,
errs.SubtypeNotConfigured,
errs.SubtypeInvalidConfig:
return "config"
}
return "config"
case errs.CategoryAPI:
switch sub {
case errs.SubtypeRateLimit:
return "rate_limit"
case errs.SubtypeConflict:
return "conflict"
case errs.SubtypeCrossTenant:
return "cross_tenant"
case errs.SubtypeCrossBrand:
return "cross_brand"
case errs.SubtypeInvalidParameters:
return "invalid_parameters"
case errs.SubtypeOwnershipMismatch:
return "ownership_mismatch"
}
return "api_error"
}
return "api_error"
}

View File

@@ -30,7 +30,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantType: "cross_tenant",
wantHint: "same tenant and region/unit",
},
{
@@ -44,7 +44,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "sheets float image invalid dims",
code: LarkErrSheetsFloatImageInvalidDims,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantType: "invalid_parameters",
wantHint: "--width / --height / --offset-x / --offset-y",
},
{
@@ -58,7 +58,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "drive permission apply not applicable",
code: LarkErrDrivePermApplyNotApplicable,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantType: "invalid_parameters",
wantHint: "does not accept a permission-apply request",
},
{

109
lint/README.md Normal file
View File

@@ -0,0 +1,109 @@
# lint/
Source-level static checks that guard lark-cli conventions golangci-lint
cannot express. Each lint domain is a sibling Go package under `lint/`;
the top-level `lint/main.go` aggregates results and emits a single
exit code.
`lint/` is its own Go module so its `golang.org/x/tools/go/packages`
dependency does not leak into the shipped `lark-cli` binary's module
graph.
## Layout
```
lint/
├── go.mod # module github.com/larksuite/cli/lint
├── go.sum
├── main.go # package main — dispatches to every registered domain
├── lintapi/ # shared types every domain returns
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
└── errscontract/ # first domain: typed-error contract guards
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
├── runner.go
├── typecheck.go
├── violation.go # local type aliases to lintapi
├── rule_problem_embed.go
├── rule_no_registrar.go
├── rule_adhoc_subtype.go
├── rule_declared_subtype.go
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
```
## Running
```bash
# from the repo root (one level above lint/)
go run -C lint . ..
```
`-C lint` switches Go's working directory to `lint/`; the `..` argument
is the repo root to scan (relative to `lint/`).
CI: `.github/workflows/ci.yml` step `Run errs/ lint guards (lintcheck)`.
Exit codes follow `lint/main.go`:
| Code | Meaning |
|------|---------|
| 0 | no REJECT diagnostics (LABEL / WARNING are advisory) |
| 1 | one or more REJECT diagnostics |
| 2 | a domain's `ScanRepo` returned an error |
## Adding a new lint domain
1. Create a sibling package: `lint/<domain>/`. Pick a name that reads
like a category, not a list of rules (`errscontract/` covers many
error-contract rules; `flagnaming/` would cover many flag-related
rules).
2. Inside the new package, expose one public entry:
```go
package <domain>
import "github.com/larksuite/cli/lint/lintapi"
// ScanRepo walks root and returns every violation produced by this
// domain's checks. Domains MUST return []lintapi.Violation so the
// top-level dispatcher can aggregate uniformly.
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
```
3. Per-rule files are named `rule_<name>.go` with sibling
`rule_<name>_test.go`. Each rule function returns
`[]lintapi.Violation`. `runner.go` (or `scan.go`) composes the rules.
4. Register the domain in `lint/main.go`:
```go
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepo},
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
}
```
5. Verify locally:
```bash
go test -C lint ./... # all domains' tests
go run -C lint . .. # full scan against the repo
```
6. Document the rules. If they enforce a contract that already has a
spec (e.g. `errs/ERROR_CONTRACT.md`), add the lint entry to that
contract's "CI guards" table. Otherwise create a short spec
alongside the package.
## Rule severity conventions (`lintapi.Action`)
| Action | Effect | When to use |
|--------|--------|-------------|
| `ActionReject` | exit 1, fails CI | a contract violation that must be fixed before merge |
| `ActionLabel` | stderr only; CI can grep for `[needs-taxonomy-decision]` and label the PR | governance signal that asks a human to choose (e.g. `ad_hoc_*` subtype needs a taxonomy decision) |
| `ActionWarning`| stderr only | advisory hint surfaced to reviewers (typed scope unavailable, fallback to AST-only, etc.) — never gates merges |
Only `ActionReject` contributes to a nonzero exit code; `ActionLabel`
and `ActionWarning` are reviewer signal only.

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
// CheckAdHocSubtype detects `Subtype: "ad_hoc_*"` literals (and the
// errs.Subtype("ad_hoc_*") cast form) and emits a LABEL diagnostic so a CI
// workflow can tag the PR with `needs-taxonomy-decision`. This is a
// governance signal, NOT a hard rejection — ad_hoc_* is the reserved
// temporary namespace and is allowed for ≤1 week while taxonomy is finalized.
func CheckAdHocSubtype(path, src string) []Violation {
v, _ := scanSubtype(path, src, nil, nil, nil, "")
out := v[:0]
for _, vv := range v {
if vv.Action == ActionLabel {
out = append(out, vv)
}
}
return out
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"regexp"
)
// CheckDeclaredSubtype enforces that `Subtype:` literals resolve to a
// declared constant value (allowlist), match the ad_hoc_* namespace (deferred
// to CheckAdHocSubtype), or are dynamic (WARNING). Undeclared static literals are
// rejected.
//
// allowlist holds declared Subtype const values (e.g. "missing_scope"). The
// production CLI derives this from errs/subtypes*.go via the AST; unit tests
// pass in a fixture map. Passing nil disables CheckDeclaredSubtype entirely.
//
// Use CheckDeclaredSubtypeWithNames to additionally reject typo'd selector
// references like `errs.SubtypeBogus` that pass the "Subtype*" prefix gate but
// reference no declared constant.
func CheckDeclaredSubtype(path, src string, allowlist map[string]struct{}) []Violation {
return CheckDeclaredSubtypeWithNames(path, src, allowlist, nil)
}
// CheckDeclaredSubtypeWithNames is the strengthened entry point. When
// nameset is non-nil, selector references with the form `<pkg>.SubtypeFoo`
// must resolve to a declared name in the set; otherwise they emit REJECT.
// Passing nil for nameset preserves the legacy prefix-only behaviour.
func CheckDeclaredSubtypeWithNames(path, src string, allowlist, nameset map[string]struct{}) []Violation {
if allowlist == nil {
return nil
}
v, _ := scanSubtype(path, src, allowlist, nameset, nil, "")
out := v[:0]
for _, vv := range v {
if vv.Action == ActionReject || vv.Action == ActionWarning {
out = append(out, vv)
}
}
return out
}
// checkDeclaredSubtypeWithTypedScope is the production walker invoked by ScanRepo. When
// scope is enabled, every Subtype-shaped selector is resolved via type
// information first: a confirmed errs.Subtype constant skips the AST
// nameset check, and a foreign-package Subtype constant is rejected even
// when its name matches the nameset. Scope can be nil — in which case
// behaviour collapses to CheckDeclaredSubtypeWithNames.
//
// absPath is the absolute path used during go/packages loading so the
// typed scope can locate per-file *types.Info; rel is the human-readable
// path embedded in violation reports.
func checkDeclaredSubtypeWithTypedScope(rel, absPath, src string, allowlist, nameset map[string]struct{}, scope *TypedScope) []Violation {
if allowlist == nil {
return nil
}
v, _ := scanSubtype(rel, src, allowlist, nameset, scope, absPath)
out := v[:0]
for _, vv := range v {
if vv.Action == ActionReject || vv.Action == ActionWarning {
out = append(out, vv)
}
}
return out
}
// scanSubtype walks the file AST and classifies every `Subtype:` key-value
// assignment in a composite literal. It returns the full classified list; the
// two callers (CheckAdHocSubtype / CheckDeclaredSubtype) filter by Action.
//
// nameset, when non-nil, lets the classifier reject selector references that
// pass the "Subtype*" prefix gate but resolve to no declared constant.
//
// scope+absPath, when set, enable typed resolution: every Subtype-shaped
// identifier is first resolved through go/types to verify it references a
// constant declared in the canonical errs package. A foreign-package
// Subtype-named constant is rejected even when nameset permits it (because
// selector-name matching alone cannot distinguish packages).
func scanSubtype(path, src string, allowlist, nameset map[string]struct{}, scope *TypedScope, absPath string) ([]Violation, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil, err
}
adHoc := regexp.MustCompile(`^ad_hoc_[a-z0-9_]+$`)
var out []Violation
emit := func(pos token.Pos, c subtypeClassification) {
if c.action == "" {
return
}
out = append(out, Violation{
Rule: c.rule,
Action: c.action,
File: path,
Line: fset.Position(pos).Line,
Message: c.message,
Suggestion: c.suggestion,
})
}
// Track CompositeLit nodes whose Type elides to CodeMeta (map/slice
// elements where the outer Type already names CodeMeta). We populate this
// set on the outer pass so the inner pass can recognise positional
// `{cat, subtype, retryable}` entries that don't carry their own Type
// expression.
codeMetaElided := map[*ast.CompositeLit]bool{}
ast.Inspect(file, func(n ast.Node) bool {
outer, ok := n.(*ast.CompositeLit)
if !ok || !typeYieldsCodeMeta(outer.Type) {
return true
}
for _, el := range outer.Elts {
// `key: {cat, subtype, retryable}` — map literal
if kv, ok := el.(*ast.KeyValueExpr); ok {
if inner, ok := kv.Value.(*ast.CompositeLit); ok && inner.Type == nil {
codeMetaElided[inner] = true
}
continue
}
// `{cat, subtype, retryable}` — slice/array element
if inner, ok := el.(*ast.CompositeLit); ok && inner.Type == nil {
codeMetaElided[inner] = true
}
}
return true
})
ast.Inspect(file, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
// Keyed form: `Subtype: <expr>` — covered for every struct literal.
for _, el := range cl.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
keyIdent, ok := kv.Key.(*ast.Ident)
if !ok || keyIdent.Name != "Subtype" {
continue
}
emit(kv.Pos(), classifySubtypeExpr(kv.Value, allowlist, nameset, adHoc, scope, absPath))
}
// Positional form: `{cat, subtype, retryable}` used by
// internal/errclass/codemeta*.go for CodeMeta entries. Subtype is
// element [1] by positional convention. We inspect when the
// composite literal's Type expression directly names CodeMeta OR
// when the Type was elided because the enclosing map/slice already
// declared CodeMeta as its value type.
if (isCodeMetaType(cl.Type) || codeMetaElided[cl]) && len(cl.Elts) >= 2 {
// Don't double-emit if element [1] is itself a KeyValueExpr (handled above).
if _, isKV := cl.Elts[1].(*ast.KeyValueExpr); !isKV {
emit(cl.Elts[1].Pos(), classifySubtypeExpr(cl.Elts[1], allowlist, nameset, adHoc, scope, absPath))
}
}
return true
})
return out, nil
}
// isCodeMetaType reports whether a composite-literal Type expression directly
// names the CodeMeta struct (bare or qualified).
func isCodeMetaType(expr ast.Expr) bool {
switch t := expr.(type) {
case *ast.Ident:
return t.Name == "CodeMeta"
case *ast.SelectorExpr:
return t.Sel != nil && t.Sel.Name == "CodeMeta"
}
return false
}
// typeYieldsCodeMeta reports whether a Type expression for a map/slice/array
// composite literal has CodeMeta as its element/value type. Used so we can
// recognise that the elided `{cat, subtype, retryable}` entries inside such a
// literal are positional CodeMeta values whose Subtype lives at element [1].
func typeYieldsCodeMeta(expr ast.Expr) bool {
switch t := expr.(type) {
case *ast.MapType:
return isCodeMetaType(t.Value)
case *ast.ArrayType:
return isCodeMetaType(t.Elt)
}
return false
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckNoRegistrar forbids the registrar anti-pattern in service / internal
// packages (excluding internal/errclass, which legitimately owns codeMeta).
//
// Detects two registrar anti-patterns:
// 1. Direct call to mergeCodeMeta from outside internal/output
// (mergeCodeMeta is the central registry's panic-on-dup ingress)
// 2. Calls to functions matching the (*)RegisterServiceMap(*) pattern,
// a registrar antipattern that broke production/test parity
// (the registered service map wouldn't fire in test binaries that
// didn't transitively import the registering service).
func CheckNoRegistrar(path, src string) []Violation {
if !isServiceScope(path) {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
callee := calleeName(call.Fun)
if callee == "" {
return true
}
// The registrar antipattern can hide behind middle affixes too
// (e.g. FooRegisterServiceMapBar). strings.Contains catches all
// shapes that the prefix/suffix pair missed.
if callee == "mergeCodeMeta" || strings.Contains(callee, "RegisterServiceMap") {
out = append(out, Violation{
Rule: "no_registrar",
Action: ActionReject,
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "registrar pattern forbidden: " + callee + " must not be called from service / internal code",
Suggestion: "add CodeMeta entries in internal/errclass/codemeta_<service>.go (same-package init()); " +
"registries fail silently when the service is not transitively imported by the test binary",
})
}
return true
})
return out
}
// calleeName returns the function name for a call expression, supporting
// bare Ident calls (e.g. "mergeCodeMeta(...)") and SelectorExpr forms
// (e.g. "output.RegisterServiceMap(...)").
func calleeName(expr ast.Expr) string {
switch f := expr.(type) {
case *ast.Ident:
return f.Name
case *ast.SelectorExpr:
if f.Sel != nil {
return f.Sel.Name
}
}
return ""
}
// isServiceScope reports whether a path is subject to CheckNoRegistrar. Matches paths
// under shortcuts/ or internal/ but excludes internal/errclass (which
// legitimately owns codeMeta) and test files.
func isServiceScope(path string) bool {
if strings.HasSuffix(path, "_test.go") {
return false
}
// Normalize separators for cross-platform paths.
p := strings.ReplaceAll(path, "\\", "/")
switch {
case strings.HasPrefix(p, "shortcuts/") || strings.Contains(p, "/shortcuts/"):
return true
case strings.HasPrefix(p, "internal/errclass/") || strings.Contains(p, "/internal/errclass/"):
return false
case strings.HasPrefix(p, "internal/output/") || strings.Contains(p, "/internal/output/"):
// CheckNoRegistrar carves out internal/output: it is the typed-envelope writer
// and legacy ExitError producer, not a service. Without this guard
// any legitimate registrar-shaped symbol there would trigger a
// false-positive REJECT.
return false
case strings.HasPrefix(p, "internal/") || strings.Contains(p, "/internal/"):
return true
}
return false
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckProblemEmbed enforces the errs/ typed-error contract on a single
// source file: every exported struct whose name ends in "Error" must embed the
// package-local Problem (or errs.Problem when imported).
//
// Predicate + test-coverage parity are checked at the directory level by
// CheckErrsContract; this AST-only entry is the unit-testable core.
func CheckProblemEmbed(path, src string) []Violation {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
typeSpec, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return true
}
name := typeSpec.Name.Name
// Only enforce CheckProblemEmbed on EXPORTED *Error types — unexported helper
// structs that happen to end in "Error" are internal scratch types,
// not part of the typed taxonomy.
if !ast.IsExported(name) || !strings.HasSuffix(name, "Error") {
return true
}
if !embedsProblem(structType) {
out = append(out, Violation{
Rule: "problem_embed",
Action: ActionReject,
File: path,
Line: fset.Position(typeSpec.Pos()).Line,
Message: "typed error " + name + " must embed errs.Problem (RFC 7807-aligned canonical shape)",
Suggestion: "add `errs.Problem` (or `Problem` if in errs package) as the first embedded field",
})
}
return true
})
return out
}
// embedsProblem reports whether the struct embeds the canonical Problem type
// (bare `Problem` when defined in errs, or `errs.Problem` when imported).
func embedsProblem(s *ast.StructType) bool {
for _, f := range s.Fields.List {
if len(f.Names) != 0 {
continue // not embedded
}
switch t := f.Type.(type) {
case *ast.Ident:
if t.Name == "Problem" {
return true
}
case *ast.SelectorExpr:
if x, ok := t.X.(*ast.Ident); ok && x.Name == "errs" && t.Sel != nil && t.Sel.Name == "Problem" {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/token"
"regexp"
"strings"
)
// classifySubtypeExpr inspects a single expression sitting in a `Subtype:`
// slot and returns the lint verdict. Used by scanSubtype to drive both
// CheckAdHocSubtype (ad_hoc_*) and CheckDeclaredSubtype (declared / undeclared / dynamic) signals.
func classifySubtypeExpr(expr ast.Expr, allowlist, nameset map[string]struct{}, adHoc *regexp.Regexp, scope *TypedScope, absPath string) subtypeClassification {
return subtypeExprClassifier{
allowlist: allowlist,
nameset: nameset,
adHoc: adHoc,
scope: scope,
absPath: absPath,
}.classify(expr)
}
// subtypeExprClassifier is the strategy object for classifying a single
// expression assigned to a Subtype slot. The public-ish wrapper above keeps the
// scanSubtype callsite simple, while these methods keep each AST expression
// shape isolated enough to change independently.
type subtypeExprClassifier struct {
allowlist map[string]struct{}
nameset map[string]struct{}
adHoc *regexp.Regexp
scope *TypedScope
absPath string
}
func (c subtypeExprClassifier) classify(expr ast.Expr) subtypeClassification {
switch v := expr.(type) {
case *ast.SelectorExpr:
return c.classifySelector(v)
case *ast.Ident:
return c.classifyIdent(v)
case *ast.BasicLit:
return c.classifyLiteral(v)
case *ast.CallExpr:
return c.classifyCall(v)
}
return subtypeClassification{}
}
func (c subtypeExprClassifier) classifySelector(sel *ast.SelectorExpr) subtypeClassification {
if sel == nil || sel.Sel == nil {
return subtypeClassification{}
}
// Typed-first: route every selector through type resolution, regardless
// of naming. This catches `foreign.MyKind` assigned to a Subtype slot,
// which the AST fallback intentionally cannot prove.
if result, handled := classifyConstViaTypes(sel.Sel, c.absPath, c.scope); handled {
return result
}
// AST fallback: only Subtype-prefixed selector names are treated as
// constant references. Bare `Subtype` is usually a struct-field selector
// such as `meta.Subtype`, not a constant.
if !isSubtypeConstName(sel.Sel.Name) {
return subtypeClassification{}
}
if !c.declaredName(sel.Sel.Name) {
return undeclaredSubtypeConst("selector", sel.Sel.Name)
}
return subtypeClassification{}
}
func (c subtypeExprClassifier) classifyIdent(id *ast.Ident) subtypeClassification {
if id == nil {
return subtypeClassification{}
}
// Typed-first: every identifier in a Subtype slot is type-resolved when
// scope is available, regardless of its name.
if result, handled := classifyConstViaTypes(id, c.absPath, c.scope); handled {
return result
}
// AST fallback: in-package const form `SubtypeMissingScope`. The bare
// `Subtype` identifier is the type name, not a constant reference.
if isSubtypeConstName(id.Name) {
if !c.declaredName(id.Name) {
return undeclaredSubtypeConst("identifier", id.Name)
}
return subtypeClassification{}
}
// Local identifier — unresolved value, surface as WARNING for review.
return dynamicSubtypeIdentifier(id.Name)
}
func (c subtypeExprClassifier) classifyLiteral(lit *ast.BasicLit) subtypeClassification {
if lit == nil || lit.Kind != token.STRING {
return subtypeClassification{}
}
return classifyStringValue(unquoteSimple(lit.Value), c.allowlist, c.adHoc)
}
func (c subtypeExprClassifier) classifyCall(call *ast.CallExpr) subtypeClassification {
if call == nil || !isSubtypeCast(call.Fun) || len(call.Args) != 1 {
return subtypeClassification{}
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return dynamicSubtypeCast()
}
return c.classifyLiteral(lit)
}
func (c subtypeExprClassifier) declaredName(name string) bool {
if c.nameset == nil {
return true
}
_, ok := c.nameset[name]
return ok
}
func isSubtypeConstName(name string) bool {
return strings.HasPrefix(name, "Subtype") && name != "Subtype"
}
func undeclaredSubtypeConst(kind, name string) subtypeClassification {
return subtypeClassification{
rule: "declared_subtype",
action: ActionReject,
message: "Subtype " + kind + " " + name + " is not declared in any errs/subtypes*.go file",
suggestion: "use a declared const from errs/subtypes*.go (or add one) — typo'd " + kind + " names are silently treated as the zero Subtype",
}
}
func dynamicSubtypeIdentifier(name string) subtypeClassification {
return subtypeClassification{
rule: "declared_subtype",
action: ActionWarning,
message: "Subtype assigned from identifier " + name + " — value resolution requires manual review",
suggestion: "prefer named constants from errs/subtypes.go (e.g. errs.SubtypeMissingScope); if dynamic, justify in PR description",
}
}
func dynamicSubtypeCast() subtypeClassification {
return subtypeClassification{
rule: "declared_subtype",
action: ActionWarning,
message: "errs.Subtype(...) cast from non-literal expression — value resolution requires manual review",
suggestion: "prefer named constants from errs/subtypes.go",
}
}
// classifyStringValue is the inner classifier for unquoted Subtype string
// literals: ad_hoc_* → CheckAdHocSubtype LABEL, declared → silent accept, anything
// else → CheckDeclaredSubtype REJECT.
func classifyStringValue(value string, allowlist map[string]struct{}, adHoc *regexp.Regexp) subtypeClassification {
if adHoc.MatchString(value) {
return subtypeClassification{
rule: "adhoc_subtype",
action: ActionLabel,
message: `Subtype "` + value + `" matches ad_hoc_* temporary namespace — add label "needs-taxonomy-decision" [needs-taxonomy-decision]`,
suggestion: "promote ad_hoc_* to a declared Subtype constant within 1 week",
}
}
if allowlist == nil {
return subtypeClassification{}
}
if _, ok := allowlist[value]; ok {
return subtypeClassification{}
}
return subtypeClassification{
rule: "declared_subtype",
action: ActionReject,
message: `Subtype "` + value + `" is not declared in errs/subtypes.go and does not match ad_hoc_* namespace`,
suggestion: "use a declared const from errs/subtypes.go (e.g. errs.SubtypeMissingScope), " +
"or use ad_hoc_<name> temporarily and file a taxonomy issue",
}
}
// classifyConstViaTypes is the typed-resolution gate used by CheckDeclaredSubtype for
// every selector or identifier appearing in a `Subtype:` slot. Unlike the
// AST path it does NOT pre-filter by name prefix — a foreign constant
// named `MyKind` (or any other shape) assigned to `Subtype:` is still sent
// through resolution. Return values:
//
// - handled=true, classification.action == "" : resolved to a
// declared errs.Subtype constant; accept without further AST checks.
// - handled=true, classification.action == ActionReject : resolved to a
// non-errs / non-Subtype constant; reject end-to-end.
// - handled=false : nothing to say
// (scope disabled, file not in typed load, identifier resolves to a
// non-const such as a struct field or type); caller falls back to AST.
func classifyConstViaTypes(ident *ast.Ident, absPath string, scope *TypedScope) (subtypeClassification, bool) {
if ident == nil || !scope.Enabled() {
return subtypeClassification{}, false
}
resolved, ok := scope.ResolveSubtypeIdent(absPath, ident)
if !ok {
return subtypeClassification{}, false
}
if resolved {
return subtypeClassification{}, true
}
// Resolved via type info, but the object is not a canonical errs.Subtype
// constant — either it lives in a foreign package or it is an errs
// constant that is not in the Subtype set.
return subtypeClassification{
rule: "declared_subtype",
action: ActionReject,
message: "Subtype value " + ident.Name + " resolves to a constant outside the canonical errs.Subtype declarations",
suggestion: "use a declared const from errs/subtypes*.go — typed Subtype values must originate from " + errsPkgPath,
}, true
}
// isSubtypeCast reports whether a call-expression callee is the
// `errs.Subtype` (or local `Subtype`) type-cast form.
func isSubtypeCast(fun ast.Expr) bool {
switch f := fun.(type) {
case *ast.Ident:
return f.Name == "Subtype"
case *ast.SelectorExpr:
return f.Sel != nil && f.Sel.Name == "Subtype"
}
return false
}
// unquoteSimple strips one layer of surrounding double or back quotes.
// Sufficient for Go string literals as they appear in the AST.
func unquoteSimple(quoted string) string {
if len(quoted) >= 2 && (quoted[0] == '"' || quoted[0] == '`') {
return quoted[1 : len(quoted)-1]
}
return quoted
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckTypedErrorCompleteness rejects typed `*errs.<X>Error` composite
// literals whose embedded Problem is missing any of the three required
// fields: Category, Subtype, Message. Without this check, new code can
// silently introduce typed errors that emit empty `type` / `subtype` on
// the wire and confuse downstream consumers.
//
// Fires only when:
// - the type is a qualified `errs.<X>Error` selector, OR
// - the file lives inside the canonical errs package and the type is an
// unqualified `<X>Error` ident.
//
// This intentionally excludes legacy *Error types in other packages
// (core.ConfigError, internal/auth.NeedAuthorizationError, etc.) which
// are not part of the typed taxonomy.
//
// When the inner `Problem:` value is a variable reference (e.g.
// `Problem: base`) instead of a composite literal, the check trusts that
// the variable was populated elsewhere and skips field-by-field
// verification — only literal Problem composites are inspected.
//
// Returns REJECT violations.
func CheckTypedErrorCompleteness(path, src string) []Violation {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
inErrsPackage := isErrsPackagePath(path)
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
errorName, isErrsType := typedErrorTypeName(lit.Type, inErrsPackage)
if !isErrsType {
return true
}
problemLit, kind := findProblemLiteral(lit)
switch kind {
case problemMissing:
out = append(out, completenessReject(path, fset.Position(lit.Pos()).Line, errorName, "Problem"))
case problemLiteral:
for _, required := range []string{"Category", "Subtype", "Message"} {
if !hasKeyedEntry(problemLit, required) {
out = append(out, completenessReject(path, fset.Position(problemLit.Pos()).Line, errorName, required))
}
}
}
return true
})
return out
}
// typedErrorTypeName reports whether a composite-literal Type names a
// typed *errs.XxxError struct, and returns the bare type name for the
// diagnostic. Qualified `errs.XxxError` is always recognised; unqualified
// `XxxError` only when the file itself is in the errs package.
func typedErrorTypeName(expr ast.Expr, inErrsPackage bool) (string, bool) {
switch t := expr.(type) {
case *ast.SelectorExpr:
x, ok := t.X.(*ast.Ident)
if !ok || x.Name != "errs" || t.Sel == nil {
return "", false
}
return t.Sel.Name, strings.HasSuffix(t.Sel.Name, "Error") && t.Sel.Name != "Error"
case *ast.Ident:
if !inErrsPackage {
return "", false
}
return t.Name, strings.HasSuffix(t.Name, "Error") && t.Name != "Error"
}
return "", false
}
// isErrsPackagePath reports whether the given file path is inside the
// canonical errs/ package (top-level errs/ files, not sub-packages like
// errs/projection/).
func isErrsPackagePath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(p, "errs/") && !strings.Contains(p, "/errs/") {
return false
}
// Exclude errs/<subpkg>/ — only direct errs/*.go files count.
var rest string
if i := strings.Index(p, "/errs/"); i >= 0 {
rest = p[i+len("/errs/"):]
} else {
rest = p[len("errs/"):]
}
return !strings.Contains(rest, "/")
}
// problemKind is the verdict of findProblemLiteral.
type problemKind int
const (
problemMissing problemKind = iota // no Problem key in the outer literal — REJECT
problemVariable // Problem value is a variable / call expr — trust the caller
problemLiteral // Problem value is itself a composite literal — inspect fields
)
// findProblemLiteral returns the inner Problem composite literal and a
// problemKind verdict:
//
// - problemMissing: outer literal has no Problem key at all (REJECT).
// - problemVariable: Problem value is a variable / call expr; caller
// populated it elsewhere so this check can't see the fields. Skip.
// - problemLiteral: Problem value is an in-place composite literal —
// inspect its keys for Category / Subtype / Message.
func findProblemLiteral(outer *ast.CompositeLit) (*ast.CompositeLit, problemKind) {
for _, el := range outer.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != "Problem" {
continue
}
inner, ok := kv.Value.(*ast.CompositeLit)
if !ok {
return nil, problemVariable
}
return inner, problemLiteral
}
return nil, problemMissing
}
// hasKeyedEntry reports whether a composite literal contains a
// `<key>:` keyed entry. Used to verify Problem.Category / Subtype /
// Message are present.
func hasKeyedEntry(lit *ast.CompositeLit, key string) bool {
for _, el := range lit.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
ident, ok := kv.Key.(*ast.Ident)
if !ok {
continue
}
if ident.Name == key {
return true
}
}
return false
}
func completenessReject(path string, line int, errorName, missing string) Violation {
return Violation{
Rule: "typed_error_completeness",
Action: ActionReject,
File: path,
Line: line,
Message: "typed *" + errorName + " literal is missing required Problem." + missing + " field",
Suggestion: "every typed *errs.XxxError must set Problem.Category, Problem.Subtype, and Problem.Message — " +
"missing fields emit an empty `type` / `subtype` / `message` on the wire and confuse consumers",
}
}

View File

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

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import "strings"
// RunAll executes all four checks on the given source. allowlist controls CheckDeclaredSubtype;
// pass nil to skip it. Use RunAllWithNames to enable strengthened CheckDeclaredSubtype name
// resolution.
func RunAll(path, src string, allowlist map[string]struct{}) []Violation {
return RunAllWithNames(path, src, allowlist, nil)
}
// RunAllWithNames is RunAll with the strengthened CheckDeclaredSubtype. nameset, when
// non-nil, lets CheckDeclaredSubtype reject typo'd `errs.SubtypeBogus` selectors that
// reference no declared constant.
func RunAllWithNames(path, src string, allowlist, nameset map[string]struct{}) []Violation {
var out []Violation
if strings.HasPrefix(path, "errs/") || strings.Contains(path, "/errs/") {
// CheckProblemEmbed fires on errs/ files only (caller may also enforce parity
// across directory via CheckErrsContract).
out = append(out, CheckProblemEmbed(path, src)...)
}
out = append(out, CheckNoRegistrar(path, src)...)
out = append(out, CheckAdHocSubtype(path, src)...)
out = append(out, CheckTypedErrorCompleteness(path, src)...)
if allowlist != nil {
out = append(out, CheckDeclaredSubtypeWithNames(path, src, allowlist, nameset)...)
}
return out
}

385
lint/errscontract/scan.go Normal file
View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanRepo is the production entry point for the lintcheck CLI. It walks
// the repo rooted at root and emits violations covering all four checks.
//
// root should be the repo root (the directory containing go.mod). The CheckDeclaredSubtype
// allowlist (values + declared names) is derived from every errs/subtypes*.go
// file; if no subtypes file is found, CheckDeclaredSubtype is silently skipped (CheckAdHocSubtype
// still runs).
//
// Returns the violations sorted by File/Line for stable diff against expected
// output in tests.
func ScanRepo(root string) ([]Violation, error) {
allowlist, nameset, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
if err != nil {
// "Subtype allowlist file missing" → skip CheckDeclaredSubtype; CheckAdHocSubtype still
// catches ad_hoc_*. Any other error (permission, malformed source)
// must propagate — otherwise a real taxonomy regression silently
// disables CheckDeclaredSubtype in CI.
if !os.IsNotExist(err) {
return nil, fmt.Errorf("load subtype allowlists: %w", err)
}
allowlist = nil
nameset = nil
}
var all []Violation
// CheckProblemEmbed: errs/ contract parity (types ↔ predicates ↔ tests ↔ docs).
if contractViols, err := CheckErrsContract(root); err == nil {
all = append(all, contractViols...)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("rule B: %w", err)
}
// CheckDeclaredSubtype typed resolution: load the workspace's type info once so we
// can verify Subtype selectors resolve into the canonical errs package.
// A loader failure or empty result falls back to the AST-only pass —
// the unit-test API path that ScanRepo shares with
// CheckDeclaredSubtypeWithNames already enforces nameset matching.
// When the fallback is taken on a workspace that LOOKS like a Go repo
// (has a go.mod), we emit a single advisory diagnostic so reviewers
// know CheckDeclaredSubtype ran in a less-strict mode this run. ActionWarning is
// print-only per Action semantics; it does not fail CI.
typedScope, typedErr := LoadTypedScope(root)
if typedErr != nil {
typedScope = nil
}
if !typedScope.Enabled() && hasGoMod(root) {
all = append(all, Violation{
Rule: "declared_subtype",
Action: ActionWarning,
File: "lint",
Line: 0,
Message: "CheckDeclaredSubtype typed resolution unavailable; falling back to AST name matching. " +
"Workspace was loadable as a Go repo, but errs.Subtype constants could not be resolved via go/types. " +
"CheckDeclaredSubtype will be less strict on Subtype: selectors this run.",
Suggestion: "ensure errs/subtypes*.go compile and contain typed Subtype consts; " +
"re-run with `go run -C lint . ..` after verifying.",
})
}
// Walk source tree and apply Rules C/D/E to each .go file.
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
// Skip well-known noise directories.
name := d.Name()
if name == ".git" || name == "node_modules" || name == "vendor" ||
name == "tests_e2e" || name == "skill-template" || name == "skills" ||
name == "docs" || name == "specs" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
// CheckNoRegistrar / D / E do not fire in test files: fixtures may legitimately
// exercise edge values, and CheckNoRegistrar's scope is production code only.
return nil
}
rel, _ := filepath.Rel(root, path)
src, err := os.ReadFile(path) //nolint:gosec // CLI tool; root is operator-provided.
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
all = append(all, CheckNoRegistrar(rel, string(src))...)
all = append(all, CheckAdHocSubtype(rel, string(src))...)
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
if allowlist != nil && !isErrsScope(rel) {
// CheckDeclaredSubtype does not fire inside the errs/ package itself — that
// package defines the Subtype type and its constructors take
// Subtype as a parameter, which would otherwise emit a stream
// of dynamic-identifier WARNINGs.
abs, _ := filepath.Abs(path)
all = append(all, checkDeclaredSubtypeWithTypedScope(rel, abs, string(src), allowlist, nameset, typedScope)...)
}
return nil
})
if walkErr != nil {
return nil, walkErr
}
sort.SliceStable(all, func(i, j int) bool {
if all[i].File != all[j].File {
return all[i].File < all[j].File
}
return all[i].Line < all[j].Line
})
return all, nil
}
// hasGoMod reports whether the given directory contains a go.mod file at
// its root. Used to scope the typed-resolution advisory to repos that look
// like Go workspaces; unit-test fixtures without go.mod stay silent.
func hasGoMod(root string) bool {
_, err := os.Stat(filepath.Join(root, "go.mod"))
return err == nil
}
// isErrsScope reports whether a path is inside the errs/ package (including
// any subpackage). Used to scope-out CheckDeclaredSubtype from the package
// that owns the Subtype type itself.
func isErrsScope(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
return strings.HasPrefix(p, "errs/") || strings.Contains(p, "/errs/")
}
// LoadSubtypeAllowlist parses errs/subtypes.go and returns the set of declared
// Subtype constant VALUES (not names). Used by CheckDeclaredSubtype.
//
// Deprecated: prefer LoadSubtypeAllowlists, which also captures the constant
// names across every errs/subtypes*.go file. Retained for the unit-test entry
// point that targets a single fixture file.
func LoadSubtypeAllowlist(subtypesGo string) (map[string]struct{}, error) {
values, _, err := loadSubtypeAllowlistFile(subtypesGo)
return values, err
}
// LoadSubtypeAllowlists scans every errs/subtypes*.go file under the given
// directory and returns (declared VALUES, declared NAMES). The name set lets
// CheckDeclaredSubtype reject typo'd selectors like `errs.SubtypeBogus` that satisfy the
// "Subtype*" prefix but reference no actual constant. Returns the os.Stat
// error if the directory does not exist.
func LoadSubtypeAllowlists(errsDir string) (values, names map[string]struct{}, err error) {
if _, statErr := os.Stat(errsDir); statErr != nil {
return nil, nil, statErr
}
entries, readErr := os.ReadDir(errsDir)
if readErr != nil {
return nil, nil, readErr
}
values = make(map[string]struct{})
names = make(map[string]struct{})
found := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "subtypes") || !strings.HasSuffix(name, ".go") ||
strings.HasSuffix(name, "_test.go") {
continue
}
full := filepath.Join(errsDir, name)
v, n, perr := loadSubtypeAllowlistFile(full)
if perr != nil {
return nil, nil, perr
}
for k := range v {
values[k] = struct{}{}
}
for k := range n {
names[k] = struct{}{}
}
found++
}
if found == 0 {
// Treat absence like a missing file — caller silently skips CheckDeclaredSubtype
// via os.IsNotExist on the wrapped sentinel.
return nil, nil, fmt.Errorf("%w: no subtypes*.go found under %s", os.ErrNotExist, errsDir)
}
return values, names, nil
}
func loadSubtypeAllowlistFile(subtypesGo string) (values, names map[string]struct{}, err error) {
src, err := os.ReadFile(subtypesGo) //nolint:gosec // operator-provided path.
if err != nil {
return nil, nil, err
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, subtypesGo, src, parser.ParseComments)
if err != nil {
return nil, nil, fmt.Errorf("parse %s: %w", subtypesGo, err)
}
values = make(map[string]struct{})
names = make(map[string]struct{})
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
// We only care about const blocks whose type is Subtype (the type
// declared in this same file). Untyped/iota constants are ignored.
if !isSubtypeTypeRef(vs.Type) {
continue
}
for _, n := range vs.Names {
if n.Name != "_" {
names[n.Name] = struct{}{}
}
}
for _, v := range vs.Values {
lit, ok := v.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
values[unquoteSimple(lit.Value)] = struct{}{}
}
}
}
return values, names, nil
}
func isSubtypeTypeRef(expr ast.Expr) bool {
switch t := expr.(type) {
case *ast.Ident:
return t.Name == "Subtype"
case *ast.SelectorExpr:
return t.Sel != nil && t.Sel.Name == "Subtype"
}
return false
}
// CheckErrsContract enforces CheckProblemEmbed at the directory level. It collects all
// exported `*Error` types defined in errs/, then verifies:
//
// 1. each type embeds Problem (delegated to CheckProblemEmbed per file);
// 2. each non-whitelisted type has a matching IsXxx predicate in errs/;
// 3. each type is mentioned in at least one errs/*_test.go file.
//
// Missing predicates and missing tests each emit one diagnostic per type.
//
// Also walks internal/errclass/codemeta*.go for code-meta parity; absence of
// the directory is tolerated (older repo layouts).
func CheckErrsContract(root string) ([]Violation, error) {
errsDir := filepath.Join(root, "errs")
if _, err := os.Stat(errsDir); err != nil {
return nil, err
}
var (
out []Violation
typedErrors = make(map[string]token.Position) // name → first decl position
predicateOf = make(map[string]struct{}) // type names with matching IsXxx
testMentions = make(map[string]struct{})
)
fset := token.NewFileSet()
entries, err := os.ReadDir(errsDir)
if err != nil {
return nil, err
}
// First pass: parse every .go in errs/ (no recursion — projection/ is
// covered separately if/when we extend the rule).
var testSources []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
full := filepath.Join(errsDir, e.Name())
src, readErr := os.ReadFile(full) //nolint:gosec // operator-provided path.
if readErr != nil {
return nil, readErr
}
rel, _ := filepath.Rel(root, full)
rel = filepath.ToSlash(rel)
file, parseErr := parser.ParseFile(fset, full, src, parser.ParseComments)
if parseErr != nil {
continue // parse errors aren't this lint's concern; vet/compile will catch them.
}
if strings.HasSuffix(e.Name(), "_test.go") {
testSources = append(testSources, string(src))
continue
}
// Per-file CheckProblemEmbed AST check (embeds Problem).
out = append(out, CheckProblemEmbed(rel, string(src))...)
// Collect typed error names and predicate names.
ast.Inspect(file, func(n ast.Node) bool {
switch d := n.(type) {
case *ast.TypeSpec:
// Only consider EXPORTED *Error structs — unexported helper
// types ending in "Error" are not part of the typed
// taxonomy and would create false-positive missing-
// predicate violations.
if _, ok := d.Type.(*ast.StructType); ok && ast.IsExported(d.Name.Name) && strings.HasSuffix(d.Name.Name, "Error") {
if _, dup := typedErrors[d.Name.Name]; !dup {
typedErrors[d.Name.Name] = fset.Position(d.Pos())
}
}
case *ast.FuncDecl:
if d.Recv != nil {
return true // method, not predicate
}
name := d.Name.Name
if !strings.HasPrefix(name, "Is") {
return true
}
// Predicate convention: IsValidation → ValidationError.
typeName := name[2:] + "Error"
predicateOf[typeName] = struct{}{}
}
return true
})
}
// Test-file mentions of typed error names.
for _, src := range testSources {
for name := range typedErrors {
if strings.Contains(src, name) {
testMentions[name] = struct{}{}
}
}
}
// Walk the typed errors and emit diagnostics for missing predicate / test.
for name, pos := range typedErrors {
relFile := pos.Filename
if r, relErr := filepath.Rel(root, pos.Filename); relErr == nil {
relFile = filepath.ToSlash(r)
}
// Predicate (e.g. ValidationError needs IsValidation).
if _, ok := predicateOf[name]; !ok {
out = append(out, Violation{
Rule: "problem_embed",
Action: ActionReject,
File: relFile,
Line: pos.Line,
Message: "typed error " + name + " has no matching Is" + strings.TrimSuffix(name, "Error") + " predicate in errs/predicates.go",
Suggestion: "add `func Is" + strings.TrimSuffix(name, "Error") +
"(err error) bool { var x *" + name + "; return errors.As(err, &x) }` to errs/predicates.go",
})
}
// Test mention.
if _, ok := testMentions[name]; !ok {
out = append(out, Violation{
Rule: "problem_embed",
Action: ActionReject,
File: relFile,
Line: pos.Line,
Message: "typed error " + name + " has no test exercising it in errs/*_test.go",
Suggestion: "add at least one test in errs/ that references " + name + " (smoke construct + predicate assertion is enough)",
})
}
}
return out, nil
}

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fixtureRepo lays out a tiny repo on tmpfs that mimics the live layout enough
// for ScanRepo / CheckErrsContract to exercise. Each entry is path → content.
type fixtureRepo map[string]string
func writeFixture(t *testing.T, files fixtureRepo) string {
t.Helper()
root := t.TempDir()
for rel, content := range files {
full := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", full, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", full, err)
}
}
return root
}
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs
type Subtype string
const (
SubtypeMissingScope Subtype = "missing_scope"
SubtypeRateLimit Subtype = "rate_limit"
)
const (
UnrelatedConst = "ignore_me" // not Subtype-typed
)
`,
})
got, err := LoadSubtypeAllowlist(filepath.Join(root, "errs", "subtypes.go"))
if err != nil {
t.Fatalf("LoadSubtypeAllowlist: %v", err)
}
want := map[string]struct{}{"missing_scope": {}, "rate_limit": {}}
if len(got) != len(want) {
t.Fatalf("size mismatch: got %d, want %d (%+v)", len(got), len(want), got)
}
for k := range want {
if _, ok := got[k]; !ok {
t.Errorf("missing %q in allowlist", k)
}
}
if _, ok := got["ignore_me"]; ok {
t.Errorf("untyped const leaked into allowlist")
}
}
// TestLoadSubtypeAllowlists_WalksAllSubtypesFiles pins the multi-file load:
// constants from every errs/subtypes*.go must contribute to both the values
// allowlist and the declared-names set.
func TestLoadSubtypeAllowlists_WalksAllSubtypesFiles(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs
type Subtype string
const (
SubtypeMissingScope Subtype = "missing_scope"
)
`,
"errs/subtypes_service_task.go": `package errs
const (
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
)
`,
})
values, names, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
if err != nil {
t.Fatalf("LoadSubtypeAllowlists: %v", err)
}
for _, v := range []string{"missing_scope", "task_invalid_params"} {
if _, ok := values[v]; !ok {
t.Errorf("values missing %q (across-file load broken)", v)
}
}
for _, n := range []string{"SubtypeMissingScope", "SubtypeTaskInvalidParams"} {
if _, ok := names[n]; !ok {
t.Errorf("names missing %q (across-file load broken)", n)
}
}
}
func TestCheckErrsContract_FlagsMissingPredicateAndTest(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/types.go": `package errs
type Problem struct{}
type MissingError struct {
Problem
}
`,
"errs/predicates.go": `package errs
// IsMissing predicate intentionally absent
`,
// No errs/*_test.go file → MissingError lacks test coverage.
"internal/errclass/codemeta.go": `package errclass
type CodeMeta struct{}
var codeMeta = map[int]CodeMeta{1234: {}}
`,
})
v, err := CheckErrsContract(root)
if err != nil {
t.Fatalf("CheckErrsContract: %v", err)
}
var missingPredicate, missingTest int
for _, vv := range v {
switch {
case strings.Contains(vv.Message, "no matching IsMissing predicate"):
missingPredicate++
case strings.Contains(vv.Message, "no test exercising it"):
missingTest++
}
// Diagnostics emitted by CheckErrsContract must use repo-relative paths
// (same convention as walker-side rules), not absolute filesystem paths
// resolved via parser.ParseFile.
if strings.Contains(vv.Message, "MissingError") && vv.File != "errs/types.go" {
t.Errorf("violation File = %q, want repo-relative %q: %+v",
vv.File, "errs/types.go", vv)
}
}
if missingPredicate != 1 {
t.Errorf("missing-predicate diagnostics = %d, want 1: %+v", missingPredicate, v)
}
if missingTest != 1 {
t.Errorf("missing-test diagnostics = %d, want 1: %+v", missingTest, v)
}
}
func TestCheckErrsContract_AcceptsCompleteContract(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/types.go": `package errs
type Problem struct{}
type FooError struct{ Problem }
`,
"errs/predicates.go": `package errs
func IsFoo(err error) bool { return false }
`,
"errs/foo_test.go": `package errs_test
import "testing"
func TestFooError(t *testing.T) { _ = FooError{} }
`,
"internal/errclass/codemeta.go": `package errclass
type CodeMeta struct{}
var m = map[int]CodeMeta{42: {}}
`,
})
v, err := CheckErrsContract(root)
if err != nil {
t.Fatalf("CheckErrsContract: %v", err)
}
if len(v) != 0 {
t.Errorf("complete contract should pass, got %d violations: %+v", len(v), v)
}
}
func TestScanRepo_DetectsServiceRegistrarAndBadSubtype(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/types.go": `package errs
type Problem struct{}
type Subtype string
type FooError struct{ Problem }
`,
"errs/predicates.go": `package errs
func IsFoo(err error) bool { return false }
`,
"errs/foo_test.go": `package errs_test
import "testing"
func TestFooError(t *testing.T) { _ = FooError{} }
`,
"errs/subtypes.go": `package errs
const (
SubtypeKnown Subtype = "known"
)
`,
"internal/errclass/codemeta.go": `package errclass
type CodeMeta struct{}
var m = map[int]CodeMeta{1: {}}
`,
// Service file with a registrar AND a bad Subtype literal.
"shortcuts/task/bad.go": `package task
func init() {
mergeCodeMeta(nil, "task")
}
var _ = struct{ Subtype string }{Subtype: "not_known"}
`,
// Test files are exempt from C/D/E (rule pre-filter).
"shortcuts/task/bad_test.go": `package task
func placeholder() {}
`,
})
v, err := ScanRepo(root)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
}
var sawRegistrar, sawBadSubtype bool
for _, vv := range v {
if vv.Rule == "no_registrar" && strings.Contains(vv.File, "shortcuts/task/bad.go") {
sawRegistrar = true
}
if vv.Rule == "declared_subtype" && strings.Contains(vv.Message, "not_known") {
sawBadSubtype = true
}
}
if !sawRegistrar {
t.Errorf("ScanRepo missed CheckNoRegistrar registrar; got %+v", v)
}
if !sawBadSubtype {
t.Errorf("ScanRepo missed CheckDeclaredSubtype undeclared subtype; got %+v", v)
}
}
// TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable pins Refinement 2:
// when a fixture LOOKS like a Go repo (has a go.mod) but typed loading
// cannot produce a usable errs.Subtype const set, ScanRepo emits a single
// ActionWarning advisory so reviewers know CheckDeclaredSubtype ran in a less-strict
// mode. ActionWarning is print-only — CI exit-code logic does not fail
// the run on it (proven by the lint main.go exit-code branch).
func TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable(t *testing.T) {
// Fixture: a Go-looking repo (has go.mod) but errs/ contains a
// Subtype type with NO declared Subtype consts. LoadTypedScope will
// initialize but errsSubtypeConsts stays empty → Enabled() returns
// false under the tightened contract.
root := writeFixture(t, fixtureRepo{
"go.mod": "module example.com/fixture\n\ngo 1.23\n",
"errs/types.go": `package errs
type Problem struct{}
type Subtype string
type FooError struct{ Problem }
`,
"errs/predicates.go": `package errs
func IsFoo(err error) bool { return false }
`,
"errs/foo_test.go": `package errs_test
import "testing"
func TestFooError(t *testing.T) { _ = FooError{} }
`,
// subtypes.go is present so LoadSubtypeAllowlists succeeds, but the
// const block is empty so no values/names are declared.
"errs/subtypes.go": `package errs
const SubtypeKnown Subtype = "known"
`,
})
v, err := ScanRepo(root)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
}
advisoryCount := 0
for _, vv := range v {
if vv.Rule == "declared_subtype" && vv.Action == ActionWarning &&
strings.Contains(vv.Message, "typed resolution unavailable") {
advisoryCount++
}
}
if advisoryCount != 1 {
t.Errorf("advisory count = %d, want exactly 1; got violations: %+v", advisoryCount, v)
}
// The advisory must NOT escalate to REJECT — ActionWarning is print-only.
// (We don't assert rejectCount==0 in general since the fixture may emit
// other rejections; we only assert the advisory itself is a WARNING.)
for _, vv := range v {
if vv.Action == ActionReject && strings.Contains(vv.Message, "typed resolution unavailable") {
t.Errorf("advisory must be ActionWarning, not REJECT (would fail CI): %+v", vv)
}
}
}
// TestScanRepo_NoAdvisoryWithoutGoMod pins the scoping: fixtures that lack
// a go.mod (the common unit-test shape) must NOT emit the advisory, since
// the workspace is not a Go repo from the loader's perspective.
func TestScanRepo_NoAdvisoryWithoutGoMod(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/types.go": `package errs
type Problem struct{}
type Subtype string
type FooError struct{ Problem }
`,
"errs/predicates.go": `package errs
func IsFoo(err error) bool { return false }
`,
"errs/foo_test.go": `package errs_test
import "testing"
func TestFooError(t *testing.T) { _ = FooError{} }
`,
"errs/subtypes.go": `package errs
const SubtypeKnown Subtype = "known"
`,
})
v, err := ScanRepo(root)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
}
for _, vv := range v {
if strings.Contains(vv.Message, "typed resolution unavailable") {
t.Errorf("no go.mod present → advisory must not fire; got %+v", vv)
}
}
}
func TestScanRepo_LabelTriggerForAdHocSubtype(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/types.go": `package errs
type Problem struct{}
type Subtype string
type FooError struct{ Problem }
`,
"errs/predicates.go": `package errs
func IsFoo(err error) bool { return false }
`,
"errs/foo_test.go": `package errs_test
import "testing"
func TestFooError(t *testing.T) { _ = FooError{} }
`,
"errs/subtypes.go": `package errs
const (
SubtypeKnown Subtype = "known"
)
`,
"internal/errclass/codemeta.go": `package errclass
type CodeMeta struct{}
var m = map[int]CodeMeta{}
`,
"shortcuts/task/maybe.go": `package task
var _ = struct{ Subtype string }{Subtype: "ad_hoc_quota_breach"}
`,
})
v, err := ScanRepo(root)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
}
var sawLabel bool
for _, vv := range v {
if vv.Action == ActionLabel &&
strings.Contains(vv.Message, "needs-taxonomy-decision") {
sawLabel = true
}
if vv.Action == ActionReject &&
strings.Contains(vv.Message, "ad_hoc_quota_breach") {
t.Errorf("ad_hoc_* must NOT be REJECTED (it's LABEL): %+v", vv)
}
}
if !sawLabel {
t.Errorf("ScanRepo missed CheckAdHocSubtype label trigger; got %+v", v)
}
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/types"
"path/filepath"
"strings"
"golang.org/x/tools/go/packages"
)
// errsPkgPath is the canonical import path of the typed-errors package.
// CheckDeclaredSubtype's typed-resolution pass verifies that a `pkg.SubtypeXxx` selector's
// resolved object belongs to this exact package — selector-name matching
// alone would have falsely accepted an identically-named constant from a
// foreign package.
const errsPkgPath = "github.com/larksuite/cli/errs"
// TypedScope captures the workspace-wide type information used by CheckDeclaredSubtype's
// typed-resolution pass. The zero value is a no-op (typed pass disabled);
// LoadTypedScope populates it.
//
// Once populated:
// - typedFiles maps an absolute Go file path to the *types.Info of its
// package. The walker uses it to resolve selector / ident references on
// a per-file basis: Info.Uses[ident] yields the *types.Object pointed
// at by that identifier, including the originating package.
// - errsSubtypeConsts holds the typed Subtype constants declared in the
// errs package. A resolved object is a "real" Subtype only when it
// appears in this set.
type TypedScope struct {
typedFiles map[string]*types.Info
errsSubtypeConsts map[string]*types.Const
}
// Enabled reports whether the typed-resolution pass can answer questions
// about errs.Subtype references. It requires both:
//
// - typedFiles non-empty (go/packages.Load produced usable type info);
// - errsSubtypeConsts non-empty (the canonical errs.Subtype const set
// was actually discovered).
//
// Requiring both avoids the half-loaded failure mode where typed-file
// indexing succeeded but the errs package was not visited — every
// resolution attempt would then claim "foreign const" and over-reject.
// Callers fall back to AST-only resolution when Enabled returns false.
func (s *TypedScope) Enabled() bool {
if s == nil {
return false
}
return len(s.typedFiles) > 0 && len(s.errsSubtypeConsts) > 0
}
// LookupFileInfo returns the per-package types.Info covering the given Go
// file (path matching the absolute path used during the load). Callers use
// it to resolve *ast.Ident → *types.Object via Info.Uses.
func (s *TypedScope) LookupFileInfo(absPath string) (*types.Info, bool) {
if s == nil {
return nil, false
}
info, ok := s.typedFiles[filepath.Clean(absPath)]
return info, ok
}
// LoadTypedScope loads the workspace rooted at root with full type
// information and returns a scope ready for CheckDeclaredSubtype typed resolution. A
// non-nil error reports an unrecoverable failure (the loader could not
// even start); a successful return with Enabled() == false indicates the
// loader ran but produced no usable type info (e.g. the errs package was
// missing) — in which case the caller should fall back silently to the
// AST-only path.
func LoadTypedScope(root string) (*TypedScope, error) {
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Dir: root,
Tests: false,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, err
}
scope := &TypedScope{
typedFiles: map[string]*types.Info{},
errsSubtypeConsts: map[string]*types.Const{},
}
packages.Visit(pkgs, nil, func(p *packages.Package) {
if p == nil || p.TypesInfo == nil {
return
}
// Index file → TypesInfo for the walker.
for _, f := range p.CompiledGoFiles {
scope.typedFiles[filepath.Clean(f)] = p.TypesInfo
}
// Capture declared Subtype constants from the canonical errs package
// so CheckDeclaredSubtype can reject selectors that resolve to a foreign-package
// const sharing the same name.
if p.PkgPath == errsPkgPath && p.Types != nil {
collectSubtypeConsts(p.Types, scope.errsSubtypeConsts)
}
})
return scope, nil
}
// collectSubtypeConsts scans a *types.Package for exported constants of
// type errs.Subtype whose name starts with "Subtype" and records them by
// name. The "Subtype" name prefix is enforced so the helper aligns with
// the CheckDeclaredSubtype AST pass and avoids matching the underlying `Subtype` type
// definition itself.
func collectSubtypeConsts(pkg *types.Package, into map[string]*types.Const) {
if pkg == nil || pkg.Scope() == nil {
return
}
for _, name := range pkg.Scope().Names() {
if !strings.HasPrefix(name, "Subtype") || name == "Subtype" {
continue
}
obj := pkg.Scope().Lookup(name)
c, ok := obj.(*types.Const)
if !ok {
continue
}
// Verify the constant's type is errs.Subtype (not e.g. a foreign
// "Subtype"-named string alias re-exported from this package).
named, ok := c.Type().(*types.Named)
if !ok {
continue
}
if named.Obj() == nil || named.Obj().Name() != "Subtype" ||
named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != errsPkgPath {
continue
}
into[name] = c
}
}
// ResolveSubtypeIdent inspects the identifier used as the value of a
// `Subtype:` composite-literal field and reports the typed-scope verdict
// via the (resolved, ok) tuple:
//
// - (true, true): the identifier is a declared errs.Subtype constant.
// The AST pass may skip its nameset check for this site.
// - (false, true): definitive rejection — the identifier resolved to a
// constant in a non-errs package, or to a non-Subtype constant inside
// errs. Caller MUST NOT fall back to AST resolution; CheckDeclaredSubtype should
// reject this site.
// - (false, false): typed scope cannot decide (scope disabled, no file
// info, sel==nil, no type info for the identifier, or the resolved
// object is not a constant). Caller defers to AST-only resolution.
func (s *TypedScope) ResolveSubtypeIdent(absPath string, sel *ast.Ident) (resolved, ok bool) {
if !s.Enabled() {
return false, false
}
info, found := s.LookupFileInfo(absPath)
if !found || info == nil || sel == nil {
return false, false
}
obj, found := info.Uses[sel]
if !found || obj == nil {
// No type info for this identifier — caller falls back to AST.
return false, false
}
c, isConst := obj.(*types.Const)
if !isConst {
return false, false
}
if c.Pkg() == nil || c.Pkg().Path() != errsPkgPath {
// Foreign-package constant assigned to a Subtype: slot. Reject —
// the caller routes ALL selectors through this path regardless of
// name shape, so this branch fires for both `foreign.SubtypeFoo`
// and `foreign.MyKind`.
return false, true
}
if _, declared := s.errsSubtypeConsts[c.Name()]; !declared {
// In the errs package but not a Subtype const (defense-in-depth).
return false, true
}
return true, true
}

View File

@@ -0,0 +1,312 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/importer"
"go/parser"
"go/token"
"go/types"
"testing"
)
// TestTypedScope_RejectsForeignSubtypeConst proves that the typed-resolution
// pass rejects a Subtype-named constant declared in a non-errs package, even
// when the constant's NAME matches a declared errs Subtype. This is the
// behavior selector-name matching alone could not deliver.
//
// The test exercises collectSubtypeConsts and ResolveSubtypeIdent directly
// against a synthetic types.Package. A full ScanRepo integration test would
// need a synthetic go.mod whose module path happens to be
// github.com/larksuite/cli — which would conflict with the real repo — so
// we exercise the resolution helpers directly here.
func TestTypedScope_RejectsForeignSubtypeConst(t *testing.T) {
// Synthesize what go/packages would have produced: an errs package
// holding the canonical Subtype type plus SubtypeMissingScope const,
// and a foreign consumer package that re-defines its own Subtype type
// with an identically-named SubtypeMissingScope const.
src := `package fakeerrs
type Subtype string
const SubtypeMissingScope Subtype = "missing_scope"
`
fset := token.NewFileSet()
errsFile, err := parser.ParseFile(fset, "fakeerrs/subtypes.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("parse fakeerrs: %v", err)
}
conf := &types.Config{Importer: importer.Default()}
errsPkg, err := conf.Check(errsPkgPath, fset, []*ast.File{errsFile}, nil)
if err != nil {
t.Fatalf("type-check fakeerrs: %v", err)
}
foreignSrc := `package foreign
type Subtype string
const SubtypeMissingScope Subtype = "fraudulent"
`
foreignFile, err := parser.ParseFile(fset, "foreign/foreign.go", foreignSrc, parser.ParseComments)
if err != nil {
t.Fatalf("parse foreign: %v", err)
}
foreignPkg, err := conf.Check("example.com/foreign", fset, []*ast.File{foreignFile}, nil)
if err != nil {
t.Fatalf("type-check foreign: %v", err)
}
scope := &TypedScope{
typedFiles: map[string]*types.Info{},
errsSubtypeConsts: map[string]*types.Const{},
}
// collectSubtypeConsts should pick up SubtypeMissingScope from the
// canonical errs package but NOT from the foreign one (different pkg).
collectSubtypeConsts(errsPkg, scope.errsSubtypeConsts)
collectSubtypeConsts(foreignPkg, scope.errsSubtypeConsts)
if _, ok := scope.errsSubtypeConsts["SubtypeMissingScope"]; !ok {
t.Fatalf("expected SubtypeMissingScope to be captured from errs")
}
if got := scope.errsSubtypeConsts["SubtypeMissingScope"].Pkg().Path(); got != errsPkgPath {
t.Fatalf("captured const came from %q, want %q", got, errsPkgPath)
}
// Now type-check a consumer file that uses BOTH constants, and verify
// ResolveSubtypeIdent accepts the errs reference and rejects the foreign
// one with the (resolved=false, ok=true) pair CheckDeclaredSubtype treats as REJECT.
consumerSrc := `package consumer
import (
errs "` + errsPkgPath + `"
foreign "example.com/foreign"
)
var _ errs.Subtype = errs.SubtypeMissingScope
var _ foreign.Subtype = foreign.SubtypeMissingScope
`
consumerFile, err := parser.ParseFile(fset, "consumer/x.go", consumerSrc, parser.ParseComments)
if err != nil {
t.Fatalf("parse consumer: %v", err)
}
imp := &fakeImporter{m: map[string]*types.Package{
errsPkgPath: errsPkg,
"example.com/foreign": foreignPkg,
}}
conf2 := &types.Config{Importer: imp}
info := &types.Info{
Uses: map[*ast.Ident]types.Object{},
}
if _, err := conf2.Check("example.com/consumer", fset, []*ast.File{consumerFile}, info); err != nil {
t.Fatalf("type-check consumer: %v", err)
}
scope.typedFiles["consumer/x.go"] = info
// Walk the consumer file to find the SubtypeMissingScope selectors and
// drive ResolveSubtypeIdent against each one.
var goodIdent, foreignIdent *ast.Ident
ast.Inspect(consumerFile, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "SubtypeMissingScope" {
return true
}
obj := info.Uses[sel.Sel]
if obj == nil {
return true
}
switch obj.Pkg().Path() {
case errsPkgPath:
goodIdent = sel.Sel
case "example.com/foreign":
foreignIdent = sel.Sel
}
return true
})
if goodIdent == nil || foreignIdent == nil {
t.Fatalf("did not find both selector idents in consumer source")
}
resolved, ok := scope.ResolveSubtypeIdent("consumer/x.go", goodIdent)
if !ok {
t.Fatalf("errs reference should resolve via type info")
}
if !resolved {
t.Errorf("errs.SubtypeMissingScope should resolve=true; got resolved=false")
}
resolved, ok = scope.ResolveSubtypeIdent("consumer/x.go", foreignIdent)
if !ok {
t.Fatalf("foreign reference should still produce ok=true (so CheckDeclaredSubtype can reject)")
}
if resolved {
t.Errorf("foreign.SubtypeMissingScope must NOT resolve=true; selector-name matching alone would have falsely accepted it")
}
}
// fakeImporter is a minimal types.Importer used by the test to satisfy
// cross-package imports without going through go/packages.
type fakeImporter struct {
m map[string]*types.Package
}
func (f *fakeImporter) Import(path string) (*types.Package, error) {
if p, ok := f.m[path]; ok {
return p, nil
}
return importer.Default().Import(path)
}
// TestTypedScope_FallsBackWhenDisabled documents the no-op contract: when
// the scope is empty (loader failed or the unit-test API was used), the
// production walker falls back to AST-only resolution. ResolveSubtypeIdent
// must signal ok=false so the caller knows to consult the nameset path.
func TestTypedScope_FallsBackWhenDisabled(t *testing.T) {
var scope *TypedScope
if scope.Enabled() {
t.Fatalf("nil scope must report Enabled()=false")
}
if resolved, ok := scope.ResolveSubtypeIdent("x.go", &ast.Ident{Name: "SubtypeFoo"}); resolved || ok {
t.Fatalf("disabled scope must return (false,false); got (%v,%v)", resolved, ok)
}
empty := &TypedScope{}
if empty.Enabled() {
t.Fatalf("empty scope must report Enabled()=false")
}
}
// TestTypedScope_EnabledRequiresBothTypedFilesAndSubtypeConsts pins the
// tightened Enabled() contract: half-loaded scopes (typed files indexed
// but errs.Subtype const set empty, or vice versa) must report disabled
// so callers fall back to AST instead of over-rejecting every selector.
func TestTypedScope_EnabledRequiresBothTypedFilesAndSubtypeConsts(t *testing.T) {
onlyFiles := &TypedScope{
typedFiles: map[string]*types.Info{"x.go": {Uses: map[*ast.Ident]types.Object{}}},
errsSubtypeConsts: map[string]*types.Const{},
}
if onlyFiles.Enabled() {
t.Errorf("scope with files but no errs subtype consts must be disabled — typed pass would over-reject everything")
}
onlyConsts := &TypedScope{
typedFiles: map[string]*types.Info{},
errsSubtypeConsts: map[string]*types.Const{"SubtypeFoo": nil},
}
if onlyConsts.Enabled() {
t.Errorf("scope with consts but no typed files must be disabled — no per-file lookup is possible")
}
both := &TypedScope{
typedFiles: map[string]*types.Info{"x.go": {Uses: map[*ast.Ident]types.Object{}}},
errsSubtypeConsts: map[string]*types.Const{"SubtypeFoo": nil},
}
if !both.Enabled() {
t.Errorf("scope with both populated must be enabled")
}
}
// TestTypedScope_RejectsForeignNonPrefixedConst pins the A+ behavior of
// Refinement 1: even a constant whose name does NOT begin with "Subtype"
// is rejected when assigned to a Subtype: slot, because it does not
// resolve to a declared errs.Subtype constant. The legacy AST path was
// name-gated on the "Subtype" prefix and silently accepted such
// references.
func TestTypedScope_RejectsForeignNonPrefixedConst(t *testing.T) {
fset := token.NewFileSet()
// Canonical errs package with a real Subtype const.
errsSrc := `package fakeerrs
type Subtype string
const SubtypeMissingScope Subtype = "missing_scope"
`
errsFile, err := parser.ParseFile(fset, "fakeerrs/subtypes.go", errsSrc, parser.ParseComments)
if err != nil {
t.Fatalf("parse errs: %v", err)
}
conf := &types.Config{Importer: importer.Default()}
errsPkg, err := conf.Check(errsPkgPath, fset, []*ast.File{errsFile}, nil)
if err != nil {
t.Fatalf("type-check errs: %v", err)
}
// Foreign package declaring a constant named MyKind (NOT Subtype-prefixed).
// Under the legacy AST gate this would have been ignored entirely.
foreignSrc := `package foreign
type Kind string
const MyKind Kind = "wrong"
`
foreignFile, err := parser.ParseFile(fset, "foreign/foreign.go", foreignSrc, parser.ParseComments)
if err != nil {
t.Fatalf("parse foreign: %v", err)
}
foreignPkg, err := conf.Check("example.com/foreign", fset, []*ast.File{foreignFile}, nil)
if err != nil {
t.Fatalf("type-check foreign: %v", err)
}
scope := &TypedScope{
typedFiles: map[string]*types.Info{},
errsSubtypeConsts: map[string]*types.Const{},
}
collectSubtypeConsts(errsPkg, scope.errsSubtypeConsts)
// Consumer references foreign.MyKind so the type-checker records it
// in Info.Uses; we then drive ResolveSubtypeIdent against that ident.
consumerSrc := `package consumer
import foreign "example.com/foreign"
var _ foreign.Kind = foreign.MyKind
`
consumerFile, err := parser.ParseFile(fset, "consumer/x.go", consumerSrc, parser.ParseComments)
if err != nil {
t.Fatalf("parse consumer: %v", err)
}
imp := &fakeImporter{m: map[string]*types.Package{
errsPkgPath: errsPkg,
"example.com/foreign": foreignPkg,
}}
conf2 := &types.Config{Importer: imp}
info := &types.Info{Uses: map[*ast.Ident]types.Object{}}
if _, err := conf2.Check("example.com/consumer", fset, []*ast.File{consumerFile}, info); err != nil {
t.Fatalf("type-check consumer: %v", err)
}
scope.typedFiles["consumer/x.go"] = info
var foreignIdent *ast.Ident
ast.Inspect(consumerFile, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "MyKind" {
return true
}
foreignIdent = sel.Sel
return true
})
if foreignIdent == nil {
t.Fatalf("did not find foreign.MyKind selector in consumer source")
}
resolved, ok := scope.ResolveSubtypeIdent("consumer/x.go", foreignIdent)
if !ok {
t.Fatalf("foreign non-prefixed const must produce ok=true so CheckDeclaredSubtype can reject; got ok=false")
}
if resolved {
t.Errorf("foreign.MyKind (non-Subtype-prefixed) must NOT resolve=true; legacy AST gate would have skipped it silently")
}
// Drive the classifier directly to prove end-to-end rejection.
c, handled := classifyConstViaTypes(foreignIdent, "consumer/x.go", scope)
if !handled {
t.Fatalf("typed classifier must handle resolved foreign const; got handled=false")
}
if c.action != ActionReject {
t.Errorf("classifier action = %q, want REJECT", c.action)
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import "github.com/larksuite/cli/lint/lintapi"
// Re-export the shared types so existing rule code reads Action /
// Violation locally. The canonical declarations live in lintapi.
type (
Action = lintapi.Action
Violation = lintapi.Violation
)
const (
ActionReject = lintapi.ActionReject
ActionLabel = lintapi.ActionLabel
ActionWarning = lintapi.ActionWarning
)
// subtypeClassification is the package-internal verdict produced by the
// CheckDeclaredSubtype classifier for a single Subtype: expression. Empty
// action means "accept silently".
type subtypeClassification struct {
rule, message, suggestion string
action Action
}

10
lint/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/larksuite/cli/lint
go 1.23.0
require golang.org/x/tools v0.28.0
require (
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
)

8
lint/go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=

33
lint/lintapi/violation.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package lintapi defines the shared types every lint domain returns from
// its scan entry point. New lint domains (sibling packages under lint/)
// MUST return []lintapi.Violation so cmd/main can aggregate and report
// uniformly. The domain may add its own private types for internal use.
package lintapi
// Action enumerates the response modes for a violation.
type Action string
const (
// ActionReject hard-fails CI. Only REJECT contributes to a nonzero
// lintcheck exit code.
ActionReject Action = "REJECT"
// ActionLabel emits a diagnostic so CI can label the PR but does not fail.
ActionLabel Action = "LABEL"
// ActionWarning surfaces a reviewer-attention note without failing CI.
// CI does NOT exit nonzero on warnings; they are reviewer signal only.
ActionWarning Action = "WARNING"
)
// Violation describes a single lint hit. Rule identifies which check
// produced it; the domain package owns the rule namespace.
type Violation struct {
Rule string
Action Action
File string
Line int
Message string
Suggestion string
}

87
lint/main.go Normal file
View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Command lintcheck runs the source-level errs/ contract guards (all four checks).
// The fifth contract rule (business path must use typed errors) lives in
// .golangci.yml as a forbidigo entry; the four checks here are AST-level
// guards that golangci-lint cannot express.
//
// lintcheck lives in its own Go module under lint/ so its build-time
// dependency on golang.org/x/tools/go/packages does not leak into the
// shipped lark-cli binary's module graph.
//
// Usage (from repo root):
//
// go run -C lint . . # scan the lark-cli repo
// go run -C lint . /path/to/repo # scan another path
//
// Exit codes:
//
// 0 no REJECT violations (LABEL and WARNING diagnostics are advisory)
// 1 one or more REJECT violations
//
// WARNING and LABEL diagnostics are still printed so a CI workflow can grep
// for the prefixes — LABEL emits `[needs-taxonomy-decision]` for an
// auto-labeler — but neither severity fails CI. Only REJECT does.
package main
import (
"flag"
"fmt"
"os"
"github.com/larksuite/cli/lint/errscontract"
"github.com/larksuite/cli/lint/lintapi"
)
// scanner is the contract every lint domain implements. New domains drop in
// as sibling packages under lint/ (see README.md) and are added below.
type scanner struct {
name string
fn func(root string) ([]lintapi.Violation, error)
}
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepo},
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr,
"Usage: lintcheck [repo-root]\n"+
"Runs every registered lint domain against repo-root (default: current directory).\n")
flag.PrintDefaults()
}
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
// `./...` is a common Go-toolchain idiom; map it to the working dir.
if root == "./..." {
root = "."
}
}
var all []lintapi.Violation
for _, s := range scanners {
violations, err := s.fn(root)
if err != nil {
fmt.Fprintf(os.Stderr, "lintcheck %s: %v\n", s.name, err)
os.Exit(2)
}
all = append(all, violations...)
}
exitCode := 0
for _, v := range all {
fmt.Fprintf(os.Stderr, "%s:%d: [%s/%s] %s\n", v.File, v.Line, v.Action, v.Rule, v.Message)
if v.Suggestion != "" {
fmt.Fprintf(os.Stderr, " hint: %s\n", v.Suggestion)
}
if v.Action == lintapi.ActionReject {
exitCode = 1
}
}
os.Exit(exitCode)
}

View File

@@ -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"]}}}}`

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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 ""

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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",
},

View File

@@ -643,7 +643,6 @@ func enrichDriveSearchError(err error) error {
Code: exitErr.Code,
Detail: &detail,
Err: exitErr.Err,
Raw: exitErr.Raw,
}
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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.
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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`")
}