Skip to content

Add packagelevelmutableslicemap custom linter - #52920

Merged
pelikhan merged 6 commits into
mainfrom
copilot/add-packagelevelmutableslicemap-linter
Aug 15, 2026
Merged

Add packagelevelmutableslicemap custom linter#52920
pelikhan merged 6 commits into
mainfrom
copilot/add-packagelevelmutableslicemap-linter

Conversation

Copilot AI commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Package-level mutable slices/maps are shared across every goroutine and every call into the package, risking data races and cross-call state leaks when mutated from inside a function body instead of via struct state or fresh return values.

New analyzer

  • Added pkg/linters/packagelevelmutableslicemap, a go/analysis linter that tracks package-scope var declarations with slice/map underlying types and flags mutations via:
    • append() re-assignment: s = append(s, ...)
    • index assignment: m[k] = v
    • delete(m, k)
  • Matching is based on types.Object identity, so local variables that shadow a package-level name are correctly left unflagged.
  • Supports //nolint:packagelevelmutableslicemap suppression, consistent with sibling linters.
var cache []int

func addToCache(v int) {
	cache = append(cache, v) // flagged: mutates package-level slice
}

func addLocal() {
	s := []int{1, 2}
	s = append(s, 3) // not flagged: local variable
}

Registration & docs

  • Registered the analyzer in pkg/linters/registry.go.
  • Updated pkg/linters/doc.go, pkg/linters/README.md, and pkg/linters/spec_test.go to keep the documented analyzer count (now 65) and subpackage lists in sync.

Tests

  • Added packagelevelmutableslicemap_test.go with an analysistest fixture covering append-mutation, index-assignment, delete(), a read-only slice (no diagnostic), a //nolint-suppressed case, and locally shadowed variables.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add packagelevelmutableslicemap custom linter Add packagelevelmutableslicemap custom linter Aug 15, 2026
Copilot AI requested a review from pelikhan August 15, 2026 15:59
@pelikhan
pelikhan marked this pull request as ready for review August 15, 2026 15:59
Copilot AI balanced review requested due to automatic review settings August 15, 2026 15:59
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Ponytail Reviewer completed successfully!

Lean already. Ship. Reviewed the packagelevelmutableslicemap linter PR for over-engineering; its structure mirrors sibling analyzers (mapdeletecheck, mapclearloop) in size and pattern, uses shared internal helpers (analyzerutil, filecheck, nolint) consistently, and has no speculative abstractions, unused flexibility, or hand-rolled stdlib logic.

Generated by Ponytail Reviewer for #52920

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

Test Quality Sentinel skipped because pre-fetch PR data was unavailable: unable to fetch test file diff

🧪 Test quality analysis by Test Quality Sentinel

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 custom Go analyzer for detecting mutations of package-level slices and maps.

Changes:

  • Implements and registers the analyzer.
  • Adds analysistest coverage and fixtures.
  • Updates analyzer documentation and counts.
Show a summary per file
File Description
pkg/linters/packagelevelmutableslicemap/packagelevelmutableslicemap.go Implements mutation detection.
pkg/linters/packagelevelmutableslicemap/packagelevelmutableslicemap_test.go Runs analyzer tests.
pkg/linters/packagelevelmutableslicemap/testdata/src/packagelevelmutableslicemap/packagelevelmutableslicemap.go Provides test cases.
pkg/linters/registry.go Registers the analyzer.
pkg/linters/spec_test.go Adds documentation consistency coverage.
pkg/linters/doc.go Documents the analyzer.
pkg/linters/README.md Updates public analyzer listings.

