From ccbc284cff29f0916ca69c22fd88089a65fec619 Mon Sep 17 00:00:00 2001 From: gfwangjie Date: Thu, 21 May 2026 13:01:34 +0800 Subject: [PATCH] feat(group): add rule-based HTTP mappings --- gitnexus/package-lock.json | 21 +-- gitnexus/package.json | 1 + gitnexus/src/cli/group.ts | 20 ++- gitnexus/src/core/group/config-parser.ts | 93 ++++++++++- .../group/extractors/http-patterns/node.ts | 120 +++++++++++++ gitnexus/src/core/group/http-mapping.ts | 157 ++++++++++++++++++ gitnexus/src/core/group/service.ts | 5 +- gitnexus/src/core/group/storage.ts | 2 + gitnexus/src/core/group/sync.ts | 37 ++++- gitnexus/src/core/group/types.ts | 17 ++ .../test/integration/group/group-sync.test.ts | 59 +++++++ .../test/unit/group/config-parser.test.ts | 73 +++++++- .../unit/group/http-route-extractor.test.ts | 24 +++ gitnexus/test/unit/group/sync.test.ts | 79 +++++++++ gitnexus/test/unit/group/types.test.ts | 4 + 15 files changed, 690 insertions(+), 22 deletions(-) create mode 100644 gitnexus/src/core/group/http-mapping.ts diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 4a7575c02..ca4733427 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -30,6 +30,7 @@ "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", + "path-to-regexp": "^8.4.2", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "tree-sitter": "^0.21.1", @@ -3996,6 +3997,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "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", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4340,16 +4351,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-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 0e25ed25a..dd48be32d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -75,6 +75,7 @@ "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", + "path-to-regexp": "^8.4.2", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "tree-sitter": "^0.21.1", diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 0053099b7..a59a1ad2c 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -29,14 +29,20 @@ 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}`); }); @@ -46,7 +52,9 @@ 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); @@ -57,7 +65,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 29c868171..8bda54061 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'); @@ -43,6 +49,30 @@ const DEFAULT_MATCHING = { exclude_links_param_only_paths: false, }; +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; @@ -95,6 +125,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>) || {}; @@ -105,6 +195,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-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index fbf988665..fddd8a3f5 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -18,6 +18,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers * - `axios.get(url)` / `axios.delete(url)` consumers * - `axios({ method, url })` object-form consumers + * - imported request-like clients such as `request(url)` / `request.post(url)` * - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers * - jQuery `$.ajax({ url, method | type })` consumers * @@ -148,6 +149,28 @@ const AXIOS_OBJECT_SPEC: PatternSpec> = { `, }; +// ─── Consumer: imported request-like client `request(url, opts?)` ──── +const REQUEST_CALL_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: imported request-like client `request.get(url)` ─────── +const REQUEST_MEMBER_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @client + property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + interface NodePatternBundle { controller: CompiledPatterns>; methodDecorator: CompiledPatterns>; @@ -158,6 +181,8 @@ interface NodePatternBundle { jqueryShorthand: CompiledPatterns>; jqueryAjax: CompiledPatterns>; axiosObject: CompiledPatterns>; + requestCall: CompiledPatterns>; + requestMember: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -177,6 +202,8 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), + requestCall: mk(REQUEST_CALL_SPEC, 'request-call'), + requestMember: mk(REQUEST_MEMBER_SPEC, 'request-member'), }; } @@ -233,6 +260,60 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string return null; } +const REQUEST_LIKE_IMPORT_RE = /(request|http|api|client|service)/i; + +function collectRequestLikeBindings(tree: Parser.Tree): Set { + const bindings = new Set(); + const root = tree.rootNode; + for (let i = 0; i < root.namedChildCount; i++) { + const child = root.namedChild(i); + if (!child || child.type !== 'import_statement') continue; + const text = child.text; + const sourceMatch = text.match(/\bfrom\s+['"]([^'"]+)['"]/); + const source = sourceMatch?.[1] ?? ''; + if (!REQUEST_LIKE_IMPORT_RE.test(source)) continue; + + const defaultMatch = text.match(/^import\s+([A-Za-z_$][\w$]*)\s*(?:,|\s+from)/); + if (defaultMatch) bindings.add(defaultMatch[1]); + + const namespaceMatch = text.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/); + if (namespaceMatch) bindings.add(namespaceMatch[1]); + + const namedMatch = text.match(/\{([^}]+)\}/); + if (namedMatch) { + for (const spec of namedMatch[1].split(',')) { + const trimmed = spec.trim(); + if (!trimmed) continue; + const aliasMatch = trimmed.match(/\bas\s+([A-Za-z_$][\w$]*)$/); + bindings.add(aliasMatch?.[1] ?? trimmed.split(/\s+/)[0]); + } + } + } + return bindings; +} + +function findEnclosingCall(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'call_expression') return cur; + cur = cur.parent; + } + return null; +} + +function readRequestOptionsMethod(node: Parser.SyntaxNode): string | null { + const callNode = findEnclosingCall(node); + if (!callNode) return null; + const args = callNode.childForFieldName('arguments'); + if (!args) return null; + for (let i = 0; i < args.namedChildCount; i++) { + const child = args.namedChild(i); + if (child?.type !== 'object') continue; + return readStringProp(child, ['method']); + } + return null; +} + /** * For a standalone `decorator` node (child of class_body / program), * find the related `class_declaration` node that it decorates. In @@ -297,6 +378,7 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] { const out: HttpDetection[] = []; + const requestLikeBindings = collectRequestLikeBindings(tree); // NestJS: collect `@Controller('prefix')` class decorators, keyed by // the `class_declaration` they decorate. @@ -480,6 +562,44 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } + // Consumer: request('/path', { method: 'POST' }) or request('/path'). + for (const match of runCompiledPatterns(bundle.requestCall, tree)) { + const fnNode = match.captures.fn; + const pathNode = match.captures.path; + if (!fnNode || !pathNode || !requestLikeBindings.has(fnNode.text)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + const method = readRequestOptionsMethod(pathNode)?.toUpperCase() ?? 'GET'; + out.push({ + role: 'consumer', + framework: 'request-like', + method, + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: request.get('/path') / request.post('/path'). + for (const match of runCompiledPatterns(bundle.requestMember, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode || !requestLikeBindings.has(clientNode.text)) { + continue; + } + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'request-like', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + return out; } diff --git a/gitnexus/src/core/group/http-mapping.ts b/gitnexus/src/core/group/http-mapping.ts new file mode 100644 index 000000000..3d789f790 --- /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 b957db15b..1188b93d8 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -245,6 +245,7 @@ export class GroupService { description: config.description, repos: config.repos, links: config.links, + httpMappings: config.httpMappings, }; } @@ -293,8 +294,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 bc08fd7f9..70fe43115 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -119,6 +119,8 @@ repos: {} links: [] +http_mappings: [] + packages: {} detect: diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 5c535f29f..ff38eb67e 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -12,6 +12,7 @@ import { IncludeExtractor } from './extractors/include-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js'; import { buildProviderIndex, runExactMatch, runWildcardMatch } 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'; @@ -83,6 +84,10 @@ function dedupeCrossLinks(links: CrossLink[]): CrossLink[] { return out; } +function matchedContractKey(repo: string, contractId: string): string { + return `${repo}::${contractId}`; +} + export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise { const missingRepos: string[] = []; const repoSnapshots: Record = {}; @@ -257,16 +262,40 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const providerIndex = buildProviderIndex(autoContracts, config.matching); - const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching); + const httpMappingMatches = applyHttpMappings(autoContracts, config.httpMappings ?? []); + const exactContracts = autoContracts.filter( + (contract) => + !( + contract.role === 'consumer' && + httpMappingMatches.matchedConsumerIds.has( + matchedContractKey(contract.repo, contract.contractId), + ) + ), + ); + const providerIndex = buildProviderIndex(exactContracts, config.matching); + const { matched, unmatched } = runExactMatch(exactContracts, providerIndex, config.matching); const wildcard = runWildcardMatch(unmatched, providerIndex); // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a // manifest-declared link can also emit a matchType:'exact' CrossLink with the // same endpoints. Prefer the manifest version — it reflects operator intent // and carries matchType:'manifest' which downstream consumers may rely on. - const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]); + const crossLinks = dedupeCrossLinks([ + ...manifestCrossLinks, + ...httpMappingMatches.matched, + ...matched, + ...wildcard.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 finalUnmatched = wildcard.remaining.filter( + (contract) => !matchedIds.has(matchedContractKey(contract.repo, contract.contractId)), + ); const registry: ContractRegistry = { version: 1, @@ -306,7 +335,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis return { contracts: allContracts, crossLinks, - unmatched: wildcard.remaining, + unmatched: finalUnmatched, missingRepos, repoSnapshots, }; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 8e43ff78f..7f85400e6 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; @@ -87,6 +102,8 @@ export interface CrossLink { to: CrossLinkEndpoint; type: ContractType; contractId: string; + fromContractId?: string; + toContractId?: string; matchType: MatchType; confidence: number; } diff --git a/gitnexus/test/integration/group/group-sync.test.ts b/gitnexus/test/integration/group/group-sync.test.ts index 3cceb200b..345a2b069 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/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts index 22bb2ad26..bce44eb0a 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,6 +80,7 @@ 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); @@ -150,6 +174,37 @@ links: expect(config.links[0].contract).toBe('billing.v1.OrderService/PlaceOrder'); }); + 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 http mappings using snake_case group.yaml keys', () => { + const config = parseGroupConfig(HTTP_MAPPING_YAML); + const serialized = serializeGroupConfig(config); + + expect(serialized).toHaveProperty('http_mappings'); + expect(serialized).not.toHaveProperty('httpMappings'); + expect((serialized.http_mappings as unknown[])[0]).toEqual({ + from: 'frontend', + to: { repo: 'backend', service: 'order-service' }, + methods: ['GET', 'POST'], + match: '/api/titans/:service/:version/*rest', + rewrite: '/orders/*rest', + when: { service: 'order', version: '1.0.0' }, + }); + }); + it('throws on missing required fields', () => { expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i); expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i); @@ -214,4 +269,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 8fc6690da..ccb43ff52 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1016,6 +1016,30 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); ).toBeDefined(); }); + it('extracts imported 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/api.ts'), + ` +import request from '@/utils/request'; + +export const getItems = () => request('/api/items'); +export const createItem = (data: unknown) => request('/api/items', { method: 'POST', data }); +export const deleteItem = (id: string) => request.delete(\`/api/items/\${id}\`); +`, + ); + + 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/items')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/items')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/items/{param}'), + ).toBeDefined(); + }); + it('extracts jQuery $.get and $.post shorthand', async () => { const dir = path.join(tmpDir, 'jquery-shorthand'); fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 4fc320076..0af87c519 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -20,6 +20,7 @@ describe('syncGroup', () => { description: '', repos, links: [], + httpMappings: [], packages: {}, detect: { http: true, @@ -122,6 +123,84 @@ 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'); + }); + 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 025ed4bbb..e67dc08b0 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, @@ -85,6 +86,7 @@ describe('Group types', () => { description: 'All company microservices', repos: { orders: 'orders-repo' }, links: [], + httpMappings: [], packages: {}, detect: { http: true, @@ -93,6 +95,8 @@ describe('Group types', () => { topics: true, shared_libs: true, embedding_fallback: true, + includes: false, + workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, };