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

335 lines
10 KiB
Go

package languages
import (
"maps"
"strings"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/sql"
)
// goSQLExecMethods is the set of Go database-driver method names
// whose first string-literal argument is treated as SQL. Covers
// database/sql (Query, Exec, QueryRow, Prepare and their *Context
// variants), sqlx (Get, Select, NamedExec, NamedQuery, MustExec),
// pgx (Query, Exec, QueryRow on a pool/conn). Other Go SQL
// libraries that share these method names land transparently —
// the heuristic is shape-driven, not import-driven, which keeps
// the detector free of per-library plumbing.
//
// Methods with name collisions outside SQL contexts (e.g. Get on a
// cache, Query on a search index) are accepted as a known false-
// positive surface — the spec recommends the gate stays default-off
// for exactly this reason. Users opt in via
// .gortex.yaml::index.coverage.sql.enabled.
var goSQLExecMethods = map[string]struct{}{
// database/sql
"Query": {},
"QueryContext": {},
"QueryRow": {},
"QueryRowContext": {},
"Exec": {},
"ExecContext": {},
"Prepare": {},
"PrepareContext": {},
// sqlx
"Get": {},
"GetContext": {},
"Select": {},
"SelectContext": {},
"NamedExec": {},
"NamedQuery": {},
"MustExec": {},
// pgx
"QueryRowContextScan": {},
}
// goSQLEvent is a deferred record of one detected SQL call site.
// Mirrors the goObservabilityEvent / goFlagEvent shape so the
// post-pass emit step can match the same patterns.
//
// query holds the raw string-literal SQL that produced the tables /
// columns slices, used to anchor a KindString node under context="sql"
// so the SQL extractor can be re-run from the string registry alone
// (short-circuit, without re-parsing source).
type goSQLEvent struct {
method string
tables []sql.TableRef
columns []sql.ColumnRef
query string
line int
}
// detectGoSQLCall returns the table refs and the raw query extracted
// from a callm.expr capture when the method name matches the SQL exec
// set and the call's first argument is a string literal. ok=false on
// any other shape — non-SQL methods, dynamic queries, no string
// argument. The query is returned alongside the refs so the caller
// can seed a KindString context="sql" registry node for downstream
// short-circuit rebuilds without re-parsing source.
func detectGoSQLCall(callExpr *sitter.Node, method string, src []byte) ([]sql.TableRef, []sql.ColumnRef, string, bool) {
if callExpr == nil {
return nil, nil, "", false
}
if _, hit := goSQLExecMethods[method]; !hit {
return nil, nil, "", false
}
args := callExpr.ChildByFieldName("arguments")
if args == nil {
return nil, nil, "", false
}
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
c := args.NamedChild(i)
if c == nil {
continue
}
t := c.Type()
if t != "interpreted_string_literal" && t != "raw_string_literal" {
continue
}
query := strings.Trim(c.Content(src), "\"`")
refs := sql.ExtractTables(query)
if len(refs) == 0 {
return nil, nil, "", false
}
cols := sql.ExtractColumns(query)
return refs, cols, query, true
}
return nil, nil, "", false
}
// goSQLDriverDialects maps Go SQL driver import paths to the
// dialect tag stamped on KindTable nodes when the file uses them.
// The map is intentionally narrow — only widely-used drivers are
// recognised; everything else falls through to the generic
// dialect, matching the spec's "best-effort, default-off" stance
// for SQL extraction. Entries match by exact path or by prefix
// (the imports map carries the literal import path so a `pgx/v5`
// suffix still resolves to postgres via prefix-stripped lookup).
var goSQLDriverDialects = map[string]string{
// Postgres
"github.com/lib/pq": "postgres",
"github.com/jackc/pgx": "postgres",
"github.com/jackc/pgx/v4": "postgres",
"github.com/jackc/pgx/v5": "postgres",
"github.com/jackc/pgconn": "postgres",
"github.com/jackc/pgxpool": "postgres",
// MySQL / MariaDB
"github.com/go-sql-driver/mysql": "mysql",
"github.com/go-mysql-org/go-mysql": "mysql",
// SQLite
"github.com/mattn/go-sqlite3": "sqlite",
"github.com/glebarez/go-sqlite": "sqlite",
"modernc.org/sqlite": "sqlite",
// SQL Server
"github.com/microsoft/go-mssqldb": "mssql",
"github.com/denisenkom/go-mssqldb": "mssql",
}
// inferGoSQLDialect picks the most likely SQL dialect for a Go
// file given its import map. Returns "generic" when no driver
// import is recognised. Multiple drivers in one file is rare in
// practice; when it happens we pick the first match in iteration
// order — agents can disambiguate via the import edges if the
// dialect tag matters and is wrong.
func inferGoSQLDialect(imports map[string]string) string {
for _, path := range imports {
if dialect, ok := goSQLDriverDialects[path]; ok {
return dialect
}
// Prefix-stripped lookup for major-version suffixes
// (`pgx/v6`, `pgx/v7` future-proofing). Walk parent
// directories until a match or the path runs out.
p := path
for {
i := lastSegmentSlash(p)
if i < 0 {
break
}
p = p[:i]
if dialect, ok := goSQLDriverDialects[p]; ok {
return dialect
}
}
}
return "generic"
}
// lastSegmentSlash returns the index of the last `/` in s, or -1
// when s contains no slash. Cheaper than strings.LastIndex when
// inlined in the dialect-resolution hot loop.
func lastSegmentSlash(s string) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' {
return i
}
}
return -1
}
// emitGoSQLEvents turns deferred SQL records into KindTable nodes
// plus EdgeQueries edges. Tables share IDs across files in a repo
// — the same `users` table referenced from multiple call sites
// produces a single node that every caller links to. The dialect
// argument is inferred per-file from the import map; when two
// files in the same repo use different drivers and both reference
// "users", they produce distinct nodes (db::postgres::users vs
// db::mysql::users) — the right behaviour for repos that span
// dialects.
func emitGoSQLEvents(events []goSQLEvent, dialect string, callerLookup func(line int) string, filePath string, result *parser.ExtractionResult) {
if len(events) == 0 {
return
}
if dialect == "" {
dialect = "generic"
}
seenNodes := make(map[string]struct{})
seenStringEdges := make(map[string]struct{})
for _, e := range events {
callerID := callerLookup(e.line)
if callerID == "" {
continue
}
// Anchor a KindString context="sql" registry node for this
// query alongside the table edges. The node ID hashes the raw
// query so two call sites issuing the same SQL share a single
// registry entry, and downstream sql_rebuild can walk these
// nodes to rederive KindTable / KindColumn without re-parsing
// source. Empty queries (defensive — should not happen since
// detectGoSQLCall returns ok=false) are skipped.
if e.query != "" {
tableIDs := make([]string, 0, len(e.tables))
ops := make([]string, 0, len(e.tables))
seenOp := make(map[string]struct{}, len(e.tables))
for _, ref := range e.tables {
tableIDs = append(tableIDs, sql.TableNodeID(dialect, ref.Schema, ref.Table))
if _, dup := seenOp[ref.Op]; !dup && ref.Op != "" {
seenOp[ref.Op] = struct{}{}
ops = append(ops, ref.Op)
}
}
nodeMeta := map[string]any{
"dialect": dialect,
"tables": tableIDs,
}
if len(ops) > 0 {
nodeMeta["ops"] = ops
}
edgeMeta := map[string]any{
"dialect": dialect,
}
strID := goStringNodeID(stringCtxSQL, e.query)
if _, dup := seenNodes[strID]; !dup {
seenNodes[strID] = struct{}{}
meta := map[string]any{
"context": string(stringCtxSQL),
"value": e.query,
}
maps.Copy(meta, nodeMeta)
result.Nodes = append(result.Nodes, &graph.Node{
ID: strID,
Kind: graph.KindString,
Name: e.query,
FilePath: filePath, // first sighting; not authoritative
Language: "go",
Meta: meta,
})
}
edgeKey := callerID + "->" + strID
if _, dup := seenStringEdges[edgeKey]; !dup {
seenStringEdges[edgeKey] = struct{}{}
em := map[string]any{
"context": string(stringCtxSQL),
"method": e.method,
}
maps.Copy(em, edgeMeta)
result.Edges = append(result.Edges, &graph.Edge{
From: callerID,
To: strID,
Kind: graph.EdgeEmits,
FilePath: filePath,
Line: e.line,
Origin: graph.OriginASTInferred,
Meta: em,
})
}
}
for _, ref := range e.tables {
tableID := sql.TableNodeID(dialect, ref.Schema, ref.Table)
if _, ok := seenNodes[tableID]; !ok {
seenNodes[tableID] = struct{}{}
meta := map[string]any{
"table": ref.Table,
"dialect": dialect,
}
if ref.Schema != "" {
meta["schema"] = ref.Schema
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: tableID,
Kind: graph.KindTable,
Name: ref.Table,
FilePath: filePath, // first sighting; not authoritative
Language: "go",
Meta: meta,
})
}
result.Edges = append(result.Edges, &graph.Edge{
From: callerID,
To: tableID,
Kind: graph.EdgeQueries,
FilePath: filePath,
Line: e.line,
Origin: graph.OriginTextMatched,
Meta: map[string]any{
"op": ref.Op,
"method": e.method,
},
})
}
// Column-level reads/writes — same call, finer granularity.
// One KindColumn node per (table, column) tuple, one
// EdgeReadsCol or EdgeWritesCol per call site.
for _, col := range e.columns {
colID := sql.ColumnNodeID(dialect, col.Schema, col.Table, col.Column)
if _, ok := seenNodes[colID]; !ok {
seenNodes[colID] = struct{}{}
meta := map[string]any{
"table": col.Table,
"column": col.Column,
"dialect": dialect,
}
if col.Schema != "" {
meta["schema"] = col.Schema
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: colID,
Kind: graph.KindColumn,
Name: col.Column,
FilePath: filePath,
Language: "go",
Meta: meta,
})
}
edgeKind := graph.EdgeReadsCol
if col.Op == "write" {
edgeKind = graph.EdgeWritesCol
}
result.Edges = append(result.Edges, &graph.Edge{
From: callerID,
To: colID,
Kind: edgeKind,
FilePath: filePath,
Line: e.line,
Origin: graph.OriginTextMatched,
Meta: map[string]any{
"method": e.method,
},
})
}
}
}