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
501 lines
15 KiB
Go
501 lines
15 KiB
Go
package store_sqlite
|
|
|
|
// This file implements the moderate-SQL analysis capability interfaces
|
|
// for the SQLite graph.Store backend. Each method mirrors the in-memory
|
|
// reference implementation in internal/graph/graph.go and is verified
|
|
// against the same conformance suite (internal/graph/storetest).
|
|
//
|
|
// Shape: push the structural filter into one indexed SELECT via the raw-
|
|
// SQL helpers (queryNodesSQL / s.db.Query), then do any Meta-dependent
|
|
// (JSON-decoded) or distinct-counting filtering in Go. No new prepared
|
|
// statements are added — every query rides the secondary indexes already
|
|
// created in schema.go (edges_by_from / edges_by_to / nodes_by_kind).
|
|
|
|
import (
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Compile-time assertions: *Store satisfies each analysis capability.
|
|
var _ graph.DeadCodeCandidator = (*Store)(nil)
|
|
var _ graph.IfaceImplementsScanner = (*Store)(nil)
|
|
var _ graph.MemberMethodsByType = (*Store)(nil)
|
|
var _ graph.StructuralParentEdges = (*Store)(nil)
|
|
var _ graph.ExtractCandidatesScanner = (*Store)(nil)
|
|
var _ graph.CrossRepoCandidates = (*Store)(nil)
|
|
var _ graph.ThrowerErrorSurfacer = (*Store)(nil)
|
|
|
|
// anaDedupeEdgeKinds drops empty / duplicate edge kinds, preserving
|
|
// first-seen order — the EdgeKind twin of dedupeNonEmpty.
|
|
func anaDedupeEdgeKinds(in []graph.EdgeKind) []graph.EdgeKind {
|
|
seen := make(map[graph.EdgeKind]struct{}, len(in))
|
|
out := make([]graph.EdgeKind, 0, len(in))
|
|
for _, k := range in {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[k]; ok {
|
|
continue
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- DeadCodeCandidator -------------------------------------------------
|
|
|
|
// DeadCodeCandidates returns nodes of the allowed kinds that have no
|
|
// incoming edge of the corresponding allowed in-edge kinds. An empty
|
|
// per-kind allowlist (or one that dedupes to nothing) means "any incoming
|
|
// edge counts as usage". Mirrors graph.(*Graph).DeadCodeCandidates: the
|
|
// candidate set is purely structural (the analysis layer applies the
|
|
// exported / test / entry-point / synthetic post-filters in Go), so no
|
|
// node-id exclusion happens here. The NOT-EXISTS filter runs server-side
|
|
// per node kind.
|
|
func (s *Store) DeadCodeCandidates(allowedNodeKinds []graph.NodeKind, allowedInEdgeKinds map[graph.NodeKind][]graph.EdgeKind) []*graph.Node {
|
|
if len(allowedNodeKinds) == 0 {
|
|
return nil
|
|
}
|
|
var out []*graph.Node
|
|
for _, nk := range allowedNodeKinds {
|
|
allowed := anaDedupeEdgeKinds(allowedInEdgeKinds[nk])
|
|
anyKindCounts := len(allowed) == 0
|
|
|
|
var q string
|
|
var args []any
|
|
if anyKindCounts {
|
|
// Any incoming edge disqualifies the node.
|
|
q = `SELECT ` + lookupNodeCols + ` FROM nodes n
|
|
WHERE n.kind = ?
|
|
AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.to_id = n.id)
|
|
ORDER BY n.id`
|
|
args = []any{string(nk)}
|
|
} else {
|
|
// Only an incoming edge of one of the allowed kinds counts.
|
|
q = `SELECT ` + lookupNodeCols + ` FROM nodes n
|
|
WHERE n.kind = ?
|
|
AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.to_id = n.id AND e.kind IN (` + inPlaceholders(len(allowed)) + `))
|
|
ORDER BY n.id`
|
|
args = make([]any, 0, 1+len(allowed))
|
|
args = append(args, string(nk))
|
|
for _, ek := range allowed {
|
|
args = append(args, string(ek))
|
|
}
|
|
}
|
|
|
|
for _, n := range s.queryNodesSQL(q, args...) {
|
|
if n != nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- IfaceImplementsScanner ---------------------------------------------
|
|
|
|
// IfaceImplementsRows returns one row per EdgeImplements edge whose
|
|
// target is a KindInterface carrying Meta["methods"]. The interface's
|
|
// decoded Meta rides on the row (callers pull the "methods" field, which
|
|
// round-trips as []string or []any). Interfaces with no Meta or no
|
|
// "methods" key are elided server-side.
|
|
func (s *Store) IfaceImplementsRows() []graph.IfaceImplementsRow {
|
|
q := `SELECT e.from_id, n.id, n.meta
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.to_id
|
|
WHERE e.kind = ? AND n.kind = ? AND n.meta IS NOT NULL`
|
|
rows, err := s.db.Query(q, string(graph.EdgeImplements), string(graph.KindInterface))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []graph.IfaceImplementsRow
|
|
for rows.Next() {
|
|
var fromID, ifaceID string
|
|
var metaBlob []byte
|
|
if err := rows.Scan(&fromID, &ifaceID, &metaBlob); err != nil {
|
|
continue
|
|
}
|
|
meta, derr := decodeMeta(metaBlob)
|
|
if derr != nil || meta == nil {
|
|
continue
|
|
}
|
|
if _, ok := meta["methods"]; !ok {
|
|
continue
|
|
}
|
|
out = append(out, graph.IfaceImplementsRow{
|
|
TypeID: fromID,
|
|
IfaceID: ifaceID,
|
|
IfaceMeta: meta,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- MemberMethodsByType ------------------------------------------------
|
|
|
|
// MemberMethodsByType returns typeID → []MemberMethodInfo for every
|
|
// EdgeMemberOf edge whose source is a KindMethod. The columns come from
|
|
// the METHOD NODE (FilePath / StartLine / RepoPrefix), matching the
|
|
// in-memory reference. Per-type lists are deduplicated by MethodID; the
|
|
// scan is ordered by the edge PK so the first-seen winner is stable. An
|
|
// empty graph (no qualifying rows) returns nil.
|
|
func (s *Store) MemberMethodsByType() map[string][]graph.MemberMethodInfo {
|
|
q := `SELECT e.to_id, n.id, n.name, n.file_path, n.start_line, n.repo_prefix
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.from_id
|
|
WHERE e.kind = ? AND n.kind = ?
|
|
ORDER BY e.id`
|
|
rows, err := s.db.Query(q, string(graph.EdgeMemberOf), string(graph.KindMethod))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string][]graph.MemberMethodInfo)
|
|
seen := make(map[string]map[string]struct{})
|
|
for rows.Next() {
|
|
var typeID, methodID, name, filePath, repoPrefix string
|
|
var startLine int
|
|
if err := rows.Scan(&typeID, &methodID, &name, &filePath, &startLine, &repoPrefix); err != nil {
|
|
continue
|
|
}
|
|
if seen[typeID] == nil {
|
|
seen[typeID] = make(map[string]struct{})
|
|
}
|
|
if _, ok := seen[typeID][methodID]; ok {
|
|
continue
|
|
}
|
|
seen[typeID][methodID] = struct{}{}
|
|
out[typeID] = append(out[typeID], graph.MemberMethodInfo{
|
|
MethodID: methodID,
|
|
Name: name,
|
|
FilePath: filePath,
|
|
StartLine: startLine,
|
|
RepoPrefix: repoPrefix,
|
|
})
|
|
}
|
|
if len(out) == 0 {
|
|
// Match the in-memory reference: empty graph returns nil.
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- StructuralParentEdges ----------------------------------------------
|
|
|
|
// StructuralParentEdges returns every Extends / Implements / Composes
|
|
// edge whose endpoints are both Type / Interface, projected as (FromID,
|
|
// ToID, FromKind, ToKind, Origin). Endpoints that aren't both type /
|
|
// interface are filtered server-side. Empty graph or no matching edges
|
|
// returns nil.
|
|
func (s *Store) StructuralParentEdges() []graph.StructuralParentEdgeRow {
|
|
q := `SELECT e.from_id, e.to_id, nf.kind, nt.kind, e.origin
|
|
FROM edges e
|
|
JOIN nodes nf ON nf.id = e.from_id
|
|
JOIN nodes nt ON nt.id = e.to_id
|
|
WHERE e.kind IN (?,?,?)
|
|
AND nf.kind IN (?,?) AND nt.kind IN (?,?)
|
|
ORDER BY e.id`
|
|
rows, err := s.db.Query(q,
|
|
string(graph.EdgeExtends), string(graph.EdgeImplements), string(graph.EdgeComposes),
|
|
string(graph.KindType), string(graph.KindInterface),
|
|
string(graph.KindType), string(graph.KindInterface),
|
|
)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []graph.StructuralParentEdgeRow
|
|
for rows.Next() {
|
|
var fromID, toID, fromKind, toKind, origin string
|
|
if err := rows.Scan(&fromID, &toID, &fromKind, &toKind, &origin); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, graph.StructuralParentEdgeRow{
|
|
FromID: fromID,
|
|
ToID: toID,
|
|
FromKind: graph.NodeKind(fromKind),
|
|
ToKind: graph.NodeKind(toKind),
|
|
Origin: origin,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- ExtractCandidatesScanner -------------------------------------------
|
|
|
|
// ExtractCandidates ranks function / method nodes by extractability: line
|
|
// span (EndLine - StartLine + 1), distinct caller fan-in, and distinct
|
|
// callee fan-out, counting only edges whose kind is in the supplied set.
|
|
// Rows must clear all three thresholds. Nodes with a zero StartLine /
|
|
// EndLine are dropped; pathPrefix narrows by file-path prefix. Mirrors
|
|
// graph.(*Graph).ExtractCandidates exactly: only KindFunction +
|
|
// KindMethod nodes are considered, and the distinct-by-endpoint counting
|
|
// runs Go-side over GetInEdges / GetOutEdges.
|
|
func (s *Store) ExtractCandidates(kinds []graph.EdgeKind, minLines, minCallers, minFanOut int, pathPrefix string) []graph.ExtractCandidateRow {
|
|
if len(kinds) == 0 {
|
|
return nil
|
|
}
|
|
kindSet := make(map[graph.EdgeKind]struct{}, len(kinds))
|
|
for _, k := range kinds {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
kindSet[k] = struct{}{}
|
|
}
|
|
if len(kindSet) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Candidate nodes: function / method only, non-zero line span,
|
|
// optional path-prefix gate.
|
|
q := `SELECT ` + lookupNodeCols + ` FROM nodes
|
|
WHERE kind IN (?,?) AND start_line > 0 AND end_line > 0`
|
|
args := []any{string(graph.KindFunction), string(graph.KindMethod)}
|
|
if pathPrefix != "" {
|
|
q += ` AND file_path LIKE ? ESCAPE '\'`
|
|
args = append(args, escapeLikePattern(pathPrefix)+"%")
|
|
}
|
|
q += ` ORDER BY id`
|
|
nodes := s.queryNodesSQL(q, args...)
|
|
|
|
var out []graph.ExtractCandidateRow
|
|
for _, n := range nodes {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
lineCount := n.EndLine - n.StartLine + 1
|
|
if lineCount < minLines {
|
|
continue
|
|
}
|
|
|
|
callerSet := make(map[string]struct{})
|
|
for _, e := range s.GetInEdges(n.ID) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if _, ok := kindSet[e.Kind]; !ok {
|
|
continue
|
|
}
|
|
callerSet[e.From] = struct{}{}
|
|
}
|
|
if len(callerSet) < minCallers {
|
|
continue
|
|
}
|
|
|
|
calleeSet := make(map[string]struct{})
|
|
for _, e := range s.GetOutEdges(n.ID) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if _, ok := kindSet[e.Kind]; !ok {
|
|
continue
|
|
}
|
|
calleeSet[e.To] = struct{}{}
|
|
}
|
|
if len(calleeSet) < minFanOut {
|
|
continue
|
|
}
|
|
|
|
out = append(out, graph.ExtractCandidateRow{
|
|
NodeID: n.ID,
|
|
Name: n.Name,
|
|
FilePath: n.FilePath,
|
|
StartLine: n.StartLine,
|
|
EndLine: n.EndLine,
|
|
LineCount: lineCount,
|
|
CallerCount: len(callerSet),
|
|
FanOut: len(calleeSet),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- CrossRepoCandidates ------------------------------------------------
|
|
|
|
// CrossRepoCandidates returns every edge whose kind is in baseKinds and
|
|
// whose endpoints carry two different non-empty RepoPrefix values. The
|
|
// edge is returned verbatim (callers rewrite Edge.CrossRepo); FromRepo /
|
|
// ToRepo are the endpoint prefixes. Empty baseKinds returns nil; single-
|
|
// repo graphs (or graphs whose nodes carry no RepoPrefix) yield nothing.
|
|
func (s *Store) CrossRepoCandidates(baseKinds []graph.EdgeKind) []graph.CrossRepoCandidateRow {
|
|
uniq := anaDedupeEdgeKinds(baseKinds)
|
|
if len(uniq) == 0 {
|
|
return nil
|
|
}
|
|
args := make([]any, 0, len(uniq))
|
|
for _, k := range uniq {
|
|
args = append(args, string(k))
|
|
}
|
|
q := `SELECT e.from_id, e.to_id, e.kind, e.file_path, e.line,
|
|
e.confidence, e.confidence_label, e.origin, e.tier, e.cross_repo, e.meta,
|
|
nf.repo_prefix, nt.repo_prefix
|
|
FROM edges e
|
|
JOIN nodes nf ON nf.id = e.from_id
|
|
JOIN nodes nt ON nt.id = e.to_id
|
|
WHERE e.kind IN (` + inPlaceholders(len(uniq)) + `)
|
|
AND nf.repo_prefix <> '' AND nt.repo_prefix <> ''
|
|
AND nf.repo_prefix <> nt.repo_prefix
|
|
ORDER BY e.id`
|
|
rows, err := s.db.Query(q, args...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []graph.CrossRepoCandidateRow
|
|
for rows.Next() {
|
|
var (
|
|
fromRepo, toRepo string
|
|
e graph.Edge
|
|
metaBlob []byte
|
|
crossRepo int64
|
|
)
|
|
if err := rows.Scan(
|
|
&e.From, &e.To, &e.Kind, &e.FilePath, &e.Line,
|
|
&e.Confidence, &e.ConfidenceLabel, &e.Origin, &e.Tier,
|
|
&crossRepo, &metaBlob,
|
|
&fromRepo, &toRepo,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
e.CrossRepo = crossRepo != 0
|
|
if len(metaBlob) > 0 {
|
|
if m, derr := decodeMeta(metaBlob); derr == nil {
|
|
e.Meta = m
|
|
}
|
|
}
|
|
edge := e
|
|
out = append(out, graph.CrossRepoCandidateRow{
|
|
Edge: &edge,
|
|
FromRepo: fromRepo,
|
|
ToRepo: toRepo,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- ThrowerErrorSurfacer -----------------------------------------------
|
|
|
|
// ThrowerErrorSurface returns one row per thrower (a node with outgoing
|
|
// EdgeThrows edges), aggregating the distinct error targets and the
|
|
// distinct literal error-message strings it emits (KindString nodes with
|
|
// Meta["context"] == "error_msg", linked by EdgeEmits). pathPrefix gates
|
|
// the EdgeThrows rows by their stored FilePath prefix. Throws counts the
|
|
// underlying EdgeThrows edges; FilePath / Line seed from the first throws
|
|
// edge, falling back to the thrower node's own coordinates when the edge
|
|
// carries none — matching the in-memory reference.
|
|
func (s *Store) ThrowerErrorSurface(pathPrefix string) []graph.ThrowerErrorRow {
|
|
type rowAccum struct {
|
|
row graph.ThrowerErrorRow
|
|
targetSeen map[string]struct{}
|
|
msgSeen map[string]struct{}
|
|
}
|
|
accums := make(map[string]*rowAccum)
|
|
var order []string
|
|
|
|
// Pass 1: EdgeThrows aggregation (count + distinct targets), keyed by
|
|
// thrower. The first edge (by PK insertion order) seeds FilePath /
|
|
// Line; an empty edge file/line falls back to the thrower node.
|
|
tq := `SELECT from_id, to_id, file_path, line FROM edges WHERE kind = ?`
|
|
targs := []any{string(graph.EdgeThrows)}
|
|
if pathPrefix != "" {
|
|
tq += ` AND file_path LIKE ? ESCAPE '\'`
|
|
targs = append(targs, escapeLikePattern(pathPrefix)+"%")
|
|
}
|
|
tq += ` ORDER BY id`
|
|
trows, err := s.db.Query(tq, targs...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for trows.Next() {
|
|
var from, to, filePath string
|
|
var line int
|
|
if err := trows.Scan(&from, &to, &filePath, &line); err != nil {
|
|
continue
|
|
}
|
|
acc := accums[from]
|
|
if acc == nil {
|
|
file := filePath
|
|
ln := line
|
|
if file == "" || ln == 0 {
|
|
if n := s.GetNode(from); n != nil {
|
|
if file == "" {
|
|
file = n.FilePath
|
|
}
|
|
if ln == 0 {
|
|
ln = n.StartLine
|
|
}
|
|
}
|
|
}
|
|
acc = &rowAccum{
|
|
row: graph.ThrowerErrorRow{
|
|
ThrowerID: from,
|
|
FilePath: file,
|
|
Line: ln,
|
|
},
|
|
targetSeen: make(map[string]struct{}),
|
|
msgSeen: make(map[string]struct{}),
|
|
}
|
|
accums[from] = acc
|
|
order = append(order, from)
|
|
}
|
|
acc.row.Throws++
|
|
if _, ok := acc.targetSeen[to]; !ok {
|
|
acc.targetSeen[to] = struct{}{}
|
|
acc.row.ErrorTargets = append(acc.row.ErrorTargets, to)
|
|
}
|
|
}
|
|
_ = trows.Close()
|
|
if len(accums) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Pass 2: attach the literal error messages each thrower emits. Join
|
|
// each thrower's EdgeEmits out-edges to KindString targets and filter
|
|
// Meta["context"] == "error_msg" Go-side (the context lives in the
|
|
// JSON Meta blob).
|
|
for _, id := range order {
|
|
acc := accums[id]
|
|
mq := `SELECT n.name, n.meta
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.to_id
|
|
WHERE e.from_id = ? AND e.kind = ? AND n.kind = ? AND n.meta IS NOT NULL
|
|
ORDER BY e.id`
|
|
mrows, err := s.db.Query(mq, id, string(graph.EdgeEmits), string(graph.KindString))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for mrows.Next() {
|
|
var name string
|
|
var metaBlob []byte
|
|
if err := mrows.Scan(&name, &metaBlob); err != nil {
|
|
continue
|
|
}
|
|
meta, derr := decodeMeta(metaBlob)
|
|
if derr != nil || meta == nil {
|
|
continue
|
|
}
|
|
ctxLabel, _ := meta["context"].(string)
|
|
if ctxLabel != "error_msg" {
|
|
continue
|
|
}
|
|
if _, ok := acc.msgSeen[name]; ok {
|
|
continue
|
|
}
|
|
acc.msgSeen[name] = struct{}{}
|
|
acc.row.ErrorMsgs = append(acc.row.ErrorMsgs, name)
|
|
}
|
|
_ = mrows.Close()
|
|
}
|
|
|
|
out := make([]graph.ThrowerErrorRow, 0, len(order))
|
|
for _, id := range order {
|
|
out = append(out, accums[id].row)
|
|
}
|
|
return out
|
|
}
|