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