@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,332 @@
|
|
|
1
|
+
// Package reach builds an SSA program, computes a Class Hierarchy Analysis
|
|
2
|
+
// (CHA) callgraph, and returns the set of function/method symbols reachable
|
|
3
|
+
// from a set of roots (main.main + init if binary; all exported API if
|
|
4
|
+
// library).
|
|
5
|
+
//
|
|
6
|
+
// A reflect-safe post-pass grafts back methods on types that appear as
|
|
7
|
+
// arguments to reflect.TypeOf / reflect.ValueOf / reflect.New or
|
|
8
|
+
// json.Marshal/Unmarshal — the most common source of false-positive dead
|
|
9
|
+
// code in Go codebases.
|
|
10
|
+
//
|
|
11
|
+
// Design note: ADR-013 originally specified RTA, but RTA panics on generic
|
|
12
|
+
// code (golang/go issue). CHA is conservative (over-approximates reachability
|
|
13
|
+
// through interface dispatch) which is preferable for dead-code detection —
|
|
14
|
+
// it reduces false positives at the cost of slightly less precise liveness.
|
|
15
|
+
// Analysis runs with a deadline per ADR-013 §4 Q3.
|
|
16
|
+
package reach
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"go/ast"
|
|
20
|
+
"go/token"
|
|
21
|
+
"go/types"
|
|
22
|
+
"path/filepath"
|
|
23
|
+
"strings"
|
|
24
|
+
"time"
|
|
25
|
+
|
|
26
|
+
"golang.org/x/tools/go/callgraph"
|
|
27
|
+
"golang.org/x/tools/go/callgraph/cha"
|
|
28
|
+
"golang.org/x/tools/go/packages"
|
|
29
|
+
"golang.org/x/tools/go/ssa"
|
|
30
|
+
"golang.org/x/tools/go/ssa/ssautil"
|
|
31
|
+
|
|
32
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/symbols"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// Result is the reachability snapshot. Reachable holds symbol IDs that are
|
|
36
|
+
// transitively callable from the chosen roots. HasMain distinguishes binary
|
|
37
|
+
// mode (precise) from library mode (approximated via exported API).
|
|
38
|
+
type Result struct {
|
|
39
|
+
Reachable map[string]bool
|
|
40
|
+
HasMain bool
|
|
41
|
+
Timeout bool
|
|
42
|
+
Elapsed time.Duration
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Analyze runs SSA + RTA against the loaded package set. deadline bounds the
|
|
46
|
+
// RTA phase specifically (SSA build is always completed — it is cheap).
|
|
47
|
+
func Analyze(rootDir string, pkgs []*packages.Package, deadline time.Duration) *Result {
|
|
48
|
+
start := time.Now()
|
|
49
|
+
|
|
50
|
+
prog, _ := ssautil.AllPackages(pkgs, ssa.BuilderMode(0))
|
|
51
|
+
prog.Build()
|
|
52
|
+
|
|
53
|
+
pkgByTypes := make(map[*types.Package]*packages.Package)
|
|
54
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
55
|
+
if p.Types != nil {
|
|
56
|
+
pkgByTypes[p.Types] = p
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
roots, hasMain := collectRoots(prog, pkgs, rootDir)
|
|
61
|
+
|
|
62
|
+
result := &Result{Reachable: map[string]bool{}, HasMain: hasMain}
|
|
63
|
+
|
|
64
|
+
if len(roots) > 0 {
|
|
65
|
+
done := make(chan *callgraph.Graph, 1)
|
|
66
|
+
go func() {
|
|
67
|
+
// cha.CallGraph is synchronous and not cancellable — on timeout the
|
|
68
|
+
// goroutine leaks but the process exits shortly after.
|
|
69
|
+
done <- cha.CallGraph(prog)
|
|
70
|
+
}()
|
|
71
|
+
|
|
72
|
+
select {
|
|
73
|
+
case cg := <-done:
|
|
74
|
+
for fn := range reachableFromGraph(cg, roots) {
|
|
75
|
+
if id, ok := ssaFuncSymbolID(fn, rootDir); ok {
|
|
76
|
+
result.Reachable[id] = true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
case <-time.After(deadline):
|
|
80
|
+
result.Timeout = true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
applyReflectSafe(pkgs, pkgByTypes, rootDir, result.Reachable)
|
|
85
|
+
|
|
86
|
+
result.Elapsed = time.Since(start)
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// reachableFromGraph performs a BFS over the callgraph starting from the
|
|
91
|
+
// provided root functions and returns the set of reachable *ssa.Function.
|
|
92
|
+
func reachableFromGraph(cg *callgraph.Graph, roots []*ssa.Function) map[*ssa.Function]struct{} {
|
|
93
|
+
seen := make(map[*ssa.Function]struct{})
|
|
94
|
+
queue := make([]*ssa.Function, 0, len(roots))
|
|
95
|
+
for _, r := range roots {
|
|
96
|
+
if r != nil {
|
|
97
|
+
queue = append(queue, r)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for len(queue) > 0 {
|
|
101
|
+
fn := queue[0]
|
|
102
|
+
queue = queue[1:]
|
|
103
|
+
if _, ok := seen[fn]; ok {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
seen[fn] = struct{}{}
|
|
107
|
+
node := cg.Nodes[fn]
|
|
108
|
+
if node == nil {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
for _, edge := range node.Out {
|
|
112
|
+
if edge.Callee != nil && edge.Callee.Func != nil {
|
|
113
|
+
queue = append(queue, edge.Callee.Func)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return seen
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// collectRoots returns the CHA entry set. Binary packages contribute their
|
|
121
|
+
// main + init; when no main exists anywhere, every exported function/method
|
|
122
|
+
// in the module becomes a root (library approximation).
|
|
123
|
+
func collectRoots(prog *ssa.Program, pkgs []*packages.Package, rootDir string) ([]*ssa.Function, bool) {
|
|
124
|
+
var mains []*ssa.Function
|
|
125
|
+
var inits []*ssa.Function
|
|
126
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
127
|
+
sp := prog.Package(p.Types)
|
|
128
|
+
if sp == nil {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if p.Name == "main" {
|
|
132
|
+
if main := sp.Func("main"); main != nil {
|
|
133
|
+
mains = append(mains, main)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if init := sp.Func("init"); init != nil {
|
|
137
|
+
inits = append(inits, init)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if len(mains) > 0 {
|
|
142
|
+
return append(mains, inits...), true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Library mode — treat every module-local exported function/method as a root.
|
|
146
|
+
var roots []*ssa.Function
|
|
147
|
+
roots = append(roots, inits...)
|
|
148
|
+
packages.Visit(pkgs, nil, func(p *packages.Package) {
|
|
149
|
+
if !inModule(p, rootDir) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
sp := prog.Package(p.Types)
|
|
153
|
+
if sp == nil {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
for _, member := range sp.Members {
|
|
157
|
+
switch m := member.(type) {
|
|
158
|
+
case *ssa.Function:
|
|
159
|
+
if isExported(m.Name()) {
|
|
160
|
+
roots = append(roots, m)
|
|
161
|
+
}
|
|
162
|
+
case *ssa.Type:
|
|
163
|
+
// Methods on a named type live on the method set, not the member map.
|
|
164
|
+
mset := prog.MethodSets.MethodSet(m.Type())
|
|
165
|
+
for i := 0; i < mset.Len(); i++ {
|
|
166
|
+
fn := prog.MethodValue(mset.At(i))
|
|
167
|
+
if fn != nil && isExported(fn.Name()) {
|
|
168
|
+
roots = append(roots, fn)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Also pointer receiver methods.
|
|
172
|
+
ptrSet := prog.MethodSets.MethodSet(types.NewPointer(m.Type()))
|
|
173
|
+
for i := 0; i < ptrSet.Len(); i++ {
|
|
174
|
+
fn := prog.MethodValue(ptrSet.At(i))
|
|
175
|
+
if fn != nil && isExported(fn.Name()) {
|
|
176
|
+
roots = append(roots, fn)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
return roots, false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// applyReflectSafe grafts method symbols of reflect-accessed types back into
|
|
186
|
+
// the reachable set. Scope is conservative: reflect.TypeOf, reflect.ValueOf,
|
|
187
|
+
// reflect.New, and json.Unmarshal / json.NewDecoder arguments.
|
|
188
|
+
func applyReflectSafe(pkgs []*packages.Package, pkgByTypes map[*types.Package]*packages.Package, rootDir string, reachable map[string]bool) {
|
|
189
|
+
safe := make(map[*types.Named]struct{})
|
|
190
|
+
for _, p := range pkgs {
|
|
191
|
+
if p.TypesInfo == nil {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
for _, f := range p.Syntax {
|
|
195
|
+
ast.Inspect(f, func(n ast.Node) bool {
|
|
196
|
+
call, ok := n.(*ast.CallExpr)
|
|
197
|
+
if !ok {
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
if !isReflectOrJSONCall(call, p.TypesInfo) {
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
if len(call.Args) == 0 {
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
t := p.TypesInfo.TypeOf(call.Args[0])
|
|
207
|
+
if t == nil {
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if ptr, ok := t.(*types.Pointer); ok {
|
|
211
|
+
t = ptr.Elem()
|
|
212
|
+
}
|
|
213
|
+
if named, ok := t.(*types.Named); ok {
|
|
214
|
+
safe[named] = struct{}{}
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for named := range safe {
|
|
222
|
+
addMethodsToReachable(named, pkgByTypes, rootDir, reachable)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func isReflectOrJSONCall(call *ast.CallExpr, info *types.Info) bool {
|
|
227
|
+
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
228
|
+
if !ok {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
x, ok := sel.X.(*ast.Ident)
|
|
232
|
+
if !ok {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
obj := info.Uses[x]
|
|
236
|
+
pn, ok := obj.(*types.PkgName)
|
|
237
|
+
if !ok {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
path := pn.Imported().Path()
|
|
241
|
+
switch path {
|
|
242
|
+
case "reflect":
|
|
243
|
+
switch sel.Sel.Name {
|
|
244
|
+
case "TypeOf", "ValueOf", "New":
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
case "encoding/json":
|
|
248
|
+
switch sel.Sel.Name {
|
|
249
|
+
case "Unmarshal", "NewDecoder", "Marshal", "NewEncoder":
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func addMethodsToReachable(n *types.Named, pkgByTypes map[*types.Package]*packages.Package, rootDir string, reachable map[string]bool) {
|
|
257
|
+
pkg := pkgByTypes[n.Obj().Pkg()]
|
|
258
|
+
if pkg == nil || pkg.Fset == nil {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
for i := 0; i < n.NumMethods(); i++ {
|
|
262
|
+
m := n.Method(i)
|
|
263
|
+
rel := relFile(pkg.Fset, m.Pos(), rootDir)
|
|
264
|
+
if rel == "" {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
name := n.Obj().Name() + "." + m.Name()
|
|
268
|
+
reachable[symbols.SymbolID(rel, name, "method")] = true
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ssaFuncSymbolID maps an *ssa.Function back to the Ctxo symbol id format
|
|
273
|
+
// used by the symbols/edges passes. Anonymous closures and synthetic
|
|
274
|
+
// wrappers are filtered by their missing source position.
|
|
275
|
+
func ssaFuncSymbolID(fn *ssa.Function, rootDir string) (string, bool) {
|
|
276
|
+
if fn == nil || fn.Pos() == token.NoPos || fn.Prog == nil {
|
|
277
|
+
return "", false
|
|
278
|
+
}
|
|
279
|
+
file := fn.Prog.Fset.File(fn.Pos())
|
|
280
|
+
if file == nil {
|
|
281
|
+
return "", false
|
|
282
|
+
}
|
|
283
|
+
rel, err := filepath.Rel(rootDir, file.Name())
|
|
284
|
+
if err != nil || strings.HasPrefix(rel, "..") {
|
|
285
|
+
return "", false
|
|
286
|
+
}
|
|
287
|
+
rel = filepath.ToSlash(rel)
|
|
288
|
+
|
|
289
|
+
name := fn.Name()
|
|
290
|
+
kind := "function"
|
|
291
|
+
if fn.Signature != nil && fn.Signature.Recv() != nil {
|
|
292
|
+
if recv := receiverName(fn.Signature.Recv().Type()); recv != "" {
|
|
293
|
+
name = recv + "." + fn.Name()
|
|
294
|
+
kind = "method"
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return symbols.SymbolID(rel, name, kind), true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func receiverName(t types.Type) string {
|
|
301
|
+
if p, ok := t.(*types.Pointer); ok {
|
|
302
|
+
t = p.Elem()
|
|
303
|
+
}
|
|
304
|
+
if n, ok := t.(*types.Named); ok {
|
|
305
|
+
return n.Obj().Name()
|
|
306
|
+
}
|
|
307
|
+
return ""
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func inModule(p *packages.Package, rootDir string) bool {
|
|
311
|
+
if len(p.GoFiles) == 0 {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
314
|
+
rel, err := filepath.Rel(rootDir, p.GoFiles[0])
|
|
315
|
+
return err == nil && !strings.HasPrefix(rel, "..")
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func isExported(name string) bool {
|
|
319
|
+
return name != "" && name[0] >= 'A' && name[0] <= 'Z'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func relFile(fset *token.FileSet, pos token.Pos, rootDir string) string {
|
|
323
|
+
p := fset.Position(pos)
|
|
324
|
+
if !p.IsValid() {
|
|
325
|
+
return ""
|
|
326
|
+
}
|
|
327
|
+
rel, err := filepath.Rel(rootDir, p.Filename)
|
|
328
|
+
if err != nil || strings.HasPrefix(rel, "..") {
|
|
329
|
+
return ""
|
|
330
|
+
}
|
|
331
|
+
return filepath.ToSlash(rel)
|
|
332
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package reach
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/load"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestBinaryReachability(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
|
+
func used() string { return "alive" }
|
|
18
|
+
func dead() string { return "never called" }
|
|
19
|
+
|
|
20
|
+
func main() { _ = used() }
|
|
21
|
+
`,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
r := analyze(t, dir)
|
|
25
|
+
if !r.HasMain {
|
|
26
|
+
t.Fatal("expected HasMain=true for binary fixture")
|
|
27
|
+
}
|
|
28
|
+
if !r.Reachable["main.go::main::function"] {
|
|
29
|
+
t.Error("main should be reachable")
|
|
30
|
+
}
|
|
31
|
+
if !r.Reachable["main.go::used::function"] {
|
|
32
|
+
t.Error("used() should be reachable")
|
|
33
|
+
}
|
|
34
|
+
if r.Reachable["main.go::dead::function"] {
|
|
35
|
+
t.Error("dead() must not be reachable")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestLibraryMode(t *testing.T) {
|
|
40
|
+
dir := writeFixture(t, map[string]string{
|
|
41
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
42
|
+
"lib.go": `package fixture
|
|
43
|
+
|
|
44
|
+
func Public() int { return internal() }
|
|
45
|
+
func internal() int { return 1 }
|
|
46
|
+
func Orphan() int { return 2 } // exported but with no internal callers — still reachable as library root
|
|
47
|
+
func _unused() int { return 3 } // not exported, no callers
|
|
48
|
+
|
|
49
|
+
type Box struct{}
|
|
50
|
+
func (Box) Shake() {} // exported method — reachable
|
|
51
|
+
func (Box) hide() {} // unexported — not reachable unless called
|
|
52
|
+
`,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
r := analyze(t, dir)
|
|
56
|
+
if r.HasMain {
|
|
57
|
+
t.Fatal("expected library mode")
|
|
58
|
+
}
|
|
59
|
+
if !r.Reachable["lib.go::Public::function"] {
|
|
60
|
+
t.Error("Public must be reachable (library root)")
|
|
61
|
+
}
|
|
62
|
+
if !r.Reachable["lib.go::internal::function"] {
|
|
63
|
+
t.Error("internal must be reachable (called by Public)")
|
|
64
|
+
}
|
|
65
|
+
if !r.Reachable["lib.go::Orphan::function"] {
|
|
66
|
+
t.Error("Orphan must be reachable (library root)")
|
|
67
|
+
}
|
|
68
|
+
if !r.Reachable["lib.go::Box.Shake::method"] {
|
|
69
|
+
t.Error("Box.Shake must be reachable (exported method root)")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestReflectSafe(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
|
+
import "reflect"
|
|
79
|
+
|
|
80
|
+
type Target struct{}
|
|
81
|
+
func (Target) UsedViaReflect() {} // never called, but accessed via reflect
|
|
82
|
+
func (Target) AlsoReflective() {}
|
|
83
|
+
|
|
84
|
+
func main() { _ = reflect.TypeOf(Target{}) }
|
|
85
|
+
`,
|
|
86
|
+
})
|
|
87
|
+
r := analyze(t, dir)
|
|
88
|
+
if !r.Reachable["main.go::Target.UsedViaReflect::method"] {
|
|
89
|
+
t.Error("reflect.TypeOf(Target{}) should mark Target methods reachable")
|
|
90
|
+
}
|
|
91
|
+
if !r.Reachable["main.go::Target.AlsoReflective::method"] {
|
|
92
|
+
t.Error("reflect-accessed types promote all methods")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestJSONUnmarshalSafe(t *testing.T) {
|
|
97
|
+
dir := writeFixture(t, map[string]string{
|
|
98
|
+
"go.mod": "module fixture\n\ngo 1.22\n",
|
|
99
|
+
"main.go": `package main
|
|
100
|
+
|
|
101
|
+
import "encoding/json"
|
|
102
|
+
|
|
103
|
+
type Payload struct{ Name string }
|
|
104
|
+
func (Payload) String() string { return "p" }
|
|
105
|
+
|
|
106
|
+
func main() {
|
|
107
|
+
var p Payload
|
|
108
|
+
_ = json.Unmarshal([]byte("{}"), &p)
|
|
109
|
+
}
|
|
110
|
+
`,
|
|
111
|
+
})
|
|
112
|
+
r := analyze(t, dir)
|
|
113
|
+
if !r.Reachable["main.go::Payload.String::method"] {
|
|
114
|
+
t.Error("json.Unmarshal argument type methods must be reachable")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── helpers ──
|
|
119
|
+
|
|
120
|
+
func analyze(t *testing.T, dir string) *Result {
|
|
121
|
+
t.Helper()
|
|
122
|
+
res := load.Packages(dir)
|
|
123
|
+
if res.FatalError != nil {
|
|
124
|
+
t.Fatal(res.FatalError)
|
|
125
|
+
}
|
|
126
|
+
return Analyze(dir, res.Packages, 30*time.Second)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func writeFixture(t *testing.T, files map[string]string) string {
|
|
130
|
+
t.Helper()
|
|
131
|
+
dir := t.TempDir()
|
|
132
|
+
for name, body := range files {
|
|
133
|
+
path := filepath.Join(dir, name)
|
|
134
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
135
|
+
t.Fatal(err)
|
|
136
|
+
}
|
|
137
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
138
|
+
t.Fatal(err)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return dir
|
|
142
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Package symbols walks a loaded *packages.Package and produces emit.Symbol
|
|
2
|
+
// records grouped by source file. Maps Go declarations onto the kinds defined
|
|
3
|
+
// by @ctxo/plugin-api: function | class | interface | method | variable | type.
|
|
4
|
+
package symbols
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"go/ast"
|
|
8
|
+
"go/token"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"sort"
|
|
11
|
+
"strings"
|
|
12
|
+
|
|
13
|
+
"golang.org/x/tools/go/packages"
|
|
14
|
+
|
|
15
|
+
"github.com/alperhankendi/ctxo/tools/ctxo-go-analyzer/internal/emit"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// FileSymbols groups extracted symbols by the source file that declared them.
|
|
19
|
+
// RelPath is project-root-relative with forward-slash separators so it composes
|
|
20
|
+
// cleanly with the Ctxo symbol-id format on every platform.
|
|
21
|
+
type FileSymbols struct {
|
|
22
|
+
RelPath string
|
|
23
|
+
Symbols []emit.Symbol
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract walks every parsed file in the package and emits one Symbol per
|
|
27
|
+
// top-level declaration. Unexported names flow through; only the blank
|
|
28
|
+
// identifier "_" is skipped.
|
|
29
|
+
func Extract(pkg *packages.Package, rootDir string) []FileSymbols {
|
|
30
|
+
if pkg == nil || pkg.Fset == nil {
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
byFile := make(map[string]*FileSymbols)
|
|
35
|
+
getOrCreate := func(rel string) *FileSymbols {
|
|
36
|
+
if fs, ok := byFile[rel]; ok {
|
|
37
|
+
return fs
|
|
38
|
+
}
|
|
39
|
+
fs := &FileSymbols{RelPath: rel}
|
|
40
|
+
byFile[rel] = fs
|
|
41
|
+
return fs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for _, file := range pkg.Syntax {
|
|
45
|
+
rel := relativeFile(pkg.Fset, file, rootDir)
|
|
46
|
+
if rel == "" {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
fs := getOrCreate(rel)
|
|
50
|
+
|
|
51
|
+
for _, decl := range file.Decls {
|
|
52
|
+
switch d := decl.(type) {
|
|
53
|
+
case *ast.FuncDecl:
|
|
54
|
+
if d.Name == nil || d.Name.Name == "_" {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
kind := "function"
|
|
58
|
+
name := d.Name.Name
|
|
59
|
+
if d.Recv != nil && len(d.Recv.List) > 0 {
|
|
60
|
+
recv := receiverTypeName(d.Recv.List[0].Type)
|
|
61
|
+
if recv != "" {
|
|
62
|
+
name = recv + "." + d.Name.Name
|
|
63
|
+
}
|
|
64
|
+
kind = "method"
|
|
65
|
+
}
|
|
66
|
+
fs.Symbols = append(fs.Symbols, makeSymbol(pkg.Fset, rel, name, kind, d.Pos(), d.End()))
|
|
67
|
+
|
|
68
|
+
case *ast.GenDecl:
|
|
69
|
+
switch d.Tok {
|
|
70
|
+
case token.TYPE:
|
|
71
|
+
for _, spec := range d.Specs {
|
|
72
|
+
ts, ok := spec.(*ast.TypeSpec)
|
|
73
|
+
if !ok || ts.Name == nil || ts.Name.Name == "_" {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
kind := typeSpecKind(ts.Type)
|
|
77
|
+
fs.Symbols = append(fs.Symbols, makeSymbol(pkg.Fset, rel, ts.Name.Name, kind, ts.Pos(), ts.End()))
|
|
78
|
+
}
|
|
79
|
+
case token.VAR, token.CONST:
|
|
80
|
+
for _, spec := range d.Specs {
|
|
81
|
+
vs, ok := spec.(*ast.ValueSpec)
|
|
82
|
+
if !ok {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
for _, n := range vs.Names {
|
|
86
|
+
if n.Name == "_" {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
fs.Symbols = append(fs.Symbols, makeSymbol(pkg.Fset, rel, n.Name, "variable", n.Pos(), n.End()))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
out := make([]FileSymbols, 0, len(byFile))
|
|
98
|
+
for _, v := range byFile {
|
|
99
|
+
sort.SliceStable(v.Symbols, func(i, j int) bool {
|
|
100
|
+
return v.Symbols[i].StartLine < v.Symbols[j].StartLine
|
|
101
|
+
})
|
|
102
|
+
out = append(out, *v)
|
|
103
|
+
}
|
|
104
|
+
sort.Slice(out, func(i, j int) bool { return out[i].RelPath < out[j].RelPath })
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// SymbolID composes the deterministic Ctxo symbol-id format.
|
|
109
|
+
func SymbolID(relFile, name, kind string) string {
|
|
110
|
+
return relFile + "::" + name + "::" + kind
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// receiverTypeName strips pointer indirection and generic instantiation from
|
|
114
|
+
// a method receiver, returning the bare type name. Returns empty when the
|
|
115
|
+
// expression cannot be reduced to a single identifier.
|
|
116
|
+
func receiverTypeName(expr ast.Expr) string {
|
|
117
|
+
for {
|
|
118
|
+
switch x := expr.(type) {
|
|
119
|
+
case *ast.StarExpr:
|
|
120
|
+
expr = x.X
|
|
121
|
+
case *ast.IndexExpr: // generic with one type param: List[T]
|
|
122
|
+
expr = x.X
|
|
123
|
+
case *ast.IndexListExpr: // generic with multiple type params: Map[K, V]
|
|
124
|
+
expr = x.X
|
|
125
|
+
case *ast.Ident:
|
|
126
|
+
return x.Name
|
|
127
|
+
default:
|
|
128
|
+
return ""
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func typeSpecKind(expr ast.Expr) string {
|
|
134
|
+
switch expr.(type) {
|
|
135
|
+
case *ast.StructType:
|
|
136
|
+
return "class"
|
|
137
|
+
case *ast.InterfaceType:
|
|
138
|
+
return "interface"
|
|
139
|
+
default:
|
|
140
|
+
return "type"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func makeSymbol(fset *token.FileSet, rel, name, kind string, pos, end token.Pos) emit.Symbol {
|
|
145
|
+
startPos := fset.Position(pos)
|
|
146
|
+
endPos := fset.Position(end)
|
|
147
|
+
startOffset := startPos.Offset
|
|
148
|
+
endOffset := endPos.Offset
|
|
149
|
+
return emit.Symbol{
|
|
150
|
+
SymbolID: SymbolID(rel, name, kind),
|
|
151
|
+
Name: name,
|
|
152
|
+
Kind: kind,
|
|
153
|
+
StartLine: startPos.Line,
|
|
154
|
+
EndLine: endPos.Line,
|
|
155
|
+
StartOffset: &startOffset,
|
|
156
|
+
EndOffset: &endOffset,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func relativeFile(fset *token.FileSet, file *ast.File, rootDir string) string {
|
|
161
|
+
tf := fset.File(file.Pos())
|
|
162
|
+
if tf == nil {
|
|
163
|
+
return ""
|
|
164
|
+
}
|
|
165
|
+
abs := tf.Name()
|
|
166
|
+
rel, err := filepath.Rel(rootDir, abs)
|
|
167
|
+
if err != nil || strings.HasPrefix(rel, "..") {
|
|
168
|
+
// Outside the module root (likely a stdlib or external dep) — skip.
|
|
169
|
+
return ""
|
|
170
|
+
}
|
|
171
|
+
return filepath.ToSlash(rel)
|
|
172
|
+
}
|