Skip to content

feat(lenstringzero): extend linter to cover relational len-string comparisons#40621

Merged
pelikhan merged 3 commits into
mainfrom
copilot/fix-lenstringzero-coverage-gap
Jun 21, 2026
Merged

feat(lenstringzero): extend linter to cover relational len-string comparisons#40621
pelikhan merged 3 commits into
mainfrom
copilot/fix-lenstringzero-coverage-gap

Conversation

Copilot AI commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

lenstringzero flagged len(s) == 0 and len(s) != 0 but silently missed the semantically identical relational forms — most notably len(s) > 0, the most common non-empty string idiom in Go.

What changed

lenstringzero.go

  • Expanded operator filter from EQL/NEQ to all six comparison operators.
  • Added matchLenLiteralExpr: normalizes direct and yoda-order comparisons against literals 0 or 1, for both direct len() calls and stored aliases.
  • Added resolveFixOp: maps normalized (operator, literal) pairs to the correct fix op (==/!=) and verb (empty/non-empty). Deliberately excludes tautologies (len(s) >= 0) and contradictions (len(s) < 0).
  • Added flipOp and isIntOne helpers.
  • buildLenStringFix now takes an explicit fixOp instead of reusing expr.Op, so relational operators produce the correct ==/!= replacement.
  • Diagnostic message now shows the normalized form (e.g. len(s) > 0) even for yoda input.
  • Updated Analyzer.Doc and package comment.

Testdata / golden file

  • Added cases for all new patterns and their suggested fixes.
  • Non-string slices, tautologies, contradictions, and reassigned aliases remain unflagged.

Coverage

Pattern Fix Was flagged
len(s) > 0 s != "" ❌ → ✅
len(s) >= 1 s != "" ❌ → ✅
len(s) < 1 s == "" ❌ → ✅
len(s) <= 0 s == "" ❌ → ✅
0 < len(s) (yoda) s != "" ❌ → ✅
len(s) >= 0 (tautology) not flagged ✅
len(slice) > 0 (non-string) not flagged ✅

… on strings

Extend the lenstringzero linter to flag the relational equivalents of
len(s) == 0 / len(s) != 0 on string values:

  len(s) > 0   → s != ""   (non-empty)
  len(s) >= 1  → s != ""   (non-empty)
  len(s) < 1   → s == ""   (empty)
  len(s) <= 0  → s == ""   (empty)

Yoda forms (0 < len(s), 1 <= len(s), etc.) and alias forms
(n := len(s); n > 0) are also handled.

Tautologies (len(s) >= 0) and contradictions (len(s) < 0) are
deliberately not flagged.  Non-string slices/arrays remain unflagged via
the existing *types.Basic String guard.

The suggested fix rewrites to the correct == "" or != "" form.

Closes #40581

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix lenstringzero coverage gap for relational forms feat(lenstringzero): extend linter to cover relational len-string comparisons Jun 21, 2026
Copilot AI requested a review from pelikhan June 21, 2026 14:01
@pelikhan pelikhan marked this pull request as ready for review June 21, 2026 14:12
Copilot AI review requested due to automatic review settings June 21, 2026 14:12

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

This PR extends the lenstringzero Go analysis linter so it also flags relational comparisons against len(s) that are semantically equivalent to empty/non-empty string checks, and produces correct == "" / != "" fixes for direct len(...) comparisons.

Changes:

  • Expanded detection from == 0 / != 0 to all six comparison operators, normalizing yoda-style expressions (e.g. 0 < len(s)len(s) > 0).
  • Added operator/literal normalization and fix resolution so relational comparisons map to the correct ==/!= replacement (excluding tautologies/contradictions like len(s) >= 0).
  • Updated testdata and golden outputs to cover the new comparison patterns and non-flagged cases.
Show a summary per file
File Description
pkg/linters/lenstringzero/lenstringzero.go Adds relational-operator matching, normalization, and correct fix/operator mapping for empty/non-empty string comparisons.
pkg/linters/lenstringzero/testdata/src/lenstringzero/lenstringzero.go Adds new test cases for relational and yoda-order patterns, plus non-flagged tautology/contradiction and non-string cases.
pkg/linters/lenstringzero/testdata/src/lenstringzero/lenstringzero.go.golden Updates golden output to reflect the expected suggested fixes and diagnostics for the new patterns.

Copilot's findings

Tip

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

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

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jun 21, 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 pkg/linters/lenstringzero/lenstringzero.go (production code) and expands analysistest fixture files (testdata/src/lenstringzero/lenstringzero.go and its .golden counterpart) with new // want annotation cases for relational len-string comparisons. The test harness lenstringzero_test.go was not changed. Test Quality Sentinel skipped — no _test.go, *.test.cjs, or *.test.js files were added or modified.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

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

📄 Draft ADR committed: docs/adr/40621-extend-lenstringzero-to-relational-len-string-comparisons.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
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and confirm the alternatives you actually weighed
  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-40621: Extend lenstringzero to relational len-string comparisons

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. The choice to keep lenstringzero syntactic and bound it to literals 0/1 (rather than going semantic or covering arbitrary thresholds) is exactly the kind of trade-off future contributors will want recorded.

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

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 96.2 AIC · ⌖ 10.3 AIC · ⊞ 13.6K ·

@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 /grill-with-docs — no blocking issues, commenting with improvements.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gap: The yoda-order × alias combination (0 < n where n := len(s)) is the one untested path through matchLenLiteralExpr.
  • Missing "not flagged" cases: Non-string relational tests cover only > 0; >= 1, < 1, <= 0 for slices are absent. len(s) != 1 also lacks a negative test.
  • resolveFixOp doc: Silent fall-through for GTR/LEQ with lit == 1 and for EQL/NEQ with lit == 1 are unexplained, unlike the explicit tautology/contradiction comments already present.
  • Unit test for resolveFixOp: This function is the semantic kernel; a table-driven test would pin correctness directly.

Positive Highlights

  • ✅ Semantic correctness is solid — all six operator expansions map correctly, tautologies and contradictions are properly excluded.
  • ✅ Yoda-order normalization via flipOp is clean and fully tested for direct len() calls.
  • ✅ The two-function decomposition (matchLenLiteralExpr + resolveFixOp) is a clear improvement over the original inline logic.
  • buildLenStringFix correctly uses the decoupled fixOp rather than the original expression operator — this was the key correctness risk for relational operators.
  • ✅ PR description is excellent: the coverage table makes the intent immediately clear.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 111.8 AIC · ⌖ 9.42 AIC · ⊞ 6.9K

func aliasLessOrEqualZero(s string) bool {
n := len(s)
return n <= 0 // want `use s == "" to check for 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] Yoda-order alias paths are handled in matchLenLiteralExpr (implementation lines 128–136) but never exercised by the test matrix — e.g. 0 < n or 1 <= n where n := len(s). This is the only (alias × yoda) combination without coverage.

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

func aliasYodaGreaterOrEqualOne(s string) bool {
	n := len(s)
	return 1 <= n // want `use s != "" to check for non-empty string instead of len\(s\) >= 1`
}
// + yoda alias variants for < and <=

Adding all four yoda-alias variants closes the only untested code path in the new matchLenLiteralExpr.


// Non-string slice must not be flagged for relational operators.
func sliceGreaterThanZeroNotFlagged(s []byte) bool {
return 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 "not flagged" non-string guard is tested only for > 0, but the new operators add three more relational paths through the type check. A len(slice) >= 1, len(slice) < 1, and len(slice) <= 0 should also be present to pin that the types.String guard holds for all new operators.

💡 Suggested additions
func sliceGreaterOrEqualOneNotFlagged(s []byte) bool {
	return len(s) >= 1
}

func sliceLessThanOneNotFlagged(s []byte) bool {
	return len(s) < 1
}

func sliceLessOrEqualZeroNotFlagged(s []byte) bool {
	return len(s) <= 0
}

Without these, a future regression in the type-guard branch for GEQ/LSS/LEQ would go undetected.

// len(s) < 1 → s == "" (empty)
// len(s) <= 0 → s == "" (empty)
//
// Tautologies (len(s) >= 0) and contradictions (len(s) < 0) are not flagged.

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.

[/grill-with-docs] The doc comment correctly calls out tautologies and contradictions, but does not explain that len(s) == 1 and len(s) != 1 — while matched by matchLenLiteralExpr — are intentionally dropped because they do not reduce to a single string comparison. A reader tracing why NEQ + lit=1 falls through will have to reason through both functions to confirm this is deliberate.

💡 Suggested wording
// Tautologies (len(s) >= 0) and contradictions (len(s) < 0) are not flagged.
// Comparisons against 1 that are not equivalent to empty/non-empty (e.g.
// len(s) == 1, len(s) != 1, len(s) <= 1) are also intentionally excluded.

return token.EQL, "empty", true
}
// lit == 0 → len(s) < 0 is always false; do not flag.
case token.LEQ:

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.

[/grill-with-docs] GEQ and LSS each have an inline comment explaining their silent fall-through (>= 0 is always true, < 0 is always false). LEQ with lit == 1 (len(s) <= 1) and GTR with lit == 1 (len(s) > 1) also fall through silently but without explanation, making the asymmetry look like an omission.

💡 Suggested additions

In the GTR case (line ~165):

case token.GTR:
    if lit == 0 {
        return token.NEQ, "non-empty", true
    }
    // lit == 1 → len(s) > 1 means at least 2 chars; not equivalent to non-empty.

In the LEQ case (line ~178):

case token.LEQ:
    if lit == 0 {
        return token.EQL, "empty", true
    }
    // lit == 1 → len(s) <= 1 means 0 or 1 chars; not equivalent to empty.

// len(s) <= 0 → s == "" (empty)
//
// Tautologies (len(s) >= 0) and contradictions (len(s) < 0) are not flagged.
func resolveFixOp(normalOp token.Token, lit int) (fixOp token.Token, cmpVerb string, ok bool) {

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] resolveFixOp is the correctness kernel of this change — it encodes the full (operator, literal) → fixOp mapping table. It is currently tested only indirectly via analysistest.RunWithSuggestedFixes. A small table-driven unit test would both document the contract and catch any future accidental deletion of a case.

💡 Sketch
func TestResolveFixOp(t *testing.T) {
    tests := []struct {
        op    token.Token
        lit   int
        wantOk bool
        wantFix token.Token
    }{
        {token.EQL, 0, true,  token.EQL},
        {token.NEQ, 0, true,  token.NEQ},
        {token.GTR, 0, true,  token.NEQ},
        {token.GEQ, 1, true,  token.NEQ},
        {token.LSS, 1, true,  token.EQL},
        {token.LEQ, 0, true,  token.EQL},
        // non-flagged cases
        {token.GEQ, 0, false, 0}, // tautology
        {token.LSS, 0, false, 0}, // contradiction
        {token.EQL, 1, false, 0}, // len == 1
        {token.NEQ, 1, false, 0}, // len != 1
    }
    for _, tc := range tests {
        fix, _, ok := resolveFixOp(tc.op, tc.lit)
        // assert ok == tc.wantOk, fix == tc.wantFix
    }
}

@pelikhan pelikhan merged commit 2365aa9 into main Jun 21, 2026
29 checks passed
@pelikhan pelikhan deleted the copilot/fix-lenstringzero-coverage-gap branch June 21, 2026 14:42
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.

3 participants