mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
feat(group): add rule-based HTTP mappings
This commit is contained in:
parent
b565c7c990
commit
ccbc284cff
15 changed files with 690 additions and 22 deletions
21
gitnexus/package-lock.json
generated
21
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
157
gitnexus/src/core/group/http-mapping.ts
Normal file
157
gitnexus/src/core/group/http-mapping.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ repos: {}
|
|||
|
||||
links: []
|
||||
|
||||
http_mappings: []
|
||||
|
||||
packages: {}
|
||||
|
||||
detect:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue