fix(group): use positive label allowlist for custom contract resolution

Address review findings from #1254:

- Replace negative label exclusion (NOT n:File AND NOT n:Folder ...) with
  positive allowlist matching all code-definition node types. Consistent
  with how http/topic/grpc/lib contract types resolve symbols.

- Add V1 comment documenting name-only matching semantics and deterministic
  ORDER BY filePath ASC LIMIT 1 behavior for ambiguous names.

- Add test verifying query uses positive label allowlist, not negative
  exclusion.

- Add test covering ambiguous name resolution (two symbols with same name
  returns first-by-filePath deterministically).
This commit is contained in:
Christian C. Berclaz 2026-05-02 17:56:30 +02:00
parent 4476b7bf8a
commit 5643cd10a2
No known key found for this signature in database
2 changed files with 77 additions and 7 deletions

View file

@ -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`,

View file

@ -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<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'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<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'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);