| name | glint |
| description | Conventions for authoring or editing analyzers in the `glint/` Go static-analysis package — Gram's custom golangci-lint plugin built on `go/analysis`. Activate this skill whenever the task involves adding, modifying, or testing a `glint` analyzer (new rule key, new diagnostic, settings struct, fixture under `glint/testdata/`, wiring in `BuildAnalyzers`), even if the user does not say "glint" explicitly — phrases like "add a lint rule", "write a custom analyzer", "go/analysis", or "enforce X via golangci-lint" should trigger it. |
| metadata | {"relevant_files":["glint/**/*.go"]} |
glint is Gram's package of custom go/analysis analyzers, and gcl is the golangci-lint custom-build configuration that loads glint as a plugin. Together they automate enforcement of project coding conventions and bug-prevention rules so the same feedback isn't re-litigated in PR review.
- Plugin entry point: glint/plugin.go — registers the plugin via
register.Plugin("glint", New), defines the settings/ruleSettings structs, and lists every analyzer in BuildAnalyzers.
- gcl wiring: server/.custom-gcl.yml — declares
github.com/speakeasy-api/gram/glint as the imported plugin module for the custom golangci-lint binary.
The current set of analyzers and their rule keys is the source of truth in BuildAnalyzers. Read that function before adding a new one.
Quick start
When adding a new analyzer:
- Pick a kebab-case rule key and a matching
snake_case.go file name in glint/. See Naming conventions.
- Define
<rule>Settings with Disabled bool. Add nothing else unless there's a concrete user-facing reason. See Settings and ignore mechanisms.
- Implement detection in
new<Rule>Analyzer(rule <rule>Settings) *analysis.Analyzer. Prefer type-based checks over AST shape, and AST shape over string matching. See Detection methodology.
- Write the diagnostic with imperative tone and the offending identifier inlined via
%q. See Diagnostic message style.
- Wire the rule into glint/plugin.go: add a
ruleSettings field with the kebab-case JSON tag, then an if !p.settings.Rules.<X>.Disabled block in BuildAnalyzers.
- Add
<rule>_test.go with analysistest.Run(...) and a fixture under glint/testdata/src/<fixture>/. Update disabledAllRulesPlugin() and the count in TestBuildAnalyzersAllEnabled in glint/plugin_test.go. See Testing.
Detection methodology
Strongly prefer type-based detection over AST-shape matching, and AST-shape matching over source-string matching. Type-based checks are robust against renames, aliases, vendored packages, and import-path quirks; AST shape is robust against most refactors but blind to type identity; raw string matching is fragile and should only be reached for when the signal genuinely lives in source text rather than the type system.
Prefer types via pass.TypesInfo
glint/no_repo_fields_in_service.go walks struct fields, resolves each field's type via pass.TypesInfo.TypeOf, then narrows through *types.Pointer → *types.Named to identify a sqlc-generated repo handle:
fieldType := pass.TypesInfo.TypeOf(field.Type)
if fieldType == nil {
continue
}
ptr, ok := fieldType.(*types.Pointer)
if !ok {
continue
}
named, ok := ptr.Elem().(*types.Named)
if !ok {
continue
}
if !isSqlcGenerated(pass.Fset, named.Obj(), cache) {
continue
}
Reach for type assertions on types.Type (*types.Named, *types.Pointer, *types.Slice, *types.Map, *types.Interface); use pass.TypesInfo.Uses[ident] / pass.TypesInfo.Defs[ident] to resolve identifiers to their declared types.Object; inspect method receivers via Recv() when checking method-set rules. The plugin advertises register.LoadModeTypesInfo from GetLoadMode in glint/plugin.go, so type information is guaranteed to be loaded.
AST shape is acceptable when types are not load-bearing
When the rule is fundamentally about the shape of code rather than its types, AST matching is fine. glint/no_anonymous_defer.go walks *ast.DeferStmt and asserts the call target is *ast.FuncLit:
ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
ins.Preorder([]ast.Node{(*ast.DeferStmt)(nil)}, func(node ast.Node) {
deferStmt := node.(*ast.DeferStmt)
if _, ok := deferStmt.Call.Fun.(*ast.FuncLit); !ok {
return
}
pass.ReportRangef(deferStmt, "%s", message)
})
There's no *types.Type that captures "anonymous deferred function" — the property only exists at the AST level — so AST matching is the right tool.
Traversal: depend on inspect.Analyzer for deep walks
When a rule needs to find nodes anywhere in the tree (calls, defers, type specs nested in functions, etc.), declare a dependency on the shared inspector rather than hand-rolling for _, file := range pass.Files { ast.Inspect(file, ...) }. inspect.Analyzer parses each file once and shares the resulting *inspector.Inspector across every dependent analyzer, so the package's analyzers walk each file once collectively instead of once per analyzer. Preorder also filters by node type up front, replacing the node.(*ast.T) type-switch-and-return true boilerplate:
import (
"go/ast"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
return &analysis.Analyzer{
Name: enforceO11yConventionsAnalyzer,
Doc: enforceO11yConventionsDefaultMessage,
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: func(pass *analysis.Pass) (any, error) {
ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
ins.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(node ast.Node) {
callExpr := node.(*ast.CallExpr)
})
return nil, nil
},
}
Notes when adopting it:
- The callback returns nothing, so there is no
return false to prune a subtree the way ast.Inspect allows. If a rule genuinely depends on pruning descent (matching a node and deliberately skipping its children, for example), keep a manual ast.Inspect and say why in a comment.
Preorder walks every file in the package with no file boundary, so any per-file scoping has to happen per node instead of around a pass.Files loop. When a rule applies only to certain files, filter inside the callback via pass.Fset.File(n.Pos()).Name(). For example, glint/no_testing_raw_sql.go checks for the _test.go suffix there because the test-only rule no longer has a file loop to gate.
- Pulling the inspector from
pass.ResultOf only works if the analyzer declared inspect.Analyzer in its Requires; otherwise the result is nil and the type assertion panics. This bites helpers shared across analyzers, since every caller must carry the dependency. For example, findAnnotatedStructs reads the inspector, so all four analyzers that call it list inspect.Analyzer in their Requires.
Two situations where the manual walk is still the right call, and the inspector earns nothing:
- Per-file stateful detection. When a rule accumulates state across a file and emits fixes scoped to that one
*ast.File (an occurrence list, a "is this import used elsewhere" flag, import add/remove SuggestedFixes), the natural unit of work is the file, not the node. The shared inspector deliberately flattens that boundary, so adopting it would force re-bucketing nodes back by file to rebuild the same state, with no traversal saved. Keep the manual per-file ast.Inspect and leave a comment explaining the exemption. For example, glint/no_sql_err_no_rows.go collects each file's ErrNoRows occurrences alongside an otherSqlUsage flag before deciding which imports to rewrite.
- Top-level-declaration scans. When a rule only cares about package-level declarations, iterating
file.Decls directly is already a shallow single pass and expresses the intent precisely. Reaching for Preorder on *ast.GenDecl would additionally visit function-local declarations, widening the rule's scope, so the inspector is both unnecessary and subtly wrong here. For example, the audit-* analyzers (e.g. glint/audit_event_urn_naming.go) walk file.Decls to inspect only package-level *ast.GenDecl/*ast.TypeSpec.
Source-string matching is a last resort
Raw strings.Contains / regex over file content is fragile and almost never warranted. The only string-matching helper currently in the package is isSqlcGenerated in glint/no_repo_fields_in_service.go, and only because the "sqlc-generated" signal lives in a generated-file header comment that has no type-system representation:
f, err := parser.ParseFile(token.NewFileSet(), pos.Filename, nil, parser.ParseComments|parser.PackageClauseOnly)
if err == nil && ast.IsGenerated(f) {
for _, cg := range f.Comments {
if strings.Contains(cg.Text(), "sqlc") {
result = true
break
}
}
}
Note the helper caches results by filename, parses with parser.PackageClauseOnly to skip body parsing, and uses ast.IsGenerated to confirm the standard generated-code header before substring-matching. If you find yourself reaching for string matching, justify in a comment why type-based detection won't work — future readers will assume it was a last resort.
Settings and ignore mechanisms
Each analyzer accepts exactly one common setting: Disabled bool. Don't add allowlist fields (Ignored []string, regex includes/excludes, package globs, etc.) — they're a maintenance burden, they obscure the rule's contract, and golangci-lint already understands //nolint directives as the standard opt-out path.
type noRepoFieldsInServiceSettings struct {
Disabled bool `json:"disabled"`
}
The default opt-out for end users is the standard //nolint:glint directive (or //nolint:glint:<rule-key> for a specific rule). golangci-lint applies this without any analyzer-side wiring. If a particular violation is genuinely intentional in repo code, add //nolint:glint:<rule-key> with a brief comment explaining why.
The only setting-shape exception today is narrow message customization: two analyzers (no-anonymous-defer, enforce-o11y-conventions) expose a Message string that gets appended to the default diagnostic when set. From glint/no_anonymous_defer.go:
type noAnonymousDeferSettings struct {
Disabled bool `json:"disabled"`
Message string `json:"message"`
}
message := noAnonymousDeferDefaultMessage
if rule.Message != "" {
message += ": " + rule.Message
}
Add a Message field only if there's a concrete reason for end-user customization. Anything more elaborate than a single appended-suffix string warrants a discussion before being added.
Diagnostic message style
Diagnostics tell the reader what to do, not just what's wrong. Imperative present-tense, action-oriented, with the offending identifier inlined where it aids resolution. No trailing punctuation, no leading capital, match the tone of the existing analyzers.
pass.ReportRangef(field, "Repo field detected in service struct.")
Past-tense observation, capitalized, trailing period — describes the symptom but doesn't tell the reader what to change.
pass.ReportRangef(field, "field %q in %s has type %s which is sqlc-generated; services should use *pgxpool.Pool and create repo instances in methods",
field.Names[0].Name, s.name, fieldType)
(from glint/no_repo_fields_in_service.go)
Names the offending field with %q, explains why it's wrong, and tells the reader what shape the code should take instead.
pass.ReportRangef(deferStmt, "%s", message)
(from glint/no_anonymous_defer.go)
Imperative ("avoid"), short, no trailing punctuation.
When the diagnostic has a mechanical fix, attach a SuggestedFix so editor quick-fix actions and golangci-lint --fix can apply it. See Suggested fixes.
Analyzer.Doc
Set the Doc field on every *analysis.Analyzer. It's what go vet -<rule> and IDE tooling show users when surfacing the rule. Keep it short and descriptive, and where the diagnostic has a single canonical default message, reuse the same constant for both so they stay in sync. From glint/no_sql_err_no_rows.go:
const (
noSqlErrNoRowsAnalyzer = "nosqlerrnorows"
noSqlErrNoRowsDefaultMessage = "use github.com/jackc/pgx/v5.ErrNoRows instead of database/sql.ErrNoRows"
)
return &analysis.Analyzer{
Name: noSqlErrNoRowsAnalyzer,
Doc: noSqlErrNoRowsDefaultMessage,
}
Suggested fixes
When the diagnostic has a mechanical fix that's always safe to apply, attach SuggestedFixes to the analysis.Diagnostic so editor quick-fix actions and golangci-lint --fix can apply it. glint/no_sql_err_no_rows.go is the worked example:
pass.Report(analysis.Diagnostic{
Pos: occ.Pos(),
End: occ.End(),
Category: noSqlErrNoRowsAnalyzer,
Message: noSqlErrNoRowsDefaultMessage,
SuggestedFixes: []analysis.SuggestedFix{{
Message: noSqlErrNoRowsFixMessage,
TextEdits: edits,
}},
})
Things to keep in mind when assembling TextEdits (upstream rules):
- Each
TextEdit applies to a single file; End must not be earlier in the file than Pos; for a pure insertion, set End equal to Pos (or token.NoPos). Edits within the same diagnostic must not overlap.
- Build replacement text from AST nodes via
go/format.Node rather than hand-concatenating strings, so the output stays gofmt-clean. For trivial selector or identifier replacements, raw byte literals are fine.
- If the fix involves cross-occurrence work (e.g. removing an import once all call sites have been rewritten), attach the cross-cutting edits to every diagnostic's
SuggestedFix. analysistest's three-way merge dedupes identical edits, but splitting them risks an editor quick-fix branch removing the import while sibling occurrences remain unfixed. See the explanatory comment in glint/no_sql_err_no_rows.go for the reasoning.
Share editing helpers in subpackages
Common edit logic belongs in a shared subpackage under glint/, not duplicated across analyzers. Today's example is glint/imports/, which exposes reusable helpers for emitting analysis.TextEdit values that add or remove imports:
LocalName(file, path, defaultName) (string, bool) — resolves the local identifier (alias or default) for an imported package, or reports that the import is absent.
Add(file, path) (analysis.TextEdit, bool) — emits an edit that inserts a new import into the file's first grouped import block.
Remove(fset, file, path) (analysis.TextEdit, bool) — emits an edit that deletes the import line for path.
When you find yourself writing AST-rewriting helpers that another analyzer would plausibly need, add them under glint/<helper>/ with a package-level docstring describing the intended use in SuggestedFixes, and call them from your analyzer's Run function. Future analyzers should compose the helper rather than reimplement it.
Testing fixes
Test fix application with analysistest.RunWithSuggestedFixes instead of analysistest.Run. The harness applies the suggested fixes to the input file and compares the result against a <fixture>.go.golden file in the same fixture directory:
func TestNoSqlErrNoRows(t *testing.T) {
t.Parallel()
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, newNoSqlErrNoRowsAnalyzer(noSqlErrNoRowsSettings{}), "nosqlerrnorows")
}
Naming conventions
There are three parallel namings to keep aligned for each analyzer: the rule key (user-facing, in YAML/JSON settings and //nolint directives), the Go identifiers (constant, settings struct, constructor), and the file name.
Rule key (kebab-case, user-facing)
- Prohibition rules use a
no- prefix: no-anonymous-defer, no-repo-fields-in-service, no-sql-err-no-rows, no-direct-chat-message-insert.
- Other rules use a descriptive predicate that reads naturally as an assertion about correct code:
enforce-o11y-conventions, service-has-attach-func, service-has-service-assertion, audit-event-urn-naming, audit-event-typed-snapshot. Use a verb-led key when the rule is fundamentally about an action (enforce-...); use a <subject>-has-<property> or <subject>-<aspect> shape when the rule is an invariant check.
Go identifiers (lowerCamel mirroring the rule key)
The analyzer name constant, settings struct, and constructor mirror the rule key in lowerCamel form, with Go-style initialisms (SQL, URN, O11y):
| Rule key | Constant | Settings | Constructor |
|---|
no-anonymous-defer | noAnonymousDeferAnalyzer | noAnonymousDeferSettings | newNoAnonymousDeferAnalyzer |
no-repo-fields-in-service | noRepoFieldsInServiceAnalyzer | noRepoFieldsInServiceSettings | newNoRepoFieldsInServiceAnalyzer |
no-sql-err-no-rows | noSqlErrNoRowsAnalyzer | noSqlErrNoRowsSettings | newNoSqlErrNoRowsAnalyzer |
enforce-o11y-conventions | enforceO11yConventionsAnalyzer | enforceO11yConventionsSettings | newEnforceO11yConventionsAnalyzer |
service-has-attach-func | serviceHasAttachFuncAnalyzer | serviceHasAttachFuncSettings | newServiceHasAttachFuncAnalyzer |
audit-event-urn-naming | auditEventURNNamingAnalyzer | auditEventURNNamingSettings | newAuditEventURNNamingAnalyzer |
The analyzer-name string constant is also the Name field of the returned *analysis.Analyzer, and it's the directory name analysistest looks for under glint/testdata/src/. Keep them identical so the test harness, the diagnostic source, and the rule key all line up.
File names (snake_case mirroring the rule key)
Each analyzer lives in glint/<rule-key-with-underscores>.go with a sibling _test.go:
no-anonymous-defer → glint/no_anonymous_defer.go + glint/no_anonymous_defer_test.go
no-repo-fields-in-service → glint/no_repo_fields_in_service.go + glint/no_repo_fields_in_service_test.go
audit-event-urn-naming → glint/audit_event_urn_naming.go + glint/audit_event_urn_naming_test.go
ruleSettings JSON tag
In glint/plugin.go, the field on ruleSettings is UpperCamel and the json:"..." tag is the kebab-case rule key:
NoRepoFieldsInService noRepoFieldsInServiceSettings `json:"no-repo-fields-in-service"`
The JSON tag is what end users write under linters-settings.glint.rules.<key>.disabled in .golangci.yml, so it must match the rule key exactly.
Testing
Every analyzer ships with an analysistest-based test plus a fixture directory.
Default-settings test
Use golang.org/x/tools/go/analysis/analysistest and pass the constructor with zero-value settings. The fourth argument is the directory name analysistest looks up under glint/testdata/src/:
func TestNoAnonymousDefer(t *testing.T) {
t.Parallel()
testdata := analysistest.TestData()
analysistest.Run(t, testdata, newNoAnonymousDeferAnalyzer(noAnonymousDeferSettings{}), "noanonymousdefer")
}
Fixtures
Fixtures live at glint/testdata/src/<fixture-name>/<file>.go. Mark expected diagnostics with // want "<regex>" comments on the offending line:
package noanonymousdefer
func bad() {
defer func() {}()
}
For diagnostics whose text contains regex metacharacters (*, (, ., etc.), escape them in the // want directive and use backticks instead of double quotes when the message itself contains quotes:
repo *repo.Queries
Non-default settings
For each non-default setting variant (e.g. a custom Message), add a separate test function and a separate fixture directory whose // want text reflects the customized output:
func TestNoAnonymousDeferCustomMessage(t *testing.T) {
t.Parallel()
testdata := analysistest.TestData()
analysistest.Run(
t,
testdata,
newNoAnonymousDeferAnalyzer(noAnonymousDeferSettings{Message: "use a named deferred helper instead"}),
"noanonymousdefercustommessage",
)
}
Disabled-rule path
The disabled-rule test surface is centralized in glint/plugin_test.go, which exposes a disabledAllRulesPlugin() helper returning a *plugin with every rule's Disabled: true. Two assertions live there:
TestBuildAnalyzersAllDisabled — BuildAnalyzers returns an empty slice when everything is disabled.
TestBuildAnalyzersAllEnabled — asserts the expected analyzer count for a zero-value *plugin{}. Bump this number whenever a new analyzer is added.
Each analyzer's own test file then adds a per-rule registration test: start from disabledAllRulesPlugin(), flip only the rule under test back on, and assert BuildAnalyzers returns exactly that one analyzer with the expected name. From glint/no_repo_fields_in_service_test.go:
func TestBuildAnalyzersSkipsDisabledNoRepoFieldsInService(t *testing.T) {
t.Parallel()
p := disabledAllRulesPlugin()
p.settings.Rules.NoRepoFieldsInService.Disabled = false
analyzers, err := p.BuildAnalyzers()
require.NoError(t, err)
require.Len(t, analyzers, 1)
require.Equal(t, noRepoFieldsInServiceAnalyzer, analyzers[0].Name)
}
When adding a new analyzer, extend disabledAllRulesPlugin() with the new Disabled: true field, increment the expected count in TestBuildAnalyzersAllEnabled, and add the per-rule registration test alongside the analyzer's own unit tests.