mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
* [+] Add django route discovery to create cross-link for multi-repo * [+] Update ingestion * [~] Fix bugs and abstraction violation * feat(python-http): add keyword url= and variable propagation for consumer detection - Add REQUESTS_KEYWORD_URL_PATTERNS for requests.get(url='...') keyword args - Add WRAPPER_URI_PATTERNS for generic wrapper.fetch(uri='...') calls - Add WRAPPER_URI_VAR_PATTERNS + buildLocalStringMap for uri=variable propagation - Add LOCAL_STRING_ASSIGNMENTS to track uri='...' assignments - Wire both direct-string and variable-propagation loops in scan() - Add normalizeConsumerPath() helper Note: Automatic cross-link detection remains limited for runtime-computed URLs (URLs built via .format(), string concat, or module constants). Manual manifest links needed for known cross-repo contracts. * [+] add extract uri and url keywork pattern for request http * feat(python-http): add variable propagation for uri=/url= consumer patterns Re-add LOCAL_STRING_ASSIGNMENTS, WRAPPER_URI_VAR_PATTERNS, buildLocalStringMap(), and normalizeConsumerPath() lost during cherry-pick merge of upstream keyword-URL commit. Together with the upstream WRAPPER_URI_PATTERNS and REQUESTS_KEYWORD_URL_PATTERNS, we now detect: - requests.get(url='literal') keyword args - wrapper.fetch(uri='literal') keyword args - wrapper.fetch(uri=variable) where variable was assigned a string literal * fix(group): discover Django roots relative to manage.py dir + multi-project (#1836 R1) A Django project not at the repo root (e.g. backend/manage.py) discovered zero routes: the settings module path was resolved repo-root-relative only, so backend/myproj/settings.py was never found and discovery returned null. Resolve settings, star-imported base settings, ROOT_URLCONF, and the root urls.py against the manage.py's own directory first, then the repo root (resolvedSettingsPath is now project-dir-aware so relative imports anchor correctly). Iterate every manage.py so a monorepo with several Django projects yields each project's root — the provider hook becomes plural (discoverRootRouteFiles → string[]) and the main-thread pass loops over all roots (inner-scoped continues, parser hoisted once per language). Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(group): remove dead code in Django root discovery (#1836 R9) - Collapse the identical if/else in extractStarImports to one push. - Drop the unreachable baseModule.startsWith('.') branch (baseModule is always a resolved slash-path or a bare absolute module — never dot-prefixed). - Import DjangoFileReader from django.ts instead of re-declaring the type. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): walk Django includes once per prefix, not per file (#1836 R2) The include() recursion guard was keyed on file path alone and shared across the whole walk, so a urlconf included under two prefixes (a "diamond" — the same app mounted at /v1/ and /v2/) emitted routes for only the first mount. Key the guard on (resolvedFilePath, accumulatedPrefix) at all three sites (function entry, path()-wrapped include, bare include) so a file reached under two distinct prefixes is walked once per prefix while a genuine cycle (same file + same prefix) still terminates — null/'' prefixes collapse to one key so a no-prefix re-entry is treated as a cycle. MAX_INCLUDE_DEPTH remains the backstop. Adds diamond + self-include-cycle tests. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): extract Django routes from non-list urlpatterns (#1836 R3) findUrlpatternsLists only accepted a list-literal RHS, so common shapes yielded zero routes: concatenation (urlpatterns = a + b), wrapper calls (format_suffix_patterns([...]), i18n_patterns, staticfiles_urlpatterns), and tuples. Add collectUrlpatternContainers to descend binary_operator operands, known wrapper-call list arguments, and tuples. Inherently-dynamic forms (DRF router.urls, comprehensions, bare names) still yield nothing but now emit a debug log so the silent-zero case is observable rather than mysterious. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(group): thread Django parser explicitly, drop module singleton (#1836 R4) extractDjangoRoutes relied on a module-level _djangoParser set via setDjangoParser before each call — hidden state that would break if a second language ever used the include re-parse path, and an easy-to-forget contract. Pass the tree-sitter parser as an explicit parameter of extractDjangoRoutes (the extractRoutes provider hook already receives it) and delete the global plus its setter. The Python provider wires it directly; tests pass the parser in place of the removed setDjangoParser() call. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ingestion): isolate a throwing extractRoutes in the cross-file route pass (#1836 R5) The main-thread cross-file route pass called provider.extractRoutes without a guard, so a throw (e.g. a future grammar edge case in the include() walk) would propagate out of the parse phase and abort the entire analyze — unlike the worker, which isolates per-file failures. Wrap the per-root extractRoutes call in try/catch that logs a warning and continues to the next root. Export extractCrossFileRoutes and add a unit test driving a stub provider whose extractRoutes throws, asserting the pass returns [] and does not propagate. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(ingestion): bucket only route-capable languages in cross-file pass (#1836 R6) extractCrossFileRoutes runs in the deferred band on every analyze (incl. warm all-cache-hit runs). It now derives the set of languages whose provider exposes the cross-file route hooks once, returns early if none do, and buckets only those languages' paths — so a non-framework repo no longer pays to bucket the languages it doesn't use here. Route results are intentionally not persisted across runs, so a Django repo still re-derives its routes each analyze; documented inline that cross-run route caching is a deliberate follow-up rather than implemented here. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(group): prettier-format http-patterns/python.ts (#1836 R7) The file was not formatted to the root .prettierrc (the consumer-path normalizer used single-line try/catch and method chains), so the CI quality/format check (`prettier --check .`) failed. Reflow only — no logic change (`git diff -w` confines the change to normalizeConsumerPath's layout). Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): dedup Python URI detections by byte offset, not line arithmetic (#1836 R8) The wrapper-URI dedup key was lineNum*1000+methodRow, which can collide for distinct calls in files over 1000 lines (carry into the row term) and can fail to dedup a genuine duplicate when a node straddles a line boundary. Key on node byte offsets (`${pathNode.startIndex}:${methodNode.startIndex}`), matching the sibling seenVarDetections dedup a few lines below. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ingestion): end-to-end Django cross-file route extraction (#1836 R10) Adds an integration test that runs runPipelineFromRepo against a Django fixture whose project lives under backend/, asserting the resulting Route graph nodes (/health, /api/items, /api/items/<int:pk>). This exercises the previously-untested main-thread orchestration glue (discovery → parse → extractRoutes → allExtractedRoutes → Route nodes) and, because the project is in a subdirectory, regresses the subdir-discovery fix (R1) — a repo-root-only resolver would discover nothing and emit zero Route nodes. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): anchor Django include() resolution at the project root (#1836 review F1) resolveIncludedFile tried the bare repo-root candidate (app/urls.py) before the project-relative one, so in a monorepo with both a repo-root app/ and a backend/ Django project that also has an app/, include('app.urls') from the backend project resolved to the WRONG service's routes. Probe up-tree from the root urls.py for the nearest manage.py (the Django project root / sys.path entry) and try that-anchored candidate first. Absolute module paths like `app.urls` now resolve to <projectRoot>/app/urls.py unambiguously. When no manage.py is reachable (e.g. unit tests with a urls-only reader) the prior strategy order is preserved. Adds a monorepo wrong-app test. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): drop bogus Django provider source-scan, use graph routes (#1836 review F2) The DJANGO_PATH_PATTERNS / DJANGO_URL_PATTERNS source scan emitted an HTTP provider contract for every path()/re_path()/url() string literal, without checking it was inside urlpatterns, without skipping include() mount points, and without composing the include() prefix across files. For `path('api/', include('app.urls'))` + child `path('items/', view)` it emitted providers for `/api` (a mount, not a route) and `/items` (un-prefixed) — which survived the exact-contract-ID dedup alongside the correct graph route `/api/items`, polluting cross-repo matching with false providers. Remove the Django provider patterns and their scan blocks. Django provider contracts come from the graph Route nodes, which the ingestion route extractor builds with includes already composed (and now correctly, per the other fixes). Python HTTP *consumer* patterns (requests/wrapper) are unaffected. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): match method-agnostic Django providers to any-method consumers (#1836 review F3) Django function views are method-agnostic, so extractDjangoRoutes emits httpMethod '*'. That '*' was dropped by normalizeRouteMethod and then defaulted to GET by the contract extractor, while the matcher only expanded wildcard *consumers* — so a `POST /api/items` consumer never matched the Django provider that was silently narrowed to GET. - routes.ts: preserve '*' as a method-agnostic marker on the Route node, so the contract layer emits a wildcard provider (http::*::path) instead of GET. - matching.ts: make findMatchingKeys symmetric — a specific-method consumer now matches an exact-method provider OR a wildcard (http::*::) provider on the same path, mirroring the existing wildcard-consumer expansion. Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Dinh Huy <huynd86@fpt.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
842 lines
28 KiB
TypeScript
842 lines
28 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
runExactMatch,
|
|
normalizeContractId,
|
|
buildProviderIndex,
|
|
runWildcardMatch,
|
|
} from '../../../src/core/group/matching.js';
|
|
import type { StoredContract, MatchingConfig } from '../../../src/core/group/types.js';
|
|
|
|
describe('normalizeContractId', () => {
|
|
it('lowercases HTTP method', () => {
|
|
expect(normalizeContractId('http::get::/api/users')).toBe('http::GET::/api/users');
|
|
});
|
|
|
|
it('strips trailing slash from HTTP path', () => {
|
|
expect(normalizeContractId('http::GET::/api/users/')).toBe('http::GET::/api/users');
|
|
});
|
|
|
|
it('lowercases gRPC package', () => {
|
|
expect(normalizeContractId('grpc::Hr.UserService/GetUser')).toBe(
|
|
'grpc::hr.userservice/GetUser',
|
|
);
|
|
});
|
|
|
|
it('lowercases thrift package and service while preserving method case', () => {
|
|
expect(normalizeContractId('thrift::Billing.V1.OrderService/PlaceOrder')).toBe(
|
|
'thrift::billing.v1.orderservice/PlaceOrder',
|
|
);
|
|
});
|
|
|
|
it('preserves case for malformed thrift id with leading slash', () => {
|
|
expect(normalizeContractId('thrift::/PlaceOrder')).toBe('thrift::/PlaceOrder');
|
|
});
|
|
|
|
it('preserves case for malformed gRPC id with leading slash (no full-string lowercasing)', () => {
|
|
expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing');
|
|
});
|
|
|
|
it('handles malformed grpc with leading slash and no package', () => {
|
|
// grpc::/Method — leading slash, no package
|
|
expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method');
|
|
});
|
|
|
|
it('handles grpc with no slash at all', () => {
|
|
// grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token
|
|
expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename');
|
|
});
|
|
|
|
it('trims and lowercases topic', () => {
|
|
expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired');
|
|
});
|
|
|
|
it('lowercases lib package coordinates', () => {
|
|
expect(normalizeContractId('lib::@Hr/Common::UserDTO')).toBe('lib::@hr/common::userdto');
|
|
});
|
|
});
|
|
|
|
describe('runExactMatch', () => {
|
|
const makeContract = (
|
|
id: string,
|
|
role: 'provider' | 'consumer',
|
|
repo: string,
|
|
): StoredContract => ({
|
|
contractId: id,
|
|
type: 'http',
|
|
role,
|
|
symbolUid: `uid-${repo}-${id}`,
|
|
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
|
symbolName: `fn-${id}`,
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo,
|
|
});
|
|
|
|
it('matches provider and consumer with same contract ID', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::GET::/api/users');
|
|
expect(matched[0].matchType).toBe('exact');
|
|
expect(matched[0].confidence).toBe(1.0);
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('handles multiple consumers for one provider', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'frontend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'bff'),
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(2);
|
|
});
|
|
|
|
it('matches a specific-method consumer to a method-agnostic (wildcard) provider', () => {
|
|
// A Django function view is method-agnostic (provider method '*'); a POST
|
|
// consumer on the same path must still match it.
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::*::/api/items', 'provider', 'backend'),
|
|
makeContract('http::POST::/api/items', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('reports unmatched contracts', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/orphan', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toHaveLength(2);
|
|
});
|
|
|
|
it('normalizes contract IDs before matching', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/api/users/', 'provider', 'backend'),
|
|
makeContract('http::get::/api/users', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(1);
|
|
});
|
|
|
|
it('does not match contracts within the same repo', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'backend'),
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(0);
|
|
});
|
|
|
|
it('matches same-repo contracts with different service boundaries', () => {
|
|
const contracts: StoredContract[] = [
|
|
{
|
|
...makeContract('http::GET::/api/users', 'provider', 'monorepo'),
|
|
service: 'services/auth',
|
|
},
|
|
{
|
|
...makeContract('http::GET::/api/users', 'consumer', 'monorepo'),
|
|
service: 'services/gateway',
|
|
},
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].from.repo).toBe('monorepo');
|
|
expect(matched[0].to.repo).toBe('monorepo');
|
|
expect(matched[0].from.service).toBe('services/gateway');
|
|
expect(matched[0].to.service).toBe('services/auth');
|
|
});
|
|
|
|
it('does not match same-repo contracts with same service', () => {
|
|
const contracts: StoredContract[] = [
|
|
{
|
|
...makeContract('http::GET::/api/users', 'provider', 'monorepo'),
|
|
service: 'services/auth',
|
|
},
|
|
{
|
|
...makeContract('http::GET::/api/users', 'consumer', 'monorepo'),
|
|
service: 'services/auth',
|
|
},
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(0);
|
|
});
|
|
|
|
it('does not match same-repo when only one has service', () => {
|
|
const contracts: StoredContract[] = [
|
|
{
|
|
...makeContract('http::GET::/api/users', 'provider', 'monorepo'),
|
|
service: 'services/auth',
|
|
},
|
|
makeContract('http::GET::/api/users', 'consumer', 'monorepo'),
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(0);
|
|
});
|
|
|
|
it('cross-repo matching works regardless of service field', () => {
|
|
const contracts: StoredContract[] = [
|
|
{ ...makeContract('http::GET::/api/users', 'provider', 'backend'), service: 'services/auth' },
|
|
{ ...makeContract('http::GET::/api/users', 'consumer', 'frontend'), service: 'services/web' },
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].from.service).toBe('services/web');
|
|
expect(matched[0].to.service).toBe('services/auth');
|
|
});
|
|
|
|
it('matches consumer http::*::path to a concrete provider method on that path', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::POST::/api/users', 'provider', 'backend'),
|
|
makeContract('http::*::/api/users', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::*::/api/users');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers for Task 6 tests
|
|
// ---------------------------------------------------------------------------
|
|
function makeGrpcContract(
|
|
id: string,
|
|
role: 'provider' | 'consumer',
|
|
repo: string,
|
|
overrides: Partial<StoredContract> = {},
|
|
): StoredContract {
|
|
return {
|
|
contractId: id,
|
|
type: 'grpc',
|
|
role,
|
|
symbolUid: `uid-${repo}-${id}`,
|
|
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
|
symbolName: `fn-${id}`,
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeThriftContract(
|
|
id: string,
|
|
role: 'provider' | 'consumer',
|
|
repo: string,
|
|
overrides: Partial<StoredContract> = {},
|
|
): StoredContract {
|
|
return {
|
|
contractId: id,
|
|
type: 'thrift',
|
|
role,
|
|
symbolUid: `uid-${repo}-${id}`,
|
|
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
|
symbolName: `fn-${id}`,
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildProviderIndex
|
|
// ---------------------------------------------------------------------------
|
|
describe('buildProviderIndex', () => {
|
|
it('test_buildProviderIndex_creates_normalized_keys', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'),
|
|
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const index = buildProviderIndex(contracts);
|
|
|
|
// Only providers should be in the index
|
|
expect(index.size).toBe(1);
|
|
// Key should be normalized (lowercased package)
|
|
expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true);
|
|
expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1);
|
|
expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// runExactMatch — gRPC wildcard skip
|
|
// ---------------------------------------------------------------------------
|
|
describe('runExactMatch — gRPC wildcard handling', () => {
|
|
it('test_runExactMatch_skips_grpc_wildcard_contracts', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'),
|
|
makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
|
|
// gRPC wildcards should NOT be matched in exact pass
|
|
expect(matched).toHaveLength(0);
|
|
// Both should appear in unmatched
|
|
expect(unmatched).toHaveLength(2);
|
|
});
|
|
|
|
it('test_runExactMatch_skips_thrift_wildcard_contracts', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeThriftContract('thrift::billing.v1.OrderService/*', 'consumer', 'frontend'),
|
|
makeThriftContract('thrift::billing.v1.OrderService/*', 'provider', 'backend'),
|
|
];
|
|
|
|
const { matched, unmatched } = runExactMatch(contracts);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toHaveLength(2);
|
|
});
|
|
|
|
it('test_runExactMatch_does_not_skip_http_wildcards', () => {
|
|
const contracts: StoredContract[] = [
|
|
{
|
|
contractId: 'http::GET::/api/users',
|
|
type: 'http',
|
|
role: 'provider',
|
|
symbolUid: 'uid-backend-http',
|
|
symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' },
|
|
symbolName: 'fn-http',
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo: 'backend',
|
|
},
|
|
{
|
|
contractId: 'http::*::/api/users',
|
|
type: 'http',
|
|
role: 'consumer',
|
|
symbolUid: 'uid-frontend-http',
|
|
symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' },
|
|
symbolName: 'fn-http',
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo: 'frontend',
|
|
},
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
// HTTP wildcard should still match via findMatchingKeys
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::*::/api/users');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// runWildcardMatch
|
|
// ---------------------------------------------------------------------------
|
|
describe('runWildcardMatch', () => {
|
|
it('test_runWildcardMatch_fq_service_match', () => {
|
|
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.UserService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
});
|
|
|
|
it('test_runWildcardMatch_bare_name_match', () => {
|
|
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.UserService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
});
|
|
|
|
it('test_runWildcardMatch_no_match_different_service', () => {
|
|
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.OtherService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(remaining).toContainEqual(consumer);
|
|
});
|
|
|
|
it('test_runWildcardMatch_skips_wildcard_providers', () => {
|
|
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend');
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
// Wildcard provider key ends with /*, so it should be skipped
|
|
expect(matched).toHaveLength(0);
|
|
});
|
|
|
|
it('test_runWildcardMatch_confidence_min', () => {
|
|
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', {
|
|
confidence: 0.7,
|
|
});
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.UserService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
{
|
|
confidence: 0.5,
|
|
},
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].confidence).toBe(0.5);
|
|
});
|
|
|
|
it('test_runWildcardMatch_matchType_wildcard', () => {
|
|
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.UserService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].matchType).toBe('wildcard');
|
|
});
|
|
|
|
it('test_runWildcardMatch_contractId_is_consumers', () => {
|
|
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::com.example.UserService/GetUser',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('grpc::com.example.UserService/*');
|
|
});
|
|
|
|
it('matches thrift fully-qualified service wildcard to a thrift provider method', () => {
|
|
const consumer = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/*',
|
|
'consumer',
|
|
'frontend',
|
|
);
|
|
const provider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].type).toBe('thrift');
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it('matches bare thrift service wildcard to a package-qualified thrift provider', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
|
const provider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('thrift::OrderService/*');
|
|
});
|
|
|
|
it('does not match bare thrift service wildcard when multiple package-qualified services match', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
|
const billingProvider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'billing',
|
|
);
|
|
const salesProvider = makeThriftContract(
|
|
'thrift::sales.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'sales',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([billingProvider, salesProvider]);
|
|
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(remaining).toEqual([consumer]);
|
|
});
|
|
|
|
it('keeps fully-qualified thrift service wildcard matching when same bare service appears elsewhere', () => {
|
|
const consumer = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/*',
|
|
'consumer',
|
|
'frontend',
|
|
);
|
|
const billingProvider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'billing',
|
|
);
|
|
const salesProvider = makeThriftContract(
|
|
'thrift::sales.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'sales',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([billingProvider, salesProvider]);
|
|
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].to.repo).toBe('billing');
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it('matches bare thrift service method to a package-qualified thrift provider method', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
|
const provider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].type).toBe('thrift');
|
|
expect(matched[0].matchType).toBe('exact');
|
|
expect(matched[0].contractId).toBe('thrift::OrderService/PlaceOrder');
|
|
expect(matched[0].from.repo).toBe('frontend');
|
|
expect(matched[0].to.repo).toBe('backend');
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('does not match bare thrift service method to a different provider method', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
|
const provider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/GetOrderStatus',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toEqual([consumer, provider]);
|
|
});
|
|
|
|
it('does not match bare thrift service method when multiple package-qualified providers match', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
|
const billingProvider = makeThriftContract(
|
|
'thrift::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'billing',
|
|
);
|
|
const salesProvider = makeThriftContract(
|
|
'thrift::sales.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'sales',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([salesProvider, billingProvider]);
|
|
const { matched, unmatched } = runExactMatch(
|
|
[consumer, salesProvider, billingProvider],
|
|
providerIndex,
|
|
);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toEqual([consumer, salesProvider, billingProvider]);
|
|
});
|
|
|
|
it('does not match a thrift wildcard to a gRPC provider', () => {
|
|
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
|
const provider = makeGrpcContract(
|
|
'grpc::billing.v1.OrderService/PlaceOrder',
|
|
'provider',
|
|
'backend',
|
|
);
|
|
|
|
const providerIndex = buildProviderIndex([provider]);
|
|
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(remaining).toEqual([consumer]);
|
|
});
|
|
});
|
|
|
|
describe('buildNoisyContractFilter (via runExactMatch)', () => {
|
|
const makeContract = (
|
|
id: string,
|
|
role: 'provider' | 'consumer',
|
|
repo: string,
|
|
): StoredContract => ({
|
|
contractId: id,
|
|
type: 'http',
|
|
role,
|
|
symbolUid: `uid-${repo}-${id}`,
|
|
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
|
symbolName: `fn-${id}`,
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo,
|
|
});
|
|
|
|
it('exclude_links_paths prevents cross-links for configured paths', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/ping'],
|
|
exclude_links_param_only_paths: false,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/ping', 'provider', 'backend'),
|
|
makeContract('http::GET::/ping', 'consumer', 'frontend'),
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched, unmatched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::GET::/api/users');
|
|
});
|
|
|
|
it('excluded providers do not appear in matched', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/health'],
|
|
exclude_links_param_only_paths: false,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/health', 'provider', 'backend'),
|
|
makeContract('http::GET::/health', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
});
|
|
|
|
it('excluded contracts do not appear in unmatched', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/ping'],
|
|
exclude_links_param_only_paths: false,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/ping', 'provider', 'backend'),
|
|
makeContract('http::GET::/ping', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched, unmatched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('exclude_links_param_only_paths filters /{param} and /{param}/{param}', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: [],
|
|
exclude_links_param_only_paths: true,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/{param}', 'provider', 'backend'),
|
|
makeContract('http::GET::/{param}', 'consumer', 'frontend'),
|
|
makeContract('http::GET::/{param}/{param}', 'provider', 'backend'),
|
|
makeContract('http::GET::/{param}/{param}', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched, unmatched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('mixed routes like /users/{param} are NOT excluded by param_only', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: [],
|
|
exclude_links_param_only_paths: true,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/users/{param}', 'provider', 'backend'),
|
|
makeContract('http::GET::/users/{param}', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::GET::/users/{param}');
|
|
});
|
|
|
|
it('default config (no exclusions) produces no filtering', () => {
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/ping', 'provider', 'backend'),
|
|
makeContract('http::GET::/ping', 'consumer', 'frontend'),
|
|
makeContract('http::GET::/{param}', 'provider', 'backend'),
|
|
makeContract('http::GET::/{param}', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const { matched } = runExactMatch(contracts);
|
|
|
|
expect(matched).toHaveLength(2);
|
|
});
|
|
|
|
it('trailing slash on contractId still matches configured exclusion', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/ping'],
|
|
exclude_links_param_only_paths: false,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/ping/', 'provider', 'backend'),
|
|
makeContract('http::GET::/ping/', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched, unmatched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(0);
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('root path exclusion ["/"] suppresses http::GET::/ contracts', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/'],
|
|
exclude_links_param_only_paths: false,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
makeContract('http::GET::/', 'provider', 'backend'),
|
|
makeContract('http::GET::/', 'consumer', 'frontend'),
|
|
makeContract('http::GET::/api/users', 'provider', 'backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'frontend'),
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched, unmatched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
expect(matched[0].contractId).toBe('http::GET::/api/users');
|
|
expect(unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('non-HTTP contracts are never filtered', () => {
|
|
const matchingConfig: MatchingConfig = {
|
|
bm25_threshold: 0.7,
|
|
embedding_threshold: 0.65,
|
|
max_candidates_per_step: 3,
|
|
exclude_links_paths: ['/ping'],
|
|
exclude_links_param_only_paths: true,
|
|
};
|
|
|
|
const contracts: StoredContract[] = [
|
|
{
|
|
contractId: 'topic::events.ping',
|
|
type: 'topic',
|
|
role: 'provider',
|
|
symbolUid: 'uid-backend-topic',
|
|
symbolRef: { filePath: 'src/backend.ts', name: 'fn-topic' },
|
|
symbolName: 'fn-topic',
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo: 'backend',
|
|
},
|
|
{
|
|
contractId: 'topic::events.ping',
|
|
type: 'topic',
|
|
role: 'consumer',
|
|
symbolUid: 'uid-frontend-topic',
|
|
symbolRef: { filePath: 'src/frontend.ts', name: 'fn-topic' },
|
|
symbolName: 'fn-topic',
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo: 'frontend',
|
|
},
|
|
];
|
|
|
|
const providerIndex = buildProviderIndex(contracts, matchingConfig);
|
|
const { matched } = runExactMatch(contracts, providerIndex, matchingConfig);
|
|
|
|
expect(matched).toHaveLength(1);
|
|
});
|
|
});
|