Skip to content

Improve lenstringzero precision for len(string) aliases in zero-comparisons - #37750

Merged
pelikhan merged 4 commits into
mainfrom
copilot/fix-lenstringzero-precision-issue
Jun 8, 2026
Merged

Improve lenstringzero precision for len(string) aliases in zero-comparisons#37750
pelikhan merged 4 commits into
mainfrom
copilot/fix-lenstringzero-precision-issue

Conversation

Copilot AI commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

lenstringzero only matched direct len(s) == 0 / != 0 checks and missed equivalent alias forms like n := len(s); n == 0. This PR extends detection to local len(string) aliases while preserving existing string-only scope and avoiding mutation-related false positives.

  • Analyzer precision: track local len(string) aliases

    • Added a pre-pass that records local objects initialized from len(<string-expr>) in:
      • short declarations (:=)
      • var specs (ValueSpec)
    • Reuses type-checking to keep matching restricted to underlying string.
  • Alias safety: invalidate on mutation

    • Deletes tracked aliases on:
      • reassignment (=)
      • increment/decrement (++ / --)
      • range assignment targets (for k, v = range ...)
    • Prevents stale-alias diagnostics after value changes.
  • Comparison handling: support alias-vs-zero parity with direct form

    • Binary-expression matching now flags:
      • alias == 0
      • alias != 0
      • 0 == alias
      • 0 != alias
    • Uses the same diagnostic wording as existing direct len(s) matches.
  • Test fixtures: positive and negative alias coverage

    • Added analysistest cases for:
      • positive: n := len(s); n == 0 and n != 0
      • negative: alias reassigned / incremented before compare
      • negative: non-string aliases ([]byte, array) remain unflagged
func aliasEmpty(s string) bool {
	n := len(s)
	return n == 0 // now reported: use s == "" ...
}

func aliasReassignedNotFlagged(s string) bool {
	n := len(s)
	n = 1
	return n == 0 // intentionally not reported
}

Copilot AI and others added 2 commits June 8, 2026 05:46
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix lenstringzero precision for intermediate variable Improve lenstringzero precision for len(string) aliases in zero-comparisons Jun 8, 2026
Copilot AI requested a review from pelikhan June 8, 2026 05:50
@pelikhan
pelikhan marked this pull request as ready for review June 8, 2026 05:53
Copilot AI review requested due to automatic review settings June 8, 2026 05:53

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

Extends the lenstringzero Go analyzer to detect len(<string>) results stored in local variables and later compared to 0, matching the same diagnostics as direct len(s) == 0 / != 0 checks.

Changes:

  • Track local aliases initialized from len(<string-expr>) and detect alias == 0 / alias != 0 (including flipped 0 == alias forms).
  • Add alias invalidation logic for certain mutations (=, ++/--, and range assignment targets).
  • Expand analysistest fixtures to cover positive and negative alias cases.
Show a summary per file
File Description
pkg/linters/lenstringzero/lenstringzero.go Adds alias collection and extends comparison matching to handle alias-vs-zero checks.
pkg/linters/lenstringzero/testdata/src/lenstringzero/lenstringzero.go Updates fixtures to include alias-based comparisons and mutation-negative cases.

Copilot's findings

Tip

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

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

if !ok {
return nil, fmt.Errorf("inspect analyzer result has unexpected type %T", pass.ResultOf[inspect.Analyzer])
}
lenStringAliases := collectLenStringAliases(pass)
Comment on lines +145 to +163
switch stmt.Tok {
case token.DEFINE:
if obj.Pos() != ident.Pos() {
delete(aliases, obj)
continue
}
rhs, ok := rhsExprForIndex(stmt.Rhs, i)
if !ok {
delete(aliases, obj)
continue
}
if arg, ok := lenStringArg(pass, rhs); ok {
aliases[obj] = arg
} else {
delete(aliases, obj)
}
case token.ASSIGN:
delete(aliases, obj)
}
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. The PR modifies lenstringzero.go (production code) and testdata/src/lenstringzero/lenstringzero.go (an analysistest fixture file containing // want annotations). The testdata fixture does not match the test file criteria (_test.go, *.test.cjs, *.test.js), so Test Quality Sentinel skipped scoring. Note: the testdata was updated to add new alias-tracking test cases (aliasEmpty, aliasNotEmpty, aliasReassignedNotFlagged, etc.) which provide good behavioral coverage for the new alias detection feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (194 new lines in pkg/) but does not link an Architecture Decision Record (ADR). The change extends lenstringzero to track local len(string) aliases — exactly the false-negative that ADR-37618 documented as a known limitation — so it warrants its own design record.

📄 Draft ADR committed: docs/adr/37750-track-len-string-aliases-in-lenstringzero.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 — refine the decision rationale, confirm the alternatives, and add any context the AI could not infer (e.g. why a syntactic pre-pass was chosen over full flow analysis).
  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-37750: Track local len(string) aliases in lenstringzero

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. This change makes a real design choice — conservative syntactic alias tracking with mutation invalidation, rather than full dataflow analysis — that future contributors should be able to find and understand.

📋 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)?

ADRs live in docs/adr/ numbered by PR number (here, 37750-...).

🔒 Blocking: link the ADR in the PR body to unblock merge.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 72.8 AIC · ⌖ 9.77 AIC ·

@pelikhan

pelikhan commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

@copilot review all comments and review . Apply copilot-review skill

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

✅ Clean, correct refactors — no blocking issues

Both changes are logically equivalent to the code they replace and safe to merge.

Analysis summary

run() — condition reordering (lines 51-57)

Moving isIntZero(expr.Y) before lenAliasArg is semantically equivalent:

