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
272 lines
12 KiB
Go
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
|
|
}
|