Skip to content

Commit 31e4096

Browse files
committed
feat(markdown): emit typed error envelopes across the markdown domain
Emit structured validation, API, network, file, and internal error envelopes for Markdown shortcuts so users and agents can recover from failed markdown workflows using stable type, subtype, param, and code fields. Add Markdown domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
1 parent 03ea6e7 commit 31e4096

11 files changed

Lines changed: 168 additions & 80 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
76+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var migratedCommonHelperPaths = []string{
2020
"shortcuts/contact/",
2121
"shortcuts/drive/",
2222
"shortcuts/mail/",
23+
"shortcuts/markdown/",
2324
"shortcuts/minutes/",
2425
"shortcuts/okr/",
2526
"shortcuts/task/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ var migratedEnvelopePaths = []string{
2121
"shortcuts/contact/",
2222
"shortcuts/drive/",
2323
"shortcuts/mail/",
24+
"shortcuts/markdown/",
2425
"shortcuts/minutes/",
2526
"shortcuts/okr/",
2627
"shortcuts/task/",

lint/errscontract/rules_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
946946
paths := []string{
947947
"shortcuts/drive/drive_search.go",
948948
"shortcuts/mail/mail_send.go",
949+
"shortcuts/markdown/markdown_fetch.go",
949950
"shortcuts/okr/okr_progress_create.go",
950951
"shortcuts/task/task_update.go",
951952
"shortcuts/whiteboard/whiteboard_query.go",
@@ -997,6 +998,23 @@ func boom() {
997998
}
998999
}
9991000

1001+
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
1002+
src := `package migrated
1003+
1004+
import c "github.com/larksuite/cli/shortcuts/common"
1005+
1006+
func boom() {
1007+
f := c.FlagErrorf
1008+
_ = f
1009+
c.WrapInputStatError(nil)
1010+
}
1011+
`
1012+
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
1013+
if len(v) != 2 {
1014+
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
1015+
}
1016+
}
1017+
10001018
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
10011019
src := `package contact
10021020

shortcuts/markdown/helpers.go

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ package markdown
66
import (
77
"bytes"
88
"context"
9-
"errors"
109
"fmt"
1110
"io"
1211
"net/http"
@@ -19,7 +18,6 @@ import (
1918

2019
"github.com/larksuite/cli/errs"
2120
"github.com/larksuite/cli/internal/client"
22-
"github.com/larksuite/cli/internal/output"
2321
"github.com/larksuite/cli/internal/validate"
2422
"github.com/larksuite/cli/shortcuts/common"
2523
)
@@ -85,16 +83,18 @@ func (spec markdownUploadSpec) Target() markdownUploadTarget {
8583
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
8684
switch {
8785
case spec.ContentSet && spec.FileSet:
88-
return common.FlagErrorf("--content and --file are mutually exclusive")
86+
return markdownValidationError("--content and --file are mutually exclusive").
87+
WithParams(markdownInvalidParam("--content", "mutually exclusive"), markdownInvalidParam("--file", "mutually exclusive"))
8988
case !spec.ContentSet && !spec.FileSet:
90-
return common.FlagErrorf("specify exactly one of --content or --file")
89+
return markdownValidationError("specify exactly one of --content or --file").
90+
WithParams(markdownInvalidParam("--content", "required; specify exactly one"), markdownInvalidParam("--file", "required; specify exactly one"))
9191
}
9292

9393
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
94-
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
94+
return markdownValidationParamError("--folder-token", "--folder-token cannot be empty; omit it to upload into Drive root folder")
9595
}
9696
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
97-
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
97+
return markdownValidationParamError("--wiki-token", "--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
9898
}
9999
targets := 0
100100
if spec.FolderToken != "" {
@@ -104,22 +104,23 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
104104
targets++
105105
}
106106
if targets > 1 {
107-
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
107+
return markdownValidationError("--folder-token and --wiki-token are mutually exclusive").
108+
WithParams(markdownInvalidParam("--folder-token", "mutually exclusive"), markdownInvalidParam("--wiki-token", "mutually exclusive"))
108109
}
109110
if spec.FolderToken != "" {
110111
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
111-
return output.ErrValidation("%s", err)
112+
return markdownValidationParamError("--folder-token", "%s", err).WithCause(err)
112113
}
113114
}
114115
if spec.WikiToken != "" {
115116
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
116-
return output.ErrValidation("%s", err)
117+
return markdownValidationParamError("--wiki-token", "%s", err).WithCause(err)
117118
}
118119
}
119120

120121
if requireName && spec.ContentSet {
121122
if strings.TrimSpace(spec.FileName) == "" {
122-
return common.FlagErrorf("--name is required when using --content")
123+
return markdownValidationParamError("--name", "--name is required when using --content")
123124
}
124125
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
125126
return err
@@ -128,10 +129,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
128129

129130
if spec.FileSet {
130131
if strings.TrimSpace(spec.FilePath) == "" {
131-
return common.FlagErrorf("--file cannot be empty")
132+
return markdownValidationParamError("--file", "--file cannot be empty")
132133
}
133134
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
134-
return output.ErrValidation("unsafe file path: %s", err)
135+
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
135136
}
136137
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
137138
return err
@@ -154,10 +155,10 @@ func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string
154155
func validateMarkdownFileName(name, flagName string) error {
155156
trimmed := strings.TrimSpace(name)
156157
if trimmed == "" {
157-
return common.FlagErrorf("%s cannot be empty", flagName)
158+
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
158159
}
159160
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
160-
return common.FlagErrorf("%s must end with .md", flagName)
161+
return markdownValidationParamError(flagName, "%s must end with .md", flagName)
161162
}
162163
return nil
163164
}
@@ -201,22 +202,9 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
201202
return resp, nil
202203
}
203204

204-
func wrapMarkdownDownloadError(err error) error {
205-
// Preserve any already-classified error: legacy *output.ExitError or any
206-
// typed errs.* error. Only un-classified errors get wrapped as network.
207-
var exitErr *output.ExitError
208-
if errors.As(err, &exitErr) {
209-
return err
210-
}
211-
if _, ok := errs.ProblemOf(err); ok {
212-
return err
213-
}
214-
return output.ErrNetwork("download failed: %s", err)
215-
}
216-
217205
func validateNonEmptyMarkdownSize(size int64) error {
218206
if size == 0 {
219-
return output.ErrValidation("%s", markdownEmptyContentError)
207+
return markdownValidationError("%s", markdownEmptyContentError)
220208
}
221209
return nil
222210
}
@@ -227,12 +215,12 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
227215
size = int64(len(spec.Content))
228216
} else {
229217
if strings.TrimSpace(spec.FilePath) == "" {
230-
return 0, common.FlagErrorf("--file cannot be empty")
218+
return 0, markdownValidationParamError("--file", "--file cannot be empty")
231219
}
232220

233221
info, err := runtime.FileIO().Stat(spec.FilePath)
234222
if err != nil {
235-
return 0, common.WrapInputStatError(err)
223+
return 0, common.WrapInputStatErrorTyped(err)
236224
}
237225
size = info.Size()
238226
}
@@ -563,7 +551,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
563551

564552
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
565553
if readErr != nil {
566-
return output.ErrValidation("cannot read file: %s", readErr)
554+
return markdownValidationError("cannot read file: %s", readErr).WithCause(readErr)
567555
}
568556

569557
fd := larkcore.NewFormdata()

shortcuts/markdown/markdown_diff.go

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package markdown
55

66
import (
77
"context"
8-
"errors"
98
"fmt"
109
"io"
1110
"regexp"
@@ -14,6 +13,7 @@ import (
1413

1514
"github.com/sergi/go-diff/diffmatchpatch"
1615

16+
"github.com/larksuite/cli/errs"
1717
"github.com/larksuite/cli/internal/output"
1818
"github.com/larksuite/cli/internal/validate"
1919
"github.com/larksuite/cli/shortcuts/common"
@@ -65,7 +65,7 @@ type markdownDiffHunkRange struct {
6565

6666
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
6767
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
68-
return output.ErrValidation("%s", err)
68+
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
6969
}
7070
if spec.FromVersion != "" {
7171
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
@@ -79,40 +79,40 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
7979
}
8080
if spec.FilePath != "" {
8181
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
82-
return output.ErrValidation("unsafe file path: %s", err)
82+
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
8383
}
8484
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
8585
return err
8686
}
8787
}
8888
if spec.ContextLines < 0 {
89-
return output.ErrValidation("--context-lines must be >= 0")
89+
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
9090
}
9191
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
92-
return output.ErrValidation("markdown +diff only supports --format json or pretty")
92+
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
9393
}
9494
if spec.FilePath == "" {
9595
if spec.FromVersion == "" && spec.ToVersion == "" {
96-
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
96+
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
9797
}
9898
if spec.FromVersion == "" && spec.ToVersion != "" {
99-
return common.FlagErrorf("--to-version requires --from-version")
99+
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
100100
}
101101
return nil
102102
}
103103
if spec.ToVersion != "" {
104-
return common.FlagErrorf("--to-version is not supported together with --file")
104+
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
105105
}
106106
return nil
107107
}
108108

109109
func validateMarkdownDiffVersionValue(value, flagName string) error {
110110
value = strings.TrimSpace(value)
111111
if value == "" {
112-
return output.ErrValidation("%s cannot be empty", flagName)
112+
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
113113
}
114114
if !markdownDiffVersionRe.MatchString(value) {
115-
return output.ErrValidation("%s must be a numeric version string", flagName)
115+
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
116116
}
117117
return nil
118118
}
@@ -178,17 +178,16 @@ func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext
178178
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
179179
f, err := runtime.FileIO().Open(filePath)
180180
if err != nil {
181-
return "", common.WrapInputStatError(err)
181+
return "", common.WrapInputStatErrorTyped(err)
182182
}
183183
defer f.Close()
184184

185185
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
186186
if err != nil {
187-
var exitErr *output.ExitError
188-
if errors.As(err, &exitErr) {
187+
if _, ok := errs.ProblemOf(err); ok {
189188
return "", err
190189
}
191-
return "", output.ErrValidation("cannot read file: %s", err)
190+
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
192191
}
193192
return string(payload), nil
194193
}
@@ -199,7 +198,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
199198
return nil, err
200199
}
201200
if len(payload) > markdownDiffMaxContentBytes {
202-
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
201+
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
203202
}
204203
return payload, nil
205204
}

0 commit comments

Comments
 (0)