From 4208467a334897c62bdb16398c4d79f205a23f60 Mon Sep 17 00:00:00 2001 From: HuangWenjie Date: Tue, 12 May 2026 14:43:39 +0800 Subject: [PATCH] fix(ci): revert unintended group module changes that broke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gitnexus/src/core/group/config-parser.ts | 25 ++- .../group/extractors/manifest-extractor.ts | 38 +++- gitnexus/src/core/group/matching.ts | 190 +++++++++++++----- gitnexus/src/core/group/sync.ts | 102 +++++++--- gitnexus/src/core/group/types.ts | 20 ++ 5 files changed, 290 insertions(+), 85 deletions(-) diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 8116375ae..29c868171 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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.` 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 { diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index a38db4a22..f4d0f77cf 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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)}`); diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 76802f1c3..0b27655c6 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -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(); + 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::[/]`. + case 'grpc': + case 'thrift': { + // Canonical form: `::[/]`. // // 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 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 { - const providers = contracts.filter((c) => c.role === 'provider'); +export function buildProviderIndex( + contracts: StoredContract[], + matchingConfig?: MatchingConfig, +): Map { + const isNoisy = buildNoisyContractFilter(matchingConfig); + const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId)); const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); @@ -108,11 +177,15 @@ export function buildProviderIndex(contracts: StoredContract[]): Map, + 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(); @@ -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, ): 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(); 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 "::" and "/*" + const candidateProviders: StoredContract[] = []; + const matchedProviderServices = new Set(); 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 "::" 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}`); }); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 3d5f662fd..cd64fdf8c 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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) @@ -86,15 +89,18 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis let autoContracts: StoredContract[] = []; let manifestCrossLinks: CrossLink[] = []; let dbExecutors: Map | undefined; + let registryEntries: RegistryEntry[] | undefined; const eo = opts?.extractorOverride; if (eo && eo.length === 0) { autoContracts = await (eo as () => Promise)(); } 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(); @@ -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(); + 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, }; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index d290cfa30..8e43ff78f 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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 {