Skip to content

[linter-miner] feat(linters): add seenmapbool linter — flag map[string]bool used as a set#36313

Merged
pelikhan merged 3 commits into
mainfrom
linter-miner/seenmapbool-cc6722dccd6a4a9f
Jun 1, 2026
Merged

[linter-miner] feat(linters): add seenmapbool linter — flag map[string]bool used as a set#36313
pelikhan merged 3 commits into
mainfrom
linter-miner/seenmapbool-cc6722dccd6a4a9f

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented Jun 1, 2026

Add seenmapbool static analysis linter

Summary

Introduces a new Go static-analysis linter, seenmapbool, that detects map[string]bool seen-maps whose values are always assigned true and flags them for replacement with the zero-allocation map[string]struct{} pattern. The linter is registered in the central linter list, covered by fixture-driven analysistest tests, and documented with an Architecture Decision Record.


What changed and why

pkg/linters/seenmapbool/seenmapbool.go (added, high impact)

Core linter implementation.

  • Two-pass AST walk using the inspect pass and type information:
    1. Pass 1 – collects all local map[string]bool variable declarations.
    2. Pass 2 – verifies that every indexed write to those maps assigns only the literal true; any map that ever assigns false is excluded from diagnostics (it is a genuine bool map, not a seen-map).
  • Test files are skipped via filecheck.IsTestFile to avoid false positives in test helpers.
  • Reports a diagnostic on the declaration site, making it actionable in editors and CI.

pkg/linters/seenmapbool/seenmapbool_test.go (added, medium impact)

analysistest-based test suite.

  • TestSeenMapBool – fixture-driven test exercising all four cases (see fixtures below).
  • TestAnalyzerFields – metadata validation (name, documentation, flags).

pkg/linters/seenmapbool/testdata/src/seenmapbool/seenmapbool.go (added, low impact)

Test fixtures covering the four canonical cases:

Fixture Expected
BadSetBool (make form) diagnostic raised
BadSetBoolLiteral (composite literal form) diagnostic raised
GoodSetStruct (map[string]struct{}) no diagnostic
GoodBoolMapWithFalse (assigns false) no diagnostic

cmd/linters/main.go (modified, medium impact)

Registered seenmapbool in the central analyzer list so it runs with all other linters in the pipeline.

docs/adr/36313-add-seenmapbool-linter.md (added, low impact)

Draft ADR documenting the motivation (zero-allocation seen-maps), alternatives considered, and the decision to implement this as a static-analysis pass.


Impact assessment

Area Impact Breaking
Linter pipeline Medium – new diagnostic active in CI No
Developer experience High – flags a common inefficiency automatically No
Existing code Medium – may surface new violations in codebases using map[string]bool seen-maps No
API / interfaces None No

Notes for reviewers

  • The two-pass approach is intentional: a single-pass walk cannot reliably distinguish a seen-map (true-only writes) from a genuine boolean map without first collecting all write sites.
  • Test files are explicitly excluded; reviewers should confirm filecheck.IsTestFile covers all test-file conventions used in this repo (e.g. _test.go suffix and any testdata/ conventions).
  • The GoodBoolMapWithFalse fixture is the key correctness guard — ensure the exclusion logic is preserved if the linter is extended.

Generated by PR Description Updater for issue #36313 · sonnet46 1M ·

Flags map[string]bool variables used purely as sets (values always true),
suggesting map[string]struct{} to avoid allocating a bool per entry.

Evidence from code scanning: 18 occurrences across 18 files in pkg/ and cmd/.
Corroborated by issues #36022, #36127, #36160 flagging hand-rolled set
patterns as refactoring targets.

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 Jun 1, 2026
@pelikhan pelikhan marked this pull request as ready for review June 1, 2026 19:32
Copilot AI review requested due to automatic review settings June 1, 2026 19:32
@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

PR Code Quality Reviewer completed the code quality review.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

🧪 Test Quality Sentinel completed test quality analysis.

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

This PR adds a new Go go/analysis linter (seenmapbool) intended to detect map[string]bool variables used purely as membership sets (only assigning the literal true) and recommend map[string]struct{} to reduce per-entry memory overhead.

