diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9abd15b0ef..ada865f79f 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,6 +17,7 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", + "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", @@ -27,6 +28,7 @@ "mnemonist": "^0.39.0", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", + "path-to-regexp": "^8.4.2", "tree-sitter": "^0.21.1", "tree-sitter-c": "0.23.2", "tree-sitter-c-sharp": "^0.23.1", @@ -3026,6 +3028,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4299,10 +4307,14 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -4577,16 +4589,6 @@ "node": ">= 18" } }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 7699208e26..81399be785 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -69,6 +69,7 @@ "mnemonist": "^0.39.0", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", + "path-to-regexp": "^8.4.2", "tree-sitter": "^0.21.1", "tree-sitter-c": "0.23.2", "tree-sitter-c-sharp": "^0.23.1", @@ -89,7 +90,6 @@ "tree-sitter-swift": "^0.6.0" }, "devDependencies": { - "gitnexus-shared": "file:../gitnexus-shared", "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -97,6 +97,7 @@ "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", + "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^5.4.5", "vitest": "^4.0.18" diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 70ca9537a3..84e1e211b8 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -28,14 +28,18 @@ export function registerGroupCommands(program: Command): void { ) .action(async (groupName: string, groupPath: string, registryName: string) => { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); - const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const { loadGroupConfig, serializeGroupConfig } = await import('../core/group/config-parser.js'); const path = await import('node:path'); const fs = await import('node:fs/promises'); const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); const config = await loadGroupConfig(groupDir); config.repos[groupPath] = registryName; - await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + await fs.writeFile( + path.join(groupDir, 'group.yaml'), + yaml.dump(serializeGroupConfig(config)), + 'utf-8', + ); console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`); console.log(`Run: gitnexus group sync ${groupName}`); }); @@ -45,7 +49,7 @@ export function registerGroupCommands(program: Command): void { .description('Remove a repo from a group') .action(async (groupName: string, repoPath: string) => { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); - const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const { loadGroupConfig, serializeGroupConfig } = await import('../core/group/config-parser.js'); const path = await import('node:path'); const fs = await import('node:fs/promises'); const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); @@ -56,7 +60,11 @@ export function registerGroupCommands(program: Command): void { return; } delete config.repos[repoPath]; - await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + await fs.writeFile( + path.join(groupDir, 'group.yaml'), + yaml.dump(serializeGroupConfig(config)), + 'utf-8', + ); console.log(`Removed "${repoPath}" from group "${groupName}"`); }); diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index edaeeeec2e..b4d35d4596 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -1,5 +1,11 @@ import { createRequire } from 'node:module'; -import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js'; +import type { + GroupConfig, + GroupManifestLink, + ContractType, + ContractRole, + HttpMappingRule, +} from './types.js'; const _require = createRequire(import.meta.url); const yaml = _require('js-yaml') as typeof import('js-yaml'); @@ -21,6 +27,30 @@ const DEFAULT_MATCHING = { max_candidates_per_step: 3, }; +export function serializeGroupConfig(config: GroupConfig): Record { + return { + version: config.version, + name: config.name, + description: config.description, + repos: config.repos, + links: config.links, + http_mappings: config.httpMappings.map((mapping) => ({ + from: mapping.from, + to: { + repo: mapping.to.repo, + ...(mapping.to.service ? { service: mapping.to.service } : {}), + }, + ...(mapping.methods ? { methods: mapping.methods } : {}), + match: mapping.match, + rewrite: mapping.rewrite, + ...(mapping.when ? { when: mapping.when } : {}), + })), + packages: config.packages, + detect: config.detect, + matching: config.matching, + }; +} + export function parseGroupConfig(yamlContent: string): GroupConfig { const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record; @@ -73,6 +103,66 @@ export function parseGroupConfig(yamlContent: string): GroupConfig { }; }); + const rawHttpMappings = (raw.http_mappings as unknown[]) || []; + const httpMappings: HttpMappingRule[] = rawHttpMappings.map((entry: unknown, i: number) => { + const mapping = entry as Record; + if (!mapping.from || !repoPaths.has(mapping.from as string)) { + throw new Error(`http_mappings[${i}].from "${mapping.from}" does not match any repo path`); + } + if (!mapping.to || typeof mapping.to !== 'object' || Array.isArray(mapping.to)) { + throw new Error(`http_mappings[${i}].to is required and must be an object`); + } + const target = mapping.to as Record; + if (!target.repo || !repoPaths.has(target.repo as string)) { + throw new Error( + `http_mappings[${i}].to.repo "${target.repo}" does not match any repo path`, + ); + } + if (mapping.match === undefined || String(mapping.match).trim() === '') { + throw new Error(`http_mappings[${i}].match is required`); + } + if (mapping.rewrite === undefined || String(mapping.rewrite).trim() === '') { + throw new Error(`http_mappings[${i}].rewrite is required`); + } + + let methods: string[] | undefined; + if (mapping.methods !== undefined) { + if (!Array.isArray(mapping.methods)) { + throw new Error(`http_mappings[${i}].methods must be an array when provided`); + } + methods = mapping.methods.map((method) => String(method).trim().toUpperCase()).filter(Boolean); + if (methods.length === 0) { + throw new Error(`http_mappings[${i}].methods must not be empty`); + } + } + + let when: Record | undefined; + if (mapping.when !== undefined) { + if (typeof mapping.when !== 'object' || Array.isArray(mapping.when) || mapping.when === null) { + throw new Error(`http_mappings[${i}].when must be an object when provided`); + } + when = Object.fromEntries( + Object.entries(mapping.when as Record).map(([key, value]) => [ + key, + String(value), + ]), + ); + } + + return { + from: mapping.from as string, + to: { + repo: target.repo as string, + service: + target.service === undefined || target.service === null ? undefined : String(target.service), + }, + methods, + match: String(mapping.match), + rewrite: String(mapping.rewrite), + when, + }; + }); + const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) }; const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) }; const packages = (raw.packages as Record>) || {}; @@ -83,6 +173,7 @@ export function parseGroupConfig(yamlContent: string): GroupConfig { description: (raw.description as string) || '', repos, links, + httpMappings, packages, detect, matching, diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 8dfb242bfc..37e55a4f9d 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import { glob } from 'glob'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { collectRequestLikeImportBindings } from '../../ingestion/request-like-clients.js'; const HANDLES_ROUTE_QUERY = ` MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) @@ -418,6 +419,7 @@ export class HttpRouteExtractor implements ContractExtractor { if (!content) continue; out.push(...this.scanFetchConsumers(content, rel)); out.push(...this.scanAxiosConsumers(content, rel)); + out.push(...this.scanRequestLikeConsumers(content, rel)); } return this.dedupeContracts(out); } @@ -451,6 +453,40 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } + private scanRequestLikeConsumers(content: string, filePath: string): ExtractedContract[] { + const bindings = collectRequestLikeImportBindings(content); + if (bindings.size === 0) return []; + + const names = [...bindings].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const bindingRe = `(?:${names.join('|')})`; + const out: ExtractedContract[] = []; + + const directRe = new RegExp( + `${bindingRe}\\s*\\(\\s*[\\\`'"]([^\\\`'"]+)[\\\`'"](?:\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"][^}]*\\})?\\s*\\)`, + 'gi', + ); + let directMatch: RegExpExecArray | null; + while ((directMatch = directRe.exec(content)) !== null) { + const pathNorm = normalizeHttpPath(this.templateToPattern(directMatch[1])); + const method = (directMatch[2] || 'GET').toUpperCase(); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.75)); + } + + const memberRe = new RegExp( + `${bindingRe}\\.(get|post|put|delete|patch|head|options|request|ajax)\\s*\\(\\s*[\\\`'"]([^\\\`'"]+)[\\\`'"]`, + 'gi', + ); + let memberMatch: RegExpExecArray | null; + while ((memberMatch = memberRe.exec(content)) !== null) { + const rawMethod = memberMatch[1].toUpperCase(); + const method = rawMethod === 'REQUEST' || rawMethod === 'AJAX' ? 'GET' : rawMethod; + const pathNorm = normalizeHttpPath(this.templateToPattern(memberMatch[2])); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.75)); + } + + return out; + } + private makeConsumer( filePath: string, method: string, diff --git a/gitnexus/src/core/group/http-mapping.ts b/gitnexus/src/core/group/http-mapping.ts new file mode 100644 index 0000000000..3d789f7901 --- /dev/null +++ b/gitnexus/src/core/group/http-mapping.ts @@ -0,0 +1,157 @@ +import { compile, match } from 'path-to-regexp'; +import type { CrossLink, HttpMappingRule, StoredContract } from './types.js'; +import { normalizeContractId } from './matching.js'; + +interface ParsedHttpContract { + method: string; + path: string; +} + +interface CompiledHttpMappingRule { + rule: HttpMappingRule; + matchPath: ReturnType>>; + rewritePath: ReturnType; +} + +export interface HttpMappingMatchResult { + matched: CrossLink[]; + matchedConsumerIds: Set; +} + +function parseHttpContractId(contractId: string): ParsedHttpContract | null { + if (!contractId.startsWith('http::')) return null; + const parts = contractId.split('::'); + if (parts.length < 3) return null; + return { + method: parts[1].toUpperCase(), + path: parts.slice(2).join('::'), + }; +} + +function getHttpContractParts(contract: StoredContract): ParsedHttpContract | null { + if (contract.type !== 'http') return null; + const meta = contract.meta as { method?: unknown; path?: unknown } | undefined; + if (typeof meta?.method === 'string' && typeof meta?.path === 'string') { + return { + method: meta.method.toUpperCase(), + path: meta.path, + }; + } + return parseHttpContractId(contract.contractId); +} + +function providerIndexKey(contractId: string): string { + return normalizeContractId(contractId); +} + +function normalizeRewrittenPath(pathValue: string): string { + const collapsed = pathValue.replace(/\/{2,}/g, '/'); + if (!collapsed.startsWith('/')) return `/${collapsed}`; + return collapsed; +} + +function compileRules(rules: HttpMappingRule[]): CompiledHttpMappingRule[] { + return rules.map((rule) => ({ + rule, + matchPath: match>(rule.match, { decode: decodeURIComponent }), + rewritePath: compile(rule.rewrite, { encode: (value) => String(value) }), + })); +} + +function paramValue(value: string | string[]): string { + return Array.isArray(value) ? value.join('/') : value; +} + +export function applyHttpMappings( + contracts: StoredContract[], + rules: HttpMappingRule[], +): HttpMappingMatchResult { + if (rules.length === 0) { + return { + matched: [], + matchedConsumerIds: new Set(), + }; + } + + const compiledRules = compileRules(rules); + const providers = contracts.filter((contract) => contract.role === 'provider' && contract.type === 'http'); + const providerIndex = new Map(); + for (const provider of providers) { + const key = providerIndexKey(provider.contractId); + const existing = providerIndex.get(key) || []; + existing.push(provider); + providerIndex.set(key, existing); + } + + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + + for (const consumer of contracts) { + if (consumer.role !== 'consumer' || consumer.type !== 'http') continue; + + const consumerKey = `${consumer.repo}::${consumer.contractId}`; + const parts = getHttpContractParts(consumer); + if (!parts) continue; + + for (const compiledRule of compiledRules) { + const { rule } = compiledRule; + if (rule.from !== consumer.repo) continue; + if (rule.methods && !rule.methods.includes(parts.method)) continue; + + const matchResult = compiledRule.matchPath(parts.path); + if (!matchResult) continue; + + if ( + rule.when && + Object.entries(rule.when).some(([key, expected]) => { + const actual = matchResult.params[key]; + return actual === undefined || paramValue(actual) !== expected; + }) + ) { + continue; + } + + const rewrittenPath = normalizeRewrittenPath(compiledRule.rewritePath(matchResult.params)); + const canonicalContractId = normalizeContractId(`http::${parts.method}::${rewrittenPath}`); + const candidates = providerIndex + .get(canonicalContractId) + ?.filter((provider) => provider.repo === rule.to.repo) + .filter((provider) => !rule.to.service || provider.service === rule.to.service) + .filter((provider) => { + if (provider.repo !== consumer.repo) return true; + if (!provider.service || !consumer.service) return false; + return provider.service !== consumer.service; + }); + + if (!candidates || candidates.length === 0) continue; + + matchedConsumerIds.add(consumerKey); + for (const provider of candidates) { + matched.push({ + from: { + repo: consumer.repo, + service: consumer.service, + symbolUid: consumer.symbolUid, + symbolRef: consumer.symbolRef, + }, + to: { + repo: provider.repo, + service: provider.service, + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: 'http', + contractId: canonicalContractId, + fromContractId: consumer.contractId, + toContractId: provider.contractId, + matchType: 'manifest', + confidence: 1.0, + }); + } + + break; + } + } + + return { matched, matchedConsumerIds }; +} diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 1530cd6ddc..fc1447eb05 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -76,6 +76,7 @@ export class GroupService { description: config.description, repos: config.repos, links: config.links, + httpMappings: config.httpMappings, }; } @@ -113,8 +114,8 @@ export class GroupService { if (params.unmatchedOnly) { const matchedIds = new Set( registry.crossLinks.flatMap((l) => [ - `${l.from.repo}::${l.contractId}`, - `${l.to.repo}::${l.contractId}`, + `${l.from.repo}::${l.fromContractId ?? l.contractId}`, + `${l.to.repo}::${l.toContractId ?? l.contractId}`, ]), ); contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`)); diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index aa6a781a53..11dc289e2e 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -90,6 +90,8 @@ repos: {} links: [] +http_mappings: [] + packages: {} detect: diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 92cd9fe5f1..98b4770f60 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -8,11 +8,16 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { runExactMatch } from './matching.js'; +import { applyHttpMappings } from './http-mapping.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { writeContractRegistry } from './storage.js'; import type { ContractRegistry } from './types.js'; +function matchedContractKey(repo: string, contractId: string): string { + return `${repo}::${contractId}`; +} + export interface SyncOptions { extractorOverride?: | ((repo: RepoHandle) => Promise) @@ -158,9 +163,26 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const { matched, unmatched } = runExactMatch(autoContracts); - const crossLinks: CrossLink[] = matched; + const manifestMatches = applyHttpMappings(autoContracts, config.httpMappings); + const exactContracts = autoContracts.filter( + (contract) => + !( + contract.role === 'consumer' && + manifestMatches.matchedConsumerIds.has(matchedContractKey(contract.repo, contract.contractId)) + ), + ); + const exactMatches = runExactMatch(exactContracts); + const crossLinks: CrossLink[] = [...manifestMatches.matched, ...exactMatches.matched]; const allContracts: StoredContract[] = autoContracts; + const matchedIds = new Set( + crossLinks.flatMap((link) => [ + matchedContractKey(link.from.repo, link.fromContractId ?? link.contractId), + matchedContractKey(link.to.repo, link.toContractId ?? link.contractId), + ]), + ); + const unmatched = allContracts.filter( + (contract) => !matchedIds.has(matchedContractKey(contract.repo, contract.contractId)), + ); const registry: ContractRegistry = { version: 1, diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7ab0f071a2..864c00491c 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -8,6 +8,7 @@ export interface GroupConfig { description: string; repos: Record; links: GroupManifestLink[]; + httpMappings: HttpMappingRule[]; packages: Record>; detect: DetectConfig; matching: MatchingConfig; @@ -21,6 +22,20 @@ export interface GroupManifestLink { role: ContractRole; } +export interface HttpMappingTarget { + repo: string; + service?: string; +} + +export interface HttpMappingRule { + from: string; + to: HttpMappingTarget; + methods?: string[]; + match: string; + rewrite: string; + when?: Record; +} + export interface DetectConfig { http: boolean; grpc: boolean; @@ -66,6 +81,8 @@ export interface CrossLink { to: CrossLinkEndpoint; type: ContractType; contractId: string; + fromContractId?: string; + toContractId?: string; matchType: MatchType; confidence: number; } diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index dc615be1c6..9b13dcf387 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -10,6 +10,11 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { + collectRequestLikeImportBindings, + getRequestLikeCapturedUrl, + getRequestLikeMemberCapturedUrl, +} from './request-like-clients.js'; import { FUNCTION_NODE_TYPES, findEnclosingClassId, @@ -2376,6 +2381,8 @@ export const extractFetchCallsFromFiles = async ( continue; } + const requestLikeBindings = collectRequestLikeImportBindings(file.content); + for (const match of matches) { const captureMap: Record = {}; match.captures.forEach((c) => (captureMap[c.name] = c.node)); @@ -2389,11 +2396,27 @@ export const extractFetchCallsFromFiles = async ( lineNumber: captureMap['route.fetch'].startPosition.row, }); } + } else if (captureMap['request_like_client']) { + const url = getRequestLikeCapturedUrl(captureMap, requestLikeBindings); + if (url) { + result.push({ + filePath: file.path, + fetchURL: url, + lineNumber: captureMap['request_like_client'].startPosition.row, + }); + } } else if (captureMap['http_client'] && captureMap['http_client.url']) { const method = captureMap['http_client.method']?.text; const url = captureMap['http_client.url'].text; const HTTP_CLIENT_ONLY = new Set(['head', 'options', 'request', 'ajax']); - if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) { + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + result.push({ + filePath: file.path, + fetchURL: requestLikeUrl, + lineNumber: captureMap['http_client'].startPosition.row, + }); + } else if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) { result.push({ filePath: file.path, fetchURL: url, diff --git a/gitnexus/src/core/ingestion/request-like-clients.ts b/gitnexus/src/core/ingestion/request-like-clients.ts new file mode 100644 index 0000000000..daaf201d8e --- /dev/null +++ b/gitnexus/src/core/ingestion/request-like-clients.ts @@ -0,0 +1,116 @@ +import type { SyntaxNode } from './utils/ast-helpers.js'; + +const KNOWN_REQUEST_LIKE_MODULES = new Set(['umi-request']); +const REQUEST_LIKE_LOCAL_BASENAMES = new Set(['request']); +const REQUEST_LIKE_MEMBER_METHODS = new Set([ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', + 'request', + 'ajax', +]); + +const IDENT_RE = /^[A-Za-z_$][\w$]*$/; + +function stripExtension(specifier: string): string { + return specifier.replace(/\.(?:[cm]?[jt]sx?)$/i, ''); +} + +function moduleBasename(specifier: string): string { + const normalized = stripExtension(specifier.replace(/\\/g, '/')).toLowerCase(); + const parts = normalized.split('/'); + return parts[parts.length - 1] || normalized; +} + +function isRequestLikeImportSource(specifier: string): boolean { + const normalized = specifier.trim().replace(/\\/g, '/').toLowerCase(); + if (KNOWN_REQUEST_LIKE_MODULES.has(normalized)) return true; + return REQUEST_LIKE_LOCAL_BASENAMES.has(moduleBasename(normalized)); +} + +function addIdentifier(target: Set, raw: string): void { + const candidate = raw.trim().replace(/^type\s+/, ''); + if (IDENT_RE.test(candidate)) target.add(candidate); +} + +function parseImportClause(clause: string, target: Set): void { + const trimmed = clause.trim(); + if (!trimmed) return; + + const namedMatch = trimmed.match(/\{([^}]+)\}/); + if (namedMatch) { + for (const part of namedMatch[1].split(',')) { + const item = part.trim(); + if (!item) continue; + const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/); + if (aliasMatch) { + addIdentifier(target, aliasMatch[2]); + } else { + addIdentifier(target, item); + } + } + } + + const defaultPart = trimmed.split(',')[0]?.trim() || ''; + if (defaultPart && !defaultPart.startsWith('{') && !defaultPart.startsWith('*')) { + addIdentifier(target, defaultPart); + } +} + +export function collectRequestLikeImportBindings(content: string): Set { + const bindings = new Set(); + const importRe = /import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + + while ((match = importRe.exec(content)) !== null) { + const [, clause, source] = match; + if (!isRequestLikeImportSource(source)) continue; + parseImportClause(clause, bindings); + } + + return bindings; +} + +function nodeTextStartsWithPath(node: SyntaxNode): boolean { + return node.text.startsWith('/') || node.text.startsWith('`/'); +} + +function isRequestLikeBinding( + node: SyntaxNode | undefined, + requestLikeBindings: ReadonlySet, +): boolean { + return !!node && requestLikeBindings.has(node.text); +} + +export function getRequestLikeCapturedUrl( + captureMap: Record, + requestLikeBindings: ReadonlySet, +): string | null { + const fnNode = captureMap['request_like_client.fn']; + const urlNode = + captureMap['request_like_client.url'] ?? captureMap['request_like_client.template_url']; + if (!fnNode || !urlNode) return null; + if (!requestLikeBindings.has(fnNode.text)) return null; + if (!nodeTextStartsWithPath(urlNode)) return null; + return urlNode.text; +} + +export function getRequestLikeMemberCapturedUrl( + captureMap: Record, + requestLikeBindings: ReadonlySet, +): string | null { + const receiverNode = captureMap['http_client.receiver'] ?? captureMap['express_route.receiver']; + const methodNode = captureMap['http_client.method'] ?? captureMap['express_route.method']; + const urlNode = captureMap['http_client.url'] ?? captureMap['express_route.path']; + + if (!isRequestLikeBinding(receiverNode, requestLikeBindings) || !methodNode || !urlNode) { + return null; + } + if (!REQUEST_LIKE_MEMBER_METHODS.has(methodNode.text)) return null; + if (!nodeTextStartsWithPath(urlNode)) return null; + return urlNode.text; +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a6c8d74b4e..cdf897cf16 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -128,9 +128,17 @@ export const TYPESCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Imported request-like HTTP clients: request('/path'), apiClient('/path') +(call_expression + function: (identifier) @request_like_client.fn + arguments: (arguments + [(string (string_fragment) @request_like_client.url) + (template_string) @request_like_client.template_url])) @request_like_client + ; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'}) (call_expression function: (member_expression + object: (_) @http_client.receiver property: (property_identifier) @http_client.method) arguments: (arguments (string (string_fragment) @http_client.url))) @http_client @@ -144,6 +152,7 @@ export const TYPESCRIPT_QUERIES = ` ; Express/Hono route registration: app.get('/path', handler), router.post('/path', fn) (call_expression function: (member_expression + object: (_) @express_route.receiver property: (property_identifier) @express_route.method) arguments: (arguments (string (string_fragment) @express_route.path))) @express_route @@ -236,9 +245,17 @@ export const JAVASCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Imported request-like HTTP clients: request('/path'), apiClient('/path') +(call_expression + function: (identifier) @request_like_client.fn + arguments: (arguments + [(string (string_fragment) @request_like_client.url) + (template_string) @request_like_client.template_url])) @request_like_client + ; axios.get/post, $.get/post/ajax (call_expression function: (member_expression + object: (_) @http_client.receiver property: (property_identifier) @http_client.method) arguments: (arguments (string (string_fragment) @http_client.url))) @http_client @@ -246,6 +263,7 @@ export const JAVASCRIPT_QUERIES = ` ; Express/Hono route registration (call_expression function: (member_expression + object: (_) @express_route.receiver property: (property_identifier) @express_route.method) arguments: (arguments (string (string_fragment) @express_route.path))) @express_route diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 82ead976c2..bbbc6c971c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -67,6 +67,11 @@ import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; import { preprocessImportPath } from '../import-processor.js'; +import { + collectRequestLikeImportBindings, + getRequestLikeCapturedUrl, + getRequestLikeMemberCapturedUrl, +} from '../request-like-clients.js'; import { extractVueScript, extractTemplateComponents, @@ -1397,6 +1402,7 @@ const processFileGroup = ( // Per-file map: decorator end-line → decorator info, for associating with definitions const fileDecorators = new Map(); + const requestLikeBindings = collectRequestLikeImportBindings(file.content); for (const match of matches) { const captureMap: Record = {}; @@ -1500,13 +1506,32 @@ const processFileGroup = ( continue; } + if (captureMap['request_like_client']) { + const url = getRequestLikeCapturedUrl(captureMap, requestLikeBindings); + if (url) { + result.fetchCalls.push({ + filePath: file.path, + fetchURL: url, + lineNumber: captureMap['request_like_client'].startPosition.row + lineOffset, + }); + } + continue; + } + // HTTP client calls: axios.get('/path'), $.post('/path'), requests.get('/path') // Skip methods also in EXPRESS_ROUTE_METHODS to avoid double-registering Express // routes as both route definitions AND consumers (both queries match same AST node) if (captureMap['http_client'] && captureMap['http_client.url']) { const method = captureMap['http_client.method']?.text; const url = captureMap['http_client.url'].text; - if (method && HTTP_CLIENT_ONLY_METHODS.has(method) && url.startsWith('/')) { + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + result.fetchCalls.push({ + filePath: file.path, + fetchURL: requestLikeUrl, + lineNumber: captureMap['http_client'].startPosition.row + lineOffset, + }); + } else if (method && HTTP_CLIENT_ONLY_METHODS.has(method) && url.startsWith('/')) { result.fetchCalls.push({ filePath: file.path, fetchURL: url, @@ -1524,6 +1549,10 @@ const processFileGroup = ( ) { const method = captureMap['express_route.method'].text; const routePath = captureMap['express_route.path'].text; + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + continue; + } if (EXPRESS_ROUTE_METHODS.has(method) && routePath.startsWith('/')) { const httpMethod = method === 'all' || method === 'use' || method === 'route' diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts new file mode 100644 index 0000000000..2b3469fe85 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts @@ -0,0 +1,9 @@ +import request from './request'; + +export async function loadItems() { + return request.get('/api/items'); +} + +export async function createClientOnlyItem() { + return request.post('/api/client-post-only'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts new file mode 100644 index 0000000000..267b3ca415 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts @@ -0,0 +1,8 @@ +export default { + get(path: string) { + return path; + }, + post(path: string) { + return path; + }, +}; diff --git a/gitnexus/test/integration/group/group-sync.test.ts b/gitnexus/test/integration/group/group-sync.test.ts index 3cceb200b6..345a2b0690 100644 --- a/gitnexus/test/integration/group/group-sync.test.ts +++ b/gitnexus/test/integration/group/group-sync.test.ts @@ -76,4 +76,63 @@ describe('Group sync integration', () => { const healthUnmatched = result.unmatched.some((c) => c.contractId.includes('/api/health')); expect(healthUnmatched).toBe(true); }); + + it('applies http_mappings to gateway-style frontend paths', async () => { + const yamlContent = `version: 1 +name: mapped-group +repos: + frontend: test-frontend + backend: test-backend +http_mappings: + - from: frontend + to: + repo: backend + service: services/order + methods: [POST] + match: /api/titans/:service/:version/*rest + when: + service: order + version: 1.0.0 + rewrite: /orders/*rest +`; + const config = parseGroupConfig(yamlContent); + + const mockContracts: StoredContract[] = [ + { + contractId: 'http::POST::/api/titans/order/1.0.0/create', + type: 'http', + role: 'consumer', + symbolUid: 'uid-c1', + symbolRef: { filePath: 'src/api/orders.ts', name: 'createOrder' }, + symbolName: 'createOrder', + confidence: 0.85, + meta: { method: 'POST', path: '/api/titans/order/1.0.0/create' }, + repo: 'frontend', + }, + { + contractId: 'http::POST::/orders/create', + type: 'http', + role: 'provider', + symbolUid: 'uid-p1', + symbolRef: { filePath: 'src/routes/orders.ts', name: 'create' }, + symbolName: 'create', + confidence: 0.9, + meta: { method: 'POST', path: '/orders/create' }, + repo: 'backend', + service: 'services/order', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].contractId).toBe('http::POST::/orders/create'); + expect(result.crossLinks[0].fromContractId).toBe( + 'http::POST::/api/titans/order/1.0.0/create', + ); + }); }); diff --git a/gitnexus/test/integration/resolvers/express-routes.test.ts b/gitnexus/test/integration/resolvers/express-routes.test.ts index 0669bd9fb2..c9e58cadc6 100644 --- a/gitnexus/test/integration/resolvers/express-routes.test.ts +++ b/gitnexus/test/integration/resolvers/express-routes.test.ts @@ -56,4 +56,27 @@ describe('Express/Hono route detection', () => { expect(healthEdge).toBeDefined(); expect(healthEdge!.sourceFilePath).toContain('server.ts'); }); + + it('creates FETCHES edges for request-like client member calls', () => { + const edges = getRelationships(result, 'FETCHES'); + const clientFetch = edges.find( + (e) => e.sourceFilePath.includes('client.ts') && e.target === '/api/items', + ); + + expect(clientFetch).toBeDefined(); + }); + + it('does not create HANDLES_ROUTE edges from request-like client files', () => { + const edges = getRelationships(result, 'HANDLES_ROUTE'); + const clientRouteEdge = edges.find((e) => e.sourceFilePath.includes('client.ts')); + + expect(clientRouteEdge).toBeUndefined(); + }); + + it('does not create Route nodes from request-like client-only paths', () => { + const routes = getNodesByLabel(result, 'Route'); + + expect(routes).not.toContain('/api/client-only'); + expect(routes).not.toContain('/api/client-post-only'); + }); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 6f1390b457..38d1296100 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -4,9 +4,11 @@ import { seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, + extractFetchCallsFromFiles, buildImplementorMap, mergeImplementorMaps, } from '../../src/core/ingestion/call-processor.js'; +import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, @@ -1397,6 +1399,58 @@ describe('processNextjsFetchRoutes', () => { }); }); +describe('extractFetchCallsFromFiles', () => { + it('extracts imported request-like client GET calls as fetch calls', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from 'umi-request'; + +export async function loadUsers() { + return request('/api/users'); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); + + it('extracts imported request-like client member POST calls as fetch calls', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from '@/utils/request'; + +export async function createUser(data: unknown) { + return request.post('/api/users', { data }); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); +}); + describe('buildImplementorMap / mergeImplementorMaps', () => { it('records direct implements edges per interface name', () => { const heritage: ExtractedHeritage[] = [ diff --git a/gitnexus/test/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts index 50878ce7d7..2612024f24 100644 --- a/gitnexus/test/unit/group/config-parser.test.ts +++ b/gitnexus/test/unit/group/config-parser.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { loadGroupConfig, parseGroupConfig } from '../../../src/core/group/config-parser.js'; +import { + loadGroupConfig, + parseGroupConfig, + serializeGroupConfig, +} from '../../../src/core/group/config-parser.js'; const VALID_YAML = ` version: 1 @@ -32,6 +36,25 @@ matching: max_candidates_per_step: 3 `; +const HTTP_MAPPING_YAML = ` +version: 1 +name: company +repos: + frontend: libra-client + backend: libra-server +http_mappings: + - from: frontend + to: + repo: backend + service: order-service + methods: [GET, POST] + match: /api/titans/:service/:version/*rest + when: + service: order + version: 1.0.0 + rewrite: /orders/*rest +`; + describe('parseGroupConfig', () => { it('parses valid group.yaml', () => { const config = parseGroupConfig(VALID_YAML); @@ -57,11 +80,42 @@ repos: const config = parseGroupConfig(minimal); expect(config.description).toBe(''); expect(config.links).toEqual([]); + expect(config.httpMappings).toEqual([]); expect(config.packages).toEqual({}); expect(config.detect.http).toBe(true); expect(config.matching.bm25_threshold).toBe(0.7); }); + it('parses http mapping rules', () => { + const config = parseGroupConfig(HTTP_MAPPING_YAML); + expect(config.httpMappings).toHaveLength(1); + expect(config.httpMappings[0].from).toBe('frontend'); + expect(config.httpMappings[0].to.repo).toBe('backend'); + expect(config.httpMappings[0].to.service).toBe('order-service'); + expect(config.httpMappings[0].methods).toEqual(['GET', 'POST']); + expect(config.httpMappings[0].match).toBe('/api/titans/:service/:version/*rest'); + expect(config.httpMappings[0].when).toEqual({ + service: 'order', + version: '1.0.0', + }); + expect(config.httpMappings[0].rewrite).toBe('/orders/*rest'); + }); + + it('serializes group config back to snake_case yaml shape', () => { + const config = parseGroupConfig(HTTP_MAPPING_YAML); + const serialized = serializeGroupConfig(config); + expect(serialized).toHaveProperty('http_mappings'); + expect(serialized).not.toHaveProperty('httpMappings'); + expect(serialized).toMatchObject({ + version: 1, + name: 'company', + repos: { + frontend: 'libra-client', + backend: 'libra-server', + }, + }); + }); + it('throws on missing required fields', () => { expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i); expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i); @@ -126,4 +180,20 @@ links: `; expect(() => parseGroupConfig(yaml)).toThrow(/nonexistent/i); }); + + it('throws when http mapping references non-existent repo path', () => { + const yaml = ` +version: 1 +name: test +repos: + frontend: repo-a +http_mappings: + - from: frontend + to: + repo: backend + match: /api/:service/*rest + rewrite: /svc/*rest +`; + expect(() => parseGroupConfig(yaml)).toThrow(/backend/i); + }); }); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 2290c806a8..6494f7d6d1 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -206,6 +206,37 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts request-like client calls', async () => { + const dir = path.join(tmpDir, 'request-like-fe'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/request.ts'), + ` +export default { + get(path: string) { return path; }, + post(path: string) { return path; }, +}; +`, + ); + fs.writeFileSync( + path.join(dir, 'src/api.ts'), + ` +import request from './request'; + +export const loadUsers = () => request('/api/users'); +export const createUser = (data: unknown) => request('/api/users', { method: 'POST', data }); +export const updateUser = (id: string, data: unknown) => request.post(\`/api/users/\${id}\`, { data }); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users/{param}')).toBeDefined(); + }); }); describe('provider extraction — Laravel', () => { diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts index e4b10443ca..4030298150 100644 --- a/gitnexus/test/unit/group/service.test.ts +++ b/gitnexus/test/unit/group/service.test.ts @@ -233,6 +233,53 @@ describe('GroupService', () => { cleanup(); } }); + + it('test_groupContracts_unmatchedOnly_respects_manifest_rewritten_contract_ids', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const consumer = makeContract( + 'http::POST::/api/titans/order/1.0.0/create', + 'consumer', + 'app/frontend', + ); + const provider = makeContract('http::POST::/orders/create', 'provider', 'app/backend'); + const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend'); + const crossLink: CrossLink = { + from: { + repo: 'app/frontend', + symbolUid: consumer.symbolUid, + symbolRef: consumer.symbolRef, + }, + to: { + repo: 'app/backend', + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: 'http', + contractId: 'http::POST::/orders/create', + fromContractId: consumer.contractId, + toContractId: provider.contractId, + matchType: 'manifest', + confidence: 1.0, + }; + await writeContractRegistry( + groupDir, + makeRegistry([consumer, provider, orphan], [crossLink]), + ); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ + name: 'test-group', + unmatchedOnly: true, + })) as { contracts: StoredContract[] }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].contractId).toBe('http::GET::/api/health'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); }); describe('groupSync', () => { diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 50c9093b93..9251d42e08 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -13,6 +13,7 @@ describe('syncGroup', () => { description: '', repos, links: [], + httpMappings: [], packages: {}, detect: { http: true, @@ -113,6 +114,125 @@ describe('syncGroup', () => { expect(result.crossLinks[0].to.service).toBe('services/auth'); }); + it('applies http mapping rules before exact matching', async () => { + const config = makeConfig({ frontend: 'frontend-repo', backend: 'backend-repo' }); + config.httpMappings = [ + { + from: 'frontend', + to: { repo: 'backend', service: 'services/order' }, + methods: ['POST'], + match: '/api/titans/:service/:version/*rest', + when: { service: 'order', version: '1.0.0' }, + rewrite: '/orders/*rest', + }, + ]; + + const mockContracts: StoredContract[] = [ + { + ...makeContract( + 'http::POST::/api/titans/order/1.0.0/create', + 'consumer', + 'frontend', + ), + meta: { method: 'POST', path: '/api/titans/order/1.0.0/create' }, + }, + { + ...makeContract('http::POST::/orders/create', 'provider', 'backend'), + service: 'services/order', + meta: { method: 'POST', path: '/orders/create' }, + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].contractId).toBe('http::POST::/orders/create'); + expect(result.crossLinks[0].fromContractId).toBe( + 'http::POST::/api/titans/order/1.0.0/create', + ); + expect(result.crossLinks[0].toContractId).toBe('http::POST::/orders/create'); + expect(result.crossLinks[0].to.service).toBe('services/order'); + expect(result.unmatched).toHaveLength(0); + }); + + it('applies http mapping rules when the gateway path has no trailing segments', async () => { + const config = makeConfig({ frontend: 'frontend-repo', backend: 'backend-repo' }); + config.httpMappings = [ + { + from: 'frontend', + to: { repo: 'backend', service: 'libra-margin' }, + match: '/api/titans/margin/:version{/*rest}', + rewrite: '/{*rest}', + }, + ]; + + const mockContracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/titans/margin/1.0.0', 'consumer', 'frontend'), + meta: { method: 'GET', path: '/api/titans/margin/1.0.0' }, + }, + { + ...makeContract('http::GET::/', 'provider', 'backend'), + service: 'libra-margin', + meta: { method: 'GET', path: '/' }, + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].contractId).toBe('http::GET::'); + expect(result.crossLinks[0].fromContractId).toBe('http::GET::/api/titans/margin/1.0.0'); + }); + + it('preserves literal placeholder segments in rewritten http mapping paths', async () => { + const config = makeConfig({ frontend: 'frontend-repo', backend: 'backend-repo' }); + config.httpMappings = [ + { + from: 'frontend', + to: { repo: 'backend', service: 'services/order' }, + methods: ['GET'], + match: '/api/titans/order/:version{/*rest}', + rewrite: '/orders/{*rest}', + }, + ]; + + const mockContracts: StoredContract[] = [ + { + ...makeContract( + 'http::GET::/api/titans/order/1.0.0/items/{param}', + 'consumer', + 'frontend', + ), + meta: { method: 'GET', path: '/api/titans/order/1.0.0/items/{param}' }, + }, + { + ...makeContract('http::GET::/orders/items/{param}', 'provider', 'backend'), + service: 'services/order', + meta: { method: 'GET', path: '/orders/items/{param}' }, + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].contractId).toBe('http::GET::/orders/items/{param}'); + expect(result.crossLinks[0].fromContractId).toBe( + 'http::GET::/api/titans/order/1.0.0/items/{param}', + ); + expect(result.unmatched).toHaveLength(0); + }); + function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { return { contractId: id, diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts index df952e5988..0804153dd9 100644 --- a/gitnexus/test/unit/group/types.test.ts +++ b/gitnexus/test/unit/group/types.test.ts @@ -17,6 +17,7 @@ describe('Group types', () => { description: 'All company microservices', repos: { 'hr/hiring/backend': 'hr-hiring-backend' }, links: [], + httpMappings: [], packages: {}, detect: { http: true,