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
499 lines
14 KiB
Go
499 lines
14 KiB
Go
package languages
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
|
)
|
|
|
|
// detectGoORMModel inspects a Go struct's field list for ORM signals
|
|
// (gorm tags, embedded gorm.Model) and, when found, emits an
|
|
// EdgeModelsTable from the type to a synthetic KindTable node so
|
|
// `analyze kind=models` can answer "which class persists which table?"
|
|
// without re-deriving it from migrations.
|
|
//
|
|
// Table-name resolution at parse time uses the gorm-default convention:
|
|
// snake_case + plural (User → users, OrderLine → order_lines). A
|
|
// `TableName()` method override on the receiver type is detected in the
|
|
// indexer post-pass via inferGoORMTableNameOverrides — we can't see
|
|
// methods at struct-decl time because they're emitted by emitMethod
|
|
// independently.
|
|
//
|
|
// The KindTable target ID follows the existing convention from
|
|
// internal/parser/languages/go_sql.go: `db::generic::<schema>.<table>`
|
|
// without a schema prefix becomes `db::generic::<table>` when the
|
|
// dialect is unknown. We use "orm" as the dialect tag so analyzers can
|
|
// distinguish raw-SQL provenance ("go_sql") from ORM-derived nodes.
|
|
func detectGoORMModel(structNode *sitter.Node, src []byte, ownerID, ownerName, filePath string, result *parser.ExtractionResult) {
|
|
if structNode == nil {
|
|
return
|
|
}
|
|
fieldList := goStructFieldList(structNode)
|
|
if fieldList == nil {
|
|
return
|
|
}
|
|
|
|
hasGormTag := false
|
|
embedsGormModel := false
|
|
for i, _nc := 0, int(fieldList.NamedChildCount()); i < _nc; i++ {
|
|
decl := fieldList.NamedChild(i)
|
|
if decl == nil || decl.Type() != "field_declaration" {
|
|
continue
|
|
}
|
|
if structFieldHasGormTag(decl, src) {
|
|
hasGormTag = true
|
|
}
|
|
if structFieldEmbedsGormModel(decl, src) {
|
|
embedsGormModel = true
|
|
}
|
|
}
|
|
if !hasGormTag && !embedsGormModel {
|
|
return
|
|
}
|
|
|
|
tableName := defaultGormTableName(ownerName)
|
|
tableID := ormTableNodeID(tableName)
|
|
|
|
if !ormTableNodeAlreadyEmitted(result, tableID) {
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: tableID,
|
|
Kind: graph.KindTable,
|
|
Name: tableName,
|
|
FilePath: filePath,
|
|
Language: "go",
|
|
Meta: map[string]any{
|
|
"dialect": "orm",
|
|
"schema": "",
|
|
"source": "go-orm",
|
|
},
|
|
})
|
|
}
|
|
|
|
startLine := int(structNode.StartPoint().Row) + 1
|
|
binding := "gorm-tag"
|
|
if !hasGormTag && embedsGormModel {
|
|
binding = "gorm-embed"
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: ownerID,
|
|
To: tableID,
|
|
Kind: graph.EdgeModelsTable,
|
|
FilePath: filePath,
|
|
Line: startLine,
|
|
Origin: graph.OriginASTResolved,
|
|
Meta: map[string]any{
|
|
"orm": "gorm",
|
|
"binding": binding,
|
|
"table_name": tableName,
|
|
"derivation": "convention",
|
|
},
|
|
})
|
|
}
|
|
|
|
// goStructFieldList returns the field_declaration_list node from a
|
|
// struct_type, or nil when absent.
|
|
func goStructFieldList(structNode *sitter.Node) *sitter.Node {
|
|
for i, _nc := 0, int(structNode.ChildCount()); i < _nc; i++ {
|
|
c := structNode.Child(i)
|
|
if c != nil && c.Type() == "field_declaration_list" {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// structFieldHasGormTag reports whether a field_declaration carries a
|
|
// `gorm:"..."` struct tag.
|
|
func structFieldHasGormTag(decl *sitter.Node, src []byte) bool {
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c == nil || c.Type() != "raw_string_literal" {
|
|
continue
|
|
}
|
|
text := c.Content(src)
|
|
if strings.Contains(text, "gorm:") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// structFieldEmbedsGormModel reports whether a field_declaration is an
|
|
// embedded field of type `gorm.Model`. Catches both `gorm.Model` and
|
|
// `*gorm.Model` shapes.
|
|
func structFieldEmbedsGormModel(decl *sitter.Node, src []byte) bool {
|
|
hasName := false
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
if c.Type() == "field_identifier" {
|
|
hasName = true
|
|
break
|
|
}
|
|
}
|
|
if hasName {
|
|
// An explicit field name means it isn't an embed.
|
|
return false
|
|
}
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
switch c.Type() {
|
|
case "qualified_type":
|
|
text := strings.TrimSpace(c.Content(src))
|
|
if text == "gorm.Model" {
|
|
return true
|
|
}
|
|
case "pointer_type":
|
|
// `*gorm.Model` — the qualified_type lives inside.
|
|
for j, _nc := 0, int(c.NamedChildCount()); j < _nc; j++ {
|
|
inner := c.NamedChild(j)
|
|
if inner != nil && inner.Type() == "qualified_type" &&
|
|
strings.TrimSpace(inner.Content(src)) == "gorm.Model" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// defaultGormTableName returns gorm's default table name for a struct
|
|
// type — snake_case + plural. Mirrors gorm's NamingStrategy.TableName
|
|
// without depending on it: User → users, OrderLine → order_lines,
|
|
// HTTPHandler → http_handlers.
|
|
func defaultGormTableName(structName string) string {
|
|
if structName == "" {
|
|
return ""
|
|
}
|
|
snake := camelToSnake(structName)
|
|
return pluralize(snake)
|
|
}
|
|
|
|
// camelToSnake converts CamelCase / PascalCase to snake_case. Handles
|
|
// acronyms by treating runs of uppercase letters followed by lowercase
|
|
// as one word boundary (HTTPHandler → http_handler, not h_t_t_p_handler).
|
|
func camelToSnake(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
runes := []rune(s)
|
|
for i, r := range runes {
|
|
if i > 0 && isUpper(r) {
|
|
prev := runes[i-1]
|
|
next := rune(0)
|
|
if i+1 < len(runes) {
|
|
next = runes[i+1]
|
|
}
|
|
// Insert separator at:
|
|
// - lower→upper boundary (orderLine → order_line)
|
|
// - end of an acronym run (HTTPHandler → HTTP_Handler):
|
|
// upper followed by lower means we just left the run.
|
|
if !isUpper(prev) || (next != 0 && !isUpper(next)) {
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
b.WriteRune(ormToLower(r))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// pluralize returns the gorm-default plural form. Handles the common
|
|
// English suffix rules gorm itself implements:
|
|
// - ending in s, x, z, ch, sh: append "es"
|
|
// - ending in consonant + y: change "y" to "ies"
|
|
// - otherwise: append "s"
|
|
//
|
|
// Anything fancier (man → men, child → children) is intentionally not
|
|
// covered — gorm itself doesn't either, and over-correcting would
|
|
// disagree with the actual table names users created.
|
|
func pluralize(word string) string {
|
|
if word == "" {
|
|
return ""
|
|
}
|
|
if strings.HasSuffix(word, "s") || strings.HasSuffix(word, "x") ||
|
|
strings.HasSuffix(word, "z") || strings.HasSuffix(word, "ch") ||
|
|
strings.HasSuffix(word, "sh") {
|
|
return word + "es"
|
|
}
|
|
if strings.HasSuffix(word, "y") && len(word) >= 2 {
|
|
prev := word[len(word)-2]
|
|
if !isVowelByte(prev) {
|
|
return word[:len(word)-1] + "ies"
|
|
}
|
|
}
|
|
return word + "s"
|
|
}
|
|
|
|
func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
|
|
func ormToLower(r rune) rune {
|
|
if isUpper(r) {
|
|
return r + ('a' - 'A')
|
|
}
|
|
return r
|
|
}
|
|
func isVowelByte(b byte) bool {
|
|
switch b {
|
|
case 'a', 'e', 'i', 'o', 'u':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ormTableNodeID returns the canonical KindTable node ID for an ORM-
|
|
// derived table. The "db::orm::" prefix matches the existing
|
|
// "db::<dialect>::<schema>.<table>" convention from go_sql.go and
|
|
// keeps ORM-derived nodes distinguishable from raw-SQL ones for
|
|
// analyzers that care about provenance.
|
|
func ormTableNodeID(tableName string) string {
|
|
if tableName == "" {
|
|
return ""
|
|
}
|
|
return "db::orm::" + tableName
|
|
}
|
|
|
|
// ormTableNodeAlreadyEmitted reports whether result.Nodes already
|
|
// contains a node with the given ID. ExtractionResult is per-file so
|
|
// the dedup window is small; the linear scan is cheaper than a map.
|
|
func ormTableNodeAlreadyEmitted(result *parser.ExtractionResult, id string) bool {
|
|
if result == nil {
|
|
return false
|
|
}
|
|
for _, n := range result.Nodes {
|
|
if n != nil && n.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rewireORMTableNameOverrides walks the file's AST for
|
|
// `func (T) TableName() string { return "..." }` methods and rewires
|
|
// any convention-derived EdgeModelsTable for type T to the literal
|
|
// table name. Mutates result.Edges and result.Nodes in place.
|
|
//
|
|
// Why a post-pass: at struct-decl time the extractor only sees the
|
|
// struct body — it can't know whether a sibling method overrides the
|
|
// default name. Method emission runs in the same Extract pass but on a
|
|
// different match, so a single sweep at the end is the cheapest place
|
|
// to reconcile the two.
|
|
func rewireORMTableNameOverrides(root *sitter.Node, src []byte, result *parser.ExtractionResult) {
|
|
if root == nil || result == nil {
|
|
return
|
|
}
|
|
overrides := collectGoTableNameOverrides(root, src)
|
|
if len(overrides) == 0 {
|
|
return
|
|
}
|
|
// Build a quick `<owner-type-name> → existing edge` index over
|
|
// EdgeModelsTable edges so we can patch them by receiver.
|
|
type modelEdge struct {
|
|
edge *graph.Edge
|
|
ownerName string
|
|
}
|
|
var modelEdges []modelEdge
|
|
for _, e := range result.Edges {
|
|
if e == nil || e.Kind != graph.EdgeModelsTable {
|
|
continue
|
|
}
|
|
ownerName := goOwnerNameFromTypeID(e.From)
|
|
modelEdges = append(modelEdges, modelEdge{edge: e, ownerName: ownerName})
|
|
}
|
|
if len(modelEdges) == 0 {
|
|
return
|
|
}
|
|
for ownerName, tableName := range overrides {
|
|
newID := ormTableNodeID(tableName)
|
|
// Ensure the explicit table node exists; the convention-
|
|
// derived KindTable node we previously emitted may have a
|
|
// different name and stays in the graph (dropping it would
|
|
// orphan any other edges that pointed at it). The
|
|
// EdgeModelsTable rewire is the only relationship that
|
|
// matters for the table-name override semantics.
|
|
if !ormTableNodeAlreadyEmitted(result, newID) {
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: newID,
|
|
Kind: graph.KindTable,
|
|
Name: tableName,
|
|
Language: "go",
|
|
FilePath: firstFilePath(result),
|
|
Meta: map[string]any{
|
|
"dialect": "orm",
|
|
"schema": "",
|
|
"source": "go-orm",
|
|
},
|
|
})
|
|
}
|
|
for _, me := range modelEdges {
|
|
if me.ownerName != ownerName {
|
|
continue
|
|
}
|
|
me.edge.To = newID
|
|
if me.edge.Meta == nil {
|
|
me.edge.Meta = map[string]any{}
|
|
}
|
|
me.edge.Meta["table_name"] = tableName
|
|
me.edge.Meta["derivation"] = "override"
|
|
}
|
|
}
|
|
}
|
|
|
|
// collectGoTableNameOverrides returns a map from receiver-type name to
|
|
// the literal string the type's TableName() method returns. Only
|
|
// matches the gorm-shaped signature: a method named TableName with no
|
|
// parameters and a single string-literal return.
|
|
func collectGoTableNameOverrides(root *sitter.Node, src []byte) map[string]string {
|
|
out := make(map[string]string)
|
|
walkAST(root, func(n *sitter.Node) bool {
|
|
if n == nil || n.Type() != "method_declaration" {
|
|
return true
|
|
}
|
|
nameNode := n.ChildByFieldName("name")
|
|
if nameNode == nil || nameNode.Content(src) != "TableName" {
|
|
return true
|
|
}
|
|
recv := receiverTypeFromMethodNode(n, src)
|
|
if recv == "" {
|
|
return true
|
|
}
|
|
body := n.ChildByFieldName("body")
|
|
if body == nil {
|
|
return true
|
|
}
|
|
literal := firstStringReturnLiteral(body, src)
|
|
if literal == "" {
|
|
return true
|
|
}
|
|
out[recv] = literal
|
|
return false // stop descending into the method body
|
|
})
|
|
return out
|
|
}
|
|
|
|
// walkAST is a small DFS helper. visit returns false to skip n's
|
|
// subtree, true to continue.
|
|
func walkAST(n *sitter.Node, visit func(*sitter.Node) bool) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
if !visit(n) {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walkAST(n.NamedChild(i), visit)
|
|
}
|
|
}
|
|
|
|
// receiverTypeFromMethodNode returns the bare receiver type name from
|
|
// a method_declaration. Strips pointer wrappers — a method on `*User`
|
|
// and a method on `User` both attach to the User type for the
|
|
// EdgeModelsTable lookup.
|
|
func receiverTypeFromMethodNode(n *sitter.Node, src []byte) string {
|
|
recv := n.ChildByFieldName("receiver")
|
|
if recv == nil {
|
|
return ""
|
|
}
|
|
for i, _nc := 0, int(recv.NamedChildCount()); i < _nc; i++ {
|
|
decl := recv.NamedChild(i)
|
|
if decl == nil || decl.Type() != "parameter_declaration" {
|
|
continue
|
|
}
|
|
typeNode := decl.ChildByFieldName("type")
|
|
if typeNode == nil {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(typeNode.Content(src))
|
|
text = strings.TrimPrefix(text, "*")
|
|
// Strip generic parameter list, e.g. `User[T]` → `User`.
|
|
if i := strings.Index(text, "["); i > 0 {
|
|
text = text[:i]
|
|
}
|
|
return text
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstStringReturnLiteral returns the literal text of the first
|
|
// `return "..."` statement reachable from body, with quotes stripped.
|
|
// Empty when the return value isn't a single string literal.
|
|
func firstStringReturnLiteral(body *sitter.Node, src []byte) string {
|
|
var found string
|
|
walkAST(body, func(n *sitter.Node) bool {
|
|
if found != "" {
|
|
return false
|
|
}
|
|
if n == nil || n.Type() != "return_statement" {
|
|
return true
|
|
}
|
|
// return_statement's first named child is the expression list.
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
c := n.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
if c.Type() == "expression_list" && c.NamedChildCount() == 1 {
|
|
inner := c.NamedChild(0)
|
|
if inner != nil && inner.Type() == "interpreted_string_literal" {
|
|
found = stripGoStringQuotes(inner.Content(src))
|
|
return false
|
|
}
|
|
if inner != nil && inner.Type() == "raw_string_literal" {
|
|
found = strings.Trim(inner.Content(src), "`")
|
|
return false
|
|
}
|
|
}
|
|
if c.Type() == "interpreted_string_literal" {
|
|
found = stripGoStringQuotes(c.Content(src))
|
|
return false
|
|
}
|
|
if c.Type() == "raw_string_literal" {
|
|
found = strings.Trim(c.Content(src), "`")
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
return found
|
|
}
|
|
|
|
// stripGoStringQuotes removes the surrounding double quotes from a Go
|
|
// interpreted_string_literal. Doesn't decode escapes — the table name
|
|
// is almost always a simple ASCII identifier; agents can ask the
|
|
// runtime if they care about exotic escapes.
|
|
func stripGoStringQuotes(s string) string {
|
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// goOwnerNameFromTypeID extracts the bare type name from a node ID of
|
|
// shape `<file>::<TypeName>`. Returns "" when the ID doesn't follow
|
|
// the convention.
|
|
func goOwnerNameFromTypeID(id string) string {
|
|
if i := strings.LastIndex(id, "::"); i >= 0 {
|
|
return id[i+2:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstFilePath returns the FilePath of the first node in result, or
|
|
// empty when the result is empty. Used to anchor synthetic table
|
|
// nodes — without a file path the per-file dedup logic mis-buckets
|
|
// them.
|
|
func firstFilePath(result *parser.ExtractionResult) string {
|
|
for _, n := range result.Nodes {
|
|
if n != nil && n.FilePath != "" {
|
|
return n.FilePath
|
|
}
|
|
}
|
|
return ""
|
|
}
|