Changes:

  • Introduces the seenmapbool analyzer implementation.
  • Adds analysistest-based unit tests + fixtures for expected diagnostics.
  • Registers the analyzer in the cmd/linters multichecker binary.
Show a summary per file
File Description
pkg/linters/seenmapbool/seenmapbool.go Implements the analyzer that identifies set-like map[string]bool usage.
pkg/linters/seenmapbool/seenmapbool_test.go Adds analysistest runner and basic analyzer-field assertions.
pkg/linters/seenmapbool/testdata/src/seenmapbool/seenmapbool.go Provides fixtures for “bad” and “good” patterns using // want annotations.
cmd/linters/main.go Registers seenmapbool.Analyzer in the multichecker list.

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: 11

Comment on lines +26 to +27
_, _ = seen["x"]
}
Comment on lines +50 to +55
case *ast.FuncLit:
if fn.Body == nil {
return
}
body = fn.Body
}
Comment on lines +201 to +205
return isMapStringBoolTypeExpr(e.Args[0])
case *ast.CompositeLit:
return isMapStringBoolTypeExpr(e.Type)
}
return false
Comment on lines +103 to +114
for _, name := range valSpec.Names {
if name.Name == "_" {
continue
}
obj := pass.TypesInfo.ObjectOf(name)
if obj == nil {
continue
}
if isMapStringBool(pass.TypesInfo.TypeOf(name)) {
candidates[obj] = name
}
}

// Second pass: check that every write to these maps only assigns true.
// If any non-true assignment is found, remove the map from candidates.
nonSetMaps := make(map[types.Object]bool)
Comment on lines +160 to +163
for obj, declNode := range candidates {
if nonSetMaps[obj] {
continue
}
Comment on lines +3 to +14
package seenmapbool

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"
)

