Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ func TestRulesFireOnTestdata(t *testing.T) {
}{
{"simplify-slice-range", "slice0.gno"},
{"unnecessary-type-conversion", "coversion/conv0.gno"},
{"cycle-detection", "cycle/types.gno"},
{"cycle-detection", "cycle0.gno"},
{"emit-format", "emit/emit1.gno"},
{"useless-break", "break/break1.gno"},
{"early-return-opportunity", "early_return/a0.gno"},
Expand Down
207 changes: 174 additions & 33 deletions internal/lints/detect_cycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package lints

import (
"go/ast"
"go/parser"
"go/token"
"testing"
)

Expand All @@ -30,19 +28,6 @@ func TestDetectCycle(t *testing.T) {
src := `
package main

type A struct {
B *B
}

type B struct {
A *A
}

var (
x = &y
y = &x
)

func a() {
b()
}
Expand All @@ -52,27 +37,183 @@ func b() {
}

func outer() {
var inner func()
inner = func() {
outer()
}
inner()
var inner func()
inner = func() {
outer()
}
inner()
}`

fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", src, 0)
if err != nil {
t.Fatalf("failed to parse source: %v", err)
cycles := detectCyclesInSource(t, src)
// Expected: [a b a] and [outer outer$anon... outer].
if len(cycles) != 2 {
t.Errorf("expected 2 cycles, got %d: %v", len(cycles), cycles)
}
}

cycle := newCycle()
result := cycle.detectCycles(f)
if len(result) != 4 {
// [B A B]
// [B A B]
// [x y x]
// [b a b]
// [outer outer$anon<address> outer]
t.Errorf("unexpected result: %v", result)
// TestDetectCycle_NoFalsePositives covers patterns that the rule used
// to flag (type-level cycles via indirection, pointer init cycles)
// but that are valid Go and must NOT be reported. The gno-ibc Field/
// Schema reproducer is the lead case.
func TestDetectCycle_NoFalsePositives(t *testing.T) {
t.Parallel()
cases := []struct {
name string
src string
}{
{
name: "gno-ibc Field/Schema reproducer",
src: `package main
type Type int
type Field struct {
Type Type
Sub *Schema
Elem *Field
}
type Schema struct {
Fields []Field
}`,
},
{
name: "self-pointer linked list",
src: `package main
type Node struct { Next *Node }`,
},
{
name: "recursive map values",
src: `package main
type Tree struct { Children map[string]*Tree }`,
},
{
name: "self-typed interface method",
src: `package main
type I interface { F() I }`,
},
{
name: "function-typed field",
src: `package main
type Lazy struct { Eval func() Lazy }`,
},
{
name: "embedded mutual",
src: `package main
type Outer struct { *Inner }
type Inner struct { *Outer }`,
},
{
name: "pointer var cycle",
src: `package main
var x = &y
var y = &x`,
},
{
name: "slice of pointer to self",
src: `package main
type T []*T`,
},
{
name: "type alias chain",
src: `package main
type A = B
type B = int`,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cycles := detectCyclesInSource(t, tc.src)
if len(cycles) != 0 {
t.Errorf("expected no cycles, got %d: %v", len(cycles), cycles)
}
})
}
}

// TestDetectCycle_MutualFunctions guards the latent-bug fix: before
// the fix, analyzeFuncDecl filtered call-site idents to self-only,
// so `a()` calling `b()` and back never registered.
func TestDetectCycle_MutualFunctions(t *testing.T) {
t.Parallel()
src := `package main
func a() { b() }
func b() { a() }`
cycles := detectCyclesInSource(t, src)
if len(cycles) != 1 {
t.Fatalf("expected 1 cycle, got %d: %v", len(cycles), cycles)
}
}

// TestDetectCycle_Methods covers SelectorExpr-based recursion.
func TestDetectCycle_Methods(t *testing.T) {
t.Parallel()
cases := []struct {
name string
src string
wantCount int
}{
{
name: "same-type mutual via pointer receiver",
src: `package main
type T struct{}
func (r *T) foo() { r.bar() }
func (r *T) bar() { r.foo() }`,
wantCount: 1,
},
{
name: "same-type three-method chain",
src: `package main
type T struct{}
func (r *T) a() { r.b() }
func (r *T) b() { r.c() }
func (r *T) c() { r.a() }`,
wantCount: 1,
},
{
// Direct self-recursion is not flagged — matches the
// existing free-function behavior.
name: "same-type self-recursion via receiver",
src: `package main
type T struct{}
func (r *T) foo() { r.foo() }`,
wantCount: 0,
},
{
name: "cross-type method recursion (known limitation)",
src: `package main
type A struct{ b *B }
type B struct{ a *A }
func (a *A) f() { a.b.g() }
func (b *B) g() { b.a.f() }`,
// Resolving b.a.f() requires go/types; out of scope.
wantCount: 0,
},
{
name: "anonymous receiver (known limitation)",
src: `package main
type T struct{}
func (T) foo() { /* can't resolve calls without param name */ }
func (T) bar() {}`,
wantCount: 0,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cycles := detectCyclesInSource(t, tc.src)
if len(cycles) != tc.wantCount {
t.Errorf("expected %d cycles, got %d: %v", tc.wantCount, len(cycles), cycles)
}
})
}
}

func detectCyclesInSource(t *testing.T, src string) []string {
t.Helper()
f, _, err := ParseFile("", []byte(src))
if err != nil {
t.Fatalf("failed to parse source: %v", err)
}
return newCycle().detectCycles(f)
}
Loading
Loading