@ctxo/lang-go 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/dist/index.d.ts +68 -1
- package/dist/index.js +483 -11
- package/dist/index.js.map +1 -1
- package/package.json +13 -6
- package/tools/ctxo-go-analyzer/go.mod +10 -0
- package/tools/ctxo-go-analyzer/go.sum +8 -0
- package/tools/ctxo-go-analyzer/internal/edges/edges.go +303 -0
- package/tools/ctxo-go-analyzer/internal/edges/edges_test.go +180 -0
- package/tools/ctxo-go-analyzer/internal/emit/emit.go +189 -0
- package/tools/ctxo-go-analyzer/internal/emit/emit_test.go +84 -0
- package/tools/ctxo-go-analyzer/internal/extends/extends.go +138 -0
- package/tools/ctxo-go-analyzer/internal/extends/extends_test.go +141 -0
- package/tools/ctxo-go-analyzer/internal/implements/implements.go +115 -0
- package/tools/ctxo-go-analyzer/internal/implements/implements_test.go +141 -0
- package/tools/ctxo-go-analyzer/internal/load/load.go +162 -0
- package/tools/ctxo-go-analyzer/internal/load/load_test.go +96 -0
- package/tools/ctxo-go-analyzer/internal/reach/reach.go +332 -0
- package/tools/ctxo-go-analyzer/internal/reach/reach_test.go +142 -0
- package/tools/ctxo-go-analyzer/internal/symbols/symbols.go +172 -0
- package/tools/ctxo-go-analyzer/internal/symbols/symbols_test.go +159 -0
- package/tools/ctxo-go-analyzer/main.go +183 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package extends
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestStructEmbedding(t *testing.T) {
|
|
12
|
+
dir := writeFixture(t, map[string]string{
|
|
13
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
14
|
+
"a.go": `package fixture
|
|
15
|
+
|
|
16
|
+
type Base struct {
|
|
17
|
+
ID string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Child embeds Base (value embedding).
|
|
21
|
+
type Child struct {
|
|
22
|
+
Base
|
|
23
|
+
Extra int
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pointer embedding.
|
|
27
|
+
type PointerChild struct {
|
|
28
|
+
*Base
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Named field — NOT embedded.
|
|
32
|
+
type NotChild struct {
|
|
33
|
+
B Base
|
|
34
|
+
}
|
|
35
|
+
`,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
edges := extractAll(t, dir)
|
|
39
|
+
|
|
40
|
+
want := []string{
|
|
41
|
+
"a.go::Child::class -> a.go::Base::class (extends)",
|
|
42
|
+
"a.go::PointerChild::class -> a.go::Base::class (extends)",
|
|
43
|
+
}
|
|
44
|
+
for _, w := range want {
|
|
45
|
+
if !edges[w] {
|
|
46
|
+
t.Errorf("missing: %s\n got: %v", w, edges)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if edges["a.go::NotChild::class -> a.go::Base::class (extends)"] {
|
|
50
|
+
t.Error("named field must not be treated as embedding")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestInterfaceEmbedding(t *testing.T) {
|
|
55
|
+
dir := writeFixture(t, map[string]string{
|
|
56
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
57
|
+
"a.go": `package fixture
|
|
58
|
+
|
|
59
|
+
type Reader interface { Read() }
|
|
60
|
+
type Writer interface { Write() }
|
|
61
|
+
|
|
62
|
+
type ReadWriter interface {
|
|
63
|
+
Reader
|
|
64
|
+
Writer
|
|
65
|
+
}
|
|
66
|
+
`,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
edges := extractAll(t, dir)
|
|
70
|
+
|
|
71
|
+
want := []string{
|
|
72
|
+
"a.go::ReadWriter::interface -> a.go::Reader::interface (extends)",
|
|
73
|
+
"a.go::ReadWriter::interface -> a.go::Writer::interface (extends)",
|
|
74
|
+
}
|
|
75
|
+
for _, w := range want {
|
|
76
|
+
if !edges[w] {
|
|
77
|
+
t.Errorf("missing: %s\n got: %v", w, edges)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestSkipStdlibEmbedding(t *testing.T) {
|
|
83
|
+
dir := writeFixture(t, map[string]string{
|
|
84
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
85
|
+
"a.go": `package fixture
|
|
86
|
+
|
|
87
|
+
import "sync"
|
|
88
|
+
|
|
89
|
+
type Guarded struct {
|
|
90
|
+
sync.Mutex
|
|
91
|
+
}
|
|
92
|
+
`,
|
|
93
|
+
})
|
|
94
|
+
edges := extractAll(t, dir)
|
|
95
|
+
for key := range edges {
|
|
96
|
+
if contains(key, "sync.") || contains(key, "Mutex") {
|
|
97
|
+
t.Errorf("stdlib embedding leaked: %s", key)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── helpers ──
|
|
103
|
+
|
|
104
|
+
func extractAll(t *testing.T, dir string) map[string]bool {
|
|
105
|
+
t.Helper()
|
|
106
|
+
res := load.Packages(dir)
|
|
107
|
+
if res.FatalError != nil {
|
|
108
|
+
t.Fatal(res.FatalError)
|
|
109
|
+
}
|
|
110
|
+
out := map[string]bool{}
|
|
111
|
+
for _, list := range Extract(dir, res.Packages) {
|
|
112
|
+
for _, e := range list {
|
|
113
|
+
out[e.From+" -> "+e.To+" ("+e.Kind+")"] = true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func writeFixture(t *testing.T, files map[string]string) string {
|
|
120
|
+
t.Helper()
|
|
121
|
+
dir := t.TempDir()
|
|
122
|
+
for name, body := range files {
|
|
123
|
+
path := filepath.Join(dir, name)
|
|
124
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
125
|
+
t.Fatal(err)
|
|
126
|
+
}
|
|
127
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return dir
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func contains(s, sub string) bool {
|
|
135
|
+
for i := 0; i+len(sub) <= len(s); i++ {
|
|
136
|
+
if s[i:i+len(sub)] == sub {
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Package implements derives interface-satisfaction edges by pairing every
|
|
2
|
+
// concrete named type in the module against every module-local interface and
|
|
3
|
+
// consulting go/types. Go's structural typing means this is the only way to
|
|
4
|
+
// recover `implements` relations — there is no `implements` keyword to walk.
|
|
5
|
+
package implements
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"go/token"
|
|
9
|
+
"go/types"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
|
|
13
|
+
"golang.org/x/tools/go/packages"
|
|
14
|
+
|
|
15
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/emit"
|
|
16
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/symbols"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Extract returns `implements` edges grouped by the source file of the
|
|
20
|
+
// concrete type. Both sides of each edge are guaranteed to live inside the
|
|
21
|
+
// module root — external interfaces and stdlib types are skipped.
|
|
22
|
+
func Extract(rootDir string, pkgs []*packages.Package) map[string][]emit.Edge {
|
|
23
|
+
type typed struct {
|
|
24
|
+
named *types.Named
|
|
25
|
+
file string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var concretes []typed
|
|
29
|
+
var ifaces []typed
|
|
30
|
+
|
|
31
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
32
|
+
if p.Types == nil || p.Fset == nil {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
scope := p.Types.Scope()
|
|
36
|
+
for _, name := range scope.Names() {
|
|
37
|
+
if name == "_" {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
obj := scope.Lookup(name)
|
|
41
|
+
tn, ok := obj.(*types.TypeName)
|
|
42
|
+
if !ok {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
named, ok := tn.Type().(*types.Named)
|
|
46
|
+
if !ok {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
// Generic types are bucketed once, without instantiation — consistent
|
|
50
|
+
// with the ADR-013 §4 generics decision.
|
|
51
|
+
if named.TypeParams() != nil && named.TypeParams().Len() > 0 {
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
rel := relFile(p.Fset, obj.Pos(), rootDir)
|
|
55
|
+
if rel == "" {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
entry := typed{named: named, file: rel}
|
|
59
|
+
if _, isIface := named.Underlying().(*types.Interface); isIface {
|
|
60
|
+
ifaces = append(ifaces, entry)
|
|
61
|
+
} else {
|
|
62
|
+
concretes = append(concretes, entry)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
out := map[string][]emit.Edge{}
|
|
68
|
+
for _, iface := range ifaces {
|
|
69
|
+
iUnder := iface.named.Underlying().(*types.Interface)
|
|
70
|
+
if iUnder.NumMethods() == 0 {
|
|
71
|
+
// Empty interfaces (any, comparable-without-methods) are satisfied by
|
|
72
|
+
// every type — skipping keeps the graph useful.
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
ifaceID := symbols.SymbolID(iface.file, iface.named.Obj().Name(), "interface")
|
|
76
|
+
|
|
77
|
+
for _, c := range concretes {
|
|
78
|
+
concreteID := symbols.SymbolID(c.file, c.named.Obj().Name(), kindOf(c.named))
|
|
79
|
+
if concreteID == ifaceID {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if types.Implements(c.named, iUnder) || types.Implements(types.NewPointer(c.named), iUnder) {
|
|
83
|
+
out[c.file] = append(out[c.file], emit.Edge{
|
|
84
|
+
From: concreteID,
|
|
85
|
+
To: ifaceID,
|
|
86
|
+
Kind: "implements",
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func kindOf(n *types.Named) string {
|
|
95
|
+
switch n.Underlying().(type) {
|
|
96
|
+
case *types.Struct:
|
|
97
|
+
return "class"
|
|
98
|
+
case *types.Interface:
|
|
99
|
+
return "interface"
|
|
100
|
+
default:
|
|
101
|
+
return "type"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func relFile(fset *token.FileSet, pos token.Pos, rootDir string) string {
|
|
106
|
+
p := fset.Position(pos)
|
|
107
|
+
if !p.IsValid() {
|
|
108
|
+
return ""
|
|
109
|
+
}
|
|
110
|
+
rel, err := filepath.Rel(rootDir, p.Filename)
|
|
111
|
+
if err != nil || strings.HasPrefix(rel, "..") {
|
|
112
|
+
return ""
|
|
113
|
+
}
|
|
114
|
+
return filepath.ToSlash(rel)
|
|
115
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package implements
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestExtractSatisfiesInterface(t *testing.T) {
|
|
12
|
+
dir := writeFixture(t, map[string]string{
|
|
13
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
14
|
+
"types.go": `package fixture
|
|
15
|
+
|
|
16
|
+
type Greeter interface {
|
|
17
|
+
Greet() string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Value-receiver implementation.
|
|
21
|
+
type Hello struct{}
|
|
22
|
+
func (h Hello) Greet() string { return "hello" }
|
|
23
|
+
|
|
24
|
+
// Pointer-receiver implementation.
|
|
25
|
+
type Ciao struct{}
|
|
26
|
+
func (c *Ciao) Greet() string { return "ciao" }
|
|
27
|
+
|
|
28
|
+
// Non-implementer — missing Greet.
|
|
29
|
+
type Silent struct{}
|
|
30
|
+
|
|
31
|
+
// Empty interface — edges to it must NOT be emitted.
|
|
32
|
+
type Any interface{}
|
|
33
|
+
|
|
34
|
+
// Interface-to-interface — should not produce implements edge.
|
|
35
|
+
type LoudGreeter interface {
|
|
36
|
+
Greeter
|
|
37
|
+
Shout()
|
|
38
|
+
}
|
|
39
|
+
`,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
res := load.Packages(dir)
|
|
43
|
+
if res.FatalError != nil {
|
|
44
|
+
t.Fatal(res.FatalError)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
out := Extract(dir, res.Packages)
|
|
48
|
+
|
|
49
|
+
edges := map[string]bool{}
|
|
50
|
+
for _, list := range out {
|
|
51
|
+
for _, e := range list {
|
|
52
|
+
edges[e.From+" -> "+e.To+" ("+e.Kind+")"] = true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
mustHave := []string{
|
|
57
|
+
"types.go::Hello::class -> types.go::Greeter::interface (implements)",
|
|
58
|
+
"types.go::Ciao::class -> types.go::Greeter::interface (implements)",
|
|
59
|
+
}
|
|
60
|
+
for _, want := range mustHave {
|
|
61
|
+
if !edges[want] {
|
|
62
|
+
t.Errorf("missing edge: %s\n got: %v", want, edges)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mustNotHave := []string{
|
|
67
|
+
"types.go::Silent::class -> types.go::Greeter::interface (implements)",
|
|
68
|
+
}
|
|
69
|
+
for _, bad := range mustNotHave {
|
|
70
|
+
if edges[bad] {
|
|
71
|
+
t.Errorf("unexpected edge: %s", bad)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for key := range edges {
|
|
76
|
+
if containsSubstring(key, "Any::interface") {
|
|
77
|
+
t.Errorf("empty-interface edge leaked: %s", key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestNonStructNamedType(t *testing.T) {
|
|
83
|
+
dir := writeFixture(t, map[string]string{
|
|
84
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
85
|
+
"a.go": `package fixture
|
|
86
|
+
|
|
87
|
+
type Stringer interface { String() string }
|
|
88
|
+
|
|
89
|
+
// Non-struct named type — underlying is string, but it has methods.
|
|
90
|
+
type ID string
|
|
91
|
+
|
|
92
|
+
func (i ID) String() string { return string(i) }
|
|
93
|
+
`,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
res := load.Packages(dir)
|
|
97
|
+
if res.FatalError != nil {
|
|
98
|
+
t.Fatal(res.FatalError)
|
|
99
|
+
}
|
|
100
|
+
out := Extract(dir, res.Packages)
|
|
101
|
+
|
|
102
|
+
got := false
|
|
103
|
+
for _, list := range out {
|
|
104
|
+
for _, e := range list {
|
|
105
|
+
if e.From == "a.go::ID::type" && e.To == "a.go::Stringer::interface" && e.Kind == "implements" {
|
|
106
|
+
got = true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if !got {
|
|
111
|
+
t.Errorf("expected non-struct type ID to implement Stringer; got %v", out)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func writeFixture(t *testing.T, files map[string]string) string {
|
|
116
|
+
t.Helper()
|
|
117
|
+
dir := t.TempDir()
|
|
118
|
+
for name, body := range files {
|
|
119
|
+
path := filepath.Join(dir, name)
|
|
120
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return dir
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func containsSubstring(s, sub string) bool {
|
|
131
|
+
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func indexOf(s, sub string) int {
|
|
135
|
+
for i := 0; i+len(sub) <= len(s); i++ {
|
|
136
|
+
if s[i:i+len(sub)] == sub {
|
|
137
|
+
return i
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return -1
|
|
141
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Package load wraps golang.org/x/tools/go/packages with the configuration
|
|
2
|
+
// needed by ctxo-go-analyzer: full type info, ASTs, dependency closure, and
|
|
3
|
+
// go.work-aware module resolution.
|
|
4
|
+
package load
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"fmt"
|
|
8
|
+
"io/fs"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
|
|
13
|
+
"golang.org/x/tools/go/packages"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// Mode requests every field the analyzer needs from go/packages: name, files,
|
|
17
|
+
// syntax trees, type-checker output, and the imported-package closure. It is
|
|
18
|
+
// expensive but unavoidable for type-aware edge resolution.
|
|
19
|
+
const Mode = packages.NeedName |
|
|
20
|
+
packages.NeedFiles |
|
|
21
|
+
packages.NeedCompiledGoFiles |
|
|
22
|
+
packages.NeedImports |
|
|
23
|
+
packages.NeedDeps |
|
|
24
|
+
packages.NeedTypes |
|
|
25
|
+
packages.NeedTypesInfo |
|
|
26
|
+
packages.NeedSyntax |
|
|
27
|
+
packages.NeedTypesSizes |
|
|
28
|
+
packages.NeedModule
|
|
29
|
+
|
|
30
|
+
// skippedDirs are filesystem entries we never descend into during subdir
|
|
31
|
+
// fallback. vendor and node_modules avoid double-loading deps; hidden
|
|
32
|
+
// directories tend to be tooling state, not source.
|
|
33
|
+
var skippedDirs = map[string]bool{
|
|
34
|
+
"vendor": true,
|
|
35
|
+
".git": true,
|
|
36
|
+
".ctxo": true,
|
|
37
|
+
"node_modules": true,
|
|
38
|
+
"testdata": true,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// LoadResult bundles the loaded package set with any non-fatal errors so the
|
|
42
|
+
// caller can decide whether to surface them as warnings.
|
|
43
|
+
type LoadResult struct {
|
|
44
|
+
Packages []*packages.Package
|
|
45
|
+
// Errors are package-level Errors collected from a Visit traversal.
|
|
46
|
+
// Returned for visibility — the loader does not fail the run on these.
|
|
47
|
+
Errors []packages.Error
|
|
48
|
+
// FatalError is set when packages.Load returned a top-level error (e.g.,
|
|
49
|
+
// module-graph conflict). The caller may still get partial Packages.
|
|
50
|
+
FatalError error
|
|
51
|
+
// FallbackUsed is true when the root pattern failed and per-subdir
|
|
52
|
+
// recovery was used to gather whatever packages still loaded cleanly.
|
|
53
|
+
FallbackUsed bool
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Packages loads "./..." rooted at dir. Patterns can override the default
|
|
57
|
+
// for tests that want to scope analysis to a single sub-package.
|
|
58
|
+
//
|
|
59
|
+
// Failure mode: returns a LoadResult (possibly with empty Packages) and
|
|
60
|
+
// sets FatalError. The caller should surface the error but continue —
|
|
61
|
+
// degraded analysis beats no analysis when consumers have a flaky module.
|
|
62
|
+
func Packages(dir string, patterns ...string) *LoadResult {
|
|
63
|
+
if len(patterns) == 0 {
|
|
64
|
+
patterns = []string{"./..."}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
cfg := &packages.Config{
|
|
68
|
+
Mode: Mode,
|
|
69
|
+
Dir: dir,
|
|
70
|
+
Tests: false,
|
|
71
|
+
// GOFLAGS=-mod=mod lets `go list` automatically add missing module
|
|
72
|
+
// entries (matches what `go build` does) instead of failing with
|
|
73
|
+
// "go: updates to go.mod needed; to update it: go mod tidy".
|
|
74
|
+
// Users get analysis without being forced to run `go mod tidy` first;
|
|
75
|
+
// go.mod/go.sum may gain entries — same side effect as a normal build.
|
|
76
|
+
// `-e` keeps loading even when individual packages have type errors.
|
|
77
|
+
Env: append(os.Environ(), "GOFLAGS=-mod=mod -e"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pkgs, err := packages.Load(cfg, patterns...)
|
|
81
|
+
res := &LoadResult{Packages: pkgs}
|
|
82
|
+
if err != nil {
|
|
83
|
+
res.FatalError = fmt.Errorf("load: %w", err)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
87
|
+
res.Errors = append(res.Errors, p.Errors...)
|
|
88
|
+
})
|
|
89
|
+
return res
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// PackagesWithFallback tries to load the whole module first; if that yields
|
|
93
|
+
// zero packages (typical when the module graph has a fatal conflict), it
|
|
94
|
+
// walks top-level subdirectories and loads each independently. Subdirs that
|
|
95
|
+
// transitively avoid the conflicting imports still produce full type info.
|
|
96
|
+
func PackagesWithFallback(dir string) *LoadResult {
|
|
97
|
+
primary := Packages(dir)
|
|
98
|
+
if len(primary.Packages) > 0 {
|
|
99
|
+
return primary
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
subdirs := topLevelGoSubdirs(dir)
|
|
103
|
+
if len(subdirs) == 0 {
|
|
104
|
+
return primary
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
merged := &LoadResult{
|
|
108
|
+
FatalError: primary.FatalError,
|
|
109
|
+
FallbackUsed: true,
|
|
110
|
+
}
|
|
111
|
+
for _, sub := range subdirs {
|
|
112
|
+
pattern := "./" + filepath.ToSlash(sub) + "/..."
|
|
113
|
+
subRes := Packages(dir, pattern)
|
|
114
|
+
if len(subRes.Packages) == 0 {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
merged.Packages = append(merged.Packages, subRes.Packages...)
|
|
118
|
+
merged.Errors = append(merged.Errors, subRes.Errors...)
|
|
119
|
+
}
|
|
120
|
+
return merged
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// topLevelGoSubdirs returns immediate subdirectories of root that contain at
|
|
124
|
+
// least one .go file (recursively). Skips vendor, .git, hidden dirs, etc.
|
|
125
|
+
func topLevelGoSubdirs(root string) []string {
|
|
126
|
+
entries, err := os.ReadDir(root)
|
|
127
|
+
if err != nil {
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
var out []string
|
|
131
|
+
for _, e := range entries {
|
|
132
|
+
if !e.IsDir() {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
name := e.Name()
|
|
136
|
+
if skippedDirs[name] || strings.HasPrefix(name, ".") {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if hasGoFile(filepath.Join(root, name)) {
|
|
140
|
+
out = append(out, name)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func hasGoFile(dir string) bool {
|
|
147
|
+
found := false
|
|
148
|
+
_ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
|
|
149
|
+
if err != nil {
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
if d.IsDir() && skippedDirs[d.Name()] {
|
|
153
|
+
return fs.SkipDir
|
|
154
|
+
}
|
|
155
|
+
if !d.IsDir() && strings.HasSuffix(d.Name(), ".go") {
|
|
156
|
+
found = true
|
|
157
|
+
return fs.SkipAll
|
|
158
|
+
}
|
|
159
|
+
return nil
|
|
160
|
+
})
|
|
161
|
+
return found
|
|
162
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package load
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"sort"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"golang.org/x/tools/go/packages"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPackagesOnHealthyModule(t *testing.T) {
|
|
13
|
+
dir := writeFixture(t, map[string]string{
|
|
14
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
15
|
+
"a.go": "package fixture\n\nfunc Hello() string { return \"hi\" }\n",
|
|
16
|
+
})
|
|
17
|
+
res := Packages(dir)
|
|
18
|
+
if res.FatalError != nil {
|
|
19
|
+
t.Fatalf("FatalError: %v", res.FatalError)
|
|
20
|
+
}
|
|
21
|
+
if len(res.Packages) == 0 {
|
|
22
|
+
t.Fatal("expected at least one package")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestPackagesWithFallbackRecoversPartial(t *testing.T) {
|
|
27
|
+
// "broken/" imports a non-existent path so the module-wide load fails;
|
|
28
|
+
// "healthy/" is self-contained, so per-subdir loading recovers it.
|
|
29
|
+
dir := writeFixture(t, map[string]string{
|
|
30
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
31
|
+
"broken/broken.go": "package broken\n\nimport _ \"definitely.nowhere/missing\"\n",
|
|
32
|
+
"healthy/healthy.go": "package healthy\n\nfunc Y() int { return 1 }\n",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
res := PackagesWithFallback(dir)
|
|
36
|
+
if len(res.Packages) == 0 {
|
|
37
|
+
t.Fatalf("expected fallback to recover at least one package; got 0 (FatalError=%v)", res.FatalError)
|
|
38
|
+
}
|
|
39
|
+
if !pkgListContainsName(res.Packages, "healthy") {
|
|
40
|
+
t.Errorf("expected 'healthy' package recovered via fallback; got %v", pkgNames(res.Packages))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestTopLevelGoSubdirsSkipsHidden(t *testing.T) {
|
|
45
|
+
dir := writeFixture(t, map[string]string{
|
|
46
|
+
"a/file.go": "package a",
|
|
47
|
+
".hidden/x.go": "package x",
|
|
48
|
+
"vendor/foo.go": "package foo",
|
|
49
|
+
"b/file.go": "package b",
|
|
50
|
+
})
|
|
51
|
+
got := topLevelGoSubdirs(dir)
|
|
52
|
+
sort.Strings(got)
|
|
53
|
+
want := []string{"a", "b"}
|
|
54
|
+
if len(got) != len(want) {
|
|
55
|
+
t.Fatalf("got %v, want %v", got, want)
|
|
56
|
+
}
|
|
57
|
+
for i := range got {
|
|
58
|
+
if got[i] != want[i] {
|
|
59
|
+
t.Fatalf("index %d: got %q want %q", i, got[i], want[i])
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── helpers ──
|
|
65
|
+
|
|
66
|
+
func writeFixture(t *testing.T, files map[string]string) string {
|
|
67
|
+
t.Helper()
|
|
68
|
+
dir := t.TempDir()
|
|
69
|
+
for name, body := range files {
|
|
70
|
+
path := filepath.Join(dir, name)
|
|
71
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return dir
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func pkgNames(pkgs []*packages.Package) []string {
|
|
82
|
+
out := make([]string, 0, len(pkgs))
|
|
83
|
+
for _, p := range pkgs {
|
|
84
|
+
out = append(out, p.Name)
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func pkgListContainsName(pkgs []*packages.Package, name string) bool {
|
|
90
|
+
for _, p := range pkgs {
|
|
91
|
+
if p.Name == name {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|