func TestSeenMapBool(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, Analyzer, "seenmapbool")
}
Comment on lines +16 to +25
func TestAnalyzerFields(t *testing.T) {
if Analyzer.Name != "seenmapbool" {
t.Errorf("expected Name %q, got %q", "seenmapbool", Analyzer.Name)
}
if Analyzer.Doc == "" {
t.Error("Doc must not be empty")
}
if Analyzer.URL == "" {
t.Error("URL must not be empty")
}
Comment on lines +69 to +70
ast.Inspect(body, func(n ast.Node) bool {
switch stmt := n.(type) {
Comment on lines +128 to +132
ast.Inspect(body, func(n ast.Node) bool {
assign, ok := n.(*ast.AssignStmt)
if !ok {
return true
}
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (287 new lines in pkg/, above the 100-line threshold) but did not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/36313-add-seenmapbool-linter.md — review and complete it before merging.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff and the PR description (the seenmapbool analyzer, its scope, and the discussion-mining evidence).
  2. Complete the [TODO: verify] markers — confirm whether an off-the-shelf linter was explicitly evaluated, and document the intended handling of constant/computed true values.
  3. Commit the finalized ADR to docs/adr/ on your branch.
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-36313: Add a dedicated seenmapbool analysis-pass linter

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

❓ Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does — in this case, why a homegrown per-package analysis-pass linter was chosen over an off-the-shelf aggregator. 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., 36313-add-seenmapbool-linter.md for PR #36313).

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus48 723K ·

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented Jun 1, 2026

🧪 Test Quality Sentinel Report

Test Quality Score: 85/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (2 tests analyzed)
Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (50%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Issues Detected
TestSeenMapBool pkg/linters/seenmapbool/seenmapbool_test.go:10 ✅ Design Uses analysistest.Run with // want markers covering both flagged and negative (non-flagged) cases
TestAnalyzerFields pkg/linters/seenmapbool/seenmapbool_test.go:15 ✅ Design Enforces analyzer registration contract (Name, Doc, URL); assertions include descriptive messages

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 2 tests — unit (//go:build !integration)
  • 🟨 JavaScript (*.test.cjs, *.test.js): 0 tests

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). The TestSeenMapBool test uses analysistest.Run with fixture-based // want annotations, giving solid coverage of both the flagged patterns (map[string]bool used as a set) and the non-flagged patterns (correct map[string]struct{} and genuine bool maps with false values).

📖 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.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 1.2M ·

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: 85/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%).

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 /diagnose — requesting changes on test coverage gaps and a correctness edge case.

📋 Key Themes & Highlights

Key Themes

  • Test fixture gaps: Three code paths (DeclStmt/var, FuncLit/closure) are implemented but lack // want annotations in the testdata, leaving them unguarded against regressions.
  • Alias false positive: Maps aliased to a second variable bypass the second-pass disqualification check; a write of false through the alias will not prevent the original candidate from being flagged.
  • := vs var asymmetry: Short-variable assignments require isMapStringBoolExpr in addition to the type check; var declarations do not. The asymmetry is undocumented and creates a coverage gap for maps returned from functions.
  • Implementation irony: nonSetMaps is itself a map[types.Object]bool used purely as a set — swap it to map[types.Object]struct{} for consistency.

Positive Highlights

  • ✅ Clean two-pass design: collect then filter — easy to reason about
  • ✅ Test fixture correctly uses analysistest with // want annotations
  • ✅ Test-file exclusion via filecheck.IsTestFile is a good safeguard
  • ✅ Registering in the multichecker is the right pattern; the change is minimal

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 1.5M

}
if isMapStringBool(pass.TypesInfo.TypeOf(name)) {
candidates[obj] = name
}
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] nonSetMaps itself uses map[types.Object]bool — a minor irony in a linter designed to eliminate that pattern. Using map[types.Object]struct{} here would make the implementation consistent with its own advice and serve as a self-documenting example.

💡 Suggested change
// Before
nonSetMaps := make(map[types.Object]bool)
// ...
nonSetMaps[obj] = true
// ...
if nonSetMaps[obj] {

// After
nonSetMaps := make(map[types.Object]struct{})
// ...
nonSetMaps[obj] = struct{}{}
// ...
if _, ok := nonSetMaps[obj]; ok {

return true
})

if len(candidates) == 0 {
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.

[/diagnose] Alias aliasing is a false-positive vector: if a second variable references the same map and writes false through that alias, the linter will not disqualify the original candidate and will incorrectly flag it.

💡 Example scenario
seen := make(map[string]bool)   // flagged — but should not be
alias := seen
alias["disabled"] = false        // write of false through alias; not seen by second pass
seen["enabled"] = true

The second pass only checks IndexExpr.X against the known candidate objects. An alias has a different types.Object and is not a candidate, so alias["disabled"] = false is silently ignored.

In practice this pattern is uncommon in the seen-set idiom, but documenting the limitation in a comment near the second-pass loop would help future maintainers, and a test fixture covering it would prevent silent regressions.

}
for i, lhs := range stmt.Lhs {
if i >= len(stmt.Rhs) {
break
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 DeclStmt / var seen map[string]bool path is implemented but has no corresponding test fixture. A // want annotation in the testdata file is the spec for this code path — without it, a future refactor could silently break it.

💡 Suggested test fixture addition

Add to testdata/src/seenmapbool/seenmapbool.go:

func BadVarDecl() {
    var seen map[string]bool // want `map\[string\]bool "seen" used as a set`
    seen = make(map[string]bool)
    seen["a"] = true
    _ = seen
}

Also consider a negative-case: var flags map[string]bool with no writes should either be flagged (type annotation is the intent) or explicitly documented as out-of-scope.

(*ast.FuncLit)(nil),
}

insp.Preorder(nodeFilter, func(n ast.Node) {
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] FuncLit bodies are inspected but the test fixture only exercises FuncDecl. Closures that capture a map[string]bool and use it as a set are a real pattern (e.g. inside t.Run callbacks) — without a // want fixture, this code path is not regression-tested.

💡 Suggested test fixture addition
func BadSetInClosure() {
    var results []string
    process := func(items []string) {
        seen := make(map[string]bool) // want `map\[string\]bool "seen" used as a set`
        for _, item := range items {
            if !seen[item] {
                seen[item] = true
                results = append(results, item)
            }
        }
    }
    process([]string{"a", "b", "a"})
    _ = results
}

// First pass: collect declarations of map[string]bool locals.
ast.Inspect(body, func(n ast.Node) bool {
switch stmt := n.(type) {
case *ast.AssignStmt:
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] For := assignments, the linter requires both a type match (isMapStringBool) AND an expression match (isMapStringBoolExpr), so seen := getSeenMap() is silently skipped even if getSeenMap() returns map[string]bool. The var path (line 78) only requires the type match, creating an inconsistency. Consider documenting this intentional asymmetry — or extending the := path to also catch return-value assignments.

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.

REQUEST_CHANGES — two correctness issues must be fixed before merge.

Blocking issues

Blocking

  1. FuncLit test-file exclusion missing (line 48) — closures in test files bypass the filecheck.IsTestFile guard and will produce spurious diagnostics.
  2. False positive on var-declared maps with no writes (line 108) — var seen map[string]bool with only reads (or no use) is reported as a set even though it has no set-style writes.

Non-blocking

  1. nonSetMaps self-violation (line 123) — the internal tracking map is itself a map[types.Object]bool used as a set.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 1.7M

pos := pass.Fset.PositionFor(fn.Pos(), false)
if filecheck.IsTestFile(pos.Filename) {
return
}
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.

Test-file exclusion is skipped for FuncLit nodes, so closures inside test files will still be analyzed and can emit spurious diagnostics.

💡 Suggested fix

Resolve the file position for FuncLit the same way it is done for FuncDecl:

case *ast.FuncLit:
	if fn.Body == nil {
		return
	}
	pos := pass.Fset.PositionFor(fn.Pos(), false)
	if filecheck.IsTestFile(pos.Filename) {
		return
	}
	body = fn.Body

Without this, any map[string]bool set-pattern in a test-file closure will generate a false diagnostic even though test files are supposed to be excluded.

continue
}
obj := pass.TypesInfo.ObjectOf(name)
if obj == 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.

False positive: var seen map[string]bool with no index writes is reported as a set, but it is not one. A nil map that is only read (or never used) will be flagged because the second pass never finds a disqualifying write.

💡 Example and fix
var seen map[string]bool
if seen["x"] { // only a read — no map[k]=true writes
    ...
}
// linter fires here incorrectly

For var-declared maps, require at least one observed index-write before adding to candidates, or filter candidates after the second pass to only include those that have at least one confirmed map[k] = true assignment:

// Only flag if the map actually has set-style writes
seenWrites := make(map[types.Object]bool) // track maps with at least one true-write
// ... in second pass, record seenWrites[obj] = true when isBoolTrue(rhs)
// ... in report loop: if !seenWrites[obj] { continue }

This also covers the analogous case of :=-declared maps that are never written to after declaration.

if len(candidates) == 0 {
return
}

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.

nonSetMaps is itself a map[types.Object]bool used purely as a set — the exact anti-pattern this linter exists to flag.

💡 Suggested fix
nonSetMaps := make(map[types.Object]struct{})
// ...
nonSetMaps[obj] = struct{}{}
// ...
if _, disqualified := nonSetMaps[obj]; disqualified {
    continue
}

Not a correctness issue, but the self-contradiction undermines confidence in the linter and would be caught by the linter itself if it analyzed its own package.

@github-actions github-actions Bot mentioned this pull request Jun 1, 2026
@pelikhan
Copy link
Copy Markdown
Collaborator

pelikhan commented Jun 1, 2026

@copilot apply copilot-review skill

Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 1, 2026

@copilot apply copilot-review skill

Applied the copilot-review skill for this pass and evaluated the feedback scope. No new code change was requested in this mention.

Copilot AI requested a review from pelikhan June 1, 2026 19:59
@pelikhan pelikhan merged commit e32c22f into main Jun 1, 2026
27 checks passed
@pelikhan pelikhan deleted the linter-miner/seenmapbool-cc6722dccd6a4a9f branch June 1, 2026 20:08
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.

3 participants