Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/cardboard-lint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package main

import (
"golang.org/x/tools/go/analysis/multichecker"

"pkg.package-operator.run/cardboard/internal/analyze/cardboardmeth"
"pkg.package-operator.run/cardboard/internal/analyze/cardboardselfident"
)

func main() {
multichecker.Main(
cardboardselfident.Analyzer,
cardboardmeth.Analyzer,
)
}
121 changes: 121 additions & 0 deletions cmd/sample/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"context"
"embed"
"fmt"
"os"

"pkg.package-operator.run/cardboard/run"
)

var (
mgr *run.Manager

//go:embed *.go
source embed.FS
)

func main() {
ctx := context.Background()

mgr = run.New(run.WithSources(source))
mgr.Register()
if err := mgr.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "\n%s\n", err)
os.Exit(1)
}
}

func FuncTarget(ctx context.Context) error {
self := run.Fn(FuncTarget)
return mgr.ParallelDeps(ctx, self, run.Fn(func() {
fmt.Println("ok")
}))
}

type (
empty struct{}
suspect struct{}
)

func (s *suspect) Meth(ctx context.Context) error { return nil }

type coll struct{}

func (c *coll) MethTarget(ctx context.Context, args []string) error {
self := run.Meth1(c, c.MethTarget, args)
return mgr.ParallelDeps(ctx, self, run.Fn(FuncTarget))
}

func (c *coll) MethTargetWrongName(ctx context.Context, args []string) error {
this := run.Meth1(c, c.MethTarget, args)
return mgr.ParallelDeps(ctx, this, run.Fn(FuncTarget))
}

func (c *coll) MethTargetWrongInline(ctx context.Context, args []string) error {
return mgr.ParallelDeps(ctx, run.Meth1(c, c.MethTarget, args), run.Fn(FuncTarget))
}

func (c *coll) MethTargetWrongInlineAndBare(ctx context.Context, args []string) error {
mgr.ParallelDeps(ctx, run.Meth1(c, c.MethTarget, args), run.Fn(FuncTarget))
return nil
}

func (c *coll) MethTargetWrongInlineAndIf(ctx context.Context, args []string) error {
if mgr.ParallelDeps(ctx, run.Meth1(c, c.MethTarget, args), run.Fn(FuncTarget)) != nil {
return nil
}
return nil
}

func (c *coll) MethTargetWrongInlineAndIfBlock(ctx context.Context, args []string) error {
if err := mgr.ParallelDeps(ctx, run.Meth1(c, c.MethTarget, args), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethNoArgs(ctx context.Context) error {
if err := mgr.ParallelDeps(ctx, run.Meth(c, c.MethNoArgs), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethTwoArgs(ctx context.Context, foo, bar string) error {
if err := mgr.ParallelDeps(ctx, run.Meth2(c, c.MethTwoArgs, foo, bar), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethInvalidTwoArgs(ctx context.Context, foo, bar string) error {
if err := mgr.ParallelDeps(ctx, run.Meth2(empty{}, c.MethTwoArgs, foo, bar), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethInvalidWrongReceiverWithSameSignatureReceiverTwoArgs(ctx context.Context, foo, bar string) error {
if err := mgr.ParallelDeps(ctx, run.Meth2(suspect{}, c.MethTwoArgs, foo, bar), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethExtractedTwoArgs(ctx context.Context, foo, bar string) error {
methTwoArgs := c.MethTwoArgs
if err := mgr.ParallelDeps(ctx, run.Meth2(c, methTwoArgs, foo, bar), run.Fn(FuncTarget)); err != nil {
return err
}
return nil
}

func (c *coll) MethInvalidExtracted(ctx context.Context) error {
meth := (&suspect{}).Meth
if err := mgr.ParallelDeps(ctx, run.Meth(c, meth)); err != nil {
return err
}
return nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ require (
github.com/neilotoole/slogt v1.1.0
github.com/stretchr/testify v1.9.0
github.com/xlab/treeprint v1.2.0
golang.org/x/tools v0.1.12
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/sys v0.20.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
99 changes: 99 additions & 0 deletions internal/analyze/cardboardmeth/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cardboardmeth

import (
"bytes"
"go/ast"
"go/printer"
"go/token"
"go/types"
"reflect"
"regexp"
"strconv"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/go/types/typeutil"
)

const (
cardboardRunPackage = "pkg.package-operator.run/cardboard/run"
)

var methFnRegex = regexp.MustCompile("^Meth(.*)")

var Analyzer = &analysis.Analyzer{
Name: "cardboardmeth",
Doc: "Checks that cardboard run.MethX calls pass correct struct and method pairs.",
Run: run,

Requires: []*analysis.Analyzer{inspect.Analyzer},
}

func run(pass *analysis.Pass) (interface{}, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

nodeFilter := []ast.Node{
(*ast.CallExpr)(nil),
}

inspect.Preorder(nodeFilter, func(n ast.Node) {
ce := n.(*ast.CallExpr)

fn := typeutil.Callee(pass.TypesInfo, ce)
if fn == nil {
return
}
if pkg := fn.Pkg(); pkg == nil || pkg.Path() != cardboardRunPackage {
return // This analyzer is only interested in calls to types from the `cardboardRunPackage` package.
}

recv := fn.Type().(*types.Signature).Recv()
if recv != nil {
return // This analyzer is only interested in function calls.
}

captures := methFnRegex.FindStringSubmatch(fn.Name())
if captures == nil {
return // This analyzer is only interested in calls to run.MethX.
}

if raw := captures[1]; raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
pass.Reportf(ce.Pos(), "call ro run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
return
}
if parsed < 1 {
pass.Reportf(ce.Pos(), "call ro run.MethX with arity <1: %q", render(pass.Fset, ce))
return
}
}
Comment on lines +61 to +71
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Fix typo in diagnostics for MethX arity errors

Both diagnostics use “call ro run.MethX”; fix spelling to “call to run.MethX” for cleaner user-facing messages.

You can apply this minimal change:

-				pass.Reportf(ce.Pos(), "call ro run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
+				pass.Reportf(ce.Pos(), "call to run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))-				pass.Reportf(ce.Pos(), "call ro run.MethX with arity <1: %q", render(pass.Fset, ce))
+				pass.Reportf(ce.Pos(), "call to run.MethX with arity <1: %q", render(pass.Fset, ce))

If you rely on any specific wording conventions for other analyzers, please align these messages accordingly.


🏁 Script executed:

#!/bin/bash
# Verify the typo exists in the analyzer file
rg -n "call ro run\.MethX" internal/analyze/cardboardmeth/

Length of output: 286


🏁 Script executed:

#!/bin/bash
# Also check for the corrected version to ensure it doesn't already exist
rg -n "call to run\.MethX" internal/analyze/cardboardmeth/

Length of output: 0


Fix typo in diagnostics for MethX arity errors

Both diagnostics contain "call ro run.MethX"; fix spelling to "call to run.MethX" at lines 64 and 68:

-				pass.Reportf(ce.Pos(), "call ro run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
+				pass.Reportf(ce.Pos(), "call to run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
-				pass.Reportf(ce.Pos(), "call ro run.MethX with arity <1: %q", render(pass.Fset, ce))
+				pass.Reportf(ce.Pos(), "call to run.MethX with arity <1: %q", render(pass.Fset, ce))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if raw := captures[1]; raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
pass.Reportf(ce.Pos(), "call ro run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
return
}
if parsed < 1 {
pass.Reportf(ce.Pos(), "call ro run.MethX with arity <1: %q", render(pass.Fset, ce))
return
}
}
if raw := captures[1]; raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
pass.Reportf(ce.Pos(), "call to run.MethX with unparseable arity: %s %q", err, render(pass.Fset, ce))
return
}
if parsed < 1 {
pass.Reportf(ce.Pos(), "call to run.MethX with arity <1: %q", render(pass.Fset, ce))
return
}
}
🤖 Prompt for AI Agents
internal/analyze/cardboardmeth/analyzer.go around lines 61 to 71: the diagnostic
strings currently read "call ro run.MethX" which is a typo; update both reported
messages (the one at line ~64 for unparseable arity and the one at line ~68 for
arity <1) to read "call to run.MethX" instead, preserving the rest of each
message and existing formatting/arguments so only the typo is corrected.


groupType := pass.TypesInfo.TypeOf(ce.Args[0])

var xType types.Type
switch meth := ce.Args[1].(type) {
case *ast.SelectorExpr:
xType = pass.TypesInfo.TypeOf(meth.X)
if !reflect.DeepEqual(xType, groupType) {
pass.Reportf(ce.Args[1].Pos(), "method %q does not belong to type of %q: %q %s", render(pass.Fset, ce.Args[1]), render(pass.Fset, ce.Args[0]), render(pass.Fset, ce), xType)
}
case *ast.Ident:
pass.Reportf(ce.Args[1].Pos(), "identifier %q should be removed and method selector expression inlined: %q", render(pass.Fset, ce.Args[1]), render(pass.Fset, ce))
default:
pass.Reportf(ce.Args[1].Pos(), "not implemented: %q has method arg node type %T", render(pass.Fset, ce), meth)
}
Comment on lines +73 to +86
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Use types.Identical instead of reflect.DeepEqual and guard arg count

Comparing go/types.Type values via reflect.DeepEqual is not idiomatic and can be subtly incorrect; types.Identical (or friends) is the standard way to decide whether two types are the same. Also, adding a small guard on len(ce.Args) avoids panics if the analyzer ever runs on malformed or future MethX signatures.

A possible refactor (core part within the current block):

-		groupType := pass.TypesInfo.TypeOf(ce.Args[0])
-
-		var xType types.Type
-		switch meth := ce.Args[1].(type) {
+		if len(ce.Args) < 2 {
+			// Signature of run.MethX requires at least two arguments; be defensive.
+			return
+		}
+
+		groupType := pass.TypesInfo.TypeOf(ce.Args[0])
+
+		var xType types.Type
+		switch meth := ce.Args[1].(type) {
 		case *ast.SelectorExpr:
-			xType = pass.TypesInfo.TypeOf(meth.X)
-			if !reflect.DeepEqual(xType, groupType) {
-				pass.Reportf(ce.Args[1].Pos(), "method %q does not belong to type of %q: %q %s", render(pass.Fset, ce.Args[1]), render(pass.Fset, ce.Args[0]), render(pass.Fset, ce), xType)
-			}
+			xType = pass.TypesInfo.TypeOf(meth.X)
+			if !types.Identical(xType, groupType) {
+				pass.Reportf(
+					ce.Args[1].Pos(),
+					"method %q does not belong to type of %q: %q (receiver type: %v, group type: %v)",
+					render(pass.Fset, ce.Args[1]),
+					render(pass.Fset, ce.Args[0]),
+					render(pass.Fset, ce),
+					xType,
+					groupType,
+				)
+			}

After this change, you can also drop the now-unused reflect import at the top. Please double-check whether you prefer types.Identical, types.IdenticalIgnoreTags, or something like types.AssignableTo depending on how strict you want the receiver/group type relationship to be in run.MethX.


I'll verify the review comment by examining the code file and checking the context of the suggested changes.





🏁 Script executed:

#!/bin/bash
# Examine the file at the specified lines
echo "=== File content at lines 60-100 ==="
sed -n '60,100p' internal/analyze/cardboardmeth/analyzer.go

echo ""
echo "=== Check imports in the file ==="
head -30 internal/analyze/cardboardmeth/analyzer.go | grep -E "^import|reflect|types"

Length of output: 1484


🏁 Script executed:

#!/bin/bash
# Search for reflect.DeepEqual usage in the file
echo "=== reflect.DeepEqual usage in analyzer.go ==="
grep -n "reflect\.DeepEqual" internal/analyze/cardboardmeth/analyzer.go

echo ""
echo "=== All reflect usage in analyzer.go ==="
grep -n "reflect\." internal/analyze/cardboardmeth/analyzer.go

Length of output: 184


🏁 Script executed:

#!/bin/bash
# Search for MethX to understand the context
echo "=== MethX references in the codebase ==="
rg -n "MethX" --type go -C 3

Length of output: 1853


🏁 Script executed:

#!/bin/bash
# Check if types.Identical is standard in go/types package
echo "=== Checking if go/types package exists and types.Identical is documented ==="
# We'll search the codebase for existing uses of types package functions
echo "Existing types package usage in codebase:"
rg -n "types\." internal/analyze/cardboardmeth/analyzer.go | head -20

Length of output: 194


🏁 Script executed:

#!/bin/bash
# Check the full function context where this code appears
echo "=== Full context around the analyzed code (wider range) ==="
sed -n '50,120p' internal/analyze/cardboardmeth/analyzer.go

Length of output: 1688


🌐 Web query:

go/types.Identical function documentation and usage

💡 Result:

Summary — go/types.Identical

  • Signature: func Identical(x, y Type) bool. It reports whether x and y are identical types. Receivers of Signature types are ignored. [1]

  • Semantics: Identical implements the Go language notion of type identity (named types are identical only if they are the same named symbol; unnamed types are identical if their underlying type literals are structurally equivalent — arrays, slices, structs, funcs, interfaces, maps, channels, and instantiated generic rules follow the language spec). See the Go spec for the structural rules (arrays lengths, struct field names/tags/embeddedness, method sets for interfaces, etc.). [2]

  • Important caveats:

    • Predicates like Identical assume both operands belong to a consistent collection of symbols (Object values). Two distinct TypeName objects that represent the same source-qualified name but were created in different contexts (for example, by separate package-loading runs) are not considered the same symbol, so Identical will return false even if the printed type looks identical. To avoid this, construct/load packages with a single Importer/loader so symbols are shared. [1][3]
    • There is also IdenticalIgnoreTags(x,y Type) which behaves like Identical but ignores struct field tags. Receivers of Signature types are still ignored. [1]
  • Practical usage:

    • Use Identical when you need to test lexical/semantic type identity according to the Go type system (e.g., for type-checking, tool chains).
    • Ensure types compared come from a consistent symbol space (use a single go/types Config/Importer or packages.Load invocation) when comparing across packages to avoid false negatives. If you must compare types from separate contexts, canonicalize symbols or use string representations only as a heuristic (but beware of false positives/negatives). [1][3]

References

  • go/types package docs (Identical, IdenticalIgnoreTags). [1]
  • The Go language specification — rules for identical types. [2]
  • golang/go issue discussing Identical returning false for types loaded in different packages.Load calls. [3]

Replace reflect.DeepEqual with types.Identical and add bounds check for ce.Args

The review comment is correct and identifies a legitimate major issue. Verification confirms:

  • The code currently uses reflect.DeepEqual to compare go/types.Type values (line 79), which is not idiomatic
  • The standard approach is types.Identical, which is defined in the go/types package
  • The code accesses ce.Args[0] and ce.Args[1] directly (lines 74–75) without verifying len(ce.Args) >= 2, creating a panic risk on malformed input
  • The reflect package is imported but used only for this single DeepEqual call; it will become unused after the refactor

Apply the suggested changes:

  1. Add the len(ce.Args) < 2 guard before accessing arguments
  2. Replace reflect.DeepEqual(xType, groupType) with types.Identical(xType, groupType)
  3. Remove the "reflect" import from the file
🤖 Prompt for AI Agents
internal/analyze/cardboardmeth/analyzer.go lines 73-86: currently the code
indexes ce.Args[0] and ce.Args[1] without checking length and uses
reflect.DeepEqual to compare go/types.Type values; add a guard at the top that
returns or reports if len(ce.Args) < 2 to avoid panics, replace
reflect.DeepEqual(xType, groupType) with types.Identical(xType, groupType), and
remove the now-unused "reflect" import from the file.

})

return nil, nil
}

// from https://arslan.io/2019/06/13/using-go-analysis-to-write-a-custom-linter/
func render(fset *token.FileSet, x interface{}) string {
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, x); err != nil {
panic(err)
}
return buf.String()
}
29 changes: 29 additions & 0 deletions internal/analyze/cardboardself/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cardboardself

import (
"go/ast"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
Name: "cardboardself",
Doc: "Checks that self is a wrapped version of the caller.",
Run: run,

Requires: []*analysis.Analyzer{inspect.Analyzer},
}

func run(pass *analysis.Pass) (interface{}, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

nodeFilter := []ast.Node{}

inspect.WithStack(nodeFilter, func(n ast.Node, _ bool, stack []ast.Node) bool {
return false
})

return nil, nil
}
Loading