Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

971 lines
29 KiB
Go

package goanalysis
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/tools/go/packages"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/semantic"
)
// LoadMode controls how deeply the go/types provider analyzes the code.
type LoadMode int
const (
// ModeTypeCheck loads types only (~5-10s). Resolves all type information
// and interface implementations but does not build a call graph.
ModeTypeCheck LoadMode = iota
// ModeCallGraph loads SSA and builds a VTA call graph (~15-30s).
// Most precise but requires more time and memory.
ModeCallGraph
)
// Provider uses Go's native toolchain (go/packages, go/types) for
// compiler-level precision on Go codebases.
type Provider struct {
mode LoadMode
includeTest bool
logger *zap.Logger
// Cached per-repo state from EnrichRepo — used by LookupTypeAtLine to
// answer per-binding type queries from the contract pipeline without
// re-loading packages. Keyed by the repo's absolute root so that, when
// multiple repos are enriched (in any order, possibly concurrently) before
// their contracts are extracted, each repo's contract pass still finds its
// own loaded packages rather than the last writer's. Guarded by stateMu.
//
// A loaded stash holds the whole type-checked program (go/types.Info +
// every file's AST) for one repo — on the order of 1-2 GB for a large
// module. Retaining one per repo forever previously made the daemon's
// RSS grow without bound. The stash is therefore bounded under stateMu:
// an idle TTL (stashTTL) releases a repo's program once its contract
// pass goes quiet, and a count ceiling (maxStashes) caps how many
// coexist during a multi-repo warmup. A LookupTypeAtLine that misses an
// evicted stash returns ("", false) — the contract pipeline then falls
// back to its tree-sitter type tier, the intended graceful degradation.
stateMu sync.RWMutex
stashes map[string]*goStash // absRoot → loaded package state
maxStashes int // count ceiling (env GORTEX_GOTYPES_MAX_STASHES)
stashTTL time.Duration // idle release window (env GORTEX_GOTYPES_STASH_TTL)
sweepOnce sync.Once
stopSweep chan struct{}
}
// goStash is one repo's loaded go/packages state, retained for
// LookupTypeAtLine after EnrichRepo returns. lastUsed drives idle
// eviction; it is bumped under stateMu whenever a lookup touches the
// repo, so an actively-queried program is never released mid-contract.
type goStash struct {
pkgs []*packages.Package
fset *token.FileSet
absRoot string
lastUsed time.Time
}
// Stash-retention bounds. Both overridable via env for operators who want
// a different memory/recompute trade-off.
const (
defaultMaxStashes = 8
defaultStashTTL = 3 * time.Minute
)
// NewProvider creates a go/types provider.
func NewProvider(mode LoadMode, includeTest bool, logger *zap.Logger) *Provider {
return &Provider{
mode: mode,
includeTest: includeTest,
logger: logger,
maxStashes: envPositiveInt("GORTEX_GOTYPES_MAX_STASHES", defaultMaxStashes),
stashTTL: envPositiveDuration("GORTEX_GOTYPES_STASH_TTL", defaultStashTTL),
stopSweep: make(chan struct{}),
}
}
func (p *Provider) Name() string { return "go-types" }
func (p *Provider) Languages() []string { return []string{"go"} }
// Close stops the idle sweeper (idempotent) and drops every retained
// type-checked program so a torn-down provider holds no memory.
func (p *Provider) Close() error {
if p.stopSweep != nil {
select {
case <-p.stopSweep:
default:
close(p.stopSweep)
}
}
p.stateMu.Lock()
p.stashes = nil
p.stateMu.Unlock()
return nil
}
// goToolchainOnce caches the one-time probe for the `go` command. The
// provider shells out through go/packages (`go list`), so without the
// toolchain on PATH it can only fail — and a shipped gortex binary often
// runs on a machine with no Go installed. Probing once lets the manager's
// Available() gate skip the provider instead of attempting a package load
// that fails on every index.
var (
goToolchainOnce sync.Once
goToolchainOK bool
)
func goToolchainAvailable() bool {
goToolchainOnce.Do(func() {
_, err := exec.LookPath("go")
goToolchainOK = err == nil
})
return goToolchainOK
}
func (p *Provider) Available() bool {
// go/packages requires the Go toolchain; gate on a cached PATH probe
// so a binary running without `go` skips this provider cleanly and
// the go-ast-types supplemental provider serves Go instead.
return goToolchainAvailable()
}
func (p *Provider) Enrich(g graph.Store, repoRoot string) (*semantic.EnrichResult, error) {
return p.EnrichRepo(g, "", repoRoot)
}
// EnrichRepo runs the go/types enrichment pass with its graph scans scoped
// to repoPrefix (the multi-repo scope key; "" for a single-repo / in-memory
// graph). The go/packages load is already scoped to repoRoot; scoping the
// graph-side symbol count and implements-edge scan to one repo stops a
// multi-repo warmup from paying a whole-graph AllNodes / AllEdges walk per
// repo. Implementing this makes the provider a semantic.RepoScopedProvider,
// so the manager dispatches it per repo with the repo's prefix.
func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*semantic.EnrichResult, error) {
start := time.Now()
absRoot, err := filepath.Abs(repoRoot)
if err != nil {
return nil, fmt.Errorf("absolute path: %w", err)
}
// Load all packages with type information.
pkgs, fset, err := p.loadPackages(absRoot)
if err != nil {
return nil, fmt.Errorf("load packages: %w", err)
}
// Stash the loaded state, keyed by this repo's root, so LookupTypeAtLine
// can serve per-binding type queries from the contract pipeline without
// paying the 5-10s loadPackages cost again — and so a later repo's enrich
// does not clobber this repo's state before its contracts are extracted.
p.stateMu.Lock()
if p.stashes == nil {
p.stashes = make(map[string]*goStash)
}
p.stashes[absRoot] = &goStash{pkgs: pkgs, fset: fset, absRoot: absRoot, lastUsed: time.Now()}
p.evictLocked()
p.stateMu.Unlock()
// Release idle programs in the background so a quiet daemon doesn't hold
// every enriched repo's type tables until the next enrich.
p.startSweeper()
result := &semantic.EnrichResult{
Provider: p.Name(),
Language: "go",
}
// Serialise the graph-touching work below on the backend resolve mutex —
// the same lock every other edge-mutating pass holds — so this pass can run
// concurrently with other repos' enrichment. loadPackages (the expensive
// go/packages load) already ran above, outside the lock, so it still
// overlaps across repos; only the in-memory graph build is serialised.
rmu := g.ResolveMutex()
rmu.Lock()
defer rmu.Unlock()
// Build symbol map: go/types objects → Gortex node IDs.
symMap := semantic.NewSymbolMap()
objToNode := make(map[types.Object]string) // types.Object → Gortex node ID
// Phase 1: Map definitions.
for _, pkg := range pkgs {
if pkg.TypesInfo == nil {
continue
}
for ident, obj := range pkg.TypesInfo.Defs {
if obj == nil || ident.Pos() == token.NoPos {
continue
}
pos := fset.Position(ident.Pos())
relPath := relativePath(pos.Filename, absRoot)
if relPath == "" {
continue
}
node := semantic.MatchNodeByFileLine(g, relPath, pos.Line)
if node == nil {
node = semantic.MatchNodeByNameInFile(g, ident.Name, relPath)
}
if node != nil {
objID := objectID(obj)
symMap.Add(objID, node.ID)
objToNode[obj] = node.ID
result.SymbolsCovered++
}
}
}
// Count total Go symbols in this repo via the indexed repo-scoped scan
// rather than a whole-graph AllNodes walk (which, in a multi-repo graph,
// also wrongly counted every other repo's Go nodes against this repo's
// coverage).
for _, n := range repoGoNodes(g, repoPrefix) {
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
result.SymbolsTotal++
}
}
if result.SymbolsTotal > 0 {
result.CoveragePercent = float64(result.SymbolsCovered) / float64(result.SymbolsTotal) * 100
}
// Externals attribution: every Use of an external symbol becomes
// an EdgeCalls / EdgeReferences targeting a freshly materialised
// `ext::go:<importPath>::<name>` node, which itself carries an
// EdgeDependsOnModule to the owning KindModule. Previously the
// resolver left these calls pointing at stub strings
// (`stdlib::fmt::Println`, `dep::github.com/.../foo::Bar`) that no
// node holds; goanalysis upgrades them to real graph nodes with
// LSP-grade origin.
externals := newExternalsAttribution(g, pkgs, p.Name())
// Phase 2: Process references — confirm/add edges. External symbols
// are routed through externals.resolveSymbol so calls into stdlib
// and module-cache packages land on real graph nodes rather than
// the resolver's stub strings.
for _, pkg := range pkgs {
if pkg.TypesInfo == nil {
continue
}
for ident, obj := range pkg.TypesInfo.Uses {
if obj == nil || ident.Pos() == token.NoPos {
continue
}
pos := fset.Position(ident.Pos())
relPath := relativePath(pos.Filename, absRoot)
if relPath == "" {
continue
}
// Find the containing Gortex node (the caller).
callerNode := findContainingFunc(g, pkgs, fset, absRoot, pos)
if callerNode == nil {
continue
}
// Find the target Gortex node (the definition being used).
targetNodeID, ok := objToNode[obj]
external := false
if !ok {
targetNodeID = externals.resolveSymbol(obj)
if targetNodeID == "" {
continue
}
external = true
}
if callerNode.ID == targetNodeID {
continue
}
// External: claim a resolver-stub edge if one exists, else
// add a fresh edge. Internal: confirm or add as before.
if external {
importPath := obj.Pkg().Path()
if upgraded := externals.claimAndUpgradeStub(callerNode.ID, importPath, obj, targetNodeID, pos.Line); upgraded != nil {
result.EdgesConfirmed++
continue
}
existing := semantic.FindEdgeByTarget(g, callerNode.ID, targetNodeID)
if existing != nil {
if existing.Confidence < 1.0 {
semantic.ConfirmEdge(existing, p.Name())
result.EdgesConfirmed++
}
continue
}
kind := inferEdgeKindFromObj(obj)
if kind != "" {
semantic.AddSemanticEdge(g, callerNode.ID, targetNodeID, kind,
relPath, pos.Line, p.Name())
result.EdgesAdded++
}
continue
}
// Check if an edge already exists.
existing := semantic.FindEdgeByTarget(g, callerNode.ID, targetNodeID)
if existing != nil {
if existing.Confidence < 1.0 {
semantic.ConfirmEdge(existing, p.Name())
result.EdgesConfirmed++
}
} else {
// Determine edge kind.
kind := inferEdgeKindFromObj(obj)
if kind != "" {
semantic.AddSemanticEdge(g, callerNode.ID, targetNodeID, kind,
relPath, pos.Line, p.Name())
result.EdgesAdded++
}
}
}
}
// Stitch the externals counters into the standard result. NodesEnriched
// previously only incremented for in-repo type-meta enrichment; here
// we surface the synthetic external + module nodes the externals
// pass added so callers can see the full graph delta in one number.
result.EdgesAdded += externals.edgesAdded + externals.edgesUpgraded
result.NodesEnriched += externals.nodesAdded
// Phase 3: Interface implementations via go/types.
result.EdgesConfirmed += p.enrichImplements(g, pkgs, objToNode)
result.EdgesAdded += p.addMissingImplements(g, pkgs, objToNode, absRoot)
// Phase 4: node-driven type stamping. go/types has a Def — hence an exact
// type — for every function, method, field, parameter and local variable.
// The bottleneck is attaching each Def to its graph node. Phase 1 maps a
// Def with MatchNodeByFileLine, which returns the innermost node whose
// RANGE contains the ident line, so a function's params and locals collapse
// onto the enclosing function node and never receive their own type — that
// is why the old Defs→node stamping reached only ~20% of locals and ~24% of
// params. Here we index every named Def by (file, name) and, for each graph
// node, pick the same-named Def whose ident sits closest to the node's own
// declaration line (within its range). That attaches a local/param to ITS
// node rather than its enclosing function, lifting coverage to what go/types
// actually knows (every named symbol).
//
// EnrichNodeMeta mutates Node.Meta in place; on disk backends the node is a
// per-call GetNode reconstruction, so collect every stamped node and
// round-trip it through the store at the end (one AddBatch) or the
// semantic_type / return_type stamps are silently discarded. See
// semantic.EnrichNodeMeta.
type defEntry struct {
line int
obj types.Object
}
defsByName := make(map[string][]defEntry) // key: rel \x00 name
relSet := make(map[string]struct{})
for _, pkg := range pkgs {
if pkg.TypesInfo == nil {
continue
}
for ident, obj := range pkg.TypesInfo.Defs {
if obj == nil || ident.Pos() == token.NoPos || ident.Name == "" || ident.Name == "_" {
continue
}
pos := fset.Position(ident.Pos())
rel := relativePath(pos.Filename, absRoot)
if rel == "" {
continue
}
defsByName[rel+"\x00"+ident.Name] = append(defsByName[rel+"\x00"+ident.Name], defEntry{pos.Line, obj})
relSet[rel] = struct{}{}
}
}
var stampedNodes []*graph.Node
for rel := range relSet {
for _, node := range g.GetFileNodes(rel) {
if node.Kind == graph.KindFile || node.Kind == graph.KindImport || node.Name == "" {
continue
}
// Among same-named Defs in this file, pick the one whose ident line
// is closest to the node's start line and falls within its range —
// this distinguishes two locals of the same name in one function.
best := types.Object(nil)
bestDist := 1 << 30
for _, e := range defsByName[rel+"\x00"+node.Name] {
if e.line < node.StartLine-1 || e.line > node.EndLine+1 {
continue
}
d := e.line - node.StartLine
if d < 0 {
d = -d
}
if d < bestDist {
bestDist = d
best = e.obj
}
}
if best == nil {
continue
}
didStamp := false
if typeStr := types.TypeString(best.Type(), nil); typeStr != "" && typeStr != "invalid type" {
semantic.EnrichNodeMeta(node, "semantic_type", typeStr, p.Name())
result.NodesEnriched++
didStamp = true
}
// Add return type for functions.
if fn, ok := best.(*types.Func); ok {
if sig, ok := fn.Type().(*types.Signature); ok && sig.Results().Len() > 0 {
semantic.EnrichNodeMeta(node, "return_type", types.TypeString(sig.Results(), nil), p.Name())
didStamp = true
}
}
if didStamp {
stampedNodes = append(stampedNodes, node)
}
}
}
if len(stampedNodes) > 0 {
g.AddBatch(stampedNodes, nil)
}
result.DurationMs = time.Since(start).Milliseconds()
return result, nil
}
func (p *Provider) EnrichFile(g graph.Store, repoRoot, filePath string) (*semantic.EnrichResult, error) {
// go/types can do incremental loading per package, but for simplicity
// we re-enrich the whole graph. The manager's debounce prevents thrashing.
return nil, nil
}
// LookupTypeAtLine returns the resolved type name of the first
// short_var_declaration / var_spec / typed declaration whose start
// line matches `line` in the file at `filePath`. Returns ("", false)
// when:
// - Enrich hasn't been called (no cached state)
// - filePath isn't in any loaded package
// - no typed declaration is found at `line`
// - the type can't be resolved via go/types
//
// This is the lsp_resolved upgrade tier referenced in
// spec-contract-extraction.md §4.5: when the goanalysis provider
// has run, the contract pipeline can ask for compiler-grade type
// resolution at any line in the indexed source.
func (p *Provider) LookupTypeAtLine(filePath string, line int) (string, bool) {
p.stateMu.Lock()
p.evictLocked()
stashes := make([]*goStash, 0, len(p.stashes))
for _, s := range p.stashes {
stashes = append(stashes, s)
}
p.stateMu.Unlock()
if len(stashes) == 0 {
return "", false
}
// Try every repo's stash; the file resolves under exactly one repo root.
target := normalizeRelPath(filePath)
for _, st := range stashes {
if len(st.pkgs) == 0 || st.fset == nil || st.absRoot == "" {
continue
}
for _, pkg := range st.pkgs {
if pkg.TypesInfo == nil {
continue
}
for _, syntax := range pkg.Syntax {
if syntax == nil {
continue
}
pos := st.fset.Position(syntax.Pos())
if normalizeRelPath(relativePath(pos.Filename, st.absRoot)) != target {
continue
}
// This file belongs to st: keep the repo's program warm so the
// idle sweeper doesn't release it mid-contract-pass.
p.touch(st)
if t, ok := lookupTypeAtLineInFile(syntax, pkg.TypesInfo, st.fset, line); ok {
return t, true
}
}
}
}
return "", false
}
// touch bumps a stash's lastUsed under the lock so the idle sweeper
// treats an actively-queried repo as live.
func (p *Provider) touch(st *goStash) {
p.stateMu.Lock()
st.lastUsed = time.Now()
p.stateMu.Unlock()
}
// evictLocked releases stashes idle past stashTTL and, if still over the
// count ceiling, the least-recently-used ones. The caller holds stateMu.
// Dropping a stash frees one repo's whole type-checked program for GC.
func (p *Provider) evictLocked() {
if len(p.stashes) == 0 {
return
}
ttl := p.stashTTL
if ttl <= 0 {
ttl = defaultStashTTL
}
now := time.Now()
for root, st := range p.stashes {
if now.Sub(st.lastUsed) > ttl {
delete(p.stashes, root)
}
}
max := p.maxStashes
if max <= 0 {
max = defaultMaxStashes
}
for len(p.stashes) > max {
var lruRoot string
var lruTime time.Time
for root, st := range p.stashes {
if lruRoot == "" || st.lastUsed.Before(lruTime) {
lruRoot, lruTime = root, st.lastUsed
}
}
if lruRoot == "" {
break
}
delete(p.stashes, lruRoot)
}
}
// startSweeper lazily launches one background goroutine that periodically
// releases idle stashes, so a daemon that has gone quiet drops its
// retained type-checked programs instead of holding them until the next
// enrich. Stopped by Close.
func (p *Provider) startSweeper() {
p.sweepOnce.Do(func() {
if p.stopSweep == nil {
p.stopSweep = make(chan struct{})
}
interval := p.stashTTL
if interval <= 0 {
interval = defaultStashTTL
}
if interval /= 2; interval < 30*time.Second {
interval = 30 * time.Second
}
stop := p.stopSweep
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
p.stateMu.Lock()
p.evictLocked()
p.stateMu.Unlock()
}
}
}()
})
}
// envPositiveInt / envPositiveDuration read an operator override, falling
// back to def when the variable is unset or unparseable.
func envPositiveInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
func envPositiveDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return def
}
// lookupTypeAtLineInFile walks the file's AST and returns the type
// name of the first declaration at `line` whose LHS the type info
// table has a type for.
func lookupTypeAtLineInFile(file *ast.File, info *types.Info, fset *token.FileSet, line int) (string, bool) {
var found string
ast.Inspect(file, func(n ast.Node) bool {
if n == nil || found != "" {
return false
}
startLine := fset.Position(n.Pos()).Line
if startLine != line {
// Keep descending if this node spans the target.
endLine := fset.Position(n.End()).Line
return startLine <= line && endLine >= line
}
// We're at the target line. Try to extract a type from the
// most common declaration shapes.
switch d := n.(type) {
case *ast.AssignStmt:
if name := typeNameFromAssign(d, info); name != "" {
found = name
}
case *ast.GenDecl:
if name := typeNameFromGenDecl(d, info); name != "" {
found = name
}
case *ast.DeclStmt:
if gd, ok := d.Decl.(*ast.GenDecl); ok {
if name := typeNameFromGenDecl(gd, info); name != "" {
found = name
}
}
}
return found == ""
})
return found, found != ""
}
// typeNameFromAssign reads the LHS type from a short var declaration
// (`x := f()` or `x := Foo{...}`). Returns the underlying named
// type's name.
func typeNameFromAssign(stmt *ast.AssignStmt, info *types.Info) string {
if len(stmt.Lhs) == 0 || len(stmt.Rhs) == 0 {
return ""
}
for i, lhs := range stmt.Lhs {
ident, ok := lhs.(*ast.Ident)
if !ok || ident.Name == "_" {
continue
}
obj := info.Defs[ident]
if obj == nil {
obj = info.Uses[ident]
}
if obj != nil {
if name := unwrapTypeName(obj.Type()); name != "" {
return name
}
}
// Fall back to the RHS expression's type.
var rhs ast.Expr
if i < len(stmt.Rhs) {
rhs = stmt.Rhs[i]
} else if len(stmt.Rhs) == 1 {
rhs = stmt.Rhs[0]
}
if rhs != nil {
if t, ok := info.Types[rhs]; ok && t.Type != nil {
if name := unwrapTypeName(t.Type); name != "" {
return name
}
}
}
}
return ""
}
// typeNameFromGenDecl handles `var x Foo` / `var x = Foo{...}`.
func typeNameFromGenDecl(decl *ast.GenDecl, info *types.Info) string {
for _, spec := range decl.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range vs.Names {
if name.Name == "_" {
continue
}
obj := info.Defs[name]
if obj != nil {
if t := unwrapTypeName(obj.Type()); t != "" {
return t
}
}
if vs.Type != nil {
if t, ok := info.Types[vs.Type]; ok && t.Type != nil {
if u := unwrapTypeName(t.Type); u != "" {
return u
}
}
}
if i < len(vs.Values) {
if t, ok := info.Types[vs.Values[i]]; ok && t.Type != nil {
if u := unwrapTypeName(t.Type); u != "" {
return u
}
}
}
}
}
return ""
}
// unwrapTypeName strips slice/pointer/array wrappers and returns the
// underlying named type's bare name. Returns "" for primitives,
// interfaces, and untyped expressions.
func unwrapTypeName(t types.Type) string {
if t == nil {
return ""
}
for {
switch x := t.(type) {
case *types.Pointer:
t = x.Elem()
case *types.Slice:
t = x.Elem()
case *types.Array:
t = x.Elem()
default:
named, ok := t.(*types.Named)
if !ok {
return ""
}
return named.Obj().Name()
}
}
}
// normalizeRelPath collapses a/./b → a/b and uses forward slashes,
// so OS-dependent path separators don't trip the comparison.
func normalizeRelPath(p string) string {
if p == "" {
return ""
}
return filepath.ToSlash(filepath.Clean(p))
}
// loadPackages loads all Go packages in the given directory with type information.
func (p *Provider) loadPackages(dir string) ([]*packages.Package, *token.FileSet, error) {
mode := packages.NeedName |
packages.NeedFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedTypesInfo |
packages.NeedSyntax |
// NeedModule populates pkg.Module so the externals pass can
// classify imports as stdlib (Module == nil), module_cache
// (Module != nil && !Main), or main (Module.Main). Without
// it the loader returns nil for every Module field and we
// can't tell stdlib calls from internal-package calls.
packages.NeedModule
cfg := &packages.Config{
Mode: mode,
Dir: dir,
Tests: p.includeTest,
Fset: token.NewFileSet(),
}
patterns := []string{"./..."}
pkgs, err := packages.Load(cfg, patterns...)
if err != nil {
return nil, nil, err
}
// Filter out packages with errors (they may have partial type info).
var valid []*packages.Package
for _, pkg := range pkgs {
if len(pkg.Errors) > 0 {
p.logger.Debug("package has errors, using partial info",
zap.String("pkg", pkg.PkgPath),
zap.Int("errors", len(pkg.Errors)),
)
}
if pkg.TypesInfo != nil {
valid = append(valid, pkg)
}
}
return valid, cfg.Fset, nil
}
// repoGoNodes returns the repo's Go-language nodes via the indexed
// GetRepoNodes scan, falling back to a language-filtered AllNodes pass for
// the embedded single-repo ("") path where GetRepoNodes can come back empty.
func repoGoNodes(g graph.Store, repoPrefix string) []*graph.Node {
filter := func(nodes []*graph.Node) []*graph.Node {
out := make([]*graph.Node, 0, len(nodes))
for _, n := range nodes {
if n.Language == "go" && n.RepoPrefix == repoPrefix {
out = append(out, n)
}
}
return out
}
out := filter(g.GetRepoNodes(repoPrefix))
if len(out) == 0 && repoPrefix == "" {
return filter(g.AllNodes())
}
return out
}
// enrichImplements confirms existing EdgeImplements edges using go/types.
func (p *Provider) enrichImplements(g graph.Store, pkgs []*packages.Package, objToNode map[types.Object]string) int {
confirmed := 0
// Collect all interfaces from the loaded packages.
ifaceTypes := make(map[string]*types.Interface) // Gortex node ID → interface type
for obj, nodeID := range objToNode {
if tn, ok := obj.(*types.TypeName); ok {
if iface, ok := tn.Type().Underlying().(*types.Interface); ok {
ifaceTypes[nodeID] = iface
}
}
}
// Check existing EdgeImplements edges. Iterate the kind-indexed edge set
// (not a whole-graph AllEdges scan, but still graph-wide for this kind) so
// a cross-repo implements edge — concrete type in another repo, interface
// in this repo's loaded packages — is still confirmed, matching the
// original behavior.
for e := range g.EdgesByKind(graph.EdgeImplements) {
fromNode := g.GetNode(e.From)
if fromNode == nil || fromNode.Language != "go" {
continue
}
if e.Confidence >= 1.0 {
continue
}
// If we have type info for both sides, verify.
if _, ok := ifaceTypes[e.To]; ok {
semantic.ConfirmEdge(e, p.Name())
confirmed++
}
}
return confirmed
}
// addMissingImplements discovers interface implementations that tree-sitter missed.
func (p *Provider) addMissingImplements(g graph.Store, pkgs []*packages.Package, objToNode map[types.Object]string, absRoot string) int {
added := 0
// Collect interfaces and concrete types.
type ifaceEntry struct {
nodeID string
iface *types.Interface
}
type concreteEntry struct {
nodeID string
typ types.Type
obj types.Object
}
var ifaces []ifaceEntry
var concretes []concreteEntry
for obj, nodeID := range objToNode {
tn, ok := obj.(*types.TypeName)
if !ok {
continue
}
if iface, ok := tn.Type().Underlying().(*types.Interface); ok {
ifaces = append(ifaces, ifaceEntry{nodeID: nodeID, iface: iface})
} else {
concretes = append(concretes, concreteEntry{nodeID: nodeID, typ: tn.Type(), obj: obj})
}
}
// Check each (concrete, interface) pair.
for _, c := range concretes {
for _, i := range ifaces {
if c.nodeID == i.nodeID {
continue
}
// Check both T and *T.
if types.Implements(c.typ, i.iface) || types.Implements(types.NewPointer(c.typ), i.iface) {
existing := semantic.FindMatchingEdge(g, c.nodeID, i.nodeID, graph.EdgeImplements)
if existing == nil {
cNode := g.GetNode(c.nodeID)
if cNode != nil {
semantic.AddSemanticEdge(g, c.nodeID, i.nodeID, graph.EdgeImplements,
cNode.FilePath, cNode.StartLine, p.Name())
added++
}
}
}
}
}
return added
}
// findContainingFunc finds the Gortex function/method node that contains the given position.
func findContainingFunc(g graph.Store, pkgs []*packages.Package, fset *token.FileSet, absRoot string, pos token.Position) *graph.Node {
relPath := relativePath(pos.Filename, absRoot)
if relPath == "" {
return nil
}
nodes := g.GetFileNodes(relPath)
var best *graph.Node
bestSize := int(^uint(0) >> 1)
for _, n := range nodes {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
if n.StartLine <= pos.Line && pos.Line <= n.EndLine {
size := n.EndLine - n.StartLine
if size < bestSize {
best = n
bestSize = size
}
}
}
return best
}
// inferEdgeKindFromObj determines the edge kind from a go/types object.
func inferEdgeKindFromObj(obj types.Object) graph.EdgeKind {
switch obj.(type) {
case *types.Func:
return graph.EdgeCalls
case *types.TypeName:
return graph.EdgeReferences
case *types.Var:
return graph.EdgeReferences
case *types.Const:
return graph.EdgeReferences
default:
return ""
}
}
// objectID creates a stable string ID for a go/types object.
func objectID(obj types.Object) string {
if obj.Pkg() != nil {
return obj.Pkg().Path() + "." + obj.Name()
}
return obj.Name()
}
// relativePath converts an absolute file path to a repo-relative path.
func relativePath(absPath, repoRoot string) string {
// Skip files outside the repo (stdlib, dependencies).
if !strings.HasPrefix(absPath, repoRoot) {
return ""
}
rel, err := filepath.Rel(repoRoot, absPath)
if err != nil {
return ""
}
return filepath.ToSlash(rel)
}
// Ensure ast is used.
var _ = (*ast.File)(nil)