GitNexus/gitnexus/test/unit/group/topic-extractor.test.ts
azizur100389 4be4abe8e4
fix(group): contract extractors honour .gitnexusignore via shared IgnoreService (#1185) (#1247)
* fix(group): contract extractors honour .gitnexusignore via shared IgnoreService (#1185)

The HTTP, gRPC, and topic contract extractors each globbed the repo
with a hardcoded `ignore: ['**/node_modules/**', '**/.git/**',
'**/dist/**', '**/build/**', '**/vendor/**']` array, bypassing the
shared `IgnoreService` that the rest of the ingestion pipeline uses
for `.gitnexusignore` and `.gitignore` parsing. Result: a vendored
Python venv (`mentor_env/`), generated stubs, or any user-defined
exclusion silently produced false-positive contracts.

Replace each hardcoded array with `createIgnoreFilter(repoPath)`,
mirroring the canonical pattern in `filesystem-walker.ts`. The 5
hardcoded names are all in `DEFAULT_IGNORE_LIST`, so default
behaviour is preserved; users now also get `.gitnexusignore`
patterns, the rest of the hardcoded list (e.g. `__pycache__`,
`.pytest_cache`), and the `.gitnexusignore` negation semantics
introduced in #771.

The topic extractor additionally filters Go `*_test.go` at the glob
level. That filter is preserved via a small wrapper around
`createIgnoreFilter` that short-circuits before delegating, so
glob-level pruning still applies and the existing `_test.go` skip
test (with new content asserting the pruning is real) still passes.

Tests added to all three `*-extractor.test.ts` files exercising
`.gitnexusignore` honouring end-to-end via real temp directories.

* test(group): exercise gRPC source-scan ignore + add .gitignore-only coverage (#1185)

Addresses two findings from the @claude review on PR #1247:

[medium] The gRPC ignore test claimed to cover both proto-context and
source-scan paths but only wrote a .proto file under mentor_env/.
Added a Python `_pb2_grpc.<Name>Stub(channel)` consumer file under the
same ignored dir (mirroring the canonical pattern from
`test_extract_python_stub_returns_consumer`); without the
`.gitnexusignore` filter that file would emit a consumer contract.
The test now exercises both `createIgnoreFilter` calls inside the gRPC
extractor (`buildProtoContext` + `extract`) in a single run, with both
defence-in-depth path-prefix assertions and a specific
`role: consumer` LeakedService assertion.

[low] Added one shared .gitignore-only test on the HTTP extractor.
`createIgnoreFilter` reads both `.gitignore` and `.gitnexusignore` via
`loadIgnoreRules`, but no extractor-level test exercised the
`.gitignore` path. One shared test is sufficient because all three
extractors consume the same filter object — verified at
`IgnoreService` level already.

The remaining [low] finding — "negation semantics (!pattern) not
tested at extractor level" — is deferred deliberately, not skipped.
Three reasons:

  1. The negation logic (introduced in #771) lives entirely inside
     `createIgnoreFilter`'s `hasExplicitUnignore` ancestor-walk in
     `ignore-service.ts`. The extractors only consume the returned
     filter object — they never inspect patterns, never call
     `hasExplicitUnignore` directly, and have no code path that could
     diverge from the IgnoreService's negation behaviour.

  2. Negation is already locked in by 8 dedicated unit tests in
     `test/unit/ignore-service.test.ts` (the #771 suite), plus the
     `!parent/` + `parent/child/` last-match-wins regression test
     added in PR #1046. An extractor-level negation test would
     re-prove the same code path and would not catch any failure mode
     the existing tests don't already catch.

  3. The bot itself flagged the gap as "Acceptable to leave as
     follow-up referencing existing IgnoreService negation tests" —
     the deferral matches its own recommendation.

If a future change inserts an extractor-side wrapper around the filter
(as topic-extractor.ts already does for `*_test.go`) that could
plausibly affect negation, an extractor-level negation test should be
added at that point — not pre-emptively here.
2026-05-01 16:42:21 +01:00

541 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { TopicExtractor } from '../../../src/core/group/extractors/topic-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
describe('TopicExtractor', () => {
let tmpDir: string;
let extractor: TopicExtractor;
beforeEach(() => {
tmpDir = path.join(os.tmpdir(), `gitnexus-topic-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
extractor = new TopicExtractor();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeFile(relPath: string, content: string): void {
const full = path.join(tmpDir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: 'test/app',
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
});
describe('Kafka — Java', () => {
it('test_extract_kafka_listener_returns_consumer', async () => {
writeFile(
'src/EventHandler.java',
`@KafkaListener(topics = "user.created")
public void handleUserCreated(ConsumerRecord<String, String> record) {
// process
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::user.created');
expect(consumers[0].confidence).toBe(0.8);
expect(consumers[0].meta.broker).toBe('kafka');
});
it('test_extract_kafka_template_send_returns_producer', async () => {
writeFile(
'src/EventPublisher.java',
`public class EventPublisher {
@Autowired KafkaTemplate<String, String> template;
public void publish() {
kafkaTemplate.send("user.created", payload);
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::user.created');
expect(producers[0].meta.broker).toBe('kafka');
});
});
describe('Kafka — Node', () => {
it('test_extract_kafkajs_subscribe_returns_consumer', async () => {
writeFile(
'src/consumer.ts',
`await consumer.subscribe({ topic: 'order.placed', fromBeginning: true });`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::order.placed');
expect(consumers[0].meta.broker).toBe('kafka');
});
it('test_extract_kafkajs_producer_send_returns_producer', async () => {
writeFile(
'src/producer.ts',
`await producer.send({ topic: 'order.placed', messages: [{ value: JSON.stringify(order) }] });`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::order.placed');
});
});
describe('KafkaJS consumer run', () => {
it('test_extract_kafkajs_consumer_run_eachmessage_returns_consumer', async () => {
writeFile(
'src/consumer.ts',
`await consumer.subscribe({ topic: 'user.logged-in' });
await consumer.run({ eachMessage: async () => {} });`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::user.logged-in');
expect(consumers[0].meta.broker).toBe('kafka');
});
});
describe('RabbitMQ — Java', () => {
it('test_extract_rabbit_listener_returns_consumer', async () => {
writeFile(
'src/OrderListener.java',
`@RabbitListener(queues = "order-queue")
public void processOrder(OrderMessage msg) {}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::order-queue');
expect(consumers[0].meta.broker).toBe('rabbitmq');
});
it('test_extract_rabbit_template_send_returns_producer', async () => {
writeFile(
'src/Publisher.java',
`rabbitTemplate.convertAndSend("order-exchange", "order.new", payload);`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::order-exchange');
expect(producers[0].meta.broker).toBe('rabbitmq');
});
});
describe('RabbitMQ — Node', () => {
it('test_extract_amqplib_consume_returns_consumer', async () => {
writeFile(
'src/worker.ts',
`channel.consume("task-queue", (msg) => {
console.log(msg.content.toString());
});`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::task-queue');
expect(consumers[0].meta.broker).toBe('rabbitmq');
});
it('test_extract_amqplib_publish_returns_producer', async () => {
writeFile(
'src/publisher.ts',
`channel.publish("events", "user.signup", Buffer.from(JSON.stringify(data)));`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::events');
expect(producers[0].meta.broker).toBe('rabbitmq');
});
it('test_extract_amqplib_sendToQueue_returns_producer', async () => {
writeFile('src/sender.ts', `channel.sendToQueue("job-queue", Buffer.from(msg));`);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::job-queue');
});
});
describe('JetStream', () => {
it('test_extract_jetstream_publish_returns_provider', async () => {
writeFile('src/stream.go', `js.Publish("orders.created", payload)`);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::orders.created');
expect(producers[0].meta.broker).toBe('nats');
});
it('test_extract_jetstream_subscribe_returns_consumer', async () => {
writeFile('src/stream.go', `js.Subscribe("orders.created", handler)`);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::orders.created');
expect(consumers[0].meta.broker).toBe('nats');
});
});
describe('Python NATS', () => {
it('test_extract_python_nats_subscribe_returns_consumer', async () => {
writeFile(
'src/subscriber.py',
`nc = await nats.connect()
await nc.subscribe("orders.created", cb=handler)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::orders.created');
expect(consumers[0].meta.broker).toBe('nats');
});
it('test_extract_python_nats_publish_returns_provider', async () => {
writeFile(
'src/publisher.py',
`nc = await nats.connect()
await nc.publish("orders.created", payload)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::orders.created');
expect(producers[0].meta.broker).toBe('nats');
});
});
describe('NATS', () => {
it('test_extract_nats_subscribe_go_returns_consumer', async () => {
writeFile(
'cmd/sub.go',
`package main
nc, _ := nats.Connect(nats.DefaultURL)
nc.Subscribe("updates.weather", func(m *nats.Msg) {
fmt.Println(string(m.Data))
})`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::updates.weather');
expect(consumers[0].meta.broker).toBe('nats');
});
it('test_extract_nats_publish_go_returns_producer', async () => {
writeFile(
'cmd/pub.go',
`package main
nc, _ := nats.Connect(nats.DefaultURL)
nc.Publish("updates.weather", []byte("sunny"))`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::updates.weather');
});
it('test_extract_nats_subscribe_node_returns_consumer', async () => {
writeFile(
'src/sub.ts',
`const sub = nc.subscribe("events.order");
for await (const msg of sub) { process(msg); }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::events.order');
});
it('test_extract_nats_publish_node_returns_producer', async () => {
writeFile('src/pub.ts', `nc.publish("events.order", sc.encode(JSON.stringify(order)));`);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::events.order');
});
});
describe('Kafka — Go', () => {
it('test_extract_sarama_consume_returns_consumer', async () => {
writeFile(
'internal/consumer.go',
`package consumer
partConsumer, _ := consumer.ConsumePartition("inventory.update", 0, sarama.OffsetNewest)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::inventory.update');
expect(consumers[0].meta.broker).toBe('kafka');
});
it('test_extract_sarama_sync_producer_returns_provider', async () => {
writeFile(
'internal/publisher.go',
`package publisher
producer, _ := sarama.NewSyncProducer(brokers, cfg)
producer.SendMessage(&sarama.ProducerMessage{Topic: "inventory.update"})`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::inventory.update');
expect(producers[0].meta.broker).toBe('kafka');
});
it('test_extract_sarama_async_producer_returns_provider', async () => {
writeFile(
'internal/publisher.go',
`package publisher
producer, _ := sarama.NewAsyncProducer(brokers, cfg)
producer.Input() <- &sarama.ProducerMessage{Topic: "inventory.update"}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::inventory.update');
expect(producers[0].meta.broker).toBe('kafka');
});
it('test_extract_sarama_producer_in_loop_captures_all_topics', async () => {
// Regression: a for loop that constructs multiple ProducerMessage
// literals inside a single NewSyncProducer scope. The previous
// regex anchored on NewSyncProducer and captured only the first
// Topic within 300 chars, silently dropping the rest.
writeFile(
'internal/multi-publisher.go',
`package publisher
func publishAll(producer sarama.SyncProducer, items []Item) error {
_, _ = sarama.NewSyncProducer(brokers, cfg)
for _, item := range items {
msg1 := &sarama.ProducerMessage{Topic: "order.created"}
msg2 := &sarama.ProducerMessage{Topic: "order.shipped"}
_ = msg1
_ = msg2
}
return nil
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
const topics = producers.map((c) => c.contractId).sort();
// Both topics must appear (exact set match to catch any duplicates).
expect(topics).toEqual(['topic::order.created', 'topic::order.shipped']);
});
it('test_extract_kafka_go_writer_returns_provider', async () => {
writeFile(
'internal/writer.go',
`package publisher
writer := &kafka.Writer{Topic: "inventory.update"}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::inventory.update');
expect(producers[0].meta.broker).toBe('kafka');
});
it('test_extract_kafka_go_reader_returns_consumer', async () => {
writeFile(
'internal/reader.go',
`package consumer
reader := kafka.NewReader(kafka.ReaderConfig{Topic: "inventory.update"})`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::inventory.update');
expect(consumers[0].meta.broker).toBe('kafka');
});
});
describe('Kafka — Python', () => {
it('test_extract_kafka_python_subscribe_returns_consumer', async () => {
writeFile(
'app/consumer.py',
`from kafka import KafkaConsumer
consumer = KafkaConsumer('payment.processed', bootstrap_servers=['localhost:9092'])`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('topic::payment.processed');
});
it('test_extract_kafka_python_producer_send_returns_producer', async () => {
writeFile(
'app/producer.py',
`from kafka import KafkaProducer
producer = KafkaProducer(bootstrap_servers=['localhost:9092'])
producer.send('payment.processed', value=msg)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const producers = contracts.filter((c) => c.role === 'provider');
expect(producers).toHaveLength(1);
expect(producers[0].contractId).toBe('topic::payment.processed');
});
});
describe('edge cases', () => {
it('test_extract_empty_repo_returns_empty', async () => {
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(0);
});
it('test_extract_repo_without_queues_returns_empty', async () => {
writeFile('src/index.ts', 'console.log("hello")');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(0);
});
it('test_extract_multiple_topics_in_one_file', async () => {
writeFile(
'src/events.ts',
`await producer.send({ topic: 'user.created', messages: [] });
await producer.send({ topic: 'user.deleted', messages: [] });
await consumer.subscribe({ topic: 'order.placed' });`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(3);
const producers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(producers).toHaveLength(2);
expect(consumers).toHaveLength(1);
});
it('test_extract_ignores_go_test_files', async () => {
writeFile(
'src/orders_test.go',
`consumer.ConsumePartition("fake-topic", 0, sarama.OffsetNewest)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toEqual([]);
});
});
// ─── #1185: topic extractor must honour .gitnexusignore ─────────────
//
// The source-scan glob used to use a hardcoded ignore array; it now
// consumes the shared `IgnoreService` (mirrors `filesystem-walker.ts`)
// so any `.gitnexusignore` pattern excludes those files from contract
// extraction. Special case for this extractor: the Go-specific
// `_test.go` filter is preserved via a small wrapper around the base
// filter (so glob-level pruning still applies); the second test below
// pins that behaviour against accidental regressions.
describe('respects .gitnexusignore (#1185)', () => {
it('source-scan glob skips files matched by .gitnexusignore', async () => {
// Control: a regular @KafkaListener that SHOULD be discovered.
writeFile(
'src/EventHandler.java',
`@KafkaListener(topics = "user.created")
public void handleUserCreated(ConsumerRecord<String, String> record) {}`,
);
// Vendored Java handler under a venv-style dir.
writeFile(
'mentor_env/lib/LeakedHandler.java',
`@KafkaListener(topics = "leaked.event")
public void handleLeaked(ConsumerRecord<String, String> r) {}`,
);
writeFile('.gitnexusignore', 'mentor_env/\n');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const ids = contracts.map((c) => c.contractId);
expect(ids).toContain('topic::user.created');
expect(ids).not.toContain('topic::leaked.event');
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
});
it('still prunes `*_test.go` even when .gitnexusignore is empty (wrapper preserves glob-level filter)', async () => {
// Regression guard: replacing the hardcoded `**/*_test.go` glob
// entry with a wrapper around `createIgnoreFilter` must keep this
// file out of the scan. Without the wrapper, the previous test
// ('skips Go test files (`*_test.go`)') would still pass because
// the test scenario set up no detections, but here we WRITE a
// valid Sarama consumer call inside `_test.go` and assert the
// extractor never sees it.
writeFile(
'src/orders_test.go',
`package orders
import "github.com/IBM/sarama"
func TestSomething() {
consumer.ConsumePartition("real-topic-from-test", 0, sarama.OffsetNewest)
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts.find((c) => c.contractId === 'topic::real-topic-from-test')).toBeUndefined();
expect(contracts).toEqual([]);
});
});
});