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
971 lines
29 KiB
Go
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)
|