mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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): 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>
931 lines
30 KiB
TypeScript
931 lines
30 KiB
TypeScript
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']));
|
||
});
|
||
});
|