@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,180 @@
|
|
|
1
|
+
package edges
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/emit"
|
|
9
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestExtractCallsAndUses(t *testing.T) {
|
|
13
|
+
dir := writeFixture(t, map[string]string{
|
|
14
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
15
|
+
"main.go": `package main
|
|
16
|
+
|
|
17
|
+
type User struct{ Name string }
|
|
18
|
+
|
|
19
|
+
func (u *User) Greet() string { return "hi " + u.Name }
|
|
20
|
+
|
|
21
|
+
func greet(u *User) string { return u.Greet() }
|
|
22
|
+
|
|
23
|
+
func main() {
|
|
24
|
+
u := &User{Name: "x"}
|
|
25
|
+
_ = greet(u)
|
|
26
|
+
}
|
|
27
|
+
`,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
edges := extractAllEdges(t, dir)
|
|
31
|
+
|
|
32
|
+
want := map[string]bool{
|
|
33
|
+
// greet uses *User in its parameter list
|
|
34
|
+
"main.go::greet::function -> main.go::User::class (uses)": true,
|
|
35
|
+
// greet calls User.Greet via u.Greet()
|
|
36
|
+
"main.go::greet::function -> main.go::User.Greet::method (calls)": true,
|
|
37
|
+
// main calls greet
|
|
38
|
+
"main.go::main::function -> main.go::greet::function (calls)": true,
|
|
39
|
+
// main uses User via composite literal &User{}
|
|
40
|
+
"main.go::main::function -> main.go::User::class (uses)": true,
|
|
41
|
+
}
|
|
42
|
+
for key := range want {
|
|
43
|
+
if !edges[key] {
|
|
44
|
+
t.Errorf("missing edge: %s\n got: %v", key, sortedKeys(edges))
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestCrossPackageCalls(t *testing.T) {
|
|
51
|
+
dir := writeFixture(t, map[string]string{
|
|
52
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
53
|
+
"pkg/helper/helper.go": `package helper
|
|
54
|
+
|
|
55
|
+
func Do() int { return 42 }
|
|
56
|
+
`,
|
|
57
|
+
"main.go": `package main
|
|
58
|
+
|
|
59
|
+
import "fixture/pkg/helper"
|
|
60
|
+
|
|
61
|
+
func main() { _ = helper.Do() }
|
|
62
|
+
`,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
edges := extractAllEdges(t, dir)
|
|
66
|
+
|
|
67
|
+
key := "main.go::main::function -> pkg/helper/helper.go::Do::function (calls)"
|
|
68
|
+
if !edges[key] {
|
|
69
|
+
t.Errorf("missing cross-package call edge\n got: %v", sortedKeys(edges))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestGenericCallCarriesTypeArgs(t *testing.T) {
|
|
74
|
+
dir := writeFixture(t, map[string]string{
|
|
75
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
76
|
+
"main.go": `package main
|
|
77
|
+
|
|
78
|
+
func ID[T any](v T) T { return v }
|
|
79
|
+
|
|
80
|
+
func main() { _ = ID[int](1) }
|
|
81
|
+
`,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
res := load.Packages(dir)
|
|
85
|
+
if res.FatalError != nil {
|
|
86
|
+
t.Fatal(res.FatalError)
|
|
87
|
+
}
|
|
88
|
+
ext := NewExtractor(dir, res.Packages)
|
|
89
|
+
|
|
90
|
+
var found bool
|
|
91
|
+
for _, pkg := range res.Packages {
|
|
92
|
+
for _, file := range pkg.Syntax {
|
|
93
|
+
for _, edge := range ext.Extract(pkg, file) {
|
|
94
|
+
if edge.Kind == "calls" && edge.To == "main.go::ID::function" {
|
|
95
|
+
if len(edge.TypeArgs) != 1 || edge.TypeArgs[0] != "int" {
|
|
96
|
+
t.Errorf("typeArgs = %v, want [int]", edge.TypeArgs)
|
|
97
|
+
}
|
|
98
|
+
found = true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if !found {
|
|
104
|
+
t.Error("no calls edge to ID function found")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestNoEdgesToStdlib(t *testing.T) {
|
|
109
|
+
dir := writeFixture(t, map[string]string{
|
|
110
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
111
|
+
"main.go": `package main
|
|
112
|
+
|
|
113
|
+
import "strings"
|
|
114
|
+
|
|
115
|
+
func main() { _ = strings.ToUpper("hi") }
|
|
116
|
+
`,
|
|
117
|
+
})
|
|
118
|
+
edges := extractAllEdges(t, dir)
|
|
119
|
+
for key := range edges {
|
|
120
|
+
if containsStdlib(key) {
|
|
121
|
+
t.Errorf("edge leaks stdlib target: %s", key)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── helpers ──
|
|
127
|
+
|
|
128
|
+
func extractAllEdges(t *testing.T, dir string) map[string]bool {
|
|
129
|
+
t.Helper()
|
|
130
|
+
res := load.Packages(dir)
|
|
131
|
+
if res.FatalError != nil {
|
|
132
|
+
t.Fatal(res.FatalError)
|
|
133
|
+
}
|
|
134
|
+
ext := NewExtractor(dir, res.Packages)
|
|
135
|
+
out := map[string]bool{}
|
|
136
|
+
for _, pkg := range res.Packages {
|
|
137
|
+
for _, file := range pkg.Syntax {
|
|
138
|
+
for _, edge := range ext.Extract(pkg, file) {
|
|
139
|
+
out[formatEdge(edge)] = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func formatEdge(e emit.Edge) string {
|
|
147
|
+
return e.From + " -> " + e.To + " (" + e.Kind + ")"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func sortedKeys(m map[string]bool) []string {
|
|
151
|
+
keys := make([]string, 0, len(m))
|
|
152
|
+
for k := range m {
|
|
153
|
+
keys = append(keys, k)
|
|
154
|
+
}
|
|
155
|
+
return keys
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func containsStdlib(key string) bool {
|
|
159
|
+
for _, prefix := range []string{"strings.", "fmt.", "errors.", "runtime."} {
|
|
160
|
+
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func writeFixture(t *testing.T, files map[string]string) string {
|
|
168
|
+
t.Helper()
|
|
169
|
+
dir := t.TempDir()
|
|
170
|
+
for name, body := range files {
|
|
171
|
+
path := filepath.Join(dir, name)
|
|
172
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
173
|
+
t.Fatal(err)
|
|
174
|
+
}
|
|
175
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
176
|
+
t.Fatal(err)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return dir
|
|
180
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Package emit writes JSONL records to an io.Writer. The schema mirrors
|
|
2
|
+
// @ctxo/lang-csharp's RoslynBatchResult shape so the TypeScript composite
|
|
3
|
+
// adapter can consume Go and C# analyzer output through a shared parser.
|
|
4
|
+
package emit
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"bufio"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"sync"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Symbol is the wire shape for a declared symbol. Matches SymbolNode in
|
|
15
|
+
// @ctxo/plugin-api with optional byte offsets.
|
|
16
|
+
type Symbol struct {
|
|
17
|
+
SymbolID string `json:"symbolId"`
|
|
18
|
+
Name string `json:"name"`
|
|
19
|
+
Kind string `json:"kind"`
|
|
20
|
+
StartLine int `json:"startLine"`
|
|
21
|
+
EndLine int `json:"endLine"`
|
|
22
|
+
StartOffset *int `json:"startOffset,omitempty"`
|
|
23
|
+
EndOffset *int `json:"endOffset,omitempty"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Edge is the wire shape for a dependency relationship. Kind is one of
|
|
27
|
+
// imports | calls | extends | implements | uses. TypeArgs preserves generic
|
|
28
|
+
// instantiation metadata when Kind == "uses".
|
|
29
|
+
type Edge struct {
|
|
30
|
+
From string `json:"from"`
|
|
31
|
+
To string `json:"to"`
|
|
32
|
+
Kind string `json:"kind"`
|
|
33
|
+
TypeArgs []string `json:"typeArgs,omitempty"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Complexity is the wire shape for per-symbol metrics. Always empty from the
|
|
37
|
+
// Go binary; the tree-sitter layer fills it in.
|
|
38
|
+
type Complexity struct {
|
|
39
|
+
SymbolID string `json:"symbolId"`
|
|
40
|
+
Cyclomatic int `json:"cyclomatic"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// FileRecord is emitted once per source file analyzed.
|
|
44
|
+
type FileRecord struct {
|
|
45
|
+
Type string `json:"type"`
|
|
46
|
+
File string `json:"file"`
|
|
47
|
+
Symbols []Symbol `json:"symbols"`
|
|
48
|
+
Edges []Edge `json:"edges"`
|
|
49
|
+
Complexity []Complexity `json:"complexity"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ProjectRecord is emitted once per run to describe module-level edges.
|
|
53
|
+
type ProjectRecord struct {
|
|
54
|
+
Type string `json:"type"`
|
|
55
|
+
Projects []ProjectEntry `json:"projects"`
|
|
56
|
+
Edges []ProjectEdge `json:"edges"`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ProjectEntry struct {
|
|
60
|
+
Name string `json:"name"`
|
|
61
|
+
Path string `json:"path"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ProjectEdge struct {
|
|
65
|
+
From string `json:"from"`
|
|
66
|
+
To string `json:"to"`
|
|
67
|
+
Kind string `json:"kind"`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Summary is the final record emitted when analysis completes.
|
|
71
|
+
type Summary struct {
|
|
72
|
+
Type string `json:"type"`
|
|
73
|
+
TotalFiles int `json:"totalFiles"`
|
|
74
|
+
Elapsed string `json:"elapsed"`
|
|
75
|
+
Hint string `json:"hint,omitempty"`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Progress is an optional informational record. Consumed by the TS parser
|
|
79
|
+
// for log-forwarding only; never affects extracted data.
|
|
80
|
+
type Progress struct {
|
|
81
|
+
Type string `json:"type"`
|
|
82
|
+
Message string `json:"message"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Dead is emitted once per run, listing function/method symbol ids that are
|
|
86
|
+
// not reachable from the module's entry points. HasMain=false means library
|
|
87
|
+
// mode (reachability approximated via exported API). Timeout=true means the
|
|
88
|
+
// reach analysis exceeded its deadline and dead-code precision is degraded.
|
|
89
|
+
type Dead struct {
|
|
90
|
+
Type string `json:"type"`
|
|
91
|
+
SymbolIDs []string `json:"symbolIds"`
|
|
92
|
+
HasMain bool `json:"hasMain"`
|
|
93
|
+
Timeout bool `json:"timeout,omitempty"`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Writer serializes records to JSONL. Writes are serialized internally so a
|
|
97
|
+
// single Writer is safe to share across goroutines.
|
|
98
|
+
type Writer struct {
|
|
99
|
+
mu sync.Mutex
|
|
100
|
+
bw *bufio.Writer
|
|
101
|
+
err error
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// NewWriter wraps an io.Writer with JSONL framing.
|
|
105
|
+
func NewWriter(w io.Writer) *Writer {
|
|
106
|
+
return &Writer{bw: bufio.NewWriter(w)}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// File emits a file-scoped record.
|
|
110
|
+
func (w *Writer) File(rec FileRecord) error {
|
|
111
|
+
rec.Type = "file"
|
|
112
|
+
if rec.Symbols == nil {
|
|
113
|
+
rec.Symbols = []Symbol{}
|
|
114
|
+
}
|
|
115
|
+
if rec.Edges == nil {
|
|
116
|
+
rec.Edges = []Edge{}
|
|
117
|
+
}
|
|
118
|
+
if rec.Complexity == nil {
|
|
119
|
+
rec.Complexity = []Complexity{}
|
|
120
|
+
}
|
|
121
|
+
return w.writeJSON(rec)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Project emits the project-graph record.
|
|
125
|
+
func (w *Writer) Project(rec ProjectRecord) error {
|
|
126
|
+
rec.Type = "projectGraph"
|
|
127
|
+
if rec.Projects == nil {
|
|
128
|
+
rec.Projects = []ProjectEntry{}
|
|
129
|
+
}
|
|
130
|
+
if rec.Edges == nil {
|
|
131
|
+
rec.Edges = []ProjectEdge{}
|
|
132
|
+
}
|
|
133
|
+
return w.writeJSON(rec)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Dead emits the unreachable-symbols record.
|
|
137
|
+
func (w *Writer) Dead(rec Dead) error {
|
|
138
|
+
rec.Type = "dead"
|
|
139
|
+
if rec.SymbolIDs == nil {
|
|
140
|
+
rec.SymbolIDs = []string{}
|
|
141
|
+
}
|
|
142
|
+
return w.writeJSON(rec)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Summary emits the terminal summary record and flushes.
|
|
146
|
+
func (w *Writer) Summary(rec Summary) error {
|
|
147
|
+
rec.Type = "summary"
|
|
148
|
+
if err := w.writeJSON(rec); err != nil {
|
|
149
|
+
return err
|
|
150
|
+
}
|
|
151
|
+
return w.Flush()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Progress emits an informational message.
|
|
155
|
+
func (w *Writer) Progress(message string) error {
|
|
156
|
+
return w.writeJSON(Progress{Type: "progress", Message: message})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Flush flushes the underlying writer.
|
|
160
|
+
func (w *Writer) Flush() error {
|
|
161
|
+
w.mu.Lock()
|
|
162
|
+
defer w.mu.Unlock()
|
|
163
|
+
if w.err != nil {
|
|
164
|
+
return w.err
|
|
165
|
+
}
|
|
166
|
+
return w.bw.Flush()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func (w *Writer) writeJSON(v any) error {
|
|
170
|
+
w.mu.Lock()
|
|
171
|
+
defer w.mu.Unlock()
|
|
172
|
+
if w.err != nil {
|
|
173
|
+
return w.err
|
|
174
|
+
}
|
|
175
|
+
buf, err := json.Marshal(v)
|
|
176
|
+
if err != nil {
|
|
177
|
+
w.err = fmt.Errorf("emit: marshal: %w", err)
|
|
178
|
+
return w.err
|
|
179
|
+
}
|
|
180
|
+
if _, err := w.bw.Write(buf); err != nil {
|
|
181
|
+
w.err = err
|
|
182
|
+
return err
|
|
183
|
+
}
|
|
184
|
+
if err := w.bw.WriteByte('\n'); err != nil {
|
|
185
|
+
w.err = err
|
|
186
|
+
return err
|
|
187
|
+
}
|
|
188
|
+
return nil
|
|
189
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package emit
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestWriterEmitsJSONLWithTypeTag(t *testing.T) {
|
|
11
|
+
var buf bytes.Buffer
|
|
12
|
+
w := NewWriter(&buf)
|
|
13
|
+
|
|
14
|
+
if err := w.Progress("hello"); err != nil {
|
|
15
|
+
t.Fatalf("Progress: %v", err)
|
|
16
|
+
}
|
|
17
|
+
if err := w.File(FileRecord{File: "pkg/foo.go"}); err != nil {
|
|
18
|
+
t.Fatalf("File: %v", err)
|
|
19
|
+
}
|
|
20
|
+
if err := w.Project(ProjectRecord{}); err != nil {
|
|
21
|
+
t.Fatalf("Project: %v", err)
|
|
22
|
+
}
|
|
23
|
+
if err := w.Summary(Summary{TotalFiles: 1, Elapsed: "1ms"}); err != nil {
|
|
24
|
+
t.Fatalf("Summary: %v", err)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
28
|
+
if len(lines) != 4 {
|
|
29
|
+
t.Fatalf("got %d lines, want 4: %q", len(lines), buf.String())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
wantTypes := []string{"progress", "file", "projectGraph", "summary"}
|
|
33
|
+
for i, line := range lines {
|
|
34
|
+
var parsed map[string]any
|
|
35
|
+
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
|
36
|
+
t.Fatalf("line %d not JSON: %v (%q)", i, err, line)
|
|
37
|
+
}
|
|
38
|
+
if parsed["type"] != wantTypes[i] {
|
|
39
|
+
t.Errorf("line %d type=%v want %s", i, parsed["type"], wantTypes[i])
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestFileRecordDefaultsEmptySlices(t *testing.T) {
|
|
45
|
+
var buf bytes.Buffer
|
|
46
|
+
w := NewWriter(&buf)
|
|
47
|
+
if err := w.File(FileRecord{File: "x.go"}); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
_ = w.Flush()
|
|
51
|
+
|
|
52
|
+
var parsed map[string]any
|
|
53
|
+
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &parsed); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
for _, key := range []string{"symbols", "edges", "complexity"} {
|
|
57
|
+
arr, ok := parsed[key].([]any)
|
|
58
|
+
if !ok {
|
|
59
|
+
t.Errorf("%s not a JSON array: %T", key, parsed[key])
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if len(arr) != 0 {
|
|
63
|
+
t.Errorf("%s got %d items, want 0", key, len(arr))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestEdgeTypeArgsOmitEmpty(t *testing.T) {
|
|
69
|
+
b, err := json.Marshal(Edge{From: "a", To: "b", Kind: "calls"})
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
if strings.Contains(string(b), "typeArgs") {
|
|
74
|
+
t.Errorf("expected typeArgs omitted, got %s", b)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
b2, err := json.Marshal(Edge{From: "a", To: "b", Kind: "uses", TypeArgs: []string{"int"}})
|
|
78
|
+
if err != nil {
|
|
79
|
+
t.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
if !strings.Contains(string(b2), `"typeArgs":["int"]`) {
|
|
82
|
+
t.Errorf("expected typeArgs present, got %s", b2)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Package extends derives `extends` edges for struct-field embedding and
|
|
2
|
+
// interface-embedding — Go's closest analog to inheritance. A struct with
|
|
3
|
+
// an anonymous field of type T promotes T's methods; an interface embedding
|
|
4
|
+
// another interface inherits its method set.
|
|
5
|
+
package extends
|
|
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 `extends` edges grouped by the source file of the
|
|
20
|
+
// embedding type (the child). External embeddings (stdlib types promoted
|
|
21
|
+
// into a module-local struct) are skipped — we only care about intra-module
|
|
22
|
+
// inheritance chains for graph traversal.
|
|
23
|
+
func Extract(rootDir string, pkgs []*packages.Package) map[string][]emit.Edge {
|
|
24
|
+
type entry struct {
|
|
25
|
+
named *types.Named
|
|
26
|
+
file string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var named []entry
|
|
30
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
31
|
+
if p.Types == nil || p.Fset == nil {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
scope := p.Types.Scope()
|
|
35
|
+
for _, n := range scope.Names() {
|
|
36
|
+
if n == "_" {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
obj := scope.Lookup(n)
|
|
40
|
+
tn, ok := obj.(*types.TypeName)
|
|
41
|
+
if !ok {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
t, ok := tn.Type().(*types.Named)
|
|
45
|
+
if !ok {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if t.TypeParams() != nil && t.TypeParams().Len() > 0 {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
rel := relFile(p.Fset, obj.Pos(), rootDir)
|
|
52
|
+
if rel == "" {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
named = append(named, entry{named: t, file: rel})
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Build a lookup so we can resolve embedded-type references to a file path
|
|
60
|
+
// and kind without re-traversing packages.
|
|
61
|
+
typeIndex := make(map[*types.TypeName]entry)
|
|
62
|
+
for _, e := range named {
|
|
63
|
+
typeIndex[e.named.Obj()] = e
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
out := map[string][]emit.Edge{}
|
|
67
|
+
add := func(from entry, target *types.Named, kind string) {
|
|
68
|
+
tgt, ok := typeIndex[target.Obj()]
|
|
69
|
+
if !ok {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
fromID := symbols.SymbolID(from.file, from.named.Obj().Name(), kind)
|
|
73
|
+
toID := symbols.SymbolID(tgt.file, tgt.named.Obj().Name(), kindOf(tgt.named))
|
|
74
|
+
if fromID == toID {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
out[from.file] = append(out[from.file], emit.Edge{
|
|
78
|
+
From: fromID,
|
|
79
|
+
To: toID,
|
|
80
|
+
Kind: "extends",
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for _, e := range named {
|
|
85
|
+
switch under := e.named.Underlying().(type) {
|
|
86
|
+
case *types.Struct:
|
|
87
|
+
for i := 0; i < under.NumFields(); i++ {
|
|
88
|
+
f := under.Field(i)
|
|
89
|
+
if !f.Embedded() {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if target := asNamed(f.Type()); target != nil {
|
|
93
|
+
add(e, target, "class")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
case *types.Interface:
|
|
97
|
+
for i := 0; i < under.NumEmbeddeds(); i++ {
|
|
98
|
+
if target := asNamed(under.EmbeddedType(i)); target != nil {
|
|
99
|
+
add(e, target, "interface")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func asNamed(t types.Type) *types.Named {
|
|
108
|
+
if p, ok := t.(*types.Pointer); ok {
|
|
109
|
+
t = p.Elem()
|
|
110
|
+
}
|
|
111
|
+
if n, ok := t.(*types.Named); ok {
|
|
112
|
+
return n
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func kindOf(n *types.Named) string {
|
|
118
|
+
switch n.Underlying().(type) {
|
|
119
|
+
case *types.Struct:
|
|
120
|
+
return "class"
|
|
121
|
+
case *types.Interface:
|
|
122
|
+
return "interface"
|
|
123
|
+
default:
|
|
124
|
+
return "type"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func relFile(fset *token.FileSet, pos token.Pos, rootDir string) string {
|
|
129
|
+
p := fset.Position(pos)
|
|
130
|
+
if !p.IsValid() {
|
|
131
|
+
return ""
|
|
132
|
+
}
|
|
133
|
+
rel, err := filepath.Rel(rootDir, p.Filename)
|
|
134
|
+
if err != nil || strings.HasPrefix(rel, "..") {
|
|
135
|
+
return ""
|
|
136
|
+
}
|
|
137
|
+
return filepath.ToSlash(rel)
|
|
138
|
+
}
|