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

272 lines
12 KiB
Go

package contracts
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// TopicExtractor detects message-broker publish (provider) and
// subscribe (consumer) call sites for the four supported broker
// families: Kafka, RabbitMQ, NATS, and Redis pub-sub. Each detected
// site becomes a ContractTopic with Role provider/consumer and
// Meta["broker"] tagging the family. The Contract.ID encodes
// `topic::<broker>::<name>` so cross-broker isolation is automatic —
// a kafka topic named "foo" and a nats subject named "foo" hash to
// different contract IDs and never pair.
//
// Dynamic topics (variable arguments, function calls, identifiers)
// don't produce contracts: every regex requires a string literal in
// the topic / subject / channel / exchange slot. That means a call
// like `nc.Publish(subject, payload)` where `subject` is a runtime
// expression is invisible to the contract matcher — there's no
// reliable way to resolve it without dataflow, and a heuristic
// string-substitution pass would generate more false pairs than
// true ones.
type TopicExtractor struct{}
// topicPattern carries a regex + the broker family the call belongs to.
// Patterns are evaluated in order; the first match wins for a given
// callsite. The captured group at index 1 must be the topic / subject /
// channel / exchange name.
type topicPattern struct {
re *regexp.Regexp
broker string
}
var (
// Provider (publish/produce) patterns. Order matters: the most
// specific patterns come first so a Kafka `WriteMessages` block
// with `Topic: "x"` isn't accidentally swallowed by the broader
// `.Publish("x"` regex when both shapes appear in the same file.
topicPublishPatterns = []topicPattern{
// Kafka — segmentio/kafka-go Writer.WriteMessages emits one
// or more kafka.Message{Topic: "name", Value: ...} literals.
// We capture every Topic-tagged struct field rather than
// requiring a syntactic association with WriteMessages so
// helper builders that construct kafka.Message values away
// from the call site still produce a producer contract.
// `(?s).*?` allows nested braces (Header arrays, Time fields)
// before the Topic field; the lazy quantifier keeps the match
// short so a second Message literal further down doesn't get
// folded into the first.
{regexp.MustCompile(`(?s)kafka\.Message\s*\{.*?Topic:\s*"([^"]+)"`), "kafka"},
// Kafka — confluent-kafka-go Producer.Produce takes a
// *kafka.Message whose TopicPartition.Topic is a *string.
// The common idiom uses an inline addr-of literal:
// `Topic: &topicName,` or `Topic: kafka.StringPtr("name")`.
// We capture either shape.
{regexp.MustCompile(`(?s)TopicPartition\s*:\s*kafka\.TopicPartition\s*\{.*?Topic:\s*&?"([^"]+)"`), "kafka"},
// Kafka — sarama / kafka-go SendMessage with ProducerMessage{Topic: "..."}.
{regexp.MustCompile(`(?s)ProducerMessage\s*\{.*?Topic:\s*"([^"]+)"`), "kafka"},
// Kafka — kafkajs / TS producer.send({ topic: "name", ... }).
{regexp.MustCompile(`\.send\(\s*\{\s*topic:\s*"([^"]+)"`), "kafka"},
// Kafka — confluent-kafka-go shorthand p.Produce("topic", ...)
// (also covers kafka-python producer.produce("topic", ...)).
{regexp.MustCompile(`\.[Pp]roduce\(\s*"([^"]+)"`), "kafka"},
// RabbitMQ — streadway/amqp + amqp091-go Channel.Publish and
// PublishWithContext. Pair on the exchange (first positional
// string). routing_key is the second positional string but
// the matcher uses exchange identity for pairing.
{regexp.MustCompile(`\.PublishWithContext\(\s*[^,]+,\s*"([^"]+)"`), "rabbitmq"},
{regexp.MustCompile(`\.Publish\(\s*"([^"]+)"\s*,\s*"[^"]+"\s*,`), "rabbitmq"},
// RabbitMQ — amqplib (Node) channel.publish(exchange, routingKey, ...)
// and pika basic_publish(exchange, routing_key, ...).
{regexp.MustCompile(`channel\.publish\(\s*"([^"]+)"`), "rabbitmq"},
{regexp.MustCompile(`basic_publish\([^)]*routing_key\s*=\s*"([^"]+)"`), "rabbitmq"},
// RabbitMQ — Node amqplib sendToQueue(queue, ...).
{regexp.MustCompile(`\.sendToQueue\(\s*"([^"]+)"`), "rabbitmq"},
// Redis — go-redis Client.Publish(ctx, channel, msg). ctx is
// the first positional, so we anchor on `ctx`-style identifiers
// preceding the channel string. The ctx-first signature is the
// dominant go-redis idiom in v8/v9, and pinning the regex here
// keeps NATS' positional-string `.Publish("subject", payload)`
// out of Redis' bucket. ioredis / node-redis publish() goes
// through the `.publish(` lowercase JS variant below.
{regexp.MustCompile(`\.Publish\(\s*[A-Za-z_][A-Za-z0-9_.]*\s*,\s*"([^"]+)"`), "redis"},
// NATS — nats.go Conn.Publish(subject, data) /
// PublishRequest(subject, reply, data) / PublishMsg(msg).
// Subject is the first positional string. Patterns ordered
// specific-first so PublishRequest and PublishMsg win over
// the catchall `.Publish(` regex.
{regexp.MustCompile(`\.PublishRequest\(\s*"([^"]+)"`), "nats"},
{regexp.MustCompile(`\.PublishMsg\(\s*"([^"]+)"`), "nats"},
{regexp.MustCompile(`\.Publish\(\s*"([^"]+)"`), "nats"},
// NATS — nats.js / nats-py publish("subject", ...). Same
// lowercase shape JS Redis would otherwise reach for; Redis
// in the JS ecosystem uses `client.publish` which goes
// through the channel.publish branch (RabbitMQ) anyway.
{regexp.MustCompile(`\.publish\(\s*"([^"]+)"`), "nats"},
}
// Consumer (subscribe/consume) patterns. Same evaluation rules
// as the provider table.
topicSubscribePatterns = []topicPattern{
// Kafka — segmentio/kafka-go Reader literal with Topic field.
// The struct literal can contain nested braces (e.g.
// `Brokers: []string{"..."}`) before the Topic field, so we
// match lazily across them up to the closing `}` rather than
// stopping at the first inner `}`.
{regexp.MustCompile(`(?s)kafka\.ReaderConfig\s*\{.*?Topic:\s*"([^"]+)"`), "kafka"},
// Kafka — confluent-kafka-go Consumer.SubscribeTopics with
// a string-slice literal, and the singular Subscribe variant.
{regexp.MustCompile(`\.SubscribeTopics\(\s*\[\]string\s*\{\s*"([^"]+)"`), "kafka"},
// Kafka — kafkajs / TS consumer.subscribe({ topic: "name" })
// and consumer.run({ topics: ["name"] }).
{regexp.MustCompile(`\.subscribe\(\s*\{\s*topic:\s*"([^"]+)"`), "kafka"},
{regexp.MustCompile(`topics:\s*\[\s*"([^"]+)"`), "kafka"},
// RabbitMQ — streadway/amqp + amqp091-go Channel.Consume(queue, ...).
{regexp.MustCompile(`\.Consume\(\s*"([^"]+)"`), "rabbitmq"},
// RabbitMQ — pika channel.basic_consume(queue="name", ...).
{regexp.MustCompile(`basic_consume\([^)]*queue\s*=\s*"([^"]+)"`), "rabbitmq"},
// RabbitMQ — amqplib (Node) channel.consume("queue", handler).
{regexp.MustCompile(`channel\.consume\(\s*"([^"]+)"`), "rabbitmq"},
// Redis — go-redis Client.Subscribe / PSubscribe with
// ctx-first positional, then the channel string. Subscribe
// accepts variadic channels; we capture the first.
{regexp.MustCompile(`\.PSubscribe\(\s*[A-Za-z_][A-Za-z0-9_.]*\s*,\s*"([^"]+)"`), "redis"},
{regexp.MustCompile(`\.Subscribe\(\s*[A-Za-z_][A-Za-z0-9_.]*\s*,\s*"([^"]+)"`), "redis"},
// Redis — node-redis / ioredis subscribe("channel", handler).
{regexp.MustCompile(`\.[Pp]subscribe\(\s*"([^"]+)"\s*,[^"]*\)`), "redis"},
// NATS — nats.go Conn.QueueSubscribe(subject, queue, handler)
// and the plain Subscribe(subject, handler) variant.
{regexp.MustCompile(`\.QueueSubscribe\(\s*"([^"]+)"`), "nats"},
{regexp.MustCompile(`\.SubscribeSync\(\s*"([^"]+)"`), "nats"},
{regexp.MustCompile(`\.ChanSubscribe\(\s*"([^"]+)"`), "nats"},
{regexp.MustCompile(`\.Subscribe\(\s*"([^"]+)"`), "nats"},
// NATS / Python broker-agnostic subscribe(["name"]) — falls
// through to NATS by default; mis-tagged Kafka subscribers
// with the bare string-list form will still pair correctly
// because both endpoints are tagged with the same broker.
{regexp.MustCompile(`\.subscribe\(\s*\[\s*"([^"]+)"`), "nats"},
}
)
// SupportedLanguages reports the source languages this extractor
// recognises. Go is the primary target; TS/JS/Python patterns ride
// alongside because the regex set shares enough shape with them to
// cover the common producer/consumer idioms.
func (e *TopicExtractor) SupportedLanguages() []string {
return []string{"go", "typescript", "javascript", "python"}
}
// topicPrefilterMarkers covers every publish/subscribe regex without
// redundancy: `basic_publish` / `basic_consume` are omitted because
// `publish` / `consume` are substrings; `topic` (no colon) covers both
// `topic:` and `topics:`. A file without any of these markers cannot
// produce a topic contract, so we short-circuit before the per-broker
// FindAllStringSubmatchIndex passes.
var topicPrefilterMarkers = [][]byte{
[]byte("Publish"), // .Publish( / .PublishWithContext( / channel.publish(
[]byte("publish"), // lowercase + basic_publish + .psubscribe
[]byte("Produce"), // .Produce(
[]byte("produce"), // .produce(
[]byte("Subscribe"), // .Subscribe( / .SubscribeTopics( / .QueueSubscribe(
[]byte("subscribe"), // .subscribe( / .psubscribe(
[]byte("Consume"), // .Consume( (RabbitMQ Go)
[]byte("consume"), // .consume( / basic_consume
[]byte("topic"), // TS {topic:...} / {topics:[...]} / Kafka struct field
[]byte("ReaderConfig"), // kafka-go Reader{Topic:...}
[]byte("WriteMessages"), // kafka-go Writer.WriteMessages
[]byte("sendToQueue"), // amqplib
[]byte(".send("), // kafkajs producer.send
}
// Extract scans src for every recognised provider / consumer call
// site and emits one ContractTopic per match. The Contract.ID is
// `topic::<broker>::<name>` so the matcher's bucket key keeps
// kafka:foo and nats:foo apart. Meta["broker"] and Meta["topic"]
// carry the structured fields the pairing pass reads.
func (e *TopicExtractor) Extract(filePath string, src []byte, nodes []*graph.Node, edges []*graph.Edge) []Contract {
if !srcHasAnyMarker(src, topicPrefilterMarkers) {
return nil
}
var contracts []Contract
text := string(src)
lines := strings.Split(text, "\n")
fileNodes := filterFileNodes(filePath, nodes)
sort.Slice(fileNodes, func(i, j int) bool {
return fileNodes[i].StartLine < fileNodes[j].StartLine
})
// Track (line, role) pairs we've already emitted so two patterns
// that both fire on the same callsite — common when a Kafka
// WriteMessages call sits next to a kafka.Message{Topic: "x"}
// literal — don't double-count. The first matching pattern wins;
// the later one is suppressed for that role on that line.
type seenKey struct {
line int
role Role
}
seen := make(map[seenKey]struct{})
for _, pat := range topicPublishPatterns {
for _, m := range pat.re.FindAllStringSubmatchIndex(text, -1) {
topic := text[m[2]:m[3]]
if topic == "" {
continue
}
ln := lineNumber(lines, m[0])
key := seenKey{ln, RoleProvider}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("topic::%s::%s", pat.broker, topic),
Type: ContractTopic,
Role: RoleProvider,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{
"topic": topic,
"broker": pat.broker,
},
Confidence: 0.85,
})
}
}
for _, pat := range topicSubscribePatterns {
for _, m := range pat.re.FindAllStringSubmatchIndex(text, -1) {
topic := text[m[2]:m[3]]
if topic == "" {
continue
}
ln := lineNumber(lines, m[0])
key := seenKey{ln, RoleConsumer}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("topic::%s::%s", pat.broker, topic),
Type: ContractTopic,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{
"topic": topic,
"broker": pat.broker,
},
Confidence: 0.85,
})
}
}
return contracts
}