|
| 1 | +// Copyright (c) 2026 Lark Technologies Pte. Ltd. |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +package errscontract |
| 5 | + |
| 6 | +import ( |
| 7 | + "go/ast" |
| 8 | + "go/parser" |
| 9 | + "go/token" |
| 10 | + "strings" |
| 11 | +) |
| 12 | + |
| 13 | +// migratedEnvelopePaths lists the source-tree prefixes that have been migrated |
| 14 | +// to the typed errs.* taxonomy. On these paths, constructing a legacy |
| 15 | +// output.ExitError / output.ErrDetail envelope literal directly is forbidden — |
| 16 | +// call sites must return a typed errs.* error instead. Future domains opt in by |
| 17 | +// appending their path prefix here. |
| 18 | +var migratedEnvelopePaths = []string{ |
| 19 | + "shortcuts/drive/", |
| 20 | +} |
| 21 | + |
| 22 | +// legacyOutputImportPath is the import path of the package that declares the |
| 23 | +// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local |
| 24 | +// name (default or alias) this path is bound to in each file, so an aliased |
| 25 | +// import cannot bypass the check. |
| 26 | +const legacyOutputImportPath = "github.com/larksuite/cli/internal/output" |
| 27 | + |
| 28 | +// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy |
| 29 | +// output.ExitError / output.ErrDetail composite literals on migrated paths. |
| 30 | +// forbidigo can ban identifiers but not composite literals, so this AST rule |
| 31 | +// covers the gap left after a path is migrated to typed errs.* errors. |
| 32 | +// |
| 33 | +// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts |
| 34 | +// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a |
| 35 | +// CompositeLit, so the predicate exit-signal helper is naturally not flagged. |
| 36 | +func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { |
| 37 | + if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { |
| 38 | + return nil |
| 39 | + } |
| 40 | + fset := token.NewFileSet() |
| 41 | + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) |
| 42 | + if err != nil { |
| 43 | + return nil |
| 44 | + } |
| 45 | + // Resolve the local name(s) bound to the legacy output import path. A file |
| 46 | + // may bind it as the default `output`, an alias (`legacy "...output"`), or a |
| 47 | + // dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear |
| 48 | + // as bare unqualified idents. |
| 49 | + localNames, dotImported := resolveLegacyOutputNames(file) |
| 50 | + var out []Violation |
| 51 | + ast.Inspect(file, func(n ast.Node) bool { |
| 52 | + lit, ok := n.(*ast.CompositeLit) |
| 53 | + if !ok { |
| 54 | + return true |
| 55 | + } |
| 56 | + if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok { |
| 57 | + out = append(out, Violation{ |
| 58 | + Rule: "no_legacy_envelope_literal", |
| 59 | + Action: ActionReject, |
| 60 | + File: path, |
| 61 | + Line: fset.Position(lit.Pos()).Line, |
| 62 | + Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)", |
| 63 | + Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " + |
| 64 | + "(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)", |
| 65 | + }) |
| 66 | + } |
| 67 | + return true |
| 68 | + }) |
| 69 | + return out |
| 70 | +} |
| 71 | + |
| 72 | +// isMigratedEnvelopePath reports whether path falls under any migrated path |
| 73 | +// prefix in migratedEnvelopePaths. |
| 74 | +func isMigratedEnvelopePath(path string) bool { |
| 75 | + p := strings.ReplaceAll(path, "\\", "/") |
| 76 | + for _, prefix := range migratedEnvelopePaths { |
| 77 | + if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) { |
| 78 | + return true |
| 79 | + } |
| 80 | + } |
| 81 | + return false |
| 82 | +} |
| 83 | + |
| 84 | +// resolveLegacyOutputNames walks the file's import declarations and returns the |
| 85 | +// set of local names bound to legacyOutputImportPath, plus whether the path was |
| 86 | +// dot-imported. Default imports bind the package's own name ("output"); aliased |
| 87 | +// imports bind the alias; dot-imports bind names into the file scope. |
| 88 | +func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) { |
| 89 | + names := make(map[string]struct{}) |
| 90 | + dotImported := false |
| 91 | + for _, imp := range file.Imports { |
| 92 | + if imp.Path == nil { |
| 93 | + continue |
| 94 | + } |
| 95 | + p := strings.Trim(imp.Path.Value, "`\"") |
| 96 | + if p != legacyOutputImportPath { |
| 97 | + continue |
| 98 | + } |
| 99 | + switch { |
| 100 | + case imp.Name == nil: |
| 101 | + // Default import: local name is the package name "output". |
| 102 | + names["output"] = struct{}{} |
| 103 | + case imp.Name.Name == ".": |
| 104 | + dotImported = true |
| 105 | + case imp.Name.Name == "_": |
| 106 | + // Blank import cannot reference the types; ignore. |
| 107 | + default: |
| 108 | + names[imp.Name.Name] = struct{}{} |
| 109 | + } |
| 110 | + } |
| 111 | + return names, dotImported |
| 112 | +} |
| 113 | + |
| 114 | +// legacyEnvelopeTypeName reports whether a composite-literal Type names the |
| 115 | +// legacy ExitError / ErrDetail envelope and returns the bare type name. It |
| 116 | +// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved |
| 117 | +// local names for the legacy output import, and — when the package was |
| 118 | +// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident. |
| 119 | +func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) { |
| 120 | + if sel, ok := expr.(*ast.SelectorExpr); ok { |
| 121 | + x, ok := sel.X.(*ast.Ident) |
| 122 | + if !ok || sel.Sel == nil { |
| 123 | + return "", false |
| 124 | + } |
| 125 | + if _, bound := localNames[x.Name]; !bound { |
| 126 | + return "", false |
| 127 | + } |
| 128 | + return matchLegacyEnvelopeName(sel.Sel.Name) |
| 129 | + } |
| 130 | + if dotImported { |
| 131 | + if ident, ok := expr.(*ast.Ident); ok { |
| 132 | + return matchLegacyEnvelopeName(ident.Name) |
| 133 | + } |
| 134 | + } |
| 135 | + return "", false |
| 136 | +} |
| 137 | + |
| 138 | +// matchLegacyEnvelopeName returns the name when it is one of the legacy |
| 139 | +// envelope type names. |
| 140 | +func matchLegacyEnvelopeName(name string) (string, bool) { |
| 141 | + switch name { |
| 142 | + case "ExitError", "ErrDetail": |
| 143 | + return name, true |
| 144 | + } |
| 145 | + return "", false |
| 146 | +} |
0 commit comments