Skip to content

feat(linters): add stringsjoinone analyzer#47288

Merged
pelikhan merged 6 commits into
mainfrom
copilot/add-stringsjoinone-linter
Jul 22, 2026
Merged

feat(linters): add stringsjoinone analyzer#47288
pelikhan merged 6 commits into
mainfrom
copilot/add-stringsjoinone-linter

Conversation

Copilot AI commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Adds a new stringsjoinone custom Go analyzer that flags strings.Join([]string{s}, sep) calls with a single-element slice literal — the separator is never applied and the call is equivalent to s directly.

Before:

result := strings.Join([]string{name}, ", ")

After:

result := name

Changes

  • pkg/linters/stringsjoinone/ — new analyzer package; detects strings.Join calls whose first argument is a []string{…} composite literal with exactly one element and emits a suggested fix replacing the entire call with that element. Respects //nolint:stringsjoinone and skips generated files.
  • pkg/linters/registry.go — registers stringsjoinone.Analyzer in All()
  • pkg/linters/doc.go — adds bullet, bumps count 55 → 56
  • pkg/linters/README.md — adds entry to Overview, Subpackages table, and Dependencies
  • pkg/linters/spec_test.go — adds to documentedAnalyzers() to keep registry/doc sync tests green

Copilot AI and others added 2 commits July 22, 2026 11:54
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] Add stringsjoinone custom linter feat(linters): add stringsjoinone analyzer Jul 22, 2026
Copilot AI requested a review from pelikhan July 22, 2026 12:06
@pelikhan
pelikhan marked this pull request as ready for review July 22, 2026 12:39
Copilot AI review requested due to automatic review settings July 22, 2026 12:39

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 and registers the stringsjoinone Go analyzer for redundant single-element strings.Join calls.

Changes:

  • Implements diagnostics and suggested fixes.
  • Adds analyzer fixtures and golden tests.
  • Updates registry and documentation.
Show a summary per file
File Description
pkg/linters/stringsjoinone/stringsjoinone.go Implements the analyzer.
pkg/linters/stringsjoinone/stringsjoinone_test.go Runs analyzer fix tests.
pkg/linters/stringsjoinone/testdata/src/stringsjoinone/stringsjoinone.go Adds test cases.
pkg/linters/stringsjoinone/testdata/src/stringsjoinone/stringsjoinone.go.golden Defines expected fixes.
pkg/linters/registry.go Registers the analyzer.
pkg/linters/spec_test.go Adds documentation-sync metadata.
pkg/linters/doc.go Documents the analyzer.
pkg/linters/README.md Updates analyzer reference tables.

Review details

Tip

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

Comments suppressed due to low confidence (2)

pkg/linters/stringsjoinone/stringsjoinone.go:86

  • Replacing the call with the element text without preserving expression precedence can generate invalid or changed code. For example, strings.Join([]string{a + b}, "")[0] is rewritten as a + b[0], rather than (a + b)[0]. Parenthesize non-atomic element expressions (or conservatively parenthesize every replacement) and cover a call used as an indexed/sliced operand.
				NewText: []byte(elemText),

pkg/linters/stringsjoinone/stringsjoinone.go:86

  • These edits only replace the call, so applying the fix leaves an unused strings import when this was the file's sole use, producing code that no longer compiles. The repository already handles this case for package-replacing fixes (for example, pkg/linters/sprintfint/sprintfint.go:180-200). Add an import-removal edit when the matched call is the final use, with a single-use fixture.
			TextEdits: []analysis.TextEdit{{
				Pos:     call.Pos(),
				End:     call.End(),
				NewText: []byte(elemText),
  • Files reviewed: 8/9 changed files
  • Comments generated: 1
  • Review effort level: Medium

return
}

elemText, ok := matchSingleElementStringSlice(pass, joinCall.Args[0])
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 PR Triage

Field Value
Category feature
Risk 🟢 Low
Priority Score 55/100
Recommended Action fast_track

Score breakdown: Impact 25 + Urgency 15 + Quality 15

New stringsjoinone linter with tests and golden files. Self-contained, low-risk addition. CI still running. Recommend fast-track once CI passes.

Generated by 🔧 PR Triage Agent · sonnet46 35.4 AIC · ⌖ 5.2 AIC · ⊞ 5.7K ·

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

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

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (Go: 2, JS: 0)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 2 (100%)
Duplicate clusters 0
Inflation No
🚨 Violations 0
Test File Classification Issues
TestStringsJoinOne stringsjoinone_test.go:13 design_test None
TestRegistryMatchesDocumentation (updated) spec_test.go:251 design_test None

Verdict

Passed. 0% implementation tests (threshold: 30%). Both tests enforce behavioral contracts: TestStringsJoinOne uses analysistest.RunWithSuggestedFixes with testdata covering 3 flagged cases and 5 non-flagged edge cases (nolint suppression, zero-element, variable slice, 2-element, 3-element). The spec_test.go registry addition enforces the documentation ↔ registry sync invariant.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 28.1 AIC · ⌖ 11.4 AIC · ⊞ 7K ·
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.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%).

@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 — requesting changes on one correctness issue with the auto-fix.

📋 Key Themes & Highlights

Issue

  • Auto-fix correctness: SuggestedFixes replaces the entire strings.Join(...) call with only the single element, unconditionally discarding the sep argument. When sep is a call expression (e.g. getSep()), this silently drops its side effects — a semantic change, not just a simplification.

Positive Highlights

  • ✅ Consistent with the existing linter pattern (uses astutil, nolint, filecheck)
  • ✅ Good test coverage for the common cases (flagged/not-flagged, literal/variable, nolint)
  • ✅ Golden-file tests with RunWithSuggestedFixes — the right tool for auto-fix linters
  • ✅ Clean matchSingleElementStringSlice extraction makes the logic easy to follow

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 25.8 AIC · ⌖ 4.72 AIC · ⊞ 6.7K
Comment /matt to run again

return
}

elemText, ok := matchSingleElementStringSlice(pass, joinCall.Args[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 auto-fix unconditionally drops the sep argument; if sep is a function call (e.g. strings.Join([]string{s}, getSep())), removing it silently elides its side effects.

💡 Suggested fix

Only emit SuggestedFixes when the separator is a side-effect-free expression (literal or identifier):

func isSafeSep(expr ast.Expr) bool {
    switch expr.(type) {
    case *ast.BasicLit, *ast.Ident:
        return true
    }
    return false
}

When !isSafeSep(joinCall.Args[1]), report the diagnostic without any SuggestedFixes. Add a test case covering a call-expression separator.

@copilot please address this.

// not flagged: a nolint directive suppresses the diagnostic.
func joinSuppressed(s string) string {
return strings.Join([]string{s}, ", ") //nolint:stringsjoinone
}

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 test data has no case for strings.Join([]string{s}, getSep()) where the separator is a call expression. This is precisely the edge case where the auto-fix would drop the getSep() call — without a pinning test it can silently regress.

Add a "not fixed" case with a call-expression separator to the test file (and ensure the golden file keeps it unchanged).

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

Design Decision Gate — ADR Required

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

Draft ADR committed: docs/adr/47288-add-stringsjoinone-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 could not 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-47288: Add stringsjoinone Custom Go Analyzer](docs/adr/47288-add-stringsjoinone-linter.md)

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. 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., 47288-add-stringsjoinone-linter.md for PR #47288).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 46.6 AIC · ⌖ 9.9 AIC · ⊞ 8.5K ·
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: stringsjoinone analyzer

The implementation is clean and follows existing linter conventions. One correctness issue should be addressed before merge.

Separator side-effects (existing inline comment, line 72): If the second argument to strings.Join is a function call with side effects (e.g., strings.Join([]string{name}, getSep())), the suggested fix drops the call silently. The analyzer should guard against this by only flagging/fixing when the separator argument is a string literal or a pure expression with no side effects.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 31 AIC · ⌖ 5.57 AIC · ⊞ 5K

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher 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.

REQUEST_CHANGES — one blocking correctness issue with the autofix; the existing side-effects comment also needs resolution before merge.

### Findings

Blocking (high): The SuggestedFix in analyzeJoinOne replaces the entire strings.Join call with the raw source text of the single element without parenthesising it. When that element is a compound expression (a+b, f().(T), etc.) inside a higher-precedence context, the fix produces semantically wrong but compilation-passing code. See inline comment.

Existing open comment (high): The separator argument (Args[1]) is never inspected. When it has side effects — strings.Join([]string{s}, computeSep()) — the suggested fix silently drops the computeSep() call. Either gate the fix on the separator being a constant/literal, or emit a diagnostic-only finding with no SuggestedFix for non-trivial separators.

Low / non-blocking: No test case for compound-element or side-effecting-separator patterns; the test suite only exercises the happy-path flag and the nolint suppression.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 45.5 AIC · ⌖ 4.7 AIC · ⊞ 5.7K
Comment /review to run again

End: call.End(),
Message: fmt.Sprintf("strings.Join called with a single-element slice; use %s directly", elemText),
SuggestedFixes: []analysis.SuggestedFix{{
Message: "Replace strings.Join call with " + elemText,

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.

Unsafe suggested fix: replacing elemText without parentheses corrupts operator precedence. If the single element is a binary/compound expression (e.g., a + b) and the call appears in a higher-precedence context, the rewrite silently produces wrong code.

💡 Suggested fix

Example:

// Input: n * strings.Join([]string{a + b}, ",")
// Autofix produces: n * a + b  (parsed as (n*a)+b — semantically wrong)
// Should produce:   n * (a + b)

Fix: check whether the element is a literal or plain identifier; if not, wrap elemText in parentheses or omit the SuggestedFix for complex expressions:

if _, isIdent := elem.(*ast.Ident); !isIdent {
    if _, isLit := elem.(*ast.BasicLit); !isLit {
        elemText = "(" + elemText + ")"
    }
}

Without this guard, applying the fix via gopls or go fix on code like x * strings.Join([]string{a+b}, ",") produces a compilation-passing but semantically broken result.

Only emit the diagnostic when the separator argument is a compile-time
constant (tv.Value != nil). This prevents the suggested fix from
silently discarding observable runtime behaviour such as channel
receives or function calls in the separator position.

Add test fixtures for a function-call separator and a channel-receive
separator to document these "not flagged" cases.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Fixed in the latest commit. Added isSafeToDiscardSeparator which gates the diagnostic on the separator being a compile-time constant (pass.TypesInfo.Types[sep].Value != nil). Separators with runtime side effects — channel receives, function calls, non-constant variable reads — are now silently skipped rather than having their effects dropped by the suggested fix. Added test fixtures for both the function-call and channel-receive cases to document this behaviour.

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.0

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[linter-miner] feat(linters): add stringsjoinone linter

3 participants