package languages import ( "crypto/sha1" "encoding/hex" "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/routeguard" ) // goStringContext labels the API position the literal was found in. // Used as a discriminator on KindString node IDs and on the // EdgeEmits.Meta["context"] field so analyzers can filter by domain. type goStringContext string const ( stringCtxMetric goStringContext = "metric" stringCtxErrorMsg goStringContext = "error_msg" stringCtxRoute goStringContext = "route" stringCtxSQL goStringContext = "sql" stringCtxLogMessage goStringContext = "log_message" ) // goMetricMethods is the whitelist of method names where the first // string-literal argument is taken as the metric name. Limited to // statsd / dogstatsd-style APIs (which always pass the metric name // as the first arg). Prometheus needs a separate composite-literal // extractor (CounterOpts{Name: "..."} etc.) — out of scope here. // // Generic method names like Set / Inc / Add are deliberately // excluded — they appear on too many unrelated types. var goMetricMethods = map[string]bool{ "Increment": true, // statsd, dogstatsd "Decrement": true, "Count": true, "Gauge": true, "Histogram": true, "Distribution": true, "Timing": true, "TimeInMilliseconds": true, "Event": true, // dogstatsd Event "ServiceCheck": true, // dogstatsd ServiceCheck } // goErrorMessageCalls maps (package-or-receiver, function) to the // "error_msg" context. Both shapes look like selector calls // syntactically, so we match against the receiver name as a // heuristic — `errors.New(...)` and `fmt.Errorf(...)` are by far the // dominant idioms. var goErrorMessageCalls = map[[2]string]bool{ {"errors", "New"}: true, {"fmt", "Errorf"}: true, {"xerrors", "New"}: true, // golang.org/x/xerrors {"xerrors", "Errorf"}: true, } // goRouteMethods is the set of method names that, when called with a // string-literal path-like first argument, emit a "route" node. // Mixes net/http (HandleFunc/Handle), gorilla/mux (HandleFunc/Handle), // chi (Get/Post/...), gin/echo (GET/POST/...), and a handful of // common router shapes that don't go through the contracts pipeline. var goRouteMethods = map[string]bool{ "Handle": true, "HandleFunc": true, "Get": true, "Post": true, "Put": true, "Delete": true, "Patch": true, "Options": true, "Head": true, "Connect": true, "Trace": true, "GET": true, "POST": true, "PUT": true, "DELETE": true, "PATCH": true, "OPTIONS": true, "HEAD": true, "CONNECT": true, "TRACE": true, } // goStringEvent is one deferred string-literal observation, queued // during AST traversal and flushed at end-of-file by emitGoStringEvents. // // nodeMeta / edgeMeta are optional; when non-empty their entries are // merged into the KindString node's Meta and the EdgeEmits' Meta // respectively. Used to carry domain hints — `dialect` and `tables` // for SQL strings, `level` for log messages — that downstream // analyzers (sql_rebuild, log_events, error_surface registry) read // without re-parsing the value. type goStringEvent struct { context goStringContext method string value string line int nodeMeta map[string]any edgeMeta map[string]any } // detectGoMetric checks a method call against the metric whitelist; // returns the metric name when arg[0] is a string literal. func detectGoMetric(callExpr *sitter.Node, method string, src []byte) (string, bool) { if callExpr == nil { return "", false } if !goMetricMethods[method] { return "", false } return firstStringLiteralArg(callExpr, src) } // detectGoErrorMessage checks for errors.New / fmt.Errorf-style calls // where the first argument is a string literal. func detectGoErrorMessage(callExpr *sitter.Node, receiver, method string, src []byte) (string, bool) { if callExpr == nil { return "", false } if !goErrorMessageCalls[[2]string{receiver, method}] { return "", false } return firstStringLiteralArg(callExpr, src) } // detectGoRoute checks for HTTP-router shapes where arg[0] is a // path-like string literal. Path-likeness is enforced (must start // with "/" or contain a "/" segment) to suppress false positives // from generic method names like Get/Set on map-like types. func detectGoRoute(callExpr *sitter.Node, method string, src []byte) (string, bool) { if callExpr == nil { return "", false } if !goRouteMethods[method] { return "", false } value, ok := firstStringLiteralArg(callExpr, src) if !ok { return "", false } if !looksLikeRoute(value, method) { return "", false } return value, true } // looksLikeRoute is a cheap sanity check — keeps map.Get("foo") // and similar generics out of the route bucket. Accepts paths that // start with "/" (most common), "GET /…" / "POST /…" mux-1.22 form, // or a wildcard segment. func looksLikeRoute(s, calleeName string) bool { if s == "" { return false } // The path literal we ultimately classify. For the net/http 1.22+ // "VERB /foo" pattern form, isolate the rooted path portion so the route // guard sees a literal it can judge ("GET /etc/passwd" must still be // rejected as a filesystem path, while "GET /users" stays a route). candidate := s for _, m := range []string{"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "OPTIONS ", "HEAD "} { if strings.HasPrefix(s, m) { candidate = strings.TrimSpace(s[len(m):]) break } } // Only rooted "/..." literals reach the route bucket; a bare identifier // (map.Get("foo")) or a verb with no rooted path is not a route. if !strings.HasPrefix(candidate, "/") { return false } // The shape heuristic accepted the literal — require the route guard to // agree, so filesystem/config strings stop minting false routes. return routeguard.IsLikelyHTTPRoute(candidate, calleeName) } // firstStringLiteralArg returns the value of the first string-literal // argument of a call expression, with surrounding quotes stripped. The // helper is shared with detectGoLogEvent's logic but kept separate so // each context can apply its own filtering. func firstStringLiteralArg(callExpr *sitter.Node, src []byte) (string, bool) { args := callExpr.ChildByFieldName("arguments") if args == nil { return "", false } for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ { c := args.NamedChild(i) if c == nil { continue } if c.Type() != "interpreted_string_literal" && c.Type() != "raw_string_literal" { continue } text := strings.Trim(c.Content(src), "\"`") if text == "" { return "", false } return text, true } return "", false } // emitGoStringEvents creates one KindString node per (context, value) // pair seen and an EdgeEmits from the enclosing function/method to // each. Mirrors emitGoObservabilityEvents — same per-repo dedup // behaviour, same caller-line lookup contract. func emitGoStringEvents(events []goStringEvent, callerLookup func(line int) string, filePath string, result *parser.ExtractionResult) { if len(events) == 0 { return } seen := make(map[string]struct{}, len(events)) for _, e := range events { callerID := callerLookup(e.line) if callerID == "" { continue } strID := goStringNodeID(e.context, e.value) if _, ok := seen[strID]; !ok { seen[strID] = struct{}{} nodeMeta := map[string]any{ "context": string(e.context), "value": e.value, } maps.Copy(nodeMeta, e.nodeMeta) result.Nodes = append(result.Nodes, &graph.Node{ ID: strID, Kind: graph.KindString, Name: e.value, FilePath: filePath, // first sighting; not authoritative Language: "go", Meta: nodeMeta, }) } edgeMeta := map[string]any{ "context": string(e.context), "method": e.method, } maps.Copy(edgeMeta, e.edgeMeta) result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: strID, Kind: graph.EdgeEmits, FilePath: filePath, Line: e.line, Origin: graph.OriginASTInferred, Meta: edgeMeta, }) } } // goStringNodeID composes the canonical synthetic ID for a string // node. Long values (over 200 chars) are hashed to keep IDs sane — // the original text is preserved in node.Name and node.Meta["value"]. func goStringNodeID(ctx goStringContext, value string) string { if len(value) > 200 { h := sha1.Sum([]byte(value)) return "string::" + string(ctx) + "::sha1:" + hex.EncodeToString(h[:])[:16] } return "string::" + string(ctx) + "::" + value }