Skip to content

[linter-miner] feat(linters): add errstringmatch linter — flag strings.Contains(err.Error(), ...) calls#33117

Merged
pelikhan merged 1 commit into
mainfrom
linter-miner/err-string-match-1c72b4d343fae2f0
May 18, 2026
Merged

[linter-miner] feat(linters): add errstringmatch linter — flag strings.Contains(err.Error(), ...) calls#33117
pelikhan merged 1 commit into
mainfrom
linter-miner/err-string-match-1c72b4d343fae2f0

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

Summary

Adds a new custom Go analysis linter errstringmatch that flags calls to strings.Contains(err.Error(), "literal") — a brittle pattern that matches error messages by substring instead of using the proper errors.Is / errors.As mechanisms.

What the linter catches

// ❌ flagged — brittle substring match on error message text
if strings.Contains(err.Error(), "not found") { ... }
if strings.Contains(err.Error(), "403") { ... }

// ✅ OK — proper sentinel or predicate check
if errors.Is(err, ErrNotFound) { ... }

The diagnostic emitted is:

avoid strings.Contains(err.Error(), ...) — use errors.Is, errors.As, or a sentinel error instead

Evidence

Codebase scan found 7 real occurrences in production code:

File Pattern
pkg/cli/project_command.go:306 strings.Contains(err.Error(), "INSUFFICIENT_SCOPES")
pkg/cli/audit.go:278 strings.Contains(err.Error(), "403")
pkg/cli/add_interactive_git.go:109 strings.Contains(mergeErr.Error(), "already merged")
pkg/cli/logs_download.go:320 strings.Contains(err.Error(), "410")
pkg/workflow/schedule_preprocessing.go:192 strings.Contains(err.Error(), "syntax is not supported")

Related issues:

Files changed

  • pkg/linters/errstringmatch/errstringmatch.go — analyzer implementation
  • pkg/linters/errstringmatch/errstringmatch_test.goanalysistest-based tests
  • pkg/linters/errstringmatch/testdata/src/errstringmatch/errstringmatch.go — test fixtures with // want annotations
  • cmd/linters/main.go — registers the new analyzer

Validation

go build ./cmd/linters   ✅
go test ./pkg/linters/errstringmatch/...   ✅  ok  0.431s

Generated by Linter Miner · ● 11.5M ·

  • expires on May 25, 2026, 6:15 PM UTC

Flags strings.Contains(err.Error(), "...") calls that perform
brittle substring matching on error messages instead of using
errors.Is or errors.As.

Evidence from codebase scan:
- 7 real occurrences in pkg/cli and pkg/workflow
- Issues #32751, #32752 (error predicate duplication)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels May 18, 2026
@pelikhan pelikhan marked this pull request as ready for review May 18, 2026 18:20
Copilot AI review requested due to automatic review settings May 18, 2026 18:20
@pelikhan pelikhan merged commit c16174c into main May 18, 2026
7 of 9 checks passed
@pelikhan pelikhan deleted the linter-miner/err-string-match-1c72b4d343fae2f0 branch May 18, 2026 18:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new errstringmatch Go analysis linter that flags strings.Contains(err.Error(), ...) calls, which are brittle substring matches on error messages, encouraging use of errors.Is/errors.As/sentinel errors instead. The analyzer is registered in the cmd/linters multichecker and includes analysistest-based fixtures.

Changes:

  • New errstringmatch analyzer using go/analysis + inspect to detect the strings.Contains(<error>.Error(), <string>) pattern via type-checked receiver.
  • Test fixtures with positive and negative cases and an analysistest-driven test.
  • Registers the new analyzer in cmd/linters/main.go.
Show a summary per file
File Description
pkg/linters/errstringmatch/errstringmatch.go Analyzer implementation: matches strings.Contains calls whose first arg is an Error() method call on a value implementing error and whose second arg is a string-typed expression.
pkg/linters/errstringmatch/errstringmatch_test.go Wires analysistest.Run against the testdata fixture.
pkg/linters/errstringmatch/testdata/src/errstringmatch/errstringmatch.go Fixture with two flagged and two unflagged cases, using // want regex annotations.
cmd/linters/main.go Registers errstringmatch.Analyzer in the multichecker entry point.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 0

@github-actions
Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100

Excellent test quality

Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No (ratio 0.12 — 16 test lines / 135 prod lines)
🚨 Coding-guideline violations None

Test Classification Details

Test File Classification Issues Detected
TestErrStringMatch pkg/linters/errstringmatch/errstringmatch_test.go:13 ✅ Design None

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 1 test — unit (//go:build !integration)

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%).

