diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 80d4a8323..b65b7712d 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -269,14 +269,13 @@ export class ManifestExtractor { { contract: link.contract }, ); } else if (link.type === 'custom') { - // Exact name match on code symbols (Struct, Enum, Trait, Function, - // Class, Interface, Method). Excludes File/Folder/Community/Process - // to avoid false positives from non-symbol nodes that happen to - // share the name. Useful for Rust cross-crate types and other - // language-agnostic cross-repo contracts. + // V1: exact name-only match on code-definition nodes. + // Positive allowlist mirrors other contract types. If multiple code + // symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks + // the alphabetically-first occurrence deterministically. rows = await executor( - `MATCH (n) WHERE n.name = $contract - AND NOT n:File AND NOT n:Folder AND NOT n:Community AND NOT n:Process + `MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement) + WHERE n.name = $contract RETURN n.id AS uid, n.name AS name, n.filePath AS filePath ORDER BY n.filePath ASC LIMIT 1`, diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 3a358894a..997445f2e 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -665,6 +665,77 @@ describe('ManifestExtractor', () => { expect(provider?.symbolUid).toBe('manifest::core/units::custom::Dimension'); }); + it('custom contract query uses positive label allowlist (not negative exclusion)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'parser/mathlex', + to: 'engine/thales', + type: 'custom', + contract: 'Route', + role: 'provider', + }, + ]; + let capturedCypher = ''; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'parser/mathlex', + async (cypher) => { + capturedCypher = cypher; + return []; + }, + ], + ['engine/thales', async () => []], + ]); + + await extractor.extractFromManifest(links, dbExecutors); + + expect(capturedCypher).toContain('Function|Method|Class|Interface|Struct|Enum|Trait'); + expect(capturedCypher).not.toContain('NOT n:File'); + }); + + it('custom contract with ambiguous name returns first-by-filePath deterministically', async () => { + const links: GroupManifestLink[] = [ + { + from: 'parser/mathlex', + to: 'engine/thales', + type: 'custom', + contract: 'Token', + role: 'provider', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'parser/mathlex', + async (_cypher, params) => { + if (params?.contract === 'Token') { + return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }]; + } + return []; + }, + ], + [ + 'engine/thales', + async (_cypher, params) => { + if (params?.contract === 'Token') { + return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }]; + } + return []; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-token-first'); + }); + it('returns empty for no links', async () => { const result = await extractor.extractFromManifest([]); expect(result.contracts).toHaveLength(0);