Review details

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +136 to +137
if len(stmt.Lhs) != 1 || len(stmt.Rhs) != 1 {
return "", false
Comment on lines +32 to +43
func localSliceIsFine() {
s := []int{1, 2}
s = append(s, 3)
_ = s
}

func localMapIsFine() {
m := map[string]int{}
m["a"] = 1
delete(m, "a")
_ = m
}
@github-actions

Copy link
Copy Markdown
Contributor

Comment Memory

reviewed_at: 2026-08-15T00:00:00Z
review_event: REQUEST_CHANGES
top_themes:
  - misses named slice/map wrapper mutations
  - insufficient test coverage for analyzer blind spots
files_reviewed:
  - pkg/linters/README.md
  - pkg/linters/doc.go
  - pkg/linters/packagelevelmutableslicemap/packagelevelmutableslicemap.go
  - pkg/linters/packagelevelmutableslicemap/packagelevelmutableslicemap_test.go
  - pkg/linters/packagelevelmutableslicemap/testdata/src/packagelevelmutableslicemap/packagelevelmutableslicemap.go
  - pkg/linters/registry.go
  - pkg/linters/spec_test.go
comment_count: 1

Note

This comment is managed by comment memory.

It stores persistent context for this thread in the code block at the top of this comment.
Edit only the text inside the backtick fences; workflow metadata and the footer are regenerated automatically.

Learn more about comment memory

🔎 Code quality review by PR Code Quality Reviewer · gpt54 · 4.28 AIC · ⌖ 6.82 AIC · ⊞ 6.9K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes

This analyzer misses common package-level mutations, so it will silently let unsafe shared state through while looking authoritative.

Blocking themes
  • Method-call mutations on named map/slice wrapper types are not detected.
  • The tests only cover built-in []T/map[K]V syntax, so this blind spot would ship unnoticed.

🔎 Code quality review by PR Code Quality Reviewer · gpt54 · 4.28 AIC · ⌖ 6.82 AIC · ⊞ 6.9K
Comment /review to run again

obj := pass.TypesInfo.Defs[name]
if obj == nil {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This analyzer skips mutations on package-level named slice/map types, so it will miss exactly the same shared-state bug once that map or slice is wrapped in a domain type.

💡 Why this blocks

collectPackageLevelSliceMapVars only keeps vars whose Underlying() type is *types.Slice or *types.Map. That works for var m map[string]int, but not for common wrappers like type cache map[string]int; var global cache. In those cases Underlying() is *types.Named, so the variable is dropped before we ever inspect global[k] = v, delete(global, k), or global = append(global, x).

That leaves a large false-negative hole in a linter whose whole point is catching shared mutable package state. Please unwrap named types before the kind check and add analysistest coverage for both named map and named slice aliases/wrappers.

t := obj.Type()
for {
    named, ok := t.(*types.Named)
    if !ok {
        break
    }
    t = named.Underlying()
}
switch t.(type) {
case *types.Slice, *types.Map:
    targets[obj] = name.Name
}

Generated by the Design Decision Gate: PR #52920 adds >100 lines
of business logic without a linked ADR.
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (282 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

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

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

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and list real alternatives you considered
  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-52920: Add packagelevelmutableslicemap Static Analysis 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

"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., 52920-add-packagelevelmutableslicemap-linter.md for PR #52920).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 · 72.2 AIC · ⌖ 25.1 AIC · ⊞ 9K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: packagelevelmutableslicemap linter

The overall implementation is clean and follows existing linter conventions well. Two correctness issues need addressing before merge.

Blocking issues

  1. init() functions produce false positives — mutating package-level state in init() is idiomatic and intentional in Go. The analyzer has no guard for this case and will fire on common, correct initialization patterns. (See inline comment on line 47.)

  2. matchAppendReassign has a logic gap — the check that the first append argument equals the LHS object is overly strict and misses the case globalSlice = append(otherSlice, ...), which still mutates the package-level variable. (See inline comment on line 148.)

Suggestions (non-blocking)

  • The testdata only covers the three mutation kinds for a single variable at a time. Adding a test case for init() (expect no diagnostic) and one for globalSlice = append(localSlice, x) (expect diagnostic) would lock down both edge cases and prevent regressions.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 38.1 AIC · ⌖ 7.32 AIC · ⊞ 5.6K

(*ast.AssignStmt)(nil),
(*ast.ExprStmt)(nil),
}
return analyzerutil.Preorder(pass, nodeFilter, func(n ast.Node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

False positive: init() functions are not exempt

The analyzer walks all function bodies including init(), but mutating package-level slices/maps inside init() is idiomatic Go — it is the standard way to initialize shared state before any other code runs. The linter will fire on correct, conventional code like:

var registry []handler

func init() {
    registry = append(registry, newHandler()) // false positive
}

Fix: in the Preorder walk, check the enclosing *ast.FuncDecl; if it is named init with no parameters and no return values, skip the mutation report.

@copilot please address this.

if !tracked {
return "", false
}
call, ok := stmt.Rhs[0].(*ast.CallExpr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

matchAppendReassign only catches the self-append pattern, missing other mutations

Currently the function requires the first argument to append to be the same object as the LHS:

if pass.TypesInfo.Uses[firstArgIdent] != lhsObj {
    return "", false
}

But this misses the equally mutating case where a different slice is appended to a package-level variable:

var globalSlice []int

func populateGlobal(other []int) {
    globalSlice = append(other, 1) // not caught — LHS is global, first arg is local
}

The core invariant should be: the LHS is a tracked package-level variable and the RHS is an append() call — the first argument is irrelevant to whether globalSlice is being mutated.

Fix: remove the firstArgIdent check entirely; report whenever the LHS is a tracked target and the RHS is a call to the built-in append.

@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

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 /codebase-design — requesting changes on test coverage gaps and one correctness issue.

📋 Key Themes & Highlights

Key Themes

  • Silent false negative in matchAppendReassign: the first-argument identity guard means globalSlice = append(otherSlice, v) is never flagged, even though the mutation still happens. Document or fix.
  • Missing test coverage: no testdata case for slice index mutation (globalSlice[i] = v) and no coverage for multi-LHS assignment (_, globalMap[k] = fn()).
  • Named type scope boundary undocumented: var x MySlice where type MySlice = []int is silently skipped; a comment stating the known limit would prevent future confusion.

Positive Highlights

  • ✅ Correct use of types.Object identity for shadowing — local vars are properly excluded.
  • ✅ Consistent with sibling analyzers: nolint suppression, filecheck, analyzerutil.New wiring.
  • ✅ Thorough first-pass testdata: append, index, delete, nolint, read-only, and local variable cases are all covered.
  • ✅ Doc/registry/spec sync is clean.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 44.2 AIC · ⌖ 8.73 AIC · ⊞ 7.7K
Comment /matt to run again

return "", false
}
lhsIdent, ok := stmt.Lhs[0].(*ast.Ident)
if !ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] False negative: s = append(differentSlice, v) — any reassignment of a tracked package-level var where the first argument is not the same object will be silently missed. The mutation still occurs, so the linter's diagnostic goal is only partially met.

💡 Suggested fix or documentation

Either broaden matchAppendReassign to flag any trackedVar = append(...) regardless of the first argument:

// tracked var on LHS and append() on RHS — flag regardless of first arg
return name, true

Or document the constraint explicitly in the package comment so future maintainers are not surprised by the gap.

@copilot please address this.

}
builtin, ok := pass.TypesInfo.Uses[fnIdent].(*types.Builtin)
if !ok || builtin.Name() != "append" {
return "", false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Missing coverage: matchIndexAssign handles slice index assignment (s[i] = v) as well as map assignment, but the testdata fixture has no case for a slice index mutation (globalSlice[0] = 99). Without a golden test, a future regression removing the slice branch will go undetected.

💡 Suggested test case

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

func setSliceIndex() {
	globalSlice[0] = 99 // want `package-level slice/map variable globalSlice is mutated via index assignment; ...`
}

@copilot please address this.

t := obj.Type()
if t == nil {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] collectPackageLevelSliceMapVars only scans pass.Files — these are the files in the package under analysis, not all transitive dependencies. That is correct. However, the function silently skips const/type blocks (expected), but it also misses typed aliases (type MySlice = []int) where the underlying type is a slice. This is likely acceptable scope, but documenting the known boundary prevents future confusion.

💡 Suggestion

Add a comment to the function clarifying that named type aliases with slice/map underlying types are not in scope, so the intent is explicit:

// Note: var declarations using a named type alias (e.g. type T = []int; var x T)
// are not currently tracked; only direct slice/map type declarations are flagged.

@copilot please address this.

case *ast.ExprStmt:
analyzeExprStmt(pass, stmt, targets, generatedFiles, noLintIndex)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The analyzeAssignStmt function only reports one diagnostic per AssignStmt — if both matchAppendReassign and matchIndexAssign could apply to the same statement (unlikely but possible with multi-LHS), the second mutation would be silently skipped due to the early return. More practically: a multi-assignment like a, globalMap[k] = fn() where one LHS is a tracked index expression will be flagged correctly, but if the first LHS also matched an append pattern, the early return would suppress it. There is no test covering multi-LHS assignment.

💡 Suggested test case

Add to testdata:

func multiLHS(k string) {
    var x int
    x, globalMap[k] = 1, 2 // want `...mutated via index assignment...`
    _ = x
}

@copilot please address this.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot Quick triage for maintainer-ready follow-up:

Please refresh the branch if GitHub allows it, address the remaining reviewer feedback, and run the pr-finisher skill before handing this PR back to maintainers.

Outstanding review items (newest first):

  • github-actions requested linking the generated ADR in the PR body so the gate can clear.
  • github-actions requested fixing the analyzer blind spots around named slice/map wrapper mutations and adding coverage for that gap.
  • github-actions requested guarding init() from false positives and covering that case in tests.
  • github-actions requested fixing the matchAppendReassign logic gap for globalSlice = append(otherSlice, ...).

Failed checks from the compact candidate set:

  • None listed.

Branch update was requested automatically for this run when GitHub allows it.
Run context: https://github.com/github/gh-aw/actions/runs/31897005265

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 14 AIC · ⌖ 5.93 AIC · ⊞ 8.7K ·
Comment /souschef to run again

Copilot AI and others added 2 commits August 15, 2026 17:15
…mutations

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Quick triage for maintainer-ready follow-up:

Please refresh the branch if GitHub allows it, address the remaining reviewer feedback, and run the pr-finisher skill before handing this PR ba...

All four outstanding items are addressed:

  • ADR link: PR body now links docs/adr/52920-add-packagelevelmutableslicemap-linter.md.
  • matchAppendReassign gap: the matcher now walks every LHS/RHS pair and no longer requires the append source to be the target itself, so globalSlice = append(otherSlice, v) and globalSlice, err = append(globalSlice, v), errors.New(...) are both flagged.
  • Named slice/map wrapper mutations: named types (type registry map[string]int, type queue []int) and nested index assignment (nested[a][b] = v) are now covered by a base-identifier resolver, with fixtures for each.
  • init() false positives: mutations directly inside a top-level init() are exempt (function literals such as goroutines started from init are still flagged), with fixtures for both.

Fixtures also now shadow the package-level names locally so the types.Object identity behavior is actually exercised. Branch was refreshed by merging origin/main; make agent-report-progress (fmt, lint, impacted unit tests, recompile) passes locally. The one prior CI failure (CGO / Unit tests M-R on 6e421dd) was error generating coverage report: write |1: file already closed in pkg/cli with no failing test — unrelated to these changes and not reproducible locally. CI needs a maintainer re-trigger since agent pushes don't start workflows.

Copilot AI requested a review from gh-aw-bot August 15, 2026 17:25
@pelikhan
pelikhan merged commit 61cc8d2 into main Aug 15, 2026
@pelikhan
pelikhan deleted the copilot/add-packagelevelmutableslicemap-linter branch August 15, 2026 17:47
@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot Quick triage for maintainer-ready follow-up:

Please refresh the branch if GitHub allows it and run the pr-finisher skill before handing this PR back to maintainers.

The latest author update says the prior linter feedback and ADR-link follow-ups were addressed.

Outstanding maintainer-facing follow-up:

  • re-run or refresh the current review/check state on the latest head so the updated analyzer coverage can be re-evaluated.
  • if a blocking review remains after thread resolution, summarize why it is stale and close the loop in the PR conversation.

Failed checks from the compact candidate set:

  • None listed.

Run context: https://github.com/github/gh-aw/actions/runs/31899148351

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 17.1 AIC · ⌖ 6.96 AIC · ⊞ 6.3K ·
Comment /souschef to run again

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.87.0

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[linter-miner] Add packagelevelmutableslicemap custom linter

4 participants