fix(ci): revert unintended group module changes that broke tests

Restore types.ts, config-parser.ts, matching.ts, sync.ts, and
manifest-extractor.ts to upstream/main versions. The original commit
accidentally removed fields (thrift, workspace_deps, exclude_links_paths,
exclude_links_param_only_paths) from DetectConfig/MatchingConfig/ContractType
which are still referenced by matching.test.ts, config-parser.test.ts,
sync.test.ts and other integration tests.

This PR's scope is C++ scope-resolution parity only — group module
type definitions and logic should remain unchanged.
This commit is contained in:
HuangWenjie 2026-05-12 14:43:39 +08:00
parent 2ba7d57e43
commit 4208467a33
5 changed files with 290 additions and 85 deletions

View file

@ -4,22 +4,43 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom', 'include'];
const VALID_CONTRACT_TYPES: ContractType[] = [
'http',
'grpc',
'thrift',
'topic',
'lib',
'custom',
'include',
];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
// Defaults matter for backward compatibility: any group.yaml that omits a
// `detect.<type>` key inherits its value from this constant. Adding a new
// extractor that defaults to `true` silently changes the behavior of every
// existing group on the next sync. New extractors must default to `false`
// (opt-in) so operators consciously enable them via group.yaml.
//
// `includes`: opt-in. The C/C++ IncludeExtractor (PR #1156) ships disabled by
// default; enable with `detect.includes: true` for groups containing C/C++
// repos that need cross-repo header tracking.
const DEFAULT_DETECT = {
http: true,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: true,
includes: false,
workspace_deps: false,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
exclude_links_paths: [] as string[],
exclude_links_param_only_paths: false,
};
export function parseGroupConfig(yamlContent: string): GroupConfig {

View file

@ -1,6 +1,7 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
import { logger } from '../../logger.js';
export interface ManifestExtractResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
@ -177,7 +178,7 @@ export class ManifestExtractor {
// NOTE: All lookups use EXACT equality on the relevant name field and
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
// for fuzzy matching (plus an unconditional IDL file fallback for gRPC)
// which produced silent false positives: e.g. manifest "/orders" would
// match "/suborders", and a gRPC manifest entry in a repo with any
// .proto file would attach to a random proto symbol.
@ -225,16 +226,21 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'grpc') {
} else if (link.type === 'grpc' || link.type === 'thrift') {
// Contract is "Service/Method" or just "Service" (or package.Service
// variants). Prefer matching by method name when present, otherwise
// by service name. NO .proto path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one proto file.
// by service name. Thrift generated Java classes often use
// package.Service in manifests while graph Class/Interface names are
// stored as bare Service, so strip the package prefix for thrift
// service-name lookups. NO IDL path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one IDL file.
// Label filters scope lookups: methods → Function|Method, services
// → Class|Interface (no label match = no silent wrong hits on
// File/Variable nodes that happen to share the name).
const parts = link.contract.split('/');
const serviceName = parts[0]?.trim() ?? '';
const rawServiceName = parts[0]?.trim() ?? '';
const serviceName =
link.type === 'thrift' ? (rawServiceName.split('.').pop() ?? '') : rawServiceName;
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
@ -276,6 +282,21 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// Workspace extractors produce qualified contracts like "mathlex::Expression".
// Graph nodes store the unqualified symbol name ("Expression"), so strip
// the "provider::" prefix before querying.
const symbolName = link.contract.includes('::')
? link.contract.split('::').pop()!
: link.contract;
rows = await executor(
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
WHERE n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ symbolName },
);
} else {
return null;
}
@ -291,8 +312,7 @@ export class ManifestExtractor {
// fail the whole manifest extraction. Unresolved contracts still
// get a synthetic symbolUid below, so cross-impact can proceed.
const message = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` +
`in ${repoPathKey}: ${message}`,
);
@ -338,6 +358,8 @@ export class ManifestExtractor {
}
case 'grpc':
return `grpc::${contract}`;
case 'thrift':
return `thrift::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':
@ -346,8 +368,6 @@ export class ManifestExtractor {
return `custom::${contract}`;
case 'include':
return `include::${contract}`;
case 'thrift':
return `thrift::${contract}`;
default: {
const _exhaustive: never = type;
throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`);

View file

@ -1,4 +1,4 @@
import type { StoredContract, CrossLink } from './types.js';
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
export interface MatchResult {
matched: CrossLink[];
@ -10,8 +10,45 @@ export interface WildcardMatchResult {
remaining: StoredContract[];
}
function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
function isServiceWildcard(cid: string): boolean {
return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*');
}
/**
* Detect HTTP contracts that are too generic or infrastructure-level to
* produce meaningful cross-repo links. These are still extracted (useful
* for documentation / route maps) but excluded from cross-link matching.
*
* Two categories:
* 1. Health-check / readiness endpoints — every service has one, matching
* them produces N×M false links.
* 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that
* collapse to a single catch-all after normalization. These match any
* service with a similar shape, producing false positives.
*
* Both are configurable via matching.exclude_links_paths and
* matching.exclude_links_param_only_paths in group.yaml.
*/
function buildNoisyContractFilter(
matchingConfig?: MatchingConfig,
): (contractId: string) => boolean {
const excludePaths = matchingConfig?.exclude_links_paths?.length
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
: new Set<string>();
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
return function isNoisyHttpContract(contractId: string): boolean {
if (!contractId.startsWith('http::')) return false;
const parts = contractId.split('::');
if (parts.length < 3) return false;
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
if (excludePaths.has(pathPart)) return true;
if (excludeParamOnly) {
const segments = pathPart.split('/').filter(Boolean);
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
}
return false;
};
}
export function normalizeContractId(id: string): string {
@ -32,8 +69,9 @@ export function normalizeContractId(id: string): string {
}
return id;
}
case 'grpc': {
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
case 'grpc':
case 'thrift': {
// Canonical form: `<type>::<lowercased-package-or-service>[/<method>]`.
//
// The package/service segment is lowercased because gRPC package
// names are effectively case-insensitive across language bindings
@ -47,22 +85,23 @@ export function normalizeContractId(id: string): string {
// as DISTINCT canonical forms: `grpc::userservice` does not match
// `grpc::userservice/Login`. That's by design — callers that want
// service-level manifest matching against method-level providers
// should use the gRPC wildcard form `grpc::UserService/*` which is
// should use the service wildcard form `grpc::UserService/*` or
// `thrift::UserService/*` which is
// handled by runWildcardMatch below.
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const pkg = rest.substring(0, slashIdx).toLowerCase();
const method = rest.substring(slashIdx);
return `grpc::${pkg}${method}`;
return `${type}::${pkg}${method}`;
}
if (slashIdx === 0) {
// Malformed "/method" with leading slash — keep as-is so two
// equally malformed ids can still match each other.
return `grpc::${rest}`;
return `${type}::${rest}`;
}
// No slash: package/service only. Lowercase to match the package
// segment produced by the pkg/method branch above.
return `grpc::${rest.toLowerCase()}`;
return `${type}::${rest.toLowerCase()}`;
}
case 'topic':
return `topic::${rest.trim().toLowerCase()}`;
@ -90,11 +129,41 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
return matches;
}
if (normalized.startsWith('thrift::')) {
const rest = normalized.substring('thrift::'.length);
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const service = rest.substring(0, slashIdx);
const method = rest.substring(slashIdx + 1);
if (!service.includes('.') && method && method !== '*') {
const matches: string[] = [];
for (const key of index.keys()) {
if (!key.startsWith('thrift::') || key.endsWith('/*')) continue;
const providerRest = key.substring('thrift::'.length);
const providerSlashIdx = providerRest.indexOf('/');
if (providerSlashIdx < 0) continue;
const providerService = providerRest.substring(0, providerSlashIdx);
const providerMethod = providerRest.substring(providerSlashIdx + 1);
if (providerMethod !== method) continue;
if (providerService === service || providerService.endsWith('.' + service)) {
matches.push(key);
}
}
matches.sort();
return matches.length === 1 ? matches : [];
}
}
}
return [];
}
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
export function buildProviderIndex(
contracts: StoredContract[],
matchingConfig?: MatchingConfig,
): Map<string, StoredContract[]> {
const isNoisy = buildNoisyContractFilter(matchingConfig);
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
@ -108,11 +177,15 @@ export function buildProviderIndex(contracts: StoredContract[]): Map<string, Sto
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
matchingConfig?: MatchingConfig,
): MatchResult {
const index = providerIndex ?? buildProviderIndex(contracts);
const isNoisy = buildNoisyContractFilter(matchingConfig);
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
// Skip service wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter(
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
@ -156,14 +229,15 @@ export function runExactMatch(
// normalUnmatched: contracts that weren't matched in exact pass
const normalUnmatched = contracts.filter((c) => {
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
// Re-add gRPC wildcard contracts — they were never in exact matching
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...grpcWildcards];
// Re-add service wildcard contracts — they were never in exact matching
const serviceWildcards = contracts.filter((c) => isServiceWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...serviceWildcards];
return { matched, unmatched };
}
@ -173,21 +247,28 @@ export function runWildcardMatch(
providerIndex: Map<string, StoredContract[]>,
): WildcardMatchResult {
const wildcardConsumers = unmatched.filter(
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
(c) => c.role === 'consumer' && isServiceWildcard(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
for (const consumer of wildcardConsumers) {
const normalized = normalizeContractId(consumer.contractId);
const typeEnd = normalized.indexOf('::');
const consumerType = normalized.slice(0, typeEnd);
// "grpc::com.example.userservice/*" → "com.example.userservice"
// "grpc::userservice/*" → "userservice"
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
// "thrift::userservice/*" → "userservice"
const fqService = normalized.slice(typeEnd + 2, -2); // strip "<type>::" and "/*"
const candidateProviders: StoredContract[] = [];
const matchedProviderServices = new Set<string>();
for (const [key, providers] of providerIndex) {
// Only match against non-wildcard gRPC providers (method-level IDs)
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
const afterPrefix = key.slice(6); // strip "grpc::"
// Only match against non-wildcard same-type providers (method-level IDs).
const keyTypeEnd = key.indexOf('::');
if (keyTypeEnd < 0 || key.endsWith('/*')) continue;
const providerType = key.slice(0, keyTypeEnd);
if (providerType !== consumerType) continue;
const afterPrefix = key.slice(keyTypeEnd + 2); // strip "<type>::"
const slashIdx = afterPrefix.indexOf('/');
if (slashIdx < 0) continue;
const providerFqService = afterPrefix.slice(0, slashIdx);
@ -199,39 +280,46 @@ export function runWildcardMatch(
if (!isMatch) continue;
for (const provider of providers) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matchedProviderServices.add(providerFqService);
candidateProviders.push(...providers);
}
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: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
if (consumerType === 'thrift' && !fqService.includes('.') && matchedProviderServices.size > 1) {
continue;
}
for (const provider of candidateProviders) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
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: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
}
}
const remaining = unmatched.filter((c) => {
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
if (c.role !== 'consumer' || !isServiceWildcard(c.contractId)) return true;
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
});

View file

@ -6,16 +6,19 @@ import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js'
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { runExactMatch } from './matching.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import { writeBridge } from './bridge-db.js';
import type { ContractRegistry } from './types.js';
import { logger } from '../logger.js';
export interface SyncOptions {
extractorOverride?:
| ((repo: RepoHandle) => Promise<StoredContract[]>)
@ -86,15 +89,18 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
const entries = await readRegistry();
registryEntries = await readRegistry();
const entries = registryEntries;
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
const includeEx = new IncludeExtractor();
dbExecutors = new Map<string, CypherExecutor>();
@ -143,6 +149,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.thrift) {
const extracted = await thriftEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.topics) {
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
@ -191,46 +208,69 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Process manifest links declared in group.yaml.
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
// and merge them with explicit manifest links. Discovered links use the same
// ManifestExtractor pipeline as hand-written links in group.yaml.
let allLinks = [...config.links];
if (config.detect.workspace_deps) {
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
if (e) repoPaths.set(groupPath, e.path);
}
const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
for (const s of wsResult.stats) {
logger.info(
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
);
}
}
}
}
// Process manifest links declared in group.yaml (plus any auto-discovered).
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
if (allLinks.length > 0) {
const knownRepos = new Set(Object.keys(config.repos));
for (const link of config.links) {
for (const link of allLinks) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
);
}
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
// eslint-disable-next-line no-console
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
logger.info(
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
);
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const providerIndex = buildProviderIndex(autoContracts, config.matching);
const { matched, unmatched } = runExactMatch(autoContracts, 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]);
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
const allContracts: StoredContract[] = autoContracts;
const registry: ContractRegistry = {
@ -244,18 +284,34 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
// writeBridge failure (disk full, schema error, permission denied) must
// not mask the registry — contracts.json was just written successfully
// and is the canonical source of truth. A stale or absent bridge
// degrades impact queries to empty results, which is recoverable on
// the next sync. Surface the failure as a warning so operators can
// act, but do not propagate it.
// (PR #1156 follow-up review: writeBridge error in sync.ts propagates
// uncaught.)
try {
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
{ err: msg, groupDir: opts.groupDir },
'⚠️ writeBridge failed; contracts.json is intact but bridge.lbug is stale. Re-run `gitnexus group sync` to retry.',
);
}
}
return {
contracts: allContracts,
crossLinks,
unmatched,
unmatched: wildcard.remaining,
missingRepos,
repoSnapshots,
};

View file

@ -24,16 +24,36 @@ export interface GroupManifestLink {
export interface DetectConfig {
http: boolean;
grpc: boolean;
thrift: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
/**
* HTTP paths to exclude from cross-link matching. Contracts at these paths
* are still extracted and visible in the registry, but they don't produce
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
* that every service exposes and would otherwise create N×M false links.
* Trailing slashes are normalized before comparison.
* @default []
*/
exclude_links_paths?: string[];
/**
* When `true`, exclude HTTP routes where every path segment is `{param}`
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
* routes like `/users/{param}` are not affected. These param-only routes
* collapse to a single catch-all after normalization and produce false
* positives across unrelated services.
* @default false
*/
exclude_links_param_only_paths?: boolean;
}
export interface SymbolRef {