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
226 lines
7.5 KiB
Go
226 lines
7.5 KiB
Go
package contracts
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// GraphQLExtractor detects GraphQL schema definitions (providers) and query usage (consumers).
|
|
type GraphQLExtractor struct{}
|
|
|
|
var (
|
|
// Schema providers: type Query { field: Type } or type Mutation { ... }
|
|
gqlTypeBlockRe = regexp.MustCompile(`(?m)type\s+(Query|Mutation|Subscription)\s*\{([^}]*)\}`)
|
|
gqlFieldRe = regexp.MustCompile(`(?m)^\s*(\w+)`)
|
|
|
|
// Consumer query strings: query { users { ... } } or mutation { createUser(...) { ... } }
|
|
gqlQueryOpRe = regexp.MustCompile(`(?m)(query|mutation|subscription)\s*(?:\w+\s*)?\{([^}]*)\}`)
|
|
gqlGqlTagRe = regexp.MustCompile("(?s)gql`([^`]*)`")
|
|
gqlTopFieldRe = regexp.MustCompile(`(?m)^\s*(\w+)`)
|
|
)
|
|
|
|
func (e *GraphQLExtractor) SupportedLanguages() []string {
|
|
return []string{"graphql", "go", "typescript", "javascript", "python"}
|
|
}
|
|
|
|
// gqlConsumerMarkers is the substring prefilter for the consumer
|
|
// scan. Every consumer pattern needs one of:
|
|
// - a gql“ tagged template literal (marker: "gql`")
|
|
// - a bare `query` / `mutation` / `subscription` operation block
|
|
//
|
|
// Reject any file that contains none. Schema files (.graphql/.gql)
|
|
// are already routed to extractSchemaProviders before this check.
|
|
var gqlConsumerMarkers = [][]byte{
|
|
[]byte("gql`"),
|
|
[]byte("query"),
|
|
[]byte("mutation"),
|
|
[]byte("subscription"),
|
|
}
|
|
|
|
func (e *GraphQLExtractor) Extract(filePath string, src []byte, nodes []*graph.Node, edges []*graph.Edge) []Contract {
|
|
var contracts []Contract
|
|
|
|
isSchemaFile := strings.HasSuffix(filePath, ".graphql") || strings.HasSuffix(filePath, ".gql")
|
|
if isSchemaFile {
|
|
contracts = append(contracts, e.extractSchemaProviders(filePath, src)...)
|
|
}
|
|
|
|
// NestJS code-first resolvers (@Query/@Mutation/@Subscription) live in
|
|
// source files and must run before the consumer-marker prefilter below,
|
|
// which would otherwise short-circuit a resolver file with no gql tag.
|
|
contracts = append(contracts, e.extractNestResolvers(filePath, src, nodes)...)
|
|
|
|
// Consumer scan: skip entirely when the file has no marker
|
|
// substring that any consumer regex could possibly match. A
|
|
// schema-only .graphql file lacks lowercase `query`/`mutation`/
|
|
// `subscription` operation blocks and no gql-tagged templates,
|
|
// so it short-circuits here — the provider pass ran above.
|
|
if !srcHasAnyMarker(src, gqlConsumerMarkers) {
|
|
return contracts
|
|
}
|
|
|
|
// Consumer queries can live in any source file. Thread file nodes
|
|
// through so findEnclosingSymbol can anchor each consumer contract
|
|
// on its calling function — required for EdgeMatches bridges.
|
|
fileNodes := filterFileNodes(filePath, nodes)
|
|
sort.Slice(fileNodes, func(i, j int) bool {
|
|
return fileNodes[i].StartLine < fileNodes[j].StartLine
|
|
})
|
|
contracts = append(contracts, e.extractConsumers(filePath, src, fileNodes)...)
|
|
|
|
return contracts
|
|
}
|
|
|
|
func (e *GraphQLExtractor) extractSchemaProviders(filePath string, src []byte) []Contract {
|
|
var contracts []Contract
|
|
text := string(src)
|
|
lines := strings.Split(text, "\n")
|
|
|
|
for _, blockMatch := range gqlTypeBlockRe.FindAllStringSubmatchIndex(text, -1) {
|
|
typeName := text[blockMatch[2]:blockMatch[3]] // Query, Mutation, Subscription
|
|
body := text[blockMatch[4]:blockMatch[5]]
|
|
bodyOffset := blockMatch[4]
|
|
|
|
for _, fLine := range strings.Split(body, "\n") {
|
|
fLine = strings.TrimSpace(fLine)
|
|
if fLine == "" || strings.HasPrefix(fLine, "#") {
|
|
continue
|
|
}
|
|
fm := gqlFieldRe.FindStringSubmatch(fLine)
|
|
if fm == nil {
|
|
continue
|
|
}
|
|
fieldName := fm[1]
|
|
// Approximate offset for line number.
|
|
idx := strings.Index(text[bodyOffset:], fLine)
|
|
line := 1
|
|
if idx >= 0 {
|
|
line = lineNumber(lines, bodyOffset+idx)
|
|
}
|
|
contracts = append(contracts, Contract{
|
|
ID: fmt.Sprintf("graphql::%s::%s", typeName, fieldName),
|
|
Type: ContractGraphQL,
|
|
Role: RoleProvider,
|
|
FilePath: filePath,
|
|
Line: line,
|
|
Meta: map[string]any{"operation": typeName, "field": fieldName},
|
|
Confidence: 0.95,
|
|
})
|
|
}
|
|
}
|
|
|
|
return contracts
|
|
}
|
|
|
|
// gqlNestOpRe matches a NestJS code-first operation decorator.
|
|
var gqlNestOpRe = regexp.MustCompile(`@(Query|Mutation|Subscription)\s*\(`)
|
|
|
|
// gqlNestNameRe pulls an explicit `name: 'foo'` option out of the decorator.
|
|
var gqlNestNameRe = regexp.MustCompile(`name:\s*["']([^"']+)["']`)
|
|
|
|
// extractNestResolvers detects NestJS code-first resolver operations —
|
|
// @Query/@Mutation/@Subscription on a resolver method — and records each as a
|
|
// GraphQL provider. The operation field is the explicit `name:` option when
|
|
// present, otherwise the decorated method's name.
|
|
func (e *GraphQLExtractor) extractNestResolvers(filePath string, src []byte, nodes []*graph.Node) []Contract {
|
|
text := string(src)
|
|
if !strings.Contains(text, "@Query(") && !strings.Contains(text, "@Mutation(") && !strings.Contains(text, "@Subscription(") {
|
|
return nil
|
|
}
|
|
lines := strings.Split(text, "\n")
|
|
fileNodes := filterFileNodes(filePath, nodes)
|
|
|
|
var out []Contract
|
|
for i, line := range lines {
|
|
m := gqlNestOpRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
opType := m[1] // Query | Mutation | Subscription
|
|
handlerName, _ := nestHandlerAfter(lines, i)
|
|
field := ""
|
|
if nm := gqlNestNameRe.FindStringSubmatch(line); nm != nil {
|
|
field = nm[1]
|
|
} else {
|
|
field = handlerName
|
|
}
|
|
if field == "" {
|
|
continue
|
|
}
|
|
symbolID := ""
|
|
if handlerName != "" {
|
|
symbolID = findFunctionByName(fileNodes, handlerName)
|
|
}
|
|
out = append(out, Contract{
|
|
ID: fmt.Sprintf("graphql::%s::%s", opType, field),
|
|
Type: ContractGraphQL,
|
|
Role: RoleProvider,
|
|
SymbolID: symbolID,
|
|
FilePath: filePath,
|
|
Line: i + 1,
|
|
Meta: map[string]any{"operation": opType, "field": field, "framework": "nestjs"},
|
|
Confidence: 0.9,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e *GraphQLExtractor) extractConsumers(filePath string, src []byte, fileNodes []*graph.Node) []Contract {
|
|
var contracts []Contract
|
|
text := string(src)
|
|
lines := strings.Split(text, "\n")
|
|
|
|
// Direct query/mutation/subscription operations.
|
|
for _, m := range gqlQueryOpRe.FindAllStringSubmatchIndex(text, -1) {
|
|
opType := text[m[2]:m[3]]
|
|
body := text[m[4]:m[5]]
|
|
contracts = append(contracts, e.fieldsFromBody(filePath, opType, body, lines, m[0], fileNodes)...)
|
|
}
|
|
|
|
// gql`` tagged template literals.
|
|
for _, m := range gqlGqlTagRe.FindAllStringSubmatchIndex(text, -1) {
|
|
inner := text[m[2]:m[3]]
|
|
// Try to find the operation keyword inside the template.
|
|
for _, opM := range gqlQueryOpRe.FindAllStringSubmatch(inner, -1) {
|
|
contracts = append(contracts, e.fieldsFromBody(filePath, opM[1], opM[2], lines, m[0], fileNodes)...)
|
|
}
|
|
}
|
|
|
|
return contracts
|
|
}
|
|
|
|
func (e *GraphQLExtractor) fieldsFromBody(filePath, opType, body string, lines []string, baseOffset int, fileNodes []*graph.Node) []Contract {
|
|
var contracts []Contract
|
|
opTypeCap := strings.ToUpper(opType[:1]) + opType[1:]
|
|
ln := lineNumber(lines, baseOffset)
|
|
sym := findEnclosingSymbol(fileNodes, ln)
|
|
|
|
for _, fLine := range strings.Split(body, "\n") {
|
|
fLine = strings.TrimSpace(fLine)
|
|
if fLine == "" {
|
|
continue
|
|
}
|
|
fm := gqlTopFieldRe.FindStringSubmatch(fLine)
|
|
if fm == nil {
|
|
continue
|
|
}
|
|
fieldName := fm[1]
|
|
contracts = append(contracts, Contract{
|
|
ID: fmt.Sprintf("graphql::%s::%s", opTypeCap, fieldName),
|
|
Type: ContractGraphQL,
|
|
Role: RoleConsumer,
|
|
SymbolID: sym,
|
|
FilePath: filePath,
|
|
Line: ln,
|
|
Meta: map[string]any{"operation": opTypeCap, "field": fieldName},
|
|
Confidence: 0.8,
|
|
})
|
|
}
|
|
|
|
return contracts
|
|
}
|