Scenario Old behaviour New behaviour
n == 0 (alias on left) lenAliasArg(n).ok && isIntZero(0)lenArg = arg isIntZero(0) → inner lenAliasArg(n).oklenArg = arg
0 == n (alias on right) Falls through to 4th branch isIntZero(n)=false → 4th branch ✓
0 == 0 Neither alias branch fires isIntZero(0)=true, lenAliasArg(0).ok=falselenArg=nil

As a side-effect, lenAliasArg (a map lookup + type check) is now only called when isIntZero(Y) is already true — a minor but correct performance improvement.

The else if isIntZero(expr.Y) block cannot accidentally shadow the else if isIntZero(expr.X) block for any valid alias case because isIntZero(Y)=true implies Y is a zero literal, not an alias.

rhsExprForIndex — removed redundant switch case (lines 222-223)

case len(rhs) == 1 && idx == 0 is entirely subsumed by the following case idx < len(rhs) (0 < 1 is always true when len==1, idx==0). The removed lines are dead code — correct to delete.

Existing tests (aliasEmpty, aliasNotEmpty) exercise both changed paths.

🔎 Code quality review by PR Code Quality Reviewer · ⌖ 14.5 AIC

@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 /diagnose and /tdd — requesting changes for one correctness bug and three test-coverage gaps.

📋 Key Themes & Highlights

Key Themes

  • False-positive bug (compound assignments): n += 1 (and all other op= tokens) are not treated as mutations — the alias stays live in the map, so the subsequent n == 0 is still reported. This is the most critical issue since a linter emitting spurious diagnostics erodes trust.
  • Test-coverage gaps: Three code paths lack analysistest fixtures — flipped alias forms (0 == n), the ValueSpec (var-decl) path, and the compound-assignment mutation path.

Positive Highlights

  • ✅ Clean pre-pass design: alias tracking is neatly separated from the binary-expression walk
  • isLocalObject correctly scopes tracking to function-local variables
  • ✅ Mutation invalidation for =, ++/--, and range-assign targets is correct
  • ✅ Negative fixtures for reassignment and increment already present — a solid foundation
  • ✅ The obj.Pos() != ident.Pos() guard for := redeclarations is a subtle but correct touch

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 228.1 AIC · ⌖ 13.9 AIC

delete(aliases, obj)
}
case token.ASSIGN:
delete(aliases, obj)

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.

[/diagnose] Compound-assignment operators (+=, -=, *=, /=, %=, ...) are not invalidated — this causes false positives.

stmt.Tok for n += 1 is token.ADD_ASSIGN, which falls through the switch stmt.Tok without calling delete(aliases, obj). The alias stays in the map, so the subsequent n == 0 is reported even though n no longer holds len(s).

💡 Suggested fix — fold ASSIGN into default

Change the switch to a default case; any non-DEFINE token (plain =, +=, -=, ...) should invalidate the alias:

switch stmt.Tok {
case token.DEFINE:
	// ... existing logic ...
default:
	delete(aliases, obj)
}

A failing regression fixture to add in the test data:

func aliasCompoundAssignedNotFlagged(s string) bool {
	n := len(s)
	n += 1
	return n == 0 // should NOT be reported
}


func aliasNotEmpty(s string) bool {
n := len(s)
return n != 0 // want `use s != "" to check for non-empty string instead of len\(s\) != 0`

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 flipped alias forms 0 == n and 0 != n are handled by the code (the isIntZero(expr.X) branch in run()) but have no test fixtures.

All four direct-len flipped forms have fixtures (flippedEmpty, flippedNotEmpty); the alias path deserves parity so a future refactor cannot silently drop that branch.

💡 Suggested fixtures to add after this function
func aliasFlippedEmpty(s string) bool {
	n := len(s)
	return 0 == n // want `use s == "" to check for empty string instead of len\(s\) == 0`
}

func aliasFlippedNotEmpty(s string) bool {
	n := len(s)
	return 0 != n // want `use s != "" to check for non-empty string instead of len\(s\) != 0`
}

func aliasIncrementedNotFlagged(s string) bool {
n := len(s)
n++
return n == 0

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] No negative test for compound-assignment mutations (+=, -=, etc.).

aliasIncrementedNotFlagged covers n++ (an *ast.IncDecStmt), but compound assignments are a distinct AST node (*ast.AssignStmt with a non-=/:= token) and currently trigger a false positive (the alias is not invalidated). A fixture here would serve as a regression anchor once the bug in collectLenStringAliasesFromAssignStmt is fixed.

💡 Suggested fixture
func aliasCompoundAssignedNotFlagged(s string) bool {
	n := len(s)
	n += 1
	return n == 0 // should NOT be reported
}

func arrayAliasNotFlagged(s [1]byte) bool {
n := len(s)
return n == 0
}

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] collectLenStringAliasesFromValueSpec (the var n = len(s) path) has no test coverage.

The ValueSpec handler runs for var declarations inside functions, but no fixture exercises this code path. It is functionally symmetric to := and is worth pinning with a positive case.

💡 Suggested fixture
func aliasVarDecl(s string) bool {
	var n = len(s)
	return n == 0 // want `use s == "" to check for empty string instead of len\(s\) == 0`
}

@pelikhan
pelikhan merged commit 32ec3d8 into main Jun 8, 2026
1 check failed
@pelikhan
pelikhan deleted the copilot/fix-lenstringzero-precision-issue branch June 8, 2026 06:10
Copilot stopped work on behalf of pelikhan due to an error June 8, 2026 06:11
@github-actions github-actions Bot mentioned this pull request Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants