diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 3fc711d4035..6f24708a24e 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -37,6 +37,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/github-agentic-workflows.md` - `.github/aw/github-mcp-server.md` - `.github/aw/instructions.md` +- `.github/aw/linter-workflows.md` - `.github/aw/llms.md` - `.github/aw/loop.md` - `.github/aw/lsp.md` diff --git a/docs/adr/48210-add-stringscutprefix-linter.md b/docs/adr/48210-add-stringscutprefix-linter.md new file mode 100644 index 00000000000..cb5ec85aef7 --- /dev/null +++ b/docs/adr/48210-add-stringscutprefix-linter.md @@ -0,0 +1,46 @@ +# ADR-48210: Add stringscutprefix Linter for strings.CutPrefix Migration + +**Date**: 2026-07-26 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +Go 1.20 introduced `strings.CutPrefix(s, prefix)` (and the analogous `strings.CutSuffix`) as a cleaner replacement for the two-call idiom `strings.HasPrefix(s, p)` + `strings.TrimPrefix(s, p)`. The two-call pattern is widespread in older Go codebases and carries a silent correctness risk: if the prefix literal or variable is updated in the `HasPrefix` guard but not in the `TrimPrefix` call (or vice versa), the function silently returns the un-trimmed string without any compile-time or runtime error. Many projects, including gh-aw, have accumulated instances of this pattern that were never migrated after `strings.CutPrefix` became available. Manual review alone is insufficient to find and prevent new occurrences across a large codebase. + +### Decision + +We will implement and register a new custom Go static-analysis linter, `stringscutprefix`, that inspects every `*ast.IfStmt` whose condition is `strings.HasPrefix(s, prefix)` and flags it when the if-body contains a `strings.TrimPrefix(s, prefix)` call with the same arguments (verified by type-object identity for identifiers and by value equality for literals/selector expressions). The linter reports the finding on the `HasPrefix` call site and recommends `strings.CutPrefix`. This linter follows the same structural conventions as the rest of the gh-aw custom linter suite and is registered in `pkg/linters/registry.go`. + +### Alternatives Considered + +#### Alternative 1: Rely on an Existing Community Linter (e.g., gocritic, revive, or usestdlibvars) + +`golangci-lint` bundles several meta-linters (`gocritic`, `revive`, `staticcheck`) that cover a wide range of idioms. If one of them already detected this pattern, a custom linter would be redundant. However, none of the bundled linters enforce the `HasPrefix`+`TrimPrefix` → `CutPrefix` migration at the time of this decision. Using a community linter would eliminate maintenance burden, but the pattern would remain undetected in CI. + +#### Alternative 2: Enforce by Coding Guideline Only (No Automated Check) + +A written guideline in a style guide or contributing doc would inform developers about `strings.CutPrefix` without requiring tooling changes. This approach has zero implementation cost but provides no enforcement: new occurrences of the anti-pattern would only be caught during code review, inconsistently and long after the code was written. Given the volume of existing violations and the subtle silent-failure mode of the two-call idiom, a documentation-only approach is insufficient. + +### Consequences + +#### Positive +- CI automatically rejects new instances of the `HasPrefix`+`TrimPrefix` idiom, preventing silent correctness bugs from argument mismatch. +- The linter's argument-identity check (by Go type object) correctly handles both literal strings and variable references, including field selectors, with no regex or text-matching fragility. +- Follows the established gh-aw custom linter pattern (AST traversal via `astutil.Root`, `nolint` directive support, `filecheck` to skip generated files), so the implementation is consistent and immediately reviewable by existing contributors. +- Fixture-based tests via `analysistest` ensure both flagged and non-flagged cases are explicitly enumerated, making the detection boundary clear. + +#### Negative +- Adds a new linter package to the registry that the team must maintain going forward (e.g., if the `strings` package API changes or internal `astutil`/`nolint`/`filecheck` helpers are refactored). +- The linter only detects the case where `TrimPrefix` appears directly inside the `IfStmt` body; more complex patterns (e.g., `TrimPrefix` called in a nested helper, or gated on an `else` branch) are not flagged — these require manual review. +- Teams with intentional uses of the two-call idiom (e.g., where the return value of `HasPrefix` is also used for a side effect) must add a `//nolint:stringscutprefix` directive, introducing a small per-site maintenance cost. + +#### Neutral +- The linter is appended to the `All()` slice in `registry.go` and inherits the same run-on-all-files behavior as every other registered analyzer; no separate opt-in configuration is required. +- Existing occurrences of the anti-pattern in the codebase are not auto-fixed by this PR; a separate migration sweep would be needed to address historical violations. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json index 2165df7dc76..abade9196c2 100644 --- a/pkg/cli/data/agentic_workflows_fallback_aw_files.json +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -22,6 +22,7 @@ "github-mcp-server.md", "instructions.md", "llms.md", + "linter-workflows.md", "loop.md", "lsp.md", "mcp-clis.md", diff --git a/pkg/linters/README.md b/pkg/linters/README.md index 8ec9dd722d0..3e53e945bf3 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -54,6 +54,7 @@ This package currently provides custom Go analyzers in the following subpackages - `stringreplaceminusone` — reports `strings.Replace` calls whose `n` argument is `-1`, which should use the more readable `strings.ReplaceAll`. - `stringsconcatloop` — reports `string +=` concatenation inside `for`/`range` loop bodies, which allocates a new string copy on every iteration (O(n²) memory); use `strings.Builder` instead. - `stringscountcontains` — reports `strings.Count(s, sub)` comparisons with `0` or `1` (e.g. `> 0`, `>= 1`, `== 0`, `!= 0`, `< 1`, `<= 0`) and their yoda-order variants that should use `strings.Contains(s, sub)` or `!strings.Contains(s, sub)` instead. +- `stringscutprefix` — reports `strings.HasPrefix(s, prefix)` guards whose bodies directly call `strings.TrimPrefix(s, prefix)` and recommends `strings.CutPrefix`. - `stringsindexcontains` — reports `strings.Index(s, substr)` comparisons with `-1` or `0` (e.g. `!= -1`, `>= 0`, `> -1`, `== -1`, `< 0`, `<= -1`) and their yoda-order variants that should use `strings.Contains(s, substr)` or `!strings.Contains(s, substr)` instead. - `stringsindexhasprefix` — reports `strings.Index(s, sub)` comparisons with `0` (`== 0`, `!= 0`) and their yoda-order variants that should use `strings.HasPrefix(s, sub)` or `!strings.HasPrefix(s, sub)` instead. - `stringsjoinone` — reports `strings.Join([]string{s}, sep)` calls with a single-element slice literal where the separator is never used and the call is equivalent to just `s`. @@ -121,6 +122,7 @@ This package currently provides custom Go analyzers in the following subpackages | `stringreplaceminusone` | Custom `go/analysis` analyzer that flags `strings.Replace` calls with `n=-1` that should use `strings.ReplaceAll` | | `stringsconcatloop` | Custom `go/analysis` analyzer that flags `string +=` concatenation inside `for`/`range` loops that should use `strings.Builder` | | `stringscountcontains` | Custom `go/analysis` analyzer that flags `strings.Count(s, sub)` comparisons with `0` or `1` that should use `strings.Contains` or `!strings.Contains` | +| `stringscutprefix` | Custom `go/analysis` analyzer that flags `strings.HasPrefix(s, prefix)` guards whose bodies directly call `strings.TrimPrefix(s, prefix)` and should use `strings.CutPrefix` | | `stringsindexcontains` | Custom `go/analysis` analyzer that flags `strings.Index(s, substr)` comparisons with `-1` or `0` that should use `strings.Contains` or `!strings.Contains` | | `stringsindexhasprefix` | Custom `go/analysis` analyzer that flags `strings.Index(s, sub)` comparisons with `0` (`== 0`, `!= 0`) that should use `strings.HasPrefix` or `!strings.HasPrefix` | | `stringsjoinone` | Custom `go/analysis` analyzer that flags `strings.Join([]string{s}, sep)` calls with a single-element slice literal where the separator is unused and the call is equivalent to just `s` | @@ -251,6 +253,7 @@ _ = trimleftright.Analyzer - `github.com/github/gh-aw/pkg/linters/stringbytesroundtrip` — string-bytes-round-trip analyzer subpackage - `github.com/github/gh-aw/pkg/linters/stringreplaceminusone` — string-replace-minus-one analyzer subpackage - `github.com/github/gh-aw/pkg/linters/stringscountcontains` — strings-count-contains analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/stringscutprefix` — strings-cut-prefix analyzer subpackage - `github.com/github/gh-aw/pkg/linters/stringsindexcontains` — strings-index-contains analyzer subpackage - `github.com/github/gh-aw/pkg/linters/stringsindexhasprefix` — strings-index-has-prefix analyzer subpackage - `github.com/github/gh-aw/pkg/linters/stringsjoinone` — strings-join-one analyzer subpackage diff --git a/pkg/linters/doc.go b/pkg/linters/doc.go index a99645363fb..0bfcce8f645 100644 --- a/pkg/linters/doc.go +++ b/pkg/linters/doc.go @@ -1,6 +1,6 @@ // Package linters is a namespace for gh-aw's custom Go analysis linters. // -// All 59 active analyzers: +// All 60 active analyzers: // // - appendbytestring — flags append(b, []byte(s)...) calls where s is a string that can be simplified to append(b, s...) // - appendoneelement — flags append(s, []T{x}...) calls where a single-element slice literal is spread and can be simplified to append(s, x) @@ -50,6 +50,7 @@ // - stringreplaceminusone — flags strings.Replace calls with n=-1 that should use strings.ReplaceAll // - stringsconcatloop — flags string += concatenation inside for/range loops that should use strings.Builder // - stringscountcontains — reports strings.Count(s, sub) comparisons with 0 or 1 (e.g. > 0, >= 1, == 0, != 0, < 1, <= 0) and their yoda-order variants that should use strings.Contains(s, sub) or !strings.Contains(s, sub) +// - stringscutprefix — flags strings.HasPrefix(s, p) guards whose bodies directly call strings.TrimPrefix(s, p), recommending strings.CutPrefix // - stringsindexcontains — flags strings.Index(s, substr) comparisons that should use strings.Contains // - stringsindexhasprefix — reports strings.Index(s, sub) comparisons with 0 (== 0 and != 0) and their yoda-order variants that should use strings.HasPrefix(s, sub) or !strings.HasPrefix(s, sub) // - stringsjoinone — flags strings.Join([]string{s}, sep) calls with a single-element slice literal where the separator is unused and the call is equivalent to just s diff --git a/pkg/linters/registry.go b/pkg/linters/registry.go index b81f1bd1ebf..e2a25ea3210 100644 --- a/pkg/linters/registry.go +++ b/pkg/linters/registry.go @@ -51,6 +51,7 @@ import ( "github.com/github/gh-aw/pkg/linters/stringreplaceminusone" "github.com/github/gh-aw/pkg/linters/stringsconcatloop" "github.com/github/gh-aw/pkg/linters/stringscountcontains" + "github.com/github/gh-aw/pkg/linters/stringscutprefix" "github.com/github/gh-aw/pkg/linters/stringsindexcontains" "github.com/github/gh-aw/pkg/linters/stringsindexhasprefix" "github.com/github/gh-aw/pkg/linters/stringsjoinone" @@ -117,6 +118,7 @@ func All() []*analysis.Analyzer { stringsconcatloop.Analyzer, stringsindexcontains.Analyzer, stringsindexhasprefix.Analyzer, + stringscutprefix.Analyzer, stringsjoinone.Analyzer, stringscountcontains.Analyzer, jsonmarshalignoredeerror.Analyzer, diff --git a/pkg/linters/spec_test.go b/pkg/linters/spec_test.go index 1a0c2fb898a..7698ac42529 100644 --- a/pkg/linters/spec_test.go +++ b/pkg/linters/spec_test.go @@ -59,6 +59,7 @@ import ( "github.com/github/gh-aw/pkg/linters/stringreplaceminusone" "github.com/github/gh-aw/pkg/linters/stringsconcatloop" "github.com/github/gh-aw/pkg/linters/stringscountcontains" + "github.com/github/gh-aw/pkg/linters/stringscutprefix" "github.com/github/gh-aw/pkg/linters/stringsindexcontains" "github.com/github/gh-aw/pkg/linters/stringsindexhasprefix" "github.com/github/gh-aw/pkg/linters/stringsjoinone" @@ -85,7 +86,7 @@ type docAnalyzer struct { } // documentedAnalyzers returns the analyzer subpackages documented in the README -// "Public API > Subpackages" table. The README documents 59 analyzers +// "Public API > Subpackages" table. The README documents 60 analyzers // subpackages (the non-analyzer `internal` helper subpackage is excluded because // it exposes no Analyzer). // @@ -96,7 +97,7 @@ type docAnalyzer struct { // hardcodedfilepath, httpnoctx, httprespbodyclose, httpstatuscode, ioutildeprecated, jsonmarshalignoredeerror, largefunc, lenstringsplit, lenstringzero, // logfatallibrary, manualmutexunlock, mapclearloop, mapdeletecheck, nilctxpassed, osexitinlibrary, osgetenvlibrary, ossetenvlibrary, panic-in-library-code, rawloginlib, // regexpcompileinfunction, seenmapbool, sortslice, sprintferrdot, sprintferrorsnew, sprintfbool, sprintfint, ssljson, -// strconvparseignorederror, stringbytesroundtrip, stringreplaceminusone, stringsconcatloop, stringscountcontains, stringsindexcontains, stringsindexhasprefix, stringsjoinone, timeafterleak, timesleepnocontext, timenowsub, +// strconvparseignorederror, stringbytesroundtrip, stringreplaceminusone, stringsconcatloop, stringscountcontains, stringscutprefix, stringsindexcontains, stringsindexhasprefix, stringsjoinone, timeafterleak, timesleepnocontext, timenowsub, // tolowerequalfold, trimleftright, uncheckedtypeassertion, wgdonenotdeferred, writebytestring func documentedAnalyzers() []docAnalyzer { return []docAnalyzer{ @@ -148,6 +149,7 @@ func documentedAnalyzers() []docAnalyzer { {"stringreplaceminusone", stringreplaceminusone.Analyzer}, {"stringsconcatloop", stringsconcatloop.Analyzer}, {"stringscountcontains", stringscountcontains.Analyzer}, + {"stringscutprefix", stringscutprefix.Analyzer}, {"stringsindexcontains", stringsindexcontains.Analyzer}, {"stringsindexhasprefix", stringsindexhasprefix.Analyzer}, {"stringsjoinone", stringsjoinone.Analyzer}, diff --git a/pkg/linters/stringscutprefix/stringscutprefix.go b/pkg/linters/stringscutprefix/stringscutprefix.go new file mode 100644 index 00000000000..851032e80c4 --- /dev/null +++ b/pkg/linters/stringscutprefix/stringscutprefix.go @@ -0,0 +1,227 @@ +// Package stringscutprefix implements a Go analysis linter that flags +// if-blocks that use strings.HasPrefix followed by strings.TrimPrefix +// and suggests strings.CutPrefix instead. +package stringscutprefix + +import ( + "go/ast" + "go/types" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + + "github.com/github/gh-aw/pkg/linters/internal/astutil" + "github.com/github/gh-aw/pkg/linters/internal/filecheck" + "github.com/github/gh-aw/pkg/linters/internal/nolint" +) + +// Analyzer is the strings-cut-prefix analysis pass. +var Analyzer = &analysis.Analyzer{ + Name: "stringscutprefix", + Doc: "reports if-blocks that check strings.HasPrefix then call strings.TrimPrefix on the same args, suggesting strings.CutPrefix", + URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/stringscutprefix", + Requires: []*analysis.Analyzer{inspect.Analyzer, nolint.Analyzer, filecheck.Analyzer}, + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + root, err := astutil.Root(pass) + if err != nil { + return nil, err + } + noLintIndex, err := nolint.Index(pass) + if err != nil { + return nil, err + } + generatedFiles, err := filecheck.Index(pass) + if err != nil { + return nil, err + } + + for cur := range root.Preorder((*ast.IfStmt)(nil)) { + ifStmt, ok := cur.Node().(*ast.IfStmt) + if !ok { + continue + } + + pos := pass.Fset.PositionFor(ifStmt.Pos(), false) + if filecheck.ShouldSkipFilename(pos.Filename, generatedFiles) { + continue + } + if nolint.HasDirectiveForLinter(pos, noLintIndex, "stringscutprefix") { + continue + } + + // Condition must be strings.HasPrefix(s, prefix) + hasPrefixCall, s, prefix := extractHasPrefixCall(pass, ifStmt.Cond) + if hasPrefixCall == nil { + continue + } + + // Body must contain strings.TrimPrefix(s, prefix) with same args + if bodyContainsTrimPrefix(pass, ifStmt.Body, s, prefix) { + pass.ReportRangef(hasPrefixCall, + "strings.HasPrefix + strings.TrimPrefix can be replaced with strings.CutPrefix") + } + } + + return nil, nil +} + +// extractHasPrefixCall returns the call expression, the s argument, and the +// prefix argument if the expression is strings.HasPrefix(s, prefix). +func extractHasPrefixCall(pass *analysis.Pass, expr ast.Expr) (*ast.CallExpr, ast.Expr, ast.Expr) { + call, ok := expr.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + return nil, nil, nil + } + if !isStringsFunc(pass, call, "HasPrefix") { + return nil, nil, nil + } + return call, call.Args[0], call.Args[1] +} + +// bodyContainsTrimPrefix returns true if the block contains a direct statement +// that calls strings.TrimPrefix with the same arguments, before either argument +// is reassigned in the enclosing if body. +func bodyContainsTrimPrefix(pass *analysis.Pass, body *ast.BlockStmt, s, prefix ast.Expr) bool { + for _, stmt := range body.List { + if stmtContainsTrimPrefix(pass, stmt, s, prefix) { + return true + } + if stmtReassignsExpr(pass, stmt, s) || stmtReassignsExpr(pass, stmt, prefix) { + return false + } + } + return false +} + +func stmtContainsTrimPrefix(pass *analysis.Pass, stmt ast.Stmt, s, prefix ast.Expr) bool { + found := false + ast.Inspect(stmt, func(n ast.Node) bool { + if found || n == nil { + return false + } + if n != stmt && shouldSkipNestedFlow(n) { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + return true + } + if !isStringsFunc(pass, call, "TrimPrefix") { + return true + } + if sameExpr(pass, call.Args[0], s) && sameExpr(pass, call.Args[1], prefix) { + found = true + } + return true + }) + return found +} + +func stmtReassignsExpr(pass *analysis.Pass, stmt ast.Stmt, target ast.Expr) bool { + reassigned := false + ast.Inspect(stmt, func(n ast.Node) bool { + if reassigned || n == nil { + return false + } + if _, ok := n.(*ast.FuncLit); ok { + return false + } + switch node := n.(type) { + case *ast.AssignStmt: + for _, lhs := range node.Lhs { + if sameExpr(pass, lhs, target) { + reassigned = true + return false + } + } + case *ast.IncDecStmt: + if sameExpr(pass, node.X, target) { + reassigned = true + return false + } + } + return true + }) + return reassigned +} + +func shouldSkipNestedFlow(n ast.Node) bool { + switch n.(type) { + case *ast.BlockStmt, *ast.FuncLit, *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt: + return true + default: + return false + } +} + +// isStringsFunc reports whether call invokes strings.. +func isStringsFunc(pass *analysis.Pass, call *ast.CallExpr, name string) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != name { + return false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + obj := pass.TypesInfo.ObjectOf(ident) + if obj == nil { + return false + } + pkgName, ok := obj.(*types.PkgName) + return ok && pkgName.Imported().Path() == "strings" +} + +// sameExpr returns true when a and b are syntactically equivalent simple +// expressions (identifiers or selector expressions) referring to the same +// object, or are equal basic literals. +func sameExpr(pass *analysis.Pass, a, b ast.Expr) bool { + for { + paren, ok := a.(*ast.ParenExpr) + if !ok { + break + } + a = paren.X + } + for { + paren, ok := b.(*ast.ParenExpr) + if !ok { + break + } + b = paren.X + } + + switch av := a.(type) { + case *ast.Ident: + bv, ok := b.(*ast.Ident) + if !ok { + return false + } + ao := pass.TypesInfo.ObjectOf(av) + bo := pass.TypesInfo.ObjectOf(bv) + return ao != nil && ao == bo + case *ast.BasicLit: + bv, ok := b.(*ast.BasicLit) + return ok && av.Kind == bv.Kind && av.Value == bv.Value + case *ast.SelectorExpr: + bv, ok := b.(*ast.SelectorExpr) + if !ok { + return false + } + ao := pass.TypesInfo.ObjectOf(av.Sel) + bo := pass.TypesInfo.ObjectOf(bv.Sel) + return ao != nil && ao == bo && sameExpr(pass, av.X, bv.X) + case *ast.IndexExpr: + bv, ok := b.(*ast.IndexExpr) + if !ok { + return false + } + return sameExpr(pass, av.X, bv.X) && sameExpr(pass, av.Index, bv.Index) + } + // Complex expressions are not structurally compared; treat them as unequal + // to avoid false positives. + return false +} diff --git a/pkg/linters/stringscutprefix/stringscutprefix_test.go b/pkg/linters/stringscutprefix/stringscutprefix_test.go new file mode 100644 index 00000000000..2d42e58c702 --- /dev/null +++ b/pkg/linters/stringscutprefix/stringscutprefix_test.go @@ -0,0 +1,15 @@ +//go:build !integration + +package stringscutprefix_test + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" + + "github.com/github/gh-aw/pkg/linters/stringscutprefix" +) + +func TestAnalyzer(t *testing.T) { + analysistest.Run(t, analysistest.TestData(), stringscutprefix.Analyzer, "stringscutprefix") +} diff --git a/pkg/linters/stringscutprefix/testdata/src/stringscutprefix/stringscutprefix.go b/pkg/linters/stringscutprefix/testdata/src/stringscutprefix/stringscutprefix.go new file mode 100644 index 00000000000..1429dd66774 --- /dev/null +++ b/pkg/linters/stringscutprefix/testdata/src/stringscutprefix/stringscutprefix.go @@ -0,0 +1,89 @@ +package stringscutprefix + +import "strings" + +// flagged: HasPrefix check then TrimPrefix in the body +func processPrefix(s string) string { + if strings.HasPrefix(s, "foo") { // want `strings\.HasPrefix \+ strings\.TrimPrefix can be replaced with strings\.CutPrefix` + return strings.TrimPrefix(s, "foo") + } + return s +} + +// not flagged: HasPrefix but no TrimPrefix in body +func checkOnly(s string) bool { + if strings.HasPrefix(s, "bar") { + return true + } + return false +} + +// not flagged: TrimPrefix called with different prefix +func mismatchedPrefix(s string) string { + if strings.HasPrefix(s, "foo") { + return strings.TrimPrefix(s, "bar") + } + return s +} + +// flagged: field selector expressions also detected +type Obj struct{ Name string } + +func processObj(o Obj) string { + if strings.HasPrefix(o.Name, "v") { // want `strings\.HasPrefix \+ strings\.TrimPrefix can be replaced with strings\.CutPrefix` + return strings.TrimPrefix(o.Name, "v") + } + return o.Name +} + +// flagged: parenthesized arguments still match +func processParen(s string) string { + if strings.HasPrefix((s), "foo") { // want `strings\.HasPrefix \+ strings\.TrimPrefix can be replaced with strings\.CutPrefix` + return strings.TrimPrefix(s, "foo") + } + return s +} + +// not flagged: uses CutPrefix already (control case) +func usesCutPrefix(s string) (string, bool) { + after, found := strings.CutPrefix(s, "foo") + return after, found +} + +// not flagged: TrimPrefix inside a nested closure is not guaranteed to run here +func closureCase(s string) func() string { + if strings.HasPrefix(s, "foo") { + return func() string { + return strings.TrimPrefix(s, "foo") + } + } + return nil +} + +// not flagged: TrimPrefix in a nested branch is not a direct equivalent shape +func nestedBranch(s string, debug bool) string { + if strings.HasPrefix(s, "foo") { + if debug { + return strings.TrimPrefix(s, "foo") + } + } + return s +} + +// not flagged: s is reassigned before TrimPrefix runs +func reassignedValue(s string) string { + if strings.HasPrefix(s, "foo") { + s = strings.TrimSpace(s) + return strings.TrimPrefix(s, "foo") + } + return s +} + +// not flagged: prefix is reassigned before TrimPrefix runs +func reassignedPrefix(s, prefix string) string { + if strings.HasPrefix(s, prefix) { + prefix = strings.TrimSpace(prefix) + return strings.TrimPrefix(s, prefix) + } + return s +}