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 # ScanRepoWithOptions(root, opts) ← 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
├── domaincontract/ # resolver ownership + approved public hostname policy
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
├── unapproved.go # Go AST/type-aware hostname extraction
├── policy.go # exact public/fixture allowlist validation
├── diff.go # added-line attribution
└── *_test.go
└── flagcontract/ # framework ownership for flag aliases
├── scan.go # rejects local name normalizers
└── scan_test.go
Flag alias contract (flagcontract)
flagcontract keeps exact flag-name synonyms on the shared framework path. It
rejects production calls to SetNormalizeFunc outside internal/flagalias.
Exact synonyms belong in the canonical common.Flag.Aliases; legacy inputs
with a different value grammar or meaning remain real hidden flags and
normalize into canonical state inside the business-owned Shortcut.Normalize
execution stage.
Endpoint domain contract (domaincontract)
domaincontract contains two complementary Go source guards.
The resolver-ownership guard rejects:
- string literals containing a resolver-owned host FQDN
(
{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}), and
- direct references to the SDK base-URL globals (
FeishuBaseUrl / LarkBaseUrl)
selected off an import of the SDK root package, which pick a host without
going through the resolver. Unrelated identifiers sharing the name are not
flagged.
Host literals are permitted only inside the resolver's ResolveEndpoints
function body (internal/core/types.go) and in this rule's own host list
(lint/domaincontract/scan.go); a helper elsewhere in the resolver file
returning a hardcoded host is still rejected. Comments and _test.go files
are not scanned. Literals are unquoted before matching (escape sequences
cannot hide a host) and match case-insensitively, and dot-imports of the SDK
root package are rejected outright (they would hide the globals from this
parse-level guard). The forbidden-host list is bound to the resolver source by
TestForbiddenHostsMatchResolver, so adding a resolver domain without updating
the guard fails the lint module's tests.
The approved-domain guard parses every Git-tracked Go file in full. In CI,
unapproved-host findings are limited to values whose expressions intersect an
added line; policy validation and unused-entry checks remain repository-wide.
It rejects an exact hostname unless it is present in one of:
internal/qualitygate/config/allowlists/public-domains.txt, for production
and test code; or
internal/qualitygate/config/allowlists/fixture-domains.txt, only for
*_test.go, the repository-root tests/, and any testdata/ (never
skills/).
RFC 2606 example/test names are accepted independently of those lists. This
includes the reserved .test, .example, .invalid, and .localhost
namespaces and the exact names example.com, example.net, and example.org;
they are safe placeholders rather than supported public endpoints.
High-confidence evidence is deliberately limited to static string expressions
assigned to host, hostname, or domain semantic names (including common
case/plural forms and collections), plus static strings whose entire value is
an absolute http, https, ws, or wss URL. It supports Go literals,
escapes, compile-time concatenation, constant references, grouped declarations,
multi-value assignments, and multiline expressions. Bare domain-shaped strings
without hostname semantics are not blocked.
Sequence values are scanned individually. For a hostname-semantic map, a key or
value is evidence only when it is the sole hostname-shaped side of that entry;
ambiguous string-to-string entries are not guessed. Struct fields use Go type
information so known non-network Host / Domain fields do not become hostname
evidence merely because an enum or command category contains a dot.
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
hostnames, and have a current in-scope use. See
internal/qualitygate/config/README.md for admission and approval rules.
This is not a general outbound-URL or cross-language data-flow analyzer. It does
not inspect non-Go assets or dynamically constructed values.
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
than hardcoding the host elsewhere.
Running
# PR-scoped scan from the repo root (one level above lint/)
go run -C lint . --changed-from <base-revision> ..
# Full inventory (also reports historical unapproved hostnames)
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 source-contract 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
-
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).
-
Inside the new package, expose one public entry:
package <domain>
import "github.com/larksuite/cli/lint/lintapi"
type ScanOptions struct {
ChangedFrom string
}
// ScanRepoWithOptions 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 ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
-
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.
-
Register the domain in lint/main.go:
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
-
Verify locally:
go test -C lint ./... # all domains' tests
go run -C lint . .. # full scan against the repo
-
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.