feat(group): add rule-based HTTP mappings

This commit is contained in:
gfwangjie 2026-05-21 13:01:34 +08:00
parent b565c7c990
commit ccbc284cff
15 changed files with 690 additions and 22 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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}"`);
});

View file

@ -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<string, unknown> {
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<string, unknown>;
@ -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<string, unknown>;
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<string, unknown>;
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<string, string> | 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<string, unknown>).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<string, Record<string, string>>) || {};
@ -105,6 +195,7 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
description: (raw.description as string) || '',
repos,
links,
httpMappings,
packages,
detect,
matching,

View file

@ -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<Record<string, never>> = {
`,
};
// ─── Consumer: imported request-like client `request(url, opts?)` ────
const REQUEST_CALL_SPEC: PatternSpec<Record<string, never>> = {
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<Record<string, never>> = {
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<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
@ -158,6 +181,8 @@ interface NodePatternBundle {
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
requestCall: CompiledPatterns<Record<string, never>>;
requestMember: CompiledPatterns<Record<string, never>>;
}
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<string> {
const bindings = new Set<string>();
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;
}

View file

@ -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<typeof match<Record<string, string | string[]>>>;
rewritePath: ReturnType<typeof compile>;
}
export interface HttpMappingMatchResult {
matched: CrossLink[];
matchedConsumerIds: Set<string>;
}
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<Record<string, string | string[]>>(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<string>(),
};
}
const compiledRules = compileRules(rules);
const providers = contracts.filter((contract) => contract.role === 'provider' && contract.type === 'http');
const providerIndex = new Map<string, StoredContract[]>();
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<string>();
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 };
}

View file

@ -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}`));

View file

@ -119,6 +119,8 @@ repos: {}
links: []
http_mappings: []
packages: {}
detect:

View file

@ -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<SyncResult> {
const missingRepos: string[] = [];
const repoSnapshots: Record<string, RepoSnapshot> = {};
@ -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,
};

View file

@ -8,6 +8,7 @@ export interface GroupConfig {
description: string;
repos: Record<string, string>;
links: GroupManifestLink[];
httpMappings: HttpMappingRule[];
packages: Record<string, Record<string, string>>;
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<string, string>;
}
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;
}

View file

@ -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',
);
});
});

View file

@ -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);
});
});

View file

@ -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 });

View file

@ -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,

View file

@ -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 },
};