diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b379a4dbd..b5782d9b3 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { glob } from 'glob'; import Parser from 'tree-sitter'; +import { createIgnoreFilter } from '../../../config/ignore-service.js'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; @@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{ servicesByName: Map; }> { const servicesByName = new Map(); + // `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService — + // see `filesystem-walker.ts` for the canonical pattern. Replaces a + // hardcoded `[node_modules, .git, vendor]` array; those names plus the + // rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185). + const protoIgnoreFilter = await createIgnoreFilter(repoPath); const protoFiles = await glob('**/*.proto', { cwd: repoPath, absolute: false, nodir: true, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], + ignore: protoIgnoreFilter, }); const contents = new Map(); @@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor { } // ─── Source files (+ .proto when plugin available) ──────────── + // Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService — + // mirrors `filesystem-walker.ts`. Replaces a hardcoded + // `[node_modules, .git, vendor, dist, build]` array; those names are all + // in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185). + const sourceIgnoreFilter = await createIgnoreFilter(repoPath); const sourceFiles = await glob(GRPC_SCAN_GLOB, { cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + ignore: sourceIgnoreFilter, nodir: true, }); diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index f2914613d..d989876a8 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { glob } from 'glob'; import Parser from 'tree-sitter'; +import { createIgnoreFilter } from '../../../config/ignore-service.js'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; @@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor { } private async scanFiles(repoPath: string): Promise { + // Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService + // so contract extraction respects the same exclusion rules as the rest of + // the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the + // same shape. Replaces a hardcoded `[node_modules, .git, dist, build, + // vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so + // default behaviour is preserved (#1185). + const ignoreFilter = await createIgnoreFilter(repoPath); return glob(HTTP_SCAN_GLOB, { cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'], + ignore: ignoreFilter, nodir: true, }); } diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts index 1fbccac8a..4f2128a1c 100644 --- a/gitnexus/src/core/group/extractors/topic-extractor.ts +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -1,5 +1,6 @@ import { glob } from 'glob'; import Parser from 'tree-sitter'; +import { createIgnoreFilter } from '../../../config/ignore-service.js'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; @@ -56,22 +57,21 @@ export class TopicExtractor implements ContractExtractor { repoPath: string, _repo: RepoHandle, ): Promise { + // Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService — + // mirrors `filesystem-walker.ts`. The 5-name hardcoded list + // (`node_modules, .git, vendor, dist, build`) is preserved because every + // entry is in `DEFAULT_IGNORE_LIST`, so default behaviour is unchanged + // (#1185). The Go-specific `**/*_test.go` filter is layered on top via a + // small wrapper so glob-level pruning is preserved (we never read those + // files); the wrapper short-circuits before calling the base filter. + const baseFilter = await createIgnoreFilter(repoPath); + const ignoreFilter: typeof baseFilter = { + ignored: (p) => p.relative().endsWith('_test.go') || baseFilter.ignored(p), + childrenIgnored: (p) => baseFilter.childrenIgnored(p), + }; const files = await glob(TOPIC_SCAN_GLOB, { cwd: repoPath, - ignore: [ - '**/node_modules/**', - '**/.git/**', - '**/vendor/**', - '**/dist/**', - '**/build/**', - // Language-level test file conventions. Go test files - // `*_test.go` live next to source; other languages either use - // separate test directories (Python's `tests/`, Java's - // `src/test/`) or are already covered by the dist/build ignores. - // Pushed to the glob level so the orchestrator stays - // language-agnostic. - '**/*_test.go', - ], + ignore: ignoreFilter, nodir: true, }); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index 1664bd1a7..a586b5c0d 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -613,6 +613,69 @@ export class AuthGateway { expect(contracts).toHaveLength(0); }); }); + + // ─── #1185: gRPC extractor must honour .gitnexusignore ────────────── + // + // Both the `.proto` glob (in `buildProtoContext`) and the source-scan + // glob (in `extract`) used a hardcoded ignore array that bypassed + // `IgnoreService`. Both globs now consume the shared filter (mirrors + // `filesystem-walker.ts`) so any `.gitnexusignore` pattern is + // honoured. The single test below exercises BOTH paths in the same + // run: a `.proto` under `mentor_env/` (proto-context build) AND a + // Python `_pb2_grpc.Stub` consumer under `mentor_env/` + // (source-scan path) — neither produces a contract. + describe('respects .gitnexusignore (#1185)', () => { + it('proto + source globs both skip files matched by .gitnexusignore', async () => { + // Control: a regular .proto in a non-ignored dir. + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + // Vendored proto under a venv-style dir — exercises proto-context glob. + writeFile( + 'mentor_env/lib/leaked.proto', + `syntax = "proto3"; +package leaked; +service LeakedService { + rpc Ping (PingRequest) returns (PingResponse); +}`, + ); + // Vendored Python consumer under the same venv-style dir — + // exercises the second glob in `extract()` (source-scan path). + // Mirrors the canonical pattern from + // `test_extract_python_stub_returns_consumer` above; without the + // `.gitnexusignore` filter this WOULD emit a `grpc::*/LeakedService` + // consumer contract. + writeFile( + 'mentor_env/lib/leaked_consumer.py', + `import grpc +from proto import leaked_pb2_grpc + +channel = grpc.insecure_channel('localhost:50051') +stub = leaked_pb2_grpc.LeakedServiceStub(channel)`, + ); + writeFile('.gitnexusignore', 'mentor_env/\n'); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + // Control proto provider is still emitted. + expect(contracts.find((c) => c.contractId === 'grpc::auth.AuthService/Login')).toBeDefined(); + // Defence-in-depth: no contract — provider OR consumer — has a + // `symbolRef` path under the ignored directory. Catches both globs + // at once. + expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false); + // Specific assertions per glob path. + expect( + contracts.find((c) => c.contractId === 'grpc::leaked.LeakedService/Ping'), + ).toBeUndefined(); + expect( + contracts.some((c) => c.role === 'consumer' && /LeakedService/.test(c.contractId)), + ).toBe(false); + }); + }); }); describe('buildProtoMap', () => { diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index e81f1566e..69d3da2fa 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -729,4 +729,90 @@ router.get('/api/posts/{postId}', handler2); }); }); }); + + // ─── #1185: contract extractors must honour .gitnexusignore ───────── + // + // Pre-#1185 the source-scan path used a hardcoded + // `[node_modules, .git, dist, build, vendor]` glob ignore array, so a + // user's `.gitnexusignore` pattern (e.g. a Python venv `mentor_env/`, + // a generated stubs dir, a noisy fixture tree) was silently scanned + // anyway. Since #1185 the source-scan path consumes the shared + // `IgnoreService` (mirrors `filesystem-walker.ts`), so any pattern in + // `.gitnexusignore` (or `.gitignore`) prunes the glob. + describe('respects .gitnexusignore (#1185)', () => { + it('source-scan glob skips files matched by .gitnexusignore', async () => { + const dir = path.join(tmpDir, 'gitnexusignore-honoured'); + fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true }); + // Control: a normal route file that SHOULD be discovered. + fs.writeFileSync( + path.join(dir, 'src/routes/users.ts'), + `import { Router } from 'express'; +const router = Router(); +router.get('/api/users', (req, res) => res.json([])); +export default router; +`, + ); + // Vendored source under a venv-style dir: the same Express + // pattern, but inside a directory the user wants excluded. + fs.writeFileSync( + path.join(dir, 'mentor_env/lib/leaked.ts'), + `import { Router } from 'express'; +const r = Router(); +r.get('/api/leaked', (req, res) => res.json([])); +export default r; +`, + ); + fs.writeFileSync(path.join(dir, '.gitnexusignore'), 'mentor_env/\n'); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + // Control survives. + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + // Excluded path is pruned at the glob level — nothing emitted. + expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined(); + // Defence-in-depth: no contract whose symbolRef is under mentor_env/. + expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false); + }); + + // Pinned by the @claude review on PR #1247: above, only `.gitnexusignore` + // is exercised. `createIgnoreFilter` reads `.gitignore` too via + // `loadIgnoreRules`, but that integration is only proven at the + // `IgnoreService` level — no extractor-level test for the + // `.gitignore`-only code path. Adding one minimal extractor-level + // assertion here closes the gap (one shared test is sufficient + // because all three extractors consume the same filter object). + it('source-scan glob also skips files matched by `.gitignore` (no `.gitnexusignore`)', async () => { + const dir = path.join(tmpDir, 'gitignore-honoured'); + fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true }); + // Same Express pattern as above so detection logic is identical. + fs.writeFileSync( + path.join(dir, 'src/routes/users.ts'), + `import { Router } from 'express'; +const router = Router(); +router.get('/api/users', (req, res) => res.json([])); +export default router; +`, + ); + fs.writeFileSync( + path.join(dir, 'mentor_env/lib/leaked.ts'), + `import { Router } from 'express'; +const r = Router(); +r.get('/api/leaked', (req, res) => res.json([])); +export default r; +`, + ); + // Note: NO .gitnexusignore — only `.gitignore`. This proves the + // `.gitignore` code path inside `createIgnoreFilter` is wired to + // the extractors' globs. + fs.writeFileSync(path.join(dir, '.gitignore'), 'mentor_env/\n'); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined(); + expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false); + }); + }); }); diff --git a/gitnexus/test/unit/group/topic-extractor.test.ts b/gitnexus/test/unit/group/topic-extractor.test.ts index bf821de63..754cf42f5 100644 --- a/gitnexus/test/unit/group/topic-extractor.test.ts +++ b/gitnexus/test/unit/group/topic-extractor.test.ts @@ -483,4 +483,59 @@ await consumer.subscribe({ topic: 'order.placed' });`, 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 record) {}`, + ); + // Vendored Java handler under a venv-style dir. + writeFile( + 'mentor_env/lib/LeakedHandler.java', + `@KafkaListener(topics = "leaked.event") +public void handleLeaked(ConsumerRecord 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([]); + }); + }); });