Skip to content

Commit 08803dc

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

16 files changed

Lines changed: 165 additions & 121 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/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|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/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|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/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|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
@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
1818
"shortcuts/base/",
1919
"shortcuts/calendar/",
2020
"shortcuts/contact/",
21+
"shortcuts/doc/",
2122
"shortcuts/drive/",
2223
"shortcuts/mail/",
2324
"shortcuts/minutes/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
1919
"shortcuts/base/",
2020
"shortcuts/calendar/",
2121
"shortcuts/contact/",
22+
"shortcuts/doc/",
2223
"shortcuts/drive/",
2324
"shortcuts/mail/",
2425
"shortcuts/minutes/",

lint/errscontract/rules_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
944944
"HandleApiResult",
945945
}
946946
paths := []string{
947+
"shortcuts/doc/docs_fetch_v2.go",
947948
"shortcuts/drive/drive_search.go",
948949
"shortcuts/mail/mail_send.go",
949950
"shortcuts/okr/okr_progress_create.go",
@@ -997,6 +998,23 @@ func boom() {
997998
}
998999
}
9991000

1001+
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(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/doc/docs_fetch_v2.go", src)
1013+
if len(v) != 2 {
1014+
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
1015+
}
1016+
}
1017+
10001018
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
10011019
src := `package contact
10021020

shortcuts/doc/clipboard.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"regexp"
1212
"runtime"
1313
"strings"
14+
15+
"github.com/larksuite/cli/errs"
1416
)
1517

1618
// readClipboardImageBytes reads the current clipboard image and returns the
@@ -35,13 +37,13 @@ func readClipboardImageBytes() ([]byte, error) {
3537
case "linux":
3638
data, err = readClipboardLinux()
3739
default:
38-
return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS)
40+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
3941
}
4042
if err != nil {
4143
return nil, err
4244
}
4345
if len(data) == 0 {
44-
return nil, fmt.Errorf("clipboard contains no image data")
46+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
4547
}
4648
return data, nil
4749
}
@@ -91,9 +93,9 @@ func readClipboardDarwin() ([]byte, error) {
9193
}
9294

9395
if stderrText != "" {
94-
return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText)
96+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
9597
}
96-
return nil, fmt.Errorf("clipboard contains no image data")
98+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
9799
}
98100

99101
// runOsascript invokes osascript with a single AppleScript expression and
@@ -188,14 +190,14 @@ func decodeOsascriptData(s string) ([]byte, error) {
188190
// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes.
189191
func decodeHex(h string) ([]byte, error) {
190192
if len(h)%2 != 0 {
191-
return nil, fmt.Errorf("odd hex length")
193+
return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
192194
}
193195
b := make([]byte, len(h)/2)
194196
for i := 0; i < len(h); i += 2 {
195197
hi := hexVal(h[i])
196198
lo := hexVal(h[i+1])
197199
if hi < 0 || lo < 0 {
198-
return nil, fmt.Errorf("invalid hex char at %d", i)
200+
return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
199201
}
200202
b[i/2] = byte(hi<<4 | lo)
201203
}
@@ -237,12 +239,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
237239
if msg == "" {
238240
msg = err.Error()
239241
}
240-
return nil, fmt.Errorf("clipboard read failed (%s)", msg)
242+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
241243
}
242244
b64 := strings.TrimSpace(string(out))
243245
data, decErr := base64.StdEncoding.DecodeString(b64)
244246
if decErr != nil {
245-
return nil, fmt.Errorf("clipboard image decode failed: %w", decErr)
247+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
246248
}
247249
return data, nil
248250
}
@@ -325,15 +327,15 @@ func readClipboardLinux() ([]byte, error) {
325327
foundTool = true
326328
out, err := exec.Command(t.name, t.args...).Output()
327329
if err != nil {
328-
lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err)
330+
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
329331
continue
330332
}
331333
if len(out) == 0 {
332-
lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name)
334+
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
333335
continue
334336
}
335337
if t.validatePNG && !hasPNGMagic(out) {
336-
lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name)
338+
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
337339
continue
338340
}
339341
return out, nil
@@ -342,8 +344,8 @@ func readClipboardLinux() ([]byte, error) {
342344
if foundTool && lastErr != nil {
343345
return nil, lastErr
344346
}
345-
return nil, fmt.Errorf(
346-
"clipboard image read failed: no supported tool found. " +
347-
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager " +
347+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
348+
"clipboard image read failed: no supported tool found. "+
349+
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+
348350
"(apt, dnf, pacman, apk, brew, etc.).")
349351
}

shortcuts/doc/doc_errors.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package doc
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
9+
// error (preserving its subtype / code / log_id from the runtime boundary),
10+
// and only wraps a raw, unclassified error as a transport-level network error.
11+
func wrapDocNetworkErr(err error, format string, args ...any) error {
12+
if _, ok := errs.ProblemOf(err); ok {
13+
return err
14+
}
15+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
16+
}

shortcuts/doc/doc_media_download.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010

1111
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1212

13+
"github.com/larksuite/cli/errs"
1314
"github.com/larksuite/cli/extension/fileio"
14-
"github.com/larksuite/cli/internal/output"
1515
"github.com/larksuite/cli/internal/validate"
1616
"github.com/larksuite/cli/shortcuts/common"
1717
)
@@ -51,10 +51,10 @@ var DocMediaDownload = common.Shortcut{
5151
overwrite := runtime.Bool("overwrite")
5252

5353
if err := validate.ResourceName(token, "--token"); err != nil {
54-
return output.ErrValidation("%s", err)
54+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
5555
}
5656
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
57-
return output.ErrValidation("unsafe output path: %s", err)
57+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
5858
}
5959

6060
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token))
@@ -73,7 +73,7 @@ var DocMediaDownload = common.Shortcut{
7373
ApiPath: apiPath,
7474
})
7575
if err != nil {
76-
return output.ErrNetwork("download failed: %v", err)
76+
return wrapDocNetworkErr(err, "download failed: %v", err)
7777
}
7878
defer resp.Body.Close()
7979

@@ -86,14 +86,14 @@ var DocMediaDownload = common.Shortcut{
8686
// Validate final path after extension append
8787
if finalPath != outputPath {
8888
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
89-
return output.ErrValidation("unsafe output path: %s", err)
89+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
9090
}
9191
}
9292

9393
// Overwrite check on final path (after extension detection)
9494
if !overwrite {
9595
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
96-
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
96+
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
9797
}
9898
}
9999

@@ -102,7 +102,7 @@ var DocMediaDownload = common.Shortcut{
102102
ContentLength: resp.ContentLength,
103103
}, resp.Body)
104104
if err != nil {
105-
return common.WrapSaveErrorByCategory(err, "io")
105+
return common.WrapSaveErrorTyped(err)
106106
}
107107

108108
savedPath, _ := runtime.ResolveSavePath(finalPath)

0 commit comments

Comments
 (0)