diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md
index 8bdf86c481..9a8c4b972c 100644
--- a/gitnexus/src/core/group/PIPELINE.md
+++ b/gitnexus/src/core/group/PIPELINE.md
@@ -112,11 +112,14 @@ flowchart TD
EMIT --> BRIDGE[(bridge.lbug
#795)]
```
-Label-scoped queries in `resolveSymbol` keep accidental cross-matches
-out:
-- `topic` → `(n:Function|Method|Class|Interface)`
-- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)`
-- `lib` → `(n:Package|Module)`
+Label-scoped queries in `resolveSymbol` keep accidental cross-matches out.
+They use the `MATCH (n) WHERE labels(n) IN [...]` allowlist form, NOT the
+`MATCH (n:A|B)` disjunction — LadybugDB's parser rejects a disjunction that
+names a reserved keyword (e.g. `Macro`, `Union`), which is what broke the
+`custom` branch in #2325:
+- `topic` → `labels(n) IN ['Function','Method','Class','Interface']`
+- `grpc`/`thrift` method → `labels(n) IN ['Function','Method']`, service → `labels(n) IN ['Class','Interface']`
+- `lib` → `labels(n) IN ['Module']`
## Cross-impact query (PR #606)
diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts
index 79c005e921..f28f3fac4f 100644
--- a/gitnexus/src/core/group/extractors/http-route-extractor.ts
+++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts
@@ -80,10 +80,21 @@ WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels`;
-// Repo-wide lookup of a symbol by exact name (label-union, as in
-// manifest-extractor.ts). Used to resolve a provider's named handler when it is
-// defined in a file OTHER than its route registration — and only honored when
-// the result is unique (see resolveSymbolByNameUnique).
+// Repo-wide lookup of a symbol by exact name. Used to resolve a provider's
+// named handler when it is defined in a file OTHER than its route registration —
+// and only honored when the result is unique (see resolveSymbolByNameUnique).
+//
+// Label filtering uses `labels(n) IN [...]` rather than the openCypher
+// disjunction `MATCH (n:A|B|C)`. NOTE: this 3-label set (Function/Method/
+// CodeElement) actually PARSES — LadybugDB only rejects a disjunction that
+// names a reserved keyword (e.g. `Macro`, `Union`) or a missing node table,
+// neither of which applies here. So this query was NOT broken by #2325; it
+// uses the `labels(n) IN` form for consistency with the manifest custom-branch
+// fix (which WAS broken) and to stay immune if a reserved-keyword label is
+// added later. `labels(n)` returns the node's single label as a string here, so
+// `IN [...]` is an exact allowlist. (Exported so integration tests can run the
+// exact production query against a real LadybugDB — the bug shipped because no
+// test ran these strings against the real parser.)
//
// `n.filePath <> ''` excludes synthetic non-source `CodeElement` nodes that
// carry no real file — ORM model/table nodes (orm.ts emits `filePath: ''`) and
@@ -91,9 +102,9 @@ RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
// degenerate edge-less node NOR inflates the uniqueness count and masks the real
// handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
// ambiguous (>=2) never needs more than two rows (the count guard stays exact).
-const RESOLVE_BY_NAME_QUERY = `
-MATCH (n:Function|Method|CodeElement)
-WHERE n.name = $name AND n.filePath <> ''
+export const RESOLVE_BY_NAME_QUERY = `
+MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
+ AND n.name = $name AND n.filePath <> ''
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
@@ -103,9 +114,9 @@ LIMIT 2`;
// the precise rung — it survives aliases and local same-name collisions that a
// repo-wide name lookup cannot, and only resolves on a unique match within that
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
-const RESOLVE_IN_MODULE_QUERY = `
-MATCH (n:Function|Method|CodeElement)
-WHERE n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
+export const RESOLVE_IN_MODULE_QUERY = `
+MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
+ AND n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts
index 8b2a80f656..53e65c20f3 100644
--- a/gitnexus/src/core/group/extractors/manifest-extractor.ts
+++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts
@@ -7,6 +7,21 @@ export interface ManifestExtractResult {
crossLinks: CrossLink[];
}
+// Repo-wide symbol lookup for `custom` workspace contracts. Exported so the
+// #2325 integration test can run the EXACT production query against a real
+// LadybugDB — a hand-copied query string in the test would silently drift
+// from this allowlist. Uses the `labels(n) IN [...]` allowlist form rather
+// than a `MATCH (n:A|B)` disjunction: this 21-label list contains the
+// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
+// a disjunction that names a reserved keyword (#2325) — which the resolver's
+// try/catch then swallowed. `labels(n) IN` has no such collision.
+export const CUSTOM_CONTRACT_RESOLVE_QUERY = `MATCH (n)
+ WHERE labels(n) IN ['Function','Method','Class','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
+ AND n.name = $symbolName
+ RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
+ ORDER BY n.filePath ASC
+ LIMIT 1`;
+
/**
* Canonicalize an HTTP path for matching against Route.name in the graph.
* Mirrors core/ingestion/pipeline.ts ensureSlash semantics:
@@ -189,6 +204,18 @@ export class ManifestExtractor {
// Cross-impact still works: the bridge query joins on the synthetic
// uid, and the local impact engine derives the same uid for the
// unresolved symbol — name-based hints are the additional safety net.
+ //
+ // Label filtering uses `MATCH (n) WHERE labels(n) IN [...]`, NOT the
+ // openCypher disjunction `MATCH (n:A|B|C)`. LadybugDB's parser rejects a
+ // disjunction that names a reserved keyword (`Macro` and `Union` both are)
+ // OR a label with no node table (e.g. the old `lib` branch's `Package`).
+ // The `custom` branch (reserved keywords in its list) and `lib` branch
+ // (missing `Package` table) genuinely threw (#2325) and the whole try/catch
+ // below swallowed it; the other branches parsed but use the same form for
+ // consistency and future-proofing. `labels(n)` returns the node's single
+ // label as a string here, so `IN [...]` is an exact allowlist that includes
+ // listed labels and excludes everything else — and is immune to both
+ // failure modes (no keyword collision; an unknown label is just a non-match).
try {
let rows: Record[];
if (link.type === 'http') {
@@ -222,7 +249,7 @@ export class ManifestExtractor {
// avoid cross-matching Files/Variables/Imports that happen to
// share the topic name.
rows = await executor(
- `MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract
+ `MATCH (n) WHERE labels(n) IN ['Function','Method','Class','Interface'] AND n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@@ -246,7 +273,7 @@ export class ManifestExtractor {
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
- `MATCH (n:Function|Method) WHERE n.name = $methodName
+ `MATCH (n) WHERE labels(n) IN ['Function','Method'] AND n.name = $methodName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@@ -254,7 +281,7 @@ export class ManifestExtractor {
);
} else if (serviceName) {
rows = await executor(
- `MATCH (n:Class|Interface) WHERE n.name = $serviceName
+ `MATCH (n) WHERE labels(n) IN ['Class','Interface'] AND n.name = $serviceName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@@ -266,11 +293,12 @@ export class ManifestExtractor {
} else if (link.type === 'lib') {
// Only exact match on the symbol's name. Previous fallback to
// CONTAINS on n.filePath would promote "react" to "react-native"
- // or "@types/react" — silent wrong attribution. Restrict to
- // package-level labels so we don't return arbitrary symbols
- // named after a library.
+ // or "@types/react" — silent wrong attribution. Restrict to the
+ // package-level `Module` label so we don't return arbitrary symbols
+ // named after a library. (There is no `Package` node table — see
+ // NODE_TABLES — so a `Package` entry only ever matched nothing.)
rows = await executor(
- `MATCH (n:Package|Module) WHERE n.name = $contract
+ `MATCH (n) WHERE labels(n) IN ['Module'] AND n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@@ -291,14 +319,7 @@ export class ManifestExtractor {
const symbolName = link.contract.includes('::')
? link.contract.split('::').pop()!
: link.contract;
- rows = await executor(
- `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 = $symbolName
- RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
- ORDER BY n.filePath ASC
- LIMIT 1`,
- { symbolName },
- );
+ rows = await executor(CUSTOM_CONTRACT_RESOLVE_QUERY, { symbolName });
} else {
return null;
}
diff --git a/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts b/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts
new file mode 100644
index 0000000000..efc5fb99d9
--- /dev/null
+++ b/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts
@@ -0,0 +1,111 @@
+/**
+ * Issue #2325 (http-route extractor half): `RESOLVE_BY_NAME_QUERY` and
+ * `RESOLVE_IN_MODULE_QUERY` were converted from the `MATCH (n:A|B)` multi-label
+ * disjunction to the `labels(n) IN [...]` allowlist form, for consistency with
+ * the manifest custom-branch fix.
+ *
+ * IMPORTANT nuance (verified against the real parser): the actual #2325 failure
+ * was triggered by *reserved-keyword* labels. LadybugDB's parser rejects a
+ * disjunction that names a reserved keyword — `Macro` and `Union` both are — so
+ * the manifest custom branch (whose 21-label list contains `Macro`/`Union`)
+ * genuinely threw, and the resolver's try/catch swallowed it. The http-route
+ * 3-label disjunction `(n:Function|Method|CodeElement)` contains no reserved
+ * keyword and actually PARSES — so this half was a consistency conversion, not a
+ * parser fix. The value of these cases is verifying the EXPORTED production
+ * queries resolve and filter correctly against a real LadybugDB (the unit tests
+ * cover the resolution *logic* with a fake executor; these cover *parsing +
+ * filtering*). The last case pins the real reserved-keyword trigger so a future
+ * query that reintroduces a `MATCH (n:…|Macro|…)` disjunction is caught.
+ */
+import { it, expect, afterEach } from 'vitest';
+import {
+ RESOLVE_BY_NAME_QUERY,
+ RESOLVE_IN_MODULE_QUERY,
+} from '../../../src/core/group/extractors/http-route-extractor.js';
+import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
+import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
+
+const SEED = [
+ // BY_NAME: one real-file Function + a same-named File (label excluded) + a
+ // same-named CodeElement with empty filePath (excluded by `n.filePath <> ''`).
+ `CREATE (:Function {id:'fn:getOrders', name:'getOrders', filePath:'src/handlers/orders.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:File {id:'file:getOrders', name:'getOrders', filePath:'src/getOrders.ts'})`,
+ `CREATE (:CodeElement {id:'ce:getOrders', name:'getOrders', filePath:''})`,
+ // IN_MODULE: same name in two modules — only the prefixed one resolves.
+ `CREATE (:Function {id:'fn:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Function {id:'fn:listUsers:admin', name:'listUsers', filePath:'src/admin/users.ts', startLine:1, endLine:9, content:'', description:''})`,
+ // IN_MODULE label-allowlist decoy: same name, SAME module prefix, wrong label.
+ // The STARTS-WITH prefix would match it, so only the `labels(n) IN [...]` filter
+ // excludes it — drop the filter and this surfaces, flipping the row count.
+ `CREATE (:File {id:'file:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts'})`,
+ // LIMIT 2 cap: three same-named Functions — the uniqueness count must stay exact.
+ `CREATE (:Function {id:'fn:dup:1', name:'dup', filePath:'src/a.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Function {id:'fn:dup:2', name:'dup', filePath:'src/b.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Function {id:'fn:dup:3', name:'dup', filePath:'src/c.ts', startLine:1, endLine:9, content:'', description:''})`,
+];
+
+withTestLbugDB(
+ 'issue-2325-http-route-resolveSymbol',
+ (handle) => {
+ afterEach(async () => {
+ try {
+ await closeLbug(handle.repoId);
+ } catch {
+ /* best-effort */
+ }
+ });
+
+ it('RESOLVE_BY_NAME_QUERY resolves a handler by name, excluding a File and an empty-filePath node', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ const rows = await executeParameterized(handle.repoId, RESOLVE_BY_NAME_QUERY, {
+ name: 'getOrders',
+ });
+ // File (wrong label) and the empty-filePath CodeElement are both excluded.
+ expect(rows).toHaveLength(1);
+ expect(rows[0].uid).toBe('fn:getOrders');
+ expect(rows[0].filePath).toBe('src/handlers/orders.ts');
+ });
+
+ it('RESOLVE_IN_MODULE_QUERY resolves only the handler in the target module prefix', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ const rows = await executeParameterized(handle.repoId, RESOLVE_IN_MODULE_QUERY, {
+ name: 'listUsers',
+ fileDot: 'src/handlers/users.',
+ fileSlash: 'src/handlers/users/',
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0].uid).toBe('fn:listUsers:handlers');
+ expect(rows[0].filePath).toBe('src/handlers/users.ts');
+ });
+
+ it('RESOLVE_BY_NAME_QUERY caps materialization at 2 rows (uniqueness count stays exact)', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ const rows = await executeParameterized(handle.repoId, RESOLVE_BY_NAME_QUERY, {
+ name: 'dup',
+ });
+ // Three matches exist; LIMIT 2 returns exactly two so the caller treats it
+ // as ambiguous (>=2) without over-materializing.
+ expect(rows).toHaveLength(2);
+ });
+
+ it('LadybugDB rejects a disjunction naming a reserved-keyword label (the real #2325 trigger)', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ // The genuine #2325 failure: a `MATCH (n:A|B)` disjunction whose label set
+ // includes a reserved keyword (`Macro`/`Union`) is a parser error the
+ // resolver's try/catch silently swallowed. The exported queries avoid this
+ // by using the `labels(n) IN [...]` allowlist form. (The http-route
+ // `Function|Method|CodeElement` form parses — this guards the real cause.)
+ await expect(
+ executeParameterized(
+ handle.repoId,
+ `MATCH (n:Function|Macro|Union) WHERE n.name = $name RETURN n.id AS uid LIMIT 2`,
+ { name: 'getOrders' },
+ ),
+ ).rejects.toThrow(/Parser exception|Invalid input|Prepare failed/i);
+ });
+ },
+ {
+ seed: SEED,
+ poolAdapter: true,
+ },
+);
diff --git a/gitnexus/test/integration/group/manifest-resolve-symbol-2325.test.ts b/gitnexus/test/integration/group/manifest-resolve-symbol-2325.test.ts
new file mode 100644
index 0000000000..98ef9fc01d
--- /dev/null
+++ b/gitnexus/test/integration/group/manifest-resolve-symbol-2325.test.ts
@@ -0,0 +1,173 @@
+/**
+ * Regression test for issue #2325:
+ * manifest-extractor `resolveSymbol` used a multi-label Cypher disjunction
+ * (`MATCH (n:Function|Method|Class|...)`). LadybugDB's parser rejects such a
+ * disjunction only when a label is a reserved keyword (`Macro` and `Union` both
+ * are) or names a missing node table — so the `custom` branch (reserved
+ * keywords in its 21-label list) and the `lib` branch (no `Package` table)
+ * genuinely threw, silently falling back to a synthetic UID with empty
+ * `filePath`; the other branches happened to parse. The fix uses
+ * `MATCH (n) WHERE labels(n) IN [...]` uniformly — immune to both failure modes
+ * (LadybugDB returns a node's single label as a string, so `IN [...]` is an
+ * exact allowlist).
+ *
+ * These cases run the REAL production query (via `extractFromManifest`) against a
+ * real LadybugDB — the only layer that can catch a parser rejection, since the
+ * unit tests mock the executor. Each per-branch case seeds a wrong-label decoy
+ * whose `filePath` sorts BEFORE the target so a widened/broken allowlist would
+ * surface the decoy under `ORDER BY n.filePath ASC LIMIT 1` and flip the
+ * assertion — making the exclusion check non-vacuous.
+ */
+import { it, expect, afterEach } from 'vitest';
+import {
+ ManifestExtractor,
+ CUSTOM_CONTRACT_RESOLVE_QUERY,
+} from '../../../src/core/group/extractors/manifest-extractor.js';
+import type { GroupManifestLink } from '../../../src/core/group/types.js';
+import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js';
+import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
+import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
+
+// Targets + sort-first wrong-label decoys for each link-type branch.
+const SEED = [
+ // custom → Class; decoy File (not in allowlist) sorts first.
+ `CREATE (:Class {id:'cls:MyServiceFacade', name:'MyServiceFacade', filePath:'src/main/java/com/example/MyServiceFacade.java', startLine:1, endLine:42, content:'', description:''})`,
+ `CREATE (:File {id:'file:MyServiceFacade', name:'MyServiceFacade', filePath:'aaa/MyServiceFacade.java'})`,
+ // grpc/thrift method → Method; decoy Class (not in [Function,Method]) sorts first.
+ `CREATE (:Method {id:'mth:Login', name:'Login', filePath:'src/auth_grpc.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Class {id:'cls:Login', name:'Login', filePath:'aaa/Login.java', startLine:1, endLine:9, content:'', description:''})`,
+ // grpc/thrift service → Class; decoy Function (not in [Class,Interface]) sorts first.
+ `CREATE (:Class {id:'cls:AuthService', name:'AuthService', filePath:'src/auth_service.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Function {id:'fn:AuthService', name:'AuthService', filePath:'aaa/AuthService.go', startLine:1, endLine:9, content:'', description:''})`,
+ // lib → Module (there is NO Package node table); decoy Function sorts first.
+ `CREATE (:Module {id:'mod:mylib', name:'mylib', filePath:'src/index.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:Function {id:'fn:mylib', name:'mylib', filePath:'aaa/mylib.ts', startLine:1, endLine:9, content:'', description:''})`,
+ // topic → Function; decoy File sorts first.
+ `CREATE (:Function {id:'fn:orders.created', name:'orders.created', filePath:'src/consumer.ts', startLine:1, endLine:9, content:'', description:''})`,
+ `CREATE (:File {id:'file:orders.created', name:'orders.created', filePath:'aaa/orders.created.ts'})`,
+];
+
+/** Custom-branch `custom` query as emitted BEFORE the fix — kept to document
+ * the real failure: its label list contains the reserved keywords `Macro` and
+ * `Union`, which make LadybugDB's parser reject the whole disjunction. */
+const MULTI_LABEL_CUSTOM_QUERY = `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 = $symbolName
+ RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
+ ORDER BY n.filePath ASC
+ LIMIT 1`;
+
+// Direct-query canary for the `labels(n)`-is-a-string assumption the whole fix
+// relies on. Uses the EXACT production query (imported, not hand-copied) so the
+// canary can never silently drift from the real allowlist.
+
+withTestLbugDB(
+ 'issue-2325-manifest-resolveSymbol',
+ (handle) => {
+ afterEach(async () => {
+ try {
+ await closeLbug(handle.repoId);
+ } catch {
+ /* best-effort */
+ }
+ });
+
+ // role:'consumer' → providerRepo = link.to = 'repo-b' (the seeded DB); the
+ // resolved symbol lands in crossLinks[0].to. consumerRepo ('repo-a') has no
+ // executor, so to.symbolRef carries the provider-side resolution.
+ const resolveVia = async (
+ type: GroupManifestLink['type'],
+ contract: string,
+ ): Promise<{ symbolUid: string; filePath: string; name: string }> => {
+ await initLbug(handle.repoId, handle.dbPath);
+ const executor: CypherExecutor = (query, params) =>
+ executeParameterized(handle.repoId, query, params ?? {});
+ const extractor = new ManifestExtractor();
+ const result = await extractor.extractFromManifest(
+ [{ from: 'repo-a', to: 'repo-b', type, contract, role: 'consumer' }],
+ new Map([['repo-b', executor]]),
+ );
+ expect(result.crossLinks).toHaveLength(1);
+ const to = result.crossLinks[0].to;
+ return {
+ symbolUid: to.symbolUid,
+ filePath: to.symbolRef.filePath,
+ name: to.symbolRef.name,
+ };
+ };
+
+ it('LadybugDB rejects the custom-branch disjunction (its list names reserved keywords Macro/Union)', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ // The genuine #2325 trigger for the custom branch: `Macro` and `Union` are
+ // reserved keywords, so the parser rejects the whole `(n:…|Macro|…|Union|…)`
+ // disjunction — which the resolver's try/catch then swallowed.
+ await expect(
+ executeParameterized(handle.repoId, MULTI_LABEL_CUSTOM_QUERY, {
+ symbolName: 'MyServiceFacade',
+ }),
+ ).rejects.toThrow(/Parser exception|Invalid input|Prepare failed/i);
+ });
+
+ it('direct labels(n) IN query resolves the symbol (canary for labels()-is-a-string)', async () => {
+ await initLbug(handle.repoId, handle.dbPath);
+ const rows = await executeParameterized(handle.repoId, CUSTOM_CONTRACT_RESOLVE_QUERY, {
+ symbolName: 'MyServiceFacade',
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0].uid).toBe('cls:MyServiceFacade');
+ expect(rows[0].filePath).toBe('src/main/java/com/example/MyServiceFacade.java');
+ });
+
+ it('custom contract resolves the real Class symbol, excluding a same-named File (#2325)', async () => {
+ const r = await resolveVia('custom', 'custom::MyServiceFacade');
+ expect(r.symbolUid).toBe('cls:MyServiceFacade');
+ expect(r.filePath).toBe('src/main/java/com/example/MyServiceFacade.java');
+ expect(r.name).toBe('MyServiceFacade');
+ // real resolution, not the synthetic fallback the bug produced
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+
+ it('grpc method contract resolves a Method, excluding a same-named Class', async () => {
+ const r = await resolveVia('grpc', 'AuthService/Login');
+ expect(r.symbolUid).toBe('mth:Login');
+ expect(r.filePath).toBe('src/auth_grpc.ts');
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+
+ it('grpc service contract resolves a Class, excluding a same-named Function', async () => {
+ const r = await resolveVia('grpc', 'AuthService');
+ expect(r.symbolUid).toBe('cls:AuthService');
+ expect(r.filePath).toBe('src/auth_service.ts');
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+
+ it('thrift service contract strips the package prefix before resolving the Class', async () => {
+ // The thrift-only branch strips `package.` from the service name
+ // (`com.example.AuthService` -> `AuthService`). Without the strip the
+ // lookup would query for `com.example.AuthService`, match nothing, and
+ // fall back to a synthetic `manifest::` uid — so this resolving to the
+ // real Class is the load-bearing assertion for the package-strip path.
+ const r = await resolveVia('thrift', 'com.example.AuthService');
+ expect(r.symbolUid).toBe('cls:AuthService');
+ expect(r.filePath).toBe('src/auth_service.ts');
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+
+ it('lib contract resolves a Module, excluding a same-named Function', async () => {
+ const r = await resolveVia('lib', 'mylib');
+ expect(r.symbolUid).toBe('mod:mylib');
+ expect(r.filePath).toBe('src/index.ts');
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+
+ it('topic contract resolves a Function, excluding a same-named File', async () => {
+ const r = await resolveVia('topic', 'orders.created');
+ expect(r.symbolUid).toBe('fn:orders.created');
+ expect(r.filePath).toBe('src/consumer.ts');
+ expect(r.symbolUid.startsWith('manifest::')).toBe(false);
+ });
+ },
+ {
+ seed: SEED,
+ poolAdapter: true,
+ },
+);
diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts
index c732a16a30..5e15460a3c 100644
--- a/gitnexus/test/unit/group/manifest-extractor.test.ts
+++ b/gitnexus/test/unit/group/manifest-extractor.test.ts
@@ -839,7 +839,17 @@ describe('ManifestExtractor', () => {
await extractor.extractFromManifest(links, dbExecutors);
- expect(capturedCypher).toContain('Function|Method|Class|Interface|Struct|Enum|Trait');
+ // This `custom`-branch list contains the reserved keywords `Macro`/`Union`,
+ // which make LadybugDB's parser reject the `MATCH (n:A|B|C)` disjunction
+ // (#2325), so the allowlist is carried as `labels(n) IN [...]`, not `n:A|B`.
+ expect(capturedCypher).toContain('labels(n) IN [');
+ // Membership checks that tolerate label order/spacing changes in the
+ // production allowlist (the negative guards below are the real regression
+ // check — a re-introduced `:A|B` disjunction has no quoted labels at all).
+ expect(capturedCypher).toContain("'Function'");
+ expect(capturedCypher).toContain("'Method'");
+ expect(capturedCypher).toContain("'CodeElement'");
+ expect(capturedCypher).not.toContain('Function|Method');
expect(capturedCypher).not.toContain('NOT n:File');
});
diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts
index 741ecc6a5b..4b7355b27c 100644
--- a/gitnexus/vitest.config.ts
+++ b/gitnexus/vitest.config.ts
@@ -72,6 +72,8 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
+ 'test/integration/group/manifest-resolve-symbol-2325.test.ts',
+ 'test/integration/group/http-route-resolve-symbol.test.ts',
],
fileParallelism: false,
sequence: { groupOrder: 1 },
@@ -107,6 +109,8 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
+ 'test/integration/group/manifest-resolve-symbol-2325.test.ts',
+ 'test/integration/group/http-route-resolve-symbol.test.ts',
'test/integration/skills-e2e.test.ts',
],
},