GitNexus/gitnexus/test/unit/group/manifest-extractor.test.ts
Gergő Magyar e148bc089a
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(group): replace LadybugDB-incompatible multi-label Cypher (#2325) (#2327)
* fix(group): use labels(n) IN allowlist instead of LadybugDB-incompatible multi-label Cypher (#2325)

manifest-extractor and http-route-extractor built Cypher with the openCypher
label disjunction `MATCH (n:A|B|C)`, which LadybugDB's parser rejects. The
error was swallowed by try/catch, so manifest contracts silently fell back to
synthetic UIDs with empty filePath and http-route cross-file handler
resolution silently returned null.

Replace all 7 queries with `MATCH (n) WHERE labels(n) IN [...]`. LadybugDB
returns labels(n) as a single string, so this is an exact allowlist — a 1:1
behavior-preserving syntax translation (validated against LadybugDB 0.17.1).
Export the two http-route query constants so integration tests can run the
exact production strings against a real DB, and add per-branch real-DB
regression coverage (the bug shipped because no test exercised these queries).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): import CypherExecutor from contract-extractor in #2325 test

The new manifest regression test imported `CypherExecutor` from
`group/types.js`, which does not export it — the type is defined only in
`group/contract-extractor.js` (as all production extractors import it).
This was a real TS2305 under `tsc -p tsconfig.test.json`, masked from CI
because the default tsconfig excludes `test/` and `import type` is erased
at runtime. Split the import so the type resolves from its real module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): run #2325 native-LadybugDB tests in the lbug-db project

Per TESTING.md, every test that opens a real `@ladybugdb/core` handle must
be registered in the sequential `lbug-db` Vitest project (and excluded from
`default`) to avoid native-mmap file-lock conflicts across parallel forks on
Windows. The two new group integration tests use `withTestLbugDB`/pool-adapter
but were in neither list, so they ran under the parallel `default` project.
Add both to `lbug-db.include` and `default.exclude`, matching every sibling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(group): export custom-contract resolve query for #2325 test

The #2325 integration test hand-copied the 21-label `custom`-branch
resolve query into a local `LABELS_CUSTOM_QUERY` constant, so editing the
production allowlist would silently desync the canary. Promote the query to
an exported `CUSTOM_CONTRACT_RESOLVE_QUERY` (mirroring http-route-extractor's
exported query strings) and import it in the test, so the canary always runs
the exact production query. Behavior unchanged — same query string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): de-brittle the #2325 custom-query label assertion

The unit test asserted a fixed 7-label ordered substring of the 21-label
custom-branch allowlist, coupling it to label order and no-space formatting —
a harmless reorder would have broken it. Replace with order/spacing-tolerant
membership checks for a spread of individual labels, keeping the unconditional
`not.toContain('Function|Method')` guard as the real regression check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): correct #2325 http-route docstring + add real-trigger canary

The http-route test claimed `MATCH (n:Function|Method|CodeElement)` "which
LadybugDB rejects" — but that 3-label disjunction actually PARSES. Verified
against the real parser, the genuine #2325 trigger is a *reserved-keyword*
label in the disjunction: `Macro` and `Union` both are, and only the manifest
custom branch (21-label list) and the lib branch (missing `Package` table)
actually threw. The http-route conversion to `labels(n) IN [...]` was a
consistency change, not a parser fix.

Correct the misleading docstring and add a rejection canary pinned to the real
cause (`MATCH (n:Function|Macro|Union)` rejects), so a future query that
reintroduces a reserved-keyword disjunction is caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): cover the thrift package-strip path against a real LadybugDB

The thrift-only branch of resolveSymbol strips a `package.` prefix from the
service name (`com.example.AuthService` -> `AuthService`) before the
Class/Interface lookup — previously exercised only with a mocked executor.
Add a service-contract integration case (no method, so it takes the
package-strip path, not the grpc-identical method path) that resolves the real
`cls:AuthService`. Without the strip the lookup matches nothing and falls back
to a synthetic uid, so this is a non-vacuous guard for the strip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): drop vestigial 'Package' label from lib contract lookup

The `lib` branch allowlisted `labels(n) IN ['Package','Module']`, but there is
no `Package` node table (see NODE_TABLES) — the entry only ever matched
nothing. Restrict to `['Module']`, the label libraries actually resolve to.
Behavior-neutral: the lib integration case still resolves its Module symbol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(group): update PIPELINE label-scoped queries to labels(n) IN form

The resolveSymbol label-scoping bullets still showed the banned
`MATCH (n:A|B)` disjunction; a contributor copying them would reintroduce
#2325. Rewrite them in the actual `labels(n) IN [...]` form, note the real
trigger (LadybugDB rejects a disjunction naming a reserved keyword such as
`Macro`/`Union`), and reflect the lib allowlist as `['Module']` after dropping
the vestigial `Package` label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(group): correct #2325 root-cause comments in the extractors

The production comments claimed LadybugDB rejects the `MATCH (n:A|B)`
disjunction "outright". Verified against the real parser, it rejects only when
a label is a reserved keyword (`Macro`, `Union`) or names a missing node
table. So only the manifest `custom` branch (reserved keywords in its 21-label
list) and the `lib` branch (missing `Package` table) actually threw; the
http-route/grpc/thrift/topic disjunctions parse fine and were converted to
`labels(n) IN [...]` for consistency and future-proofing, not because they were
broken. Rewrite the comments to say so accurately. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): make #2325 test prose name the real reserved-keyword trigger

The manifest test docstring/title and the unit-test comment said LadybugDB
rejects the `MATCH (n:A|B)` disjunction generally. It rejects only when a label
is a reserved keyword (`Macro`/`Union`) or a missing table. Reword the docstring
(custom + lib branches threw; others parsed), retitle the rejection canary to
"its list names reserved keywords Macro/Union", and correct the unit-test
comment. The rejection canary still passes — the custom 21-label list does
contain Macro/Union. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:08:43 +01:00

