@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,159 @@
1
+ package symbols
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
10
+ )
11
+
12
+ func TestExtract(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
16
+
17
+ // Top-level function (exported)
18
+ func Hello() string { return "hi" }
19
+
20
+ // Unexported function — should still surface (no exported-only filter)
21
+ func helper() {}
22
+
23
+ // Variables and constants
24
+ var Counter = 0
25
+ const Tag = "release"
26
+
27
+ // Blank identifier — must be skipped
28
+ var _ = "ignore"
29
+ `,
30
+ "types.go": `package fixture
31
+
32
+ type User struct {
33
+ Name string
34
+ }
35
+
36
+ type Greeter interface {
37
+ Greet() string
38
+ }
39
+
40
+ type ID = string
41
+
42
+ // Method with pointer receiver
43
+ func (u *User) Greet() string { return "hi " + u.Name }
44
+
45
+ // Method with value receiver
46
+ func (u User) ID() string { return u.Name }
47
+ `,
48
+ })
49
+
50
+ res := load.Packages(dir)
51
+ if res.FatalError != nil {
52
+ t.Fatalf("Packages: %v", res.FatalError)
53
+ }
54
+ if len(res.Packages) == 0 {
55
+ t.Fatal("no packages loaded")
56
+ }
57
+
58
+ all := map[string]string{} // name -> kind
59
+ files := map[string]bool{}
60
+ for _, pkg := range res.Packages {
61
+ for _, fs := range Extract(pkg, dir) {
62
+ files[fs.RelPath] = true
63
+ for _, s := range fs.Symbols {
64
+ all[s.Name] = s.Kind
65
+ if s.StartLine == 0 {
66
+ t.Errorf("symbol %s missing StartLine", s.Name)
67
+ }
68
+ if s.StartOffset == nil || s.EndOffset == nil {
69
+ t.Errorf("symbol %s missing offsets", s.Name)
70
+ }
71
+ wantID := SymbolID(fs.RelPath, s.Name, s.Kind)
72
+ if s.SymbolID != wantID {
73
+ t.Errorf("symbol %s id=%q want %q", s.Name, s.SymbolID, wantID)
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ expected := map[string]string{
80
+ "Hello": "function",
81
+ "helper": "function",
82
+ "Counter": "variable",
83
+ "Tag": "variable",
84
+ "User": "class",
85
+ "Greeter": "interface",
86
+ "ID": "type",
87
+ // Methods qualified with receiver
88
+ "User.Greet": "method",
89
+ "User.ID": "method",
90
+ }
91
+ for name, kind := range expected {
92
+ got, ok := all[name]
93
+ if !ok {
94
+ t.Errorf("missing symbol %s", name)
95
+ continue
96
+ }
97
+ if got != kind {
98
+ t.Errorf("symbol %s kind=%s want %s", name, got, kind)
99
+ }
100
+ }
101
+
102
+ if _, ok := all["_"]; ok {
103
+ t.Error("blank identifier should be skipped")
104
+ }
105
+
106
+ for f := range files {
107
+ if !strings.HasSuffix(f, ".go") {
108
+ t.Errorf("relative path not normalized: %s", f)
109
+ }
110
+ if strings.Contains(f, "\\") {
111
+ t.Errorf("relative path uses backslash: %s", f)
112
+ }
113
+ }
114
+ }
115
+
116
+ func TestReceiverTypeName(t *testing.T) {
117
+ dir := writeFixture(t, map[string]string{
118
+ "go.mod": "module fixture\n\ngo 1.22\n",
119
+ "a.go": `package fixture
120
+
121
+ type Box[T any] struct{ v T }
122
+
123
+ func (b *Box[T]) Get() T { return b.v }
124
+ `,
125
+ })
126
+ res := load.Packages(dir)
127
+ if res.FatalError != nil {
128
+ t.Fatal(res.FatalError)
129
+ }
130
+
131
+ found := false
132
+ for _, pkg := range res.Packages {
133
+ for _, fs := range Extract(pkg, dir) {
134
+ for _, s := range fs.Symbols {
135
+ if s.Name == "Box.Get" && s.Kind == "method" {
136
+ found = true
137
+ }
138
+ }
139
+ }
140
+ }
141
+ if !found {
142
+ t.Error("expected method Box.Get with generic receiver")
143
+ }
144
+ }
145
+
146
+ func writeFixture(t *testing.T, files map[string]string) string {
147
+ t.Helper()
148
+ dir := t.TempDir()
149
+ for name, body := range files {
150
+ path := filepath.Join(dir, name)
151
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ }
158
+ return dir
159
+ }
@@ -0,0 +1,183 @@
1
+ // ctxo-go-analyzer is the full-tier Go analysis binary bundled inside the
2
+ // @ctxo/lang-go npm package. It loads a module via golang.org/x/tools/go/packages,
3
+ // walks ASTs with full type info, and emits JSONL describing symbols, edges,
4
+ // and reachability on stdout.
5
+ //
6
+ // The TypeScript composite adapter in packages/lang-go/src/analyzer/ spawns
7
+ // this binary in batch mode per index run.
8
+ package main
9
+
10
+ import (
11
+ "flag"
12
+ "fmt"
13
+ "go/ast"
14
+ "os"
15
+ "path/filepath"
16
+ "sort"
17
+ "strings"
18
+ "time"
19
+
20
+ "golang.org/x/tools/go/packages"
21
+
22
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/edges"
23
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/emit"
24
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/extends"
25
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/implements"
26
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
27
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/reach"
28
+ "github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/symbols"
29
+ )
30
+
31
+ const reachDeadline = 60 * time.Second
32
+
33
+ const version = "0.8.0-alpha.0"
34
+
35
+ func main() {
36
+ root := flag.String("root", "", "module root to analyze (required)")
37
+ showVersion := flag.Bool("version", false, "print version and exit")
38
+ flag.Parse()
39
+
40
+ if *showVersion {
41
+ fmt.Println(version)
42
+ return
43
+ }
44
+
45
+ if *root == "" {
46
+ fmt.Fprintln(os.Stderr, "ctxo-go-analyzer: --root is required")
47
+ os.Exit(2)
48
+ }
49
+
50
+ if err := run(*root, os.Stdout); err != nil {
51
+ fmt.Fprintln(os.Stderr, "ctxo-go-analyzer:", err)
52
+ os.Exit(1)
53
+ }
54
+ }
55
+
56
+ type fileAggregate struct {
57
+ symbols []emit.Symbol
58
+ edges []emit.Edge
59
+ }
60
+
61
+ func run(root string, stdout *os.File) error {
62
+ w := emit.NewWriter(stdout)
63
+ start := time.Now()
64
+
65
+ if err := w.Progress(fmt.Sprintf("analyzer v%s loading %s", version, root)); err != nil {
66
+ return err
67
+ }
68
+
69
+ res := load.PackagesWithFallback(root)
70
+ if res.FatalError != nil {
71
+ _ = w.Progress(fmt.Sprintf("module load returned a fatal error (degrading): %v", res.FatalError))
72
+ }
73
+ if res.FallbackUsed {
74
+ _ = w.Progress(fmt.Sprintf("root pattern failed; per-subdir fallback recovered %d packages", len(res.Packages)))
75
+ }
76
+ if len(res.Errors) > 0 {
77
+ _ = w.Progress(fmt.Sprintf("load reported %d package-level errors (continuing)", len(res.Errors)))
78
+ }
79
+
80
+ byFile := make(map[string]*fileAggregate)
81
+ get := func(rel string) *fileAggregate {
82
+ if agg, ok := byFile[rel]; ok {
83
+ return agg
84
+ }
85
+ agg := &fileAggregate{}
86
+ byFile[rel] = agg
87
+ return agg
88
+ }
89
+
90
+ for _, pkg := range res.Packages {
91
+ for _, fs := range symbols.Extract(pkg, root) {
92
+ get(fs.RelPath).symbols = append(get(fs.RelPath).symbols, fs.Symbols...)
93
+ }
94
+ }
95
+
96
+ ext := edges.NewExtractor(root, res.Packages)
97
+ for _, pkg := range res.Packages {
98
+ for _, file := range pkg.Syntax {
99
+ rel := relativeFile(pkg, file, root)
100
+ if rel == "" {
101
+ continue
102
+ }
103
+ get(rel).edges = append(get(rel).edges, ext.Extract(pkg, file)...)
104
+ }
105
+ }
106
+
107
+ for rel, implEdges := range implements.Extract(root, res.Packages) {
108
+ get(rel).edges = append(get(rel).edges, implEdges...)
109
+ }
110
+
111
+ for rel, extEdges := range extends.Extract(root, res.Packages) {
112
+ get(rel).edges = append(get(rel).edges, extEdges...)
113
+ }
114
+
115
+ paths := make([]string, 0, len(byFile))
116
+ for p := range byFile {
117
+ paths = append(paths, p)
118
+ }
119
+ sort.Strings(paths)
120
+ for _, p := range paths {
121
+ agg := byFile[p]
122
+ if err := w.File(emit.FileRecord{
123
+ File: p,
124
+ Symbols: agg.symbols,
125
+ Edges: agg.edges,
126
+ }); err != nil {
127
+ return err
128
+ }
129
+ }
130
+
131
+ reachRes := reach.Analyze(root, res.Packages, reachDeadline)
132
+ deadIDs := collectDead(byFile, reachRes.Reachable)
133
+ if err := w.Dead(emit.Dead{
134
+ SymbolIDs: deadIDs,
135
+ HasMain: reachRes.HasMain,
136
+ Timeout: reachRes.Timeout,
137
+ }); err != nil {
138
+ return err
139
+ }
140
+ if reachRes.Timeout {
141
+ _ = w.Progress("reachability analysis exceeded deadline; dead-code precision degraded")
142
+ }
143
+
144
+ return w.Summary(emit.Summary{
145
+ TotalFiles: len(paths),
146
+ Elapsed: time.Since(start).String(),
147
+ })
148
+ }
149
+
150
+ // collectDead walks every function/method symbol across the module and
151
+ // returns the subset not present in the reachable set. Non-callable kinds
152
+ // (type/variable/class/interface) are excluded — CHA gives us liveness for
153
+ // callable symbols only.
154
+ func collectDead(byFile map[string]*fileAggregate, reachable map[string]bool) []string {
155
+ var dead []string
156
+ for _, agg := range byFile {
157
+ for _, s := range agg.symbols {
158
+ if s.Kind != "function" && s.Kind != "method" {
159
+ continue
160
+ }
161
+ if !reachable[s.SymbolID] {
162
+ dead = append(dead, s.SymbolID)
163
+ }
164
+ }
165
+ }
166
+ sort.Strings(dead)
167
+ return dead
168
+ }
169
+
170
+ func relativeFile(pkg *packages.Package, file *ast.File, root string) string {
171
+ if pkg.Fset == nil {
172
+ return ""
173
+ }
174
+ tf := pkg.Fset.File(file.Pos())
175
+ if tf == nil {
176
+ return ""
177
+ }
178
+ rel, err := filepath.Rel(root, tf.Name())
179
+ if err != nil || strings.HasPrefix(rel, "..") {
180
+ return ""
181
+ }
182
+ return filepath.ToSlash(rel)
183
+ }