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

303 lines
11 KiB
Go

package languages
import (
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// Event pub/sub detection (F15). Mirrors the observability extractor's
// shape — a method-name + first-string-literal-argument heuristic — but
// classifies the call as a *publish* or *subscribe* operation against a
// message broker (NATS / Kafka / RabbitMQ / Redis pub-sub) or an
// in-process event channel (Node EventEmitter / Socket.IO). Publishers
// emit EdgeEmits and subscribers EdgeListensOn, both targeting a shared
// KindEvent topic node so "who publishes / who listens on topic X" is a
// single node's in-edge walk split by edge kind.
//
// The language extractors do the AST-shaped work (find the call, pull
// the receiver method name, extract the first string-literal argument);
// this file owns the cross-language classification, transport
// inference, node-ID convention, and graph-artifact emission so every
// language stays consistent.
// pubsubRolePublish / pubsubRoleSubscribe are the two operation classes
// a recognised pub/sub call falls into.
const (
pubsubRolePublish = "publish"
pubsubRoleSubscribe = "subscribe"
)
// pubsubClassification describes how a method name maps onto the pub/sub
// layer.
//
// role — pubsubRolePublish | pubsubRoleSubscribe.
// weak — the name is generic enough (emit / on / send / publish / …)
// that it should only be treated as pub/sub when the file also
// imports a recognised pub/sub library. Distinctive names
// (WriteMessages / basic_publish / QueueSubscribe / …) are not
// weak — the name alone is strong evidence.
// hint — a transport label when the method name alone identifies the
// broker; "" when the transport must be inferred from imports.
type pubsubClassification struct {
role string
weak bool
hint string
}
// pubsubMethods maps a call's method (or function) name to its pub/sub
// classification. Case-sensitive on purpose: Go brokers use PascalCase
// (Publish / Subscribe / WriteMessages), JS/Python use camelCase /
// snake_case (publish / basic_publish), and matching exactly keeps the
// false-positive surface small.
var pubsubMethods = map[string]pubsubClassification{
// EventEmitter / Socket.IO — generic names, import-gated.
"emit": {pubsubRolePublish, true, ""},
"on": {pubsubRoleSubscribe, true, ""},
"once": {pubsubRoleSubscribe, true, ""},
"addListener": {pubsubRoleSubscribe, true, "eventemitter"},
"prependListener": {pubsubRoleSubscribe, true, "eventemitter"},
"prependOnceListener": {pubsubRoleSubscribe, true, "eventemitter"},
// NATS (nats.go / nats.js / nats-py).
"Publish": {pubsubRolePublish, true, ""},
"publish": {pubsubRolePublish, true, ""},
"PublishMsg": {pubsubRolePublish, false, "nats"},
"PublishRequest": {pubsubRolePublish, false, "nats"},
"Subscribe": {pubsubRoleSubscribe, true, ""},
"subscribe": {pubsubRoleSubscribe, true, ""},
"QueueSubscribe": {pubsubRoleSubscribe, false, "nats"},
"SubscribeSync": {pubsubRoleSubscribe, false, "nats"},
"ChanSubscribe": {pubsubRoleSubscribe, false, "nats"},
// Kafka (segmentio/kafka-go, sarama, confluent-kafka-go, kafkajs,
// kafka-python).
"Produce": {pubsubRolePublish, false, "kafka"},
"WriteMessages": {pubsubRolePublish, false, "kafka"},
"SendMessage": {pubsubRolePublish, false, "kafka"},
"SendMessages": {pubsubRolePublish, false, "kafka"},
"SubscribeTopics": {pubsubRoleSubscribe, false, "kafka"},
"send": {pubsubRolePublish, true, ""},
// RabbitMQ (amqp091-go / streadway-amqp, amqplib, pika).
"PublishWithContext": {pubsubRolePublish, false, "rabbitmq"},
"sendToQueue": {pubsubRolePublish, false, "rabbitmq"},
"basic_publish": {pubsubRolePublish, false, "rabbitmq"},
"Consume": {pubsubRoleSubscribe, false, "rabbitmq"},
"consume": {pubsubRoleSubscribe, true, "rabbitmq"},
"basic_consume": {pubsubRoleSubscribe, false, "rabbitmq"},
// Redis pub/sub (go-redis, ioredis / node-redis, redis-py).
"PSubscribe": {pubsubRoleSubscribe, false, "redis"},
"psubscribe": {pubsubRoleSubscribe, false, "redis"},
}
// pubsubLibrary maps an import-path substring to a transport label.
// Entries are checked in order against every import path in a file, so
// the more specific tokens (kafkajs, amqplib) come before their broader
// roots (kafka, amqp).
type pubsubLibrary struct {
substr string
transport string
}
var pubsubLibraries = []pubsubLibrary{
{"kafkajs", "kafka"},
{"segmentio/kafka-go", "kafka"},
{"confluent-kafka", "kafka"},
{"confluentinc", "kafka"},
{"sarama", "kafka"},
{"kafka", "kafka"},
{"amqplib", "rabbitmq"},
{"amqp091", "rabbitmq"},
{"streadway/amqp", "rabbitmq"},
{"rabbitmq", "rabbitmq"},
{"pika", "rabbitmq"},
{"amqp", "rabbitmq"},
{"ioredis", "redis"},
{"go-redis", "redis"},
{"redis", "redis"},
{"socket.io", "socketio"},
{"socketio", "socketio"},
{"eventemitter3", "eventemitter"},
{"eventemitter", "eventemitter"},
{"nats", "nats"},
// React Native's NativeEventEmitter / DeviceEventEmitter is imported from
// the react-native package; its addListener handlers pair with a native
// module's sendEventWithName: emit on the rn_native_event bridge.
{"react-native", rnNativeEventTransport},
}
// inferPubsubTransport scans a file's import paths for a recognised
// pub/sub library and returns its transport label. The Node builtin
// EventEmitter module (`events` / `node:events`) is matched exactly
// rather than as a substring — "events" as a substring would tag far
// too many local module paths.
func inferPubsubTransport(importPaths []string) (string, bool) {
for _, p := range importPaths {
lp := strings.ToLower(strings.TrimSpace(p))
if lp == "events" || lp == "node:events" {
return "eventemitter", true
}
for _, lib := range pubsubLibraries {
if strings.Contains(lp, lib.substr) {
return lib.transport, true
}
}
}
return "", false
}
// resolvePubsubTransport decides the transport label for a recognised
// pub/sub call. Weak (generic-name) calls are dropped entirely unless
// the file imports a pub/sub library — the import is the disambiguator
// that keeps a plain `emitter.on("click", …)` or domain `bus.publish()`
// out of the graph. A method's own transport hint always wins; failing
// that the inferred library transport is used; "unknown" is the last
// resort for a distinctive method name in a file with no recognised
// import.
func resolvePubsubTransport(c pubsubClassification, importPaths []string) (string, bool) {
transport, imported := inferPubsubTransport(importPaths)
if c.weak && !imported {
return "", false
}
// A React Native NativeEventEmitter import re-homes the generic
// EventEmitter subscribe/publish family onto the native bridge channel so
// a JS addListener pairs with a native sendEvent — the rn_ transport wins
// over the method's generic eventemitter hint.
if imported && strings.HasPrefix(transport, "rn_") {
return transport, true
}
if c.hint != "" {
return c.hint, true
}
if imported {
return transport, true
}
return "unknown", true
}
// pubsubEvent is a deferred record of one resolved pub/sub publish or
// subscribe call site. The language extractor fills these in after its
// per-file imports and function ranges are known.
type pubsubEvent struct {
role string // pubsubRolePublish | pubsubRoleSubscribe
transport string // nats|kafka|rabbitmq|redis|socketio|eventemitter|unknown
topic string // first string-literal argument — the channel/subject/topic
method string // the matched call method name
line int // 1-based line of the call expression
}
// classifyPubsubCall is the single decision point every language
// extractor funnels a member call through. Given the call's method
// name, the topic string the extractor pulled from the first
// string-literal argument, and the file's import paths, it returns a
// fully-resolved pubsubEvent. ok is false when the method is not a
// pub/sub operation, the topic is empty, or a weak method fired in a
// file with no pub/sub import.
func classifyPubsubCall(method, topic string, importPaths []string, line int) (pubsubEvent, bool) {
c, known := pubsubMethods[method]
if !known || topic == "" {
return pubsubEvent{}, false
}
transport, ok := resolvePubsubTransport(c, importPaths)
if !ok {
return pubsubEvent{}, false
}
return pubsubEvent{
role: c.role,
transport: transport,
topic: topic,
method: method,
line: line,
}, true
}
// pubsubEventNodeID is the canonical ID for a pub/sub topic node. The
// transport is part of the ID so a Kafka topic and a Redis channel that
// happen to share a name stay distinct nodes — they are different
// systems. The `event::` prefix matches the synthetic-ID convention the
// exporter and applyRepoPrefix already recognise (alongside
// `event::log::`), so topic nodes de-duplicate across files in a repo
// without a real source-file backing.
func pubsubEventNodeID(transport, topic string) string {
return "event::pubsub::" + transport + "::" + topic
}
// emitPubsubEvents materialises one KindEvent topic node per distinct
// (transport, topic) pair plus the EdgeEmits (publish) / EdgeListensOn
// (subscribe) edge from each call site's enclosing function to that
// node. callerLookup maps a 1-based line to the enclosing function ID;
// call sites at file scope (callerLookup returns "") are skipped — a
// pub/sub call needs a function to attribute the edge to.
func emitPubsubEvents(events []pubsubEvent, callerLookup func(line int) string, filePath, language 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
}
nodeID := pubsubEventNodeID(e.transport, e.topic)
if _, ok := seen[nodeID]; !ok {
seen[nodeID] = struct{}{}
result.Nodes = append(result.Nodes, &graph.Node{
ID: nodeID,
Kind: graph.KindEvent,
Name: e.topic,
FilePath: filePath, // first sighting; not authoritative
Language: language,
Meta: map[string]any{
"event_kind": "pubsub",
"transport": e.transport,
"name": e.topic,
},
})
}
edgeKind := graph.EdgeEmits
if e.role == pubsubRoleSubscribe {
edgeKind = graph.EdgeListensOn
}
result.Edges = append(result.Edges, &graph.Edge{
From: callerID,
To: nodeID,
Kind: edgeKind,
FilePath: filePath,
Line: e.line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{
"method": e.method,
"transport": e.transport,
},
})
}
}
// importPathValues flattens an alias→path import map to the sorted set
// of distinct paths, the form inferPubsubTransport consumes. Sorting
// keeps transport inference deterministic when a file imports two
// recognised libraries.
func importPathValues(imports map[string]string) []string {
if len(imports) == 0 {
return nil
}
seen := make(map[string]struct{}, len(imports))
out := make([]string, 0, len(imports))
for _, p := range imports {
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
sort.Strings(out)
return out
}