931 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import { ManifestExtractor } from '../../../src/core/group/extractors/manifest-extractor.js';
import type { GroupManifestLink } from '../../../src/core/group/types.js';
describe('ManifestExtractor', () => {
const extractor = new ManifestExtractor();
it('creates provider + consumer contracts and a cross-link for each manifest link', async () => {
const links: GroupManifestLink[] = [
{
from: 'hr/payroll/backend',
to: 'hr/hiring/backend',
type: 'topic',
contract: 'employee.hired',
role: 'provider',
},
];
const result = await extractor.extractFromManifest(links);
expect(result.contracts).toHaveLength(2);
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider).toBeDefined();
expect(provider!.contractId).toBe('topic::employee.hired');
expect(provider!.type).toBe('topic');
expect(provider!.confidence).toBe(1.0);
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(consumer).toBeDefined();
expect(consumer!.contractId).toBe('topic::employee.hired');
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].matchType).toBe('manifest');
expect(result.crossLinks[0].confidence).toBe(1.0);
expect(result.crossLinks[0].from.repo).toBe('hr/hiring/backend');
expect(result.crossLinks[0].to.repo).toBe('hr/payroll/backend');
});
it('handles role: consumer (from-repo is consumer)', async () => {
const links: GroupManifestLink[] = [
{
from: 'sales/admin/bff',
to: 'sales/crm/backend',
type: 'http',
contract: '/api/v2/leads/*',
role: 'consumer',
},
];
const result = await extractor.extractFromManifest(links);
const provider = result.contracts.find((c) => c.role === 'provider');
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(consumer!.contractId).toBe('http::*::/api/v2/leads/*');
expect(provider!.contractId).toBe('http::*::/api/v2/leads/*');
expect(result.crossLinks[0].from.repo).toBe('sales/admin/bff');
expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend');
});
it('resolves grpc manifest provider by exact method name (no .proto fallback)', async () => {
const links: GroupManifestLink[] = [
{
from: 'platform/orders',
to: 'platform/auth',
type: 'grpc',
contract: 'auth.AuthService/Login',
role: 'consumer',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'platform/auth',
async (_cypher, params) => {
// Exact match on method name.
if (params?.methodName === 'Login') {
return [
{
uid: 'uid-auth-login',
name: 'Login',
filePath: 'src/auth.proto',
},
];
}
return [];
},
],
[
'platform/orders',
async (_cypher, params) => {
// No symbol with the exact method name — resolve returns null and
// the consumer contract gets an empty symbolUid, falling back to
// name-based hint at cross-impact time.
if (params?.methodName === 'Login') return [];
return [];
},
],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
const consumer = result.contracts.find((c) => c.role === 'consumer');
// Provider resolved to the concrete proto symbol.
expect(provider?.symbolUid).toBe('uid-auth-login');
expect(provider?.symbolRef.filePath).toBe('src/auth.proto');
// Consumer falls back to a deterministic synthetic uid + name-based ref.
// The synthetic uid lets the bridge cross-impact query anchor on it
// even when the indexer doesn't expose a matching symbol.
expect(consumer?.symbolUid).toBe('manifest::platform/orders::grpc::auth.AuthService/Login');
expect(consumer?.symbolRef.name).toBe('auth.AuthService/Login');
expect(result.crossLinks[0].to.symbolRef.filePath).toBe('src/auth.proto');
expect(result.crossLinks[0].from.symbolUid).toBe(
'manifest::platform/orders::grpc::auth.AuthService/Login',
);
});
it('does NOT resolve grpc manifest to an arbitrary .proto file', async () => {
// Regression test for a previous bug: the extractor had an unconditional
// `OR n.filePath ENDS WITH '.proto'` fallback that returned the first
// proto symbol in the repo, regardless of whether it matched the contract.
const links: GroupManifestLink[] = [
{
from: 'platform/orders',
to: 'platform/auth',
type: 'grpc',
contract: 'auth.AuthService/Login',
role: 'consumer',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'platform/auth',
// Executor returns matches for ANY query (simulates the old buggy
// fallback that returned a random .proto file). The new code must
// only accept a hit when the method/service name matches exactly.
async (_cypher, params) => {
if (params?.methodName === 'Login' || params?.serviceName === 'auth.AuthService') {
return [
{
uid: 'uid-correct-login',
name: 'Login',
filePath: 'src/auth.proto',
},
];
}
return [];
},
],
['platform/orders', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
// Must resolve to the correct symbol (not a random proto one).
expect(provider?.symbolUid).toBe('uid-correct-login');
});
it('resolves grpc package-qualified service-only manifest by full service name', async () => {
const links: GroupManifestLink[] = [
{
from: 'platform/orders',
to: 'platform/auth',
type: 'grpc',
contract: 'auth.AuthService',
role: 'consumer',
},
];
let seenServiceName: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'platform/auth',
async (_cypher, params) => {
seenServiceName = params?.serviceName as string;
if (params?.serviceName === 'auth.AuthService') {
return [
{
uid: 'uid-auth-service',
name: 'auth.AuthService',
filePath: 'src/auth.proto',
},
];
}
return [];
},
],
['platform/orders', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenServiceName).toBe('auth.AuthService');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-auth-service');
});
it('resolves thrift package-qualified service-only manifest by simple service name', async () => {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders',
type: 'thrift',
contract: 'billing.v1.OrderService',
role: 'consumer',
},
];
let seenServiceName: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders',
async (_cypher, params) => {
seenServiceName = params?.serviceName as string;
if (params?.serviceName === 'OrderService') {
return [
{
uid: 'uid-order-service',
name: 'OrderService',
filePath: 'idl/order.thrift',
},
];
}
return [];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenServiceName).toBe('OrderService');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-order-service');
});
it('resolves lib manifest links by exact name only', async () => {
const links: GroupManifestLink[] = [
{
from: 'platform/web',
to: 'platform/shared-lib',
type: 'lib',
contract: '@platform/contracts',
role: 'consumer',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'platform/shared-lib',
async (_cypher, params) => {
if (params?.contract !== '@platform/contracts') return [];
return [
{
uid: 'uid-lib',
name: '@platform/contracts',
filePath: 'src/index.ts',
},
];
},
],
[
'platform/web',
async (_cypher, params) => {
if (params?.contract !== '@platform/contracts') return [];
return [];
},
],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(provider?.symbolUid).toBe('uid-lib');
// Consumer doesn't have a symbol named exactly '@platform/contracts' —
// exact matching returns null, falling back to the synthetic manifest uid.
expect(consumer?.symbolUid).toBe('manifest::platform/web::lib::@platform/contracts');
});
it('does NOT resolve lib manifest via CONTAINS on name', async () => {
// Regression test: previous CONTAINS fallback would match "react" to
// "react-native" or "@types/react". Exact matching must reject both.
const links: GroupManifestLink[] = [
{
from: 'web',
to: 'packages/ui',
type: 'lib',
contract: 'react',
role: 'consumer',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'packages/ui',
async (_cypher, params) => {
// Executor is called with contract='react'. Only exact matches
// should come back; return only wrong candidates to verify the
// Cypher uses `=` not `CONTAINS`.
if (params?.contract === 'react') {
// Simulated DB returns nothing because it has only "react-native"
// and "@types/react" — neither is an exact match for "react".
return [];
}
return [];
},
],
['web', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
// No exact match → synthetic manifest uid, not a wrong real one.
expect(provider?.symbolUid).toBe('manifest::packages/ui::lib::react');
});
it('normalizes http contract path for exact Route.name match', async () => {
// Manifest may be written as "/api/orders/" or "api/orders"; both should
// match the canonical "/api/orders" stored in the graph.
const variants = ['/api/orders', '/api/orders/', 'api/orders', '//api//orders'];
for (const raw of variants) {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: raw,
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
return [
{
uid: 'uid-orders-list',
name: 'listOrders',
filePath: 'src/orders.ts',
},
];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenParam).toBe('/api/orders');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-orders-list');
}
});
it('resolves http contract with explicit METHOD prefix (GET::/api/orders)', async () => {
// Regression test for Codex finding F1: resolveSymbol was passing the
// raw `link.contract` through normalizeRoutePath, which turned
// "GET::/api/orders" into "/GET::/api/orders" and never matched
// Route.name = "/api/orders". The extractor must strip the METHOD::
// prefix and pass only the path portion to the Cypher executor.
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
if (seenParam === '/api/orders') {
return [
{
uid: 'uid-orders-list',
name: 'listOrders',
filePath: 'src/orders.ts',
},
];
}
return [];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
// The key assertion: $normalized must be the path only, NOT "/GET::/api/orders".
expect(seenParam).toBe('/api/orders');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-orders-list');
expect(provider?.symbolRef.filePath).toBe('src/orders.ts');
});
it('resolves http contract with parameterised path (POST::/users/:id)', async () => {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'users-svc',
type: 'http',
contract: 'POST::/users/:id',
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'users-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
if (seenParam === '/users/:id') {
return [
{
uid: 'uid-update-user',
name: 'updateUser',
filePath: 'src/users.ts',
},
];
}
return [];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenParam).toBe('/users/:id');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-update-user');
});
it('handles http contract with empty path after METHOD:: (GET::)', async () => {
// Edge case: "GET::" (empty path after prefix). Normalizer produces "/"
// — either resolves to a root route or returns null cleanly.
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'GET::',
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
return [];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenParam).toBe('/');
// No match → synthetic uid, no crash.
const provider = result.contracts.find((c) => c.role === 'provider');
// buildContractId canonicalizes the empty path to `/` so contract ids
// match regardless of trailing-slash variants in the manifest input.
expect(provider?.symbolUid).toBe('manifest::orders-svc::http::GET::/');
});
it('treats empty method portion (::/api/orders) as a bare path', async () => {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: '::/api/orders',
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
return [];
},
],
['gateway', async () => []],
]);
await extractor.extractFromManifest(links, dbExecutors);
// "::/api/orders" has no method prefix per buildContractId's regex
// (`[A-Za-z]+::`), so the whole string is treated as a bare path.
// Normalizer collapses leading slashes, so "::/api/orders" stays
// essentially as-is (no alpha prefix match).
expect(seenParam).toBe('/::/api/orders');
});
it('resolves http contract with lowercase verb (get::/api/orders)', async () => {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'get::/api/orders',
role: 'consumer',
},
];
let seenParam: string | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'orders-svc',
async (_cypher, params) => {
seenParam = params?.normalized as string;
if (seenParam === '/api/orders') {
return [
{
uid: 'uid-orders-list',
name: 'listOrders',
filePath: 'src/orders.ts',
},
];
}
return [];
},
],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
expect(seenParam).toBe('/api/orders');
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.symbolUid).toBe('uid-orders-list');
});
it('returns null cleanly when no Route matches explicit-method http contract', async () => {
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
['orders-svc', async () => []],
['gateway', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
// No match → synthetic uid, caller falls back as today.
expect(provider?.symbolUid).toBe('manifest::orders-svc::http::GET::/api/orders');
});
it('buildContractId round-trip regression for GET::/api/orders', async () => {
// Verifies buildContractId still produces http::GET::/api/orders for
// explicit-method form — i.e. the fix to resolveSymbol did not touch
// buildContractId.
const links: GroupManifestLink[] = [
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
},
];
const result = await extractor.extractFromManifest(links);
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider?.contractId).toBe('http::GET::/api/orders');
});
it('canonicalizes method casing so get::/api/orders and GET::/api/orders share a contractId', async () => {
// Regression for Copilot's review on PR #817: without canonicalization,
// `buildContractId` passed raw casing through (`http::get::/api/orders`)
// while `parseHttpContract` upper-cased during lookup, fragmenting
// cross-impact joins between providers and consumers that happened to
// use different casing conventions in their group.yaml.
const lower = await extractor.extractFromManifest([
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'get::/api/orders',
role: 'consumer',
},
]);
const upper = await extractor.extractFromManifest([
{
from: 'gateway',
to: 'orders-svc',
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
},
]);
const lowerContractId = lower.contracts.find((c) => c.role === 'provider')?.contractId;
const upperContractId = upper.contracts.find((c) => c.role === 'provider')?.contractId;
expect(lowerContractId).toBe('http::GET::/api/orders');
expect(upperContractId).toBe('http::GET::/api/orders');
expect(lowerContractId).toBe(upperContractId);
});
it('builds thrift manifest contracts with synthetic uids when unresolved', async () => {
const extractor = new ManifestExtractor();
const result = await extractor.extractFromManifest([
{
from: 'gateway',
to: 'orders',
type: 'thrift',
contract: 'billing.v1.OrderService/PlaceOrder',
role: 'consumer',
},
]);
expect(result.contracts).toHaveLength(2);
expect(result.contracts.map((c) => c.contractId)).toEqual([
'thrift::billing.v1.OrderService/PlaceOrder',
'thrift::billing.v1.OrderService/PlaceOrder',
]);
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].type).toBe('thrift');
expect(result.crossLinks[0].from.symbolUid).toBe(
'manifest::gateway::thrift::billing.v1.OrderService/PlaceOrder',
);
expect(result.crossLinks[0].to.symbolUid).toBe(
'manifest::orders::thrift::billing.v1.OrderService/PlaceOrder',
);
});
it('resolves custom manifest links by exact symbol name', async () => {
const links: GroupManifestLink[] = [
{
from: 'parser/mathlex',
to: 'engine/thales',
type: 'custom',
contract: 'Expression',
role: 'provider',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'engine/thales',
async (_cypher, params) => {
if (params?.symbolName === 'Expression') {
return [
{
uid: 'uid-expression-struct',
name: 'Expression',
filePath: 'src/expression.rs',
},
];
}
return [];
},
],
[
'parser/mathlex',
async (_cypher, params) => {
if (params?.symbolName === 'Expression') {
return [
{
uid: 'uid-expression-enum',
name: 'Expression',
filePath: 'src/ast.rs',
},
];
}
return [];
},
],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(provider?.symbolUid).toBe('uid-expression-enum');
expect(provider?.symbolRef.filePath).toBe('src/ast.rs');
expect(consumer?.symbolUid).toBe('uid-expression-struct');
expect(consumer?.symbolRef.filePath).toBe('src/expression.rs');
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].matchType).toBe('manifest');
});
it('custom contract with qualified name (provider::Symbol) strips prefix before graph query', async () => {
const links: GroupManifestLink[] = [
{
from: 'parser/mathlex',
to: 'engine/thales',
type: 'custom',
contract: 'mathlex::Expression',
role: 'provider',
},
];
let capturedParams: Record<string, unknown> | undefined;
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
[
'parser/mathlex',
async (_cypher, params) => {
capturedParams = params;
if (params?.symbolName === 'Expression') {
return [{ uid: 'uid-expr', name: 'Expression', filePath: 'src/ast.rs' }];
}
return [];
},
],
['engine/thales', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
expect(capturedParams?.symbolName).toBe('Expression');
expect(provider?.symbolUid).toBe('uid-expr');
});
it('falls back to synthetic uid when custom symbol not found in graph', async () => {
const links: GroupManifestLink[] = [
{
from: 'core/units',
to: 'engine/thales',
type: 'custom',
contract: 'Dimension',
role: 'provider',
},
];
const dbExecutors = new Map<
string,
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
>([
['engine/thales', async () => []],
['core/units', async () => []],
]);
const result = await extractor.extractFromManifest(links, dbExecutors);
const provider = result.contracts.find((c) => c.role === 'provider');
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);
// 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');
});
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?.symbolName === 'Token') {
return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }];
}
return [];
},
],
[
'engine/thales',
async (_cypher, params) => {
if (params?.symbolName === '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);
expect(result.crossLinks).toHaveLength(0);
});
it('memoizes repeated (repo, type, contract) resolutions so each tuple hits the DB once', async () => {
const calls: Array<{ repo: string; cypher: string }> = [];
const execFor = (repo: string) => async (cypher: string) => {
calls.push({ repo, cypher });
return [{ uid: `uid::${repo}`, name: 'handler', filePath: 'src/h.ts' }];
};
const dbExecutors = new Map<string, (c: string) => Promise<Record<string, unknown>[]>>([
['svc/a', execFor('svc/a')],
['svc/b', execFor('svc/b')],
]);
// Two links declare the same (repo, type, contract) triple on each side,
// so naive sequential resolution would run 4 queries; memoization collapses
// to 2 (one per distinct repo tuple).
const link: GroupManifestLink = {
from: 'svc/b',
to: 'svc/a',
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
};
await extractor.extractFromManifest([link, { ...link }], dbExecutors);
// One resolution per distinct (repo, type, contract) — not per (link × side).
expect(calls).toHaveLength(2);
expect(new Set(calls.map((c) => c.repo))).toEqual(new Set(['svc/a', 'svc/b']));
});
});