The single test uses analysistest.Run — the idiomatic framework for Go static analysis linters. It exercises the analyzer end-to-end against a testdata package that covers both positive cases (two strings.Contains(err.Error(), ...) calls that must be flagged, verified via // want comments) and negative cases (an errors.Is call and a plain strings.Contains on a non-error string that must not be flagged). This is a strong behavioral contract test: it verifies the observable output of the linter, not its internals.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §26052123418

🧪 Test quality analysis by Test Quality Sentinel · ● 5M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). The analysistest.Run-based test covers both flagged and non-flagged cases, providing strong behavioral contract coverage.

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd and /zoom-out — this is a new-feature PR adding a static analysis pass, so test coverage and internal code clarity are the right lenses.

Key Themes

  • isStringLiteral scope creep — the helper accepts any string-typed expression (variables, function-call return values), not just literals/constants. The name and docstring claim otherwise, and there's no testdata case to pin the actual policy. This may produce false positives on non-brittle patterns.
  • Dead branch in implementsErrorpass.Pkg.Scope().Lookup("error") is always nil in ordinary packages because error is a universe builtin. The outer branch is never reached, making the function harder to read than it needs to be.
  • Thin testdata — the four test cases cover the happy paths, but boundary cases (non-literal second arg, chained .Error(), wrapped errors) are absent. analysistest fixtures double as executable specifications — they're a great place to document intent.

Positive Highlights

  • ✅ Solid use of golang.org/x/tools/go/analysis — type-aware receiver check via implementsError is much more robust than a naive AST name match.
  • ✅ Build tag, package layout, and registration in cmd/linters/main.go all follow existing conventions perfectly.
  • ✅ Clear PR description with a codebase scan table — makes the motivation concrete and reviewable.

Verdict

Comments only — no blocking issues. The isStringLiteral scope question is worth settling before the linter is run on the codebase (it will fire on non-literal second args today), and implementsError is a straightforward simplification.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · ● 4.9M

if t == nil {
return false
}
basic, ok := t.Underlying().(*types.Basic)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isStringLiteral accepts any string-typed expression — not just literals and constants. The function name and comment claim "string literal or untyped string constant", but basic.Kind() == types.String is also true for a plain string variable.

This means the linter will flag patterns like:

strings.Contains(err.Error(), statusCode) // statusCode is a string var

...which is arguably less brittle than a hardcoded literal. Consider restricting to *ast.BasicLit and constant identifiers only, or rename the function to isStringArg and document that non-literal strings are intentionally flagged.

A pinning test case would clarify intent:

// document whether non-literal is flagged or not
func checkVar(err error, msg string) bool {
	return strings.Contains(err.Error(), msg)
}

// implementsError reports whether t implements the built-in error interface.
func implementsError(pass *analysis.Pass, t types.Type) bool {
errIface := pass.Pkg.Scope().Lookup("error")
if errIface == nil {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] pass.Pkg.Scope().Lookup("error") will almost never return non-nil. error is a predeclared builtin that lives in types.Universe, not in any package scope — only a pathological package that re-declares an identifier named error would trigger the non-nil branch.

This makes the function's control flow misleading: the "happy path" is actually always the nil branch. Simplify to remove the dead outer branch:

func implementsError(pass *analysis.Pass, t types.Type) bool {
	obj := types.Universe.Lookup("error")
	if obj == nil {
		return false
	}
	iface, ok := obj.Type().Underlying().(*types.Interface)
	if !ok {
		return false
	}
	return types.Implements(t, iface) || types.Implements(types.NewPointer(t), iface)
}

// not flagged: strings.Contains on a plain string, not err.Error()
func checkString(s string) bool {
return strings.Contains(s, "prefix")
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The testdata covers only two flagged cases and two safe cases — both false-negative scenarios use structurally different call sites (errors.Is and plain string), but the test suite is missing cases that pin the boundary between flagged and non-flagged:

  1. Non-literal string variable — is strings.Contains(err.Error(), msgVar) flagged or not? The current isStringLiteral implementation says yes (it accepts any string type), but that may not be the desired policy.
  2. Chained .Error() calls — e.g. strings.Contains(errors.Unwrap(err).Error(), "x") — does the receiver-type check handle this?
  3. Named return / multi-assignstrings.Contains(err.Error(), fmt.Sprintf("%d", code)) where the second arg is string but not a literal.

Adding one or two of these as // not want or // want cases would lock in the intended contract and prevent silent scope creep when the implementation is refactored.

@github-actions
Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (+179 new lines in pkg/linters/ and cmd/linters/, threshold is 100) but does not have a linked Architecture Decision Record (ADR).

Adding a new repo-specific Go static-analysis linter is exactly the kind of choice an ADR should record: it codifies a new code-quality rule, introduces a new package layout convention (pkg/linters/<name>/{analyzer.go, _test.go, testdata/}), and creates ongoing CI obligations for downstream PRs.

The gate auto-generated a draft ADR from the PR diff. It could not push the file directly to your branch (this PR originates from a fork, which push_to_pull_request_branch cannot write to), so the full draft is included below for you to commit yourself.

What to do next

  1. Create the file docs/adr/33117-errstringmatch-linter-for-brittle-error-message-matching.md with the contents in the collapsible section below.
  2. Review and refine — especially the Alternatives Considered section. The gate inferred them from the diff; add or correct any options you actually evaluated.
  3. Commit the ADR to this PR branch.
  4. Reference it in this PR body by adding:

    ADR: ADR-33117: errstringmatch Linter for Brittle Error Message Matching

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

📄 Draft ADR — copy into docs/adr/33117-errstringmatch-linter-for-brittle-error-message-matching.md
# ADR-33117: errstringmatch Linter for Brittle Error Message Matching

**Date**: 2026-05-18
**Status**: Draft
**Deciders**: pelikhan, Linter Miner (automated)

---

## Part 1 — Narrative (Human-Friendly)

### Context

The codebase performs error classification in several places by calling `strings.Contains(err.Error(), "literal")` — matching error messages by substring instead of using the structural `errors.Is` / `errors.As` API or a sentinel error. A repository scan surfaced at least 7 production occurrences spread across `pkg/cli` and `pkg/workflow`, including matches on HTTP status fragments (`"403"`, `"410"`), provider-specific messages (`"INSUFFICIENT_SCOPES"`, `"already merged"`), and parser feedback (`"syntax is not supported"`). These substrings are owned by upstream libraries and the Go runtime, so a benign rewording in any dependency silently breaks classification at runtime. The repository already hosts a `cmd/linters` multichecker fed by single-purpose analyzers under `pkg/linters/` (e.g., `ctxbackground`, `excessivefuncparams`, `largefunc`), so a custom analyzer is the established mechanism for codifying repo-specific anti-patterns.

### Decision

We will introduce a new Go `analysis.Analyzer` named `errstringmatch` under `pkg/linters/errstringmatch` that flags any call expression of the form `strings.Contains(<expr>.Error(), <stringConst>)` where `<expr>` implements the built-in `error` interface and `<stringConst>` is a string literal or string-typed constant. The analyzer is registered with the existing `cmd/linters` multichecker so it runs alongside the other custom analyzers in CI. The diagnostic is intentionally non-fixing — it tells the author to switch to `errors.Is`, `errors.As`, or a sentinel error rather than attempting an automatic rewrite, because the correct replacement depends on the upstream error type which the analyzer cannot infer.

### Alternatives Considered

#### Alternative 1: Add `staticcheck` or `golangci-lint` Rule Externally

The check could be expressed as a custom rule in an external linter aggregator such as `staticcheck` or `golangci-lint`. This was rejected because the repository already uses its own `cmd/linters` multichecker for repo-specific patterns and has no `golangci-lint` configuration; adding one solely for this check would introduce a new dependency, a new CI step, and a new config surface for a single rule. The in-repo `analysis.Analyzer` mirrors the existing convention (`ctxbackground`, `largefunc`, `osexitinlibrary`, etc.) and reuses the same `analysistest` harness.

#### Alternative 2: Grep-Based Pre-Commit Hook

A simple regular-expression scan for `strings\.Contains\(.*\.Error\(\)` could catch the same pattern without going through the Go type checker. This was rejected because a regex cannot verify that the receiver of `.Error()` implements `error`, leading to false positives on unrelated `Error()` methods. The type-aware analyzer uses `pass.TypesInfo.TypeOf` and `types.Implements` to filter accurately, which is the established Go static-analysis idiom.

#### Alternative 3: Manual Code Review Only

Reviewers could be expected to catch new occurrences during PR review. This was rejected because the pattern has already accreted 7 production instances under manual review, demonstrating that humans miss it. A mechanical check provides consistent, build-time enforcement that does not depend on reviewer attentiveness.

### Consequences

#### Positive
- New occurrences of brittle error-message substring matching fail CI at the `cmd/linters` stage, surfacing the issue at PR time rather than at runtime when an upstream message rewords.
- The analyzer is type-aware (via `pass.TypesInfo` + `types.Implements`), so it does not flag `strings.Contains(s.Error(), ...)` where `s.Error()` is an unrelated method that happens to share the name.
- Adoption is incremental: the linter ships clean by design for new code; the 7 existing call sites enumerated in the PR description can be migrated to `errors.Is` / `errors.As` in follow-up PRs without coupling them to this one.
- The analyzer pattern is now established for similar future anti-patterns (e.g., `isPermissionError` duplication tracked in #32751, `isNotFoundError` re-inlining tracked in #32752 — both candidates for follow-on analyzers under the same `pkg/linters/` umbrella).

#### Negative
- Existing call sites (`pkg/cli/project_command.go:306`, `pkg/cli/audit.go:278`, `pkg/cli/add_interactive_git.go:109`, `pkg/cli/logs_download.go:320`, `pkg/workflow/schedule_preprocessing.go:192`) will trigger diagnostics until they are migrated to structural error checks; until then `cmd/linters` reports them every run.
- The check does not produce a suggested fix; authors must manually determine the correct sentinel or predicate to use, since the analyzer cannot infer the upstream error type.
- The current implementation does not handle indirect aliasing such as `s := err.Error(); strings.Contains(s, "...")` or `strings.Contains(fmt.Sprintf("%v", err), "...")`; these escape detection. Closing those gaps is left as a future enhancement.

#### Neutral
- Adds two new package paths (`pkg/linters/errstringmatch` and `pkg/linters/errstringmatch/testdata/src/errstringmatch`) to the repository tree.
- The analyzer's `URL` field points to `https://github.com/github/gh-aw/tree/main/pkg/linters/errstringmatch`, which is rendered by IDE plugins that consume `analysis.Diagnostic.URL`.
- Tests follow the `analysistest`-based pattern used by sibling analyzers — fixtures live under `testdata/src/errstringmatch/` with `// want` comment annotations on the expected-flag lines.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119]((www.rfceditor.org/redacted)

### Linter Detection Behavior

1. The `errstringmatch` analyzer **MUST** report a diagnostic for every call expression of the form `strings.Contains(<expr>.Error(), <stringLiteralOrStringConst>)` where the receiver `<expr>` implements the built-in `error` interface.
2. The analyzer **MUST** verify that the receiver of the `.Error()` method call implements `error` using the Go type system (`pass.TypesInfo.TypeOf` plus `types.Implements`) rather than by name matching.
3. The analyzer **MUST NOT** report a diagnostic when `strings.Contains` is called on a value other than a method call to `.Error()` (e.g., a plain `string` variable).
4. The analyzer **MUST NOT** report a diagnostic when the second argument to `strings.Contains` is not of string type.
5. The analyzer **SHOULD** detect calls where the second argument is a typed or untyped string constant in addition to literal string expressions.
6. The analyzer **MAY** miss indirect forms such as `s := err.Error(); strings.Contains(s, "...")` or wrappers like `fmt.Sprintf("%v", err)`; these are not in the initial scope.

### Diagnostic Message

1. The diagnostic text emitted **MUST** instruct the author to use `errors.Is`, `errors.As`, or a sentinel error.
2. The analyzer **MUST NOT** emit a `SuggestedFix` for matched call sites in this initial revision.

### Analyzer Registration and Packaging

1. The analyzer **MUST** be exposed as a package-level variable named `Analyzer` of type `*analysis.Analyzer` under `pkg/linters/errstringmatch`.
2. The analyzer **MUST** declare `inspect.Analyzer` in its `Requires` list and use `golang.org/x/tools/go/ast/inspector` for traversal, consistent with the sibling analyzers in `pkg/linters/`.
3. The analyzer **MUST** be registered in `cmd/linters/main.go` inside the `multichecker.Main(...)` argument list, maintaining alphabetical ordering with the other registered analyzers.
4. The analyzer's `Name` field **MUST** be `"errstringmatch"`.

### Testing

1. The package **MUST** include an `analysistest`-based test (`errstringmatch_test.go`) that runs the analyzer against fixtures under `testdata/src/errstringmatch/`.
2. Fixtures **MUST** annotate every expected diagnostic line with a `// want` comment whose regular expression matches the analyzer's emitted message.
3. Fixtures **MUST** include at least one negative case using `errors.Is` and at least one negative case where `strings.Contains` is called on a non-error string, neither of which is annotated with `// want`.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*Draft generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26052127488) workflow. Review and finalize before changing status from Draft to Accepted.*

Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 33117-errstringmatch-linter-for-brittle-error-message-matching.md for this PR).

🔒 This PR cannot merge until an ADR is linked in the PR body.

References:

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · ● 7M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants