@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.
@@ -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
+ }