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