From 6b1167197f52ad25e16a9d727a77544142018f64 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 00:39:43 +0300 Subject: [PATCH 1/5] feat(group): add group infrastructure and contract matching Core foundation for repository group analysis: - Type system: ContractType, ExtractedContract, StoredContract, CrossLink with optional `service` field for intra-repo matching - Config parser for group.yaml (repos, detection flags, matching thresholds) - Contract registry storage with atomic writes - Exact matching engine with per-type normalization (HTTP, gRPC, topic) and intra-repo support (different services within same repo can match) - Extract LadybugDB pool-adapter from MCP backend for reuse by sync pipeline - Git staleness checker for group status reporting Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/package-lock.json | 27 + gitnexus/package.json | 4 +- gitnexus/src/core/augmentation/engine.ts | 2 +- gitnexus/src/core/git-staleness.ts | 39 ++ gitnexus/src/core/group/config-parser.ts | 98 +++ gitnexus/src/core/group/contract-extractor.ts | 16 + gitnexus/src/core/group/matching.ts | 127 ++++ gitnexus/src/core/group/storage.ts | 98 +++ gitnexus/src/core/group/types.ts | 133 ++++ gitnexus/src/core/lbug/pool-adapter.ts | 576 ++++++++++++++++++ gitnexus/src/core/search/bm25-index.ts | 2 +- gitnexus/src/core/wiki/graph-queries.ts | 2 +- gitnexus/src/mcp/core/lbug-adapter.ts | 575 +---------------- gitnexus/src/server/api.ts | 2 +- gitnexus/test/fixtures/group/group.yaml | 24 + gitnexus/test/helpers/test-indexed-db.ts | 4 +- .../test/unit/group/config-parser.test.ts | 129 ++++ gitnexus/test/unit/group/matching.test.ts | 182 ++++++ gitnexus/test/unit/group/storage.test.ts | 81 +++ gitnexus/test/unit/group/types.test.ts | 97 +++ gitnexus/test/unit/staleness.test.ts | 2 +- 21 files changed, 1640 insertions(+), 580 deletions(-) create mode 100644 gitnexus/src/core/git-staleness.ts create mode 100644 gitnexus/src/core/group/config-parser.ts create mode 100644 gitnexus/src/core/group/contract-extractor.ts create mode 100644 gitnexus/src/core/group/matching.ts create mode 100644 gitnexus/src/core/group/storage.ts create mode 100644 gitnexus/src/core/group/types.ts create mode 100644 gitnexus/src/core/lbug/pool-adapter.ts create mode 100644 gitnexus/test/fixtures/group/group.yaml create mode 100644 gitnexus/test/unit/group/config-parser.test.ts create mode 100644 gitnexus/test/unit/group/matching.test.ts create mode 100644 gitnexus/test/unit/group/storage.test.ts create mode 100644 gitnexus/test/unit/group/types.test.ts diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9994f13c1..595b6d5da 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -22,6 +22,7 @@ "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", "ignore": "^7.0.5", + "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", "mnemonist": "^0.39.0", "onnxruntime-node": "^1.24.0", @@ -47,6 +48,7 @@ "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -1992,6 +1994,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2285,6 +2294,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -3566,6 +3581,18 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index c77865026..118e4b647 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -50,7 +50,6 @@ "prepack": "npm run build && chmod +x dist/cli/index.js" }, "dependencies": { - "gitnexus-shared": "file:../gitnexus-shared", "@huggingface/transformers": "^3.0.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", @@ -58,11 +57,13 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", + "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", "ignore": "^7.0.5", + "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", "mnemonist": "^0.39.0", "onnxruntime-node": "^1.24.0", @@ -90,6 +91,7 @@ "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index a2045acd1..896813c22 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -93,7 +93,7 @@ export async function augment(pattern: string, cwd?: string): Promise { if (!repo) return ''; // Lazy-load lbug adapter (skip unnecessary init) - const { initLbug, executeQuery, isLbugReady } = await import('../../mcp/core/lbug-adapter.js'); + const { initLbug, executeQuery, isLbugReady } = await import('../lbug/pool-adapter.js'); const { searchFTSFromLbug } = await import('../search/bm25-index.js'); const repoId = repo.name.toLowerCase(); diff --git a/gitnexus/src/core/git-staleness.ts b/gitnexus/src/core/git-staleness.ts new file mode 100644 index 000000000..2ef8f9c75 --- /dev/null +++ b/gitnexus/src/core/git-staleness.ts @@ -0,0 +1,39 @@ +/** + * Git working tree vs index commit staleness (used by MCP resources, group status, etc.). + * Lives in core/ so application code does not depend on the MCP package layer. + */ + +import { execFileSync } from 'node:child_process'; + +export interface StalenessInfo { + isStale: boolean; + commitsBehind: number; + hint?: string; +} + +/** + * Check how many commits the index is behind HEAD (synchronous; uses git CLI). + */ +export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo { + try { + const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { + cwd: repoPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + const commitsBehind = parseInt(result, 10) || 0; + + if (commitsBehind > 0) { + return { + isStale: true, + commitsBehind, + hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`, + }; + } + + return { isStale: false, commitsBehind: 0 }; + } catch { + return { isStale: false, commitsBehind: 0 }; + } +} diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts new file mode 100644 index 000000000..edaeeeec2 --- /dev/null +++ b/gitnexus/src/core/group/config-parser.ts @@ -0,0 +1,98 @@ +import { createRequire } from 'node:module'; +import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js'; + +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']; +const VALID_ROLES: ContractRole[] = ['provider', 'consumer']; + +const DEFAULT_DETECT = { + http: true, + grpc: true, + topics: true, + shared_libs: true, + embedding_fallback: true, +}; + +const DEFAULT_MATCHING = { + bm25_threshold: 0.7, + embedding_threshold: 0.65, + max_candidates_per_step: 3, +}; + +export function parseGroupConfig(yamlContent: string): GroupConfig { + const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record; + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid YAML: expected an object'); + } + + if (raw.version === undefined) throw new Error('version is required in group.yaml'); + if (raw.version !== 1) { + throw new Error(`Unsupported group.yaml version: ${raw.version}. Expected 1.`); + } + if (!raw.name || typeof raw.name !== 'string') throw new Error('name is required in group.yaml'); + if (!raw.repos || typeof raw.repos !== 'object' || Array.isArray(raw.repos)) { + throw new Error('repos is required in group.yaml (must be a mapping)'); + } + + const repos = raw.repos as Record; + const repoPaths = new Set(Object.keys(repos)); + + const rawLinks = (raw.links as unknown[]) || []; + const links: GroupManifestLink[] = rawLinks.map((l: unknown, i: number) => { + const link = l as Record; + if (!link.from || !repoPaths.has(link.from as string)) { + throw new Error(`links[${i}].from "${link.from}" does not match any repo path in group`); + } + if (!link.to || !repoPaths.has(link.to as string)) { + throw new Error(`links[${i}].to "${link.to}" does not match any repo path in group`); + } + if (!VALID_CONTRACT_TYPES.includes(link.type as ContractType)) { + throw new Error( + `links[${i}].type "${link.type}" is invalid. Expected: ${VALID_CONTRACT_TYPES.join(', ')}`, + ); + } + if (!VALID_ROLES.includes(link.role as ContractRole)) { + throw new Error(`links[${i}].role "${link.role}" is invalid. Expected: provider | consumer`); + } + if ( + link.contract === undefined || + link.contract === null || + String(link.contract).trim() === '' + ) { + throw new Error(`links[${i}].contract is required`); + } + return { + from: link.from as string, + to: link.to as string, + type: link.type as ContractType, + contract: String(link.contract), + role: link.role as ContractRole, + }; + }); + + const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) }; + const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) }; + const packages = (raw.packages as Record>) || {}; + + return { + version: 1, + name: raw.name as string, + description: (raw.description as string) || '', + repos, + links, + packages, + detect, + matching, + }; +} + +export async function loadGroupConfig(groupDir: string): Promise { + const fsp = await import('node:fs/promises'); + const path = await import('node:path'); + const yamlPath = path.join(groupDir, 'group.yaml'); + const content = await fsp.readFile(yamlPath, 'utf-8'); + return parseGroupConfig(content); +} diff --git a/gitnexus/src/core/group/contract-extractor.ts b/gitnexus/src/core/group/contract-extractor.ts new file mode 100644 index 000000000..69f688f28 --- /dev/null +++ b/gitnexus/src/core/group/contract-extractor.ts @@ -0,0 +1,16 @@ +import type { ContractType, ExtractedContract, RepoHandle } from './types.js'; + +export interface ContractExtractor { + type: ContractType; + canExtract(repo: RepoHandle): Promise; + extract( + dbExecutor: CypherExecutor | null, + repoPath: string, + repo: RepoHandle, + ): Promise; +} + +export type CypherExecutor = ( + query: string, + params?: Record, +) => Promise[]>; diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts new file mode 100644 index 000000000..6d39f4ce4 --- /dev/null +++ b/gitnexus/src/core/group/matching.ts @@ -0,0 +1,127 @@ +import type { StoredContract, CrossLink } from './types.js'; + +export interface MatchResult { + matched: CrossLink[]; + unmatched: StoredContract[]; +} + +export function normalizeContractId(id: string): string { + const colonIdx = id.indexOf('::'); + if (colonIdx === -1) return id; + + const type = id.substring(0, colonIdx); + const rest = id.substring(colonIdx + 2); + + switch (type) { + case 'http': { + const parts = rest.split('::'); + if (parts.length >= 2) { + const method = parts[0].toUpperCase(); + let pathPart = parts.slice(1).join('::'); + pathPart = pathPart.replace(/\/+$/, ''); + return `http::${method}::${pathPart}`; + } + return id; + } + case 'grpc': { + const slashIdx = rest.indexOf('/'); + if (slashIdx > 0) { + const pkg = rest.substring(0, slashIdx).toLowerCase(); + const method = rest.substring(slashIdx); + return `grpc::${pkg}${method}`; + } + if (slashIdx === 0) { + // Malformed "package/method" with leading slash — do not lowercase the whole string + // (method segment is case-sensitive per spec). + return `grpc::${rest}`; + } + // No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase + // the whole token; differs from pkg/method split above where RPC method keeps case. + return `grpc::${rest.toLowerCase()}`; + } + case 'topic': + return `topic::${rest.trim().toLowerCase()}`; + case 'lib': + return `lib::${rest.toLowerCase()}`; + default: + return id; + } +} + +function findMatchingKeys(contractId: string, index: Map): string[] { + const normalized = normalizeContractId(contractId); + if (index.has(normalized)) return [normalized]; + + if (normalized.startsWith('http::*::')) { + const pathPart = normalized.substring('http::*::'.length); + const matches: string[] = []; + for (const key of index.keys()) { + if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) { + matches.push(key); + } + } + return matches; + } + + return []; +} + +export function runExactMatch(contracts: StoredContract[]): MatchResult { + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const providerIndex = new Map(); + for (const p of providers) { + const key = normalizeContractId(p.contractId); + const list = providerIndex.get(key) || []; + list.push(p); + providerIndex.set(key, list); + } + + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + const matchedProviderIds = new Set(); + + for (const consumer of consumers) { + const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex); + if (matchingKeys.length === 0) continue; + + const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []); + for (const provider of allMatchingProviders) { + 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, + matchType: 'exact', + confidence: 1.0, + }); + + matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`); + matchedProviderIds.add(`${provider.repo}::${provider.contractId}`); + } + } + + const unmatched = contracts.filter((c) => { + const id = `${c.repo}::${c.contractId}`; + return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); + }); + + return { matched, unmatched }; +} diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts new file mode 100644 index 000000000..568a2c66c --- /dev/null +++ b/gitnexus/src/core/group/storage.ts @@ -0,0 +1,98 @@ +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { ContractRegistry } from './types.js'; + +const CONTRACTS_FILE = 'contracts.json'; + +export function getDefaultGitnexusDir(): string { + return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); +} + +export function getGroupsBaseDir(gitnexusDir?: string): string { + return path.join(gitnexusDir || getDefaultGitnexusDir(), 'groups'); +} + +export function getGroupDir(gitnexusDir: string, groupName: string): string { + return path.join(gitnexusDir, 'groups', groupName); +} + +export async function writeContractRegistry( + groupDir: string, + registry: ContractRegistry, +): Promise { + const targetPath = path.join(groupDir, CONTRACTS_FILE); + const tmpPath = `${targetPath}.tmp.${Date.now()}`; + + await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8'); + await fsp.rename(tmpPath, targetPath); +} + +export async function readContractRegistry(groupDir: string): Promise { + const filePath = path.join(groupDir, CONTRACTS_FILE); + try { + const content = await fsp.readFile(filePath, 'utf-8'); + return JSON.parse(content) as ContractRegistry; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +export async function listGroups(gitnexusDir?: string): Promise { + const groupsDir = getGroupsBaseDir(gitnexusDir); + try { + const entries = await fsp.readdir(groupsDir, { withFileTypes: true }); + const names: string[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const yamlPath = path.join(groupsDir, entry.name, 'group.yaml'); + if (fs.existsSync(yamlPath)) { + names.push(entry.name); + } + } + } + return names; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } +} + +export async function createGroupDir( + gitnexusDir: string, + groupName: string, + force: boolean = false, +): Promise { + const groupDir = getGroupDir(gitnexusDir, groupName); + if (fs.existsSync(path.join(groupDir, 'group.yaml')) && !force) { + throw new Error(`Group "${groupName}" already exists. Use --force to overwrite.`); + } + await fsp.mkdir(groupDir, { recursive: true }); + + const template = `version: 1 +name: ${groupName} +description: "" + +repos: {} + +links: [] + +packages: {} + +detect: + http: true + grpc: true + topics: true + shared_libs: true + embedding_fallback: true + +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`; + await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8'); + return groupDir; +} diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts new file mode 100644 index 000000000..7ab0f071a --- /dev/null +++ b/gitnexus/src/core/group/types.ts @@ -0,0 +1,133 @@ +export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom'; +export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding'; +export type ContractRole = 'provider' | 'consumer'; + +export interface GroupConfig { + version: number; + name: string; + description: string; + repos: Record; + links: GroupManifestLink[]; + packages: Record>; + detect: DetectConfig; + matching: MatchingConfig; +} + +export interface GroupManifestLink { + from: string; + to: string; + type: ContractType; + contract: string; + role: ContractRole; +} + +export interface DetectConfig { + http: boolean; + grpc: boolean; + topics: boolean; + shared_libs: boolean; + embedding_fallback: boolean; +} + +export interface MatchingConfig { + bm25_threshold: number; + embedding_threshold: number; + max_candidates_per_step: number; +} + +export interface SymbolRef { + filePath: string; + name: string; +} + +export interface ExtractedContract { + contractId: string; + type: ContractType; + role: ContractRole; + symbolUid: string; + symbolRef: SymbolRef; + symbolName: string; + confidence: number; + meta: Record; + /** Service boundary within a monorepo (relative path from repo root, e.g. "services/auth"). */ + service?: string; +} + +export interface CrossLinkEndpoint { + repo: string; + /** Service boundary within a monorepo (relative path from repo root). */ + service?: string; + symbolUid: string; + symbolRef: SymbolRef; +} + +export interface CrossLink { + from: CrossLinkEndpoint; + to: CrossLinkEndpoint; + type: ContractType; + contractId: string; + matchType: MatchType; + confidence: number; +} + +export interface RepoSnapshot { + indexedAt: string; + lastCommit: string; +} + +export interface ContractRegistry { + version: number; + generatedAt: string; + repoSnapshots: Record; + missingRepos: string[]; + contracts: StoredContract[]; + crossLinks: CrossLink[]; +} + +export interface StoredContract extends ExtractedContract { + repo: string; +} + +/** Repo within a group (group path + paths; name collision with MCP RepoHandle — import from group/types only). */ +export interface RepoHandle { + id: string; + path: string; + repoPath: string; + storagePath: string; +} + +export interface GroupImpactResult { + local: unknown; + group: string; + cross: CrossRepoImpact[]; + outOfScope: OutOfScopeLink[]; + truncated: boolean; + truncatedRepos: string[]; + summary: { + direct: number; + processes_affected: number; + modules_affected: number; + cross_repo_hits: number; + }; + risk: string; +} + +export interface CrossRepoImpact { + repo: string; + repo_path: string; + contract: { + id: string; + type: ContractType; + match_type: MatchType; + confidence: number; + }; + by_depth: Record; + affected_processes: string[]; +} + +export interface OutOfScopeLink { + from: string; + to: string; + contractId: string; + confidence: number; +} diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts new file mode 100644 index 000000000..f361f1388 --- /dev/null +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -0,0 +1,576 @@ +/** + * LadybugDB connection pool (core). Used by MCP, sync, search, wiki, etc. + * + * LadybugDB Adapter (Connection Pool) + * + * Manages a pool of LadybugDB databases keyed by repoId, each with + * multiple Connection objects for safe concurrent query execution. + * + * LadybugDB Connections are NOT thread-safe — a single Connection + * segfaults if concurrent .query() calls hit it simultaneously. + * This adapter provides a checkout/return connection pool so each + * concurrent query gets its own Connection from the same Database. + * + * @see https://docs.ladybugdb.com/concurrency — multiple Connections + * from the same Database is the officially supported concurrency pattern. + */ + +import fs from 'fs/promises'; +import lbug from '@ladybugdb/core'; + +/** Per-repo pool: one Database, many Connections */ +interface PoolEntry { + db: lbug.Database; + /** Available connections ready for checkout */ + available: lbug.Connection[]; + /** Number of connections currently checked out */ + checkedOut: number; + /** Queued waiters for when all connections are busy */ + waiters: Array<(conn: lbug.Connection) => void>; + lastUsed: number; + dbPath: string; + /** Set to true when the pool entry is closed — checkin will close orphaned connections */ + closed: boolean; +} + +const pool = new Map(); + +/** + * Shared Database cache keyed by resolved dbPath. + * Multiple repoIds pointing to the same path share one native Database + * object to avoid exhausting the buffer manager's mmap budget. + */ +interface SharedDB { + db: lbug.Database; + refCount: number; + ftsLoaded: boolean; + /** When true, closeOne skips db.close() — the Database is owned externally. */ + external?: boolean; +} +const dbCache = new Map(); + +/** Max repos in the pool (LRU eviction) */ +const MAX_POOL_SIZE = 5; +/** Idle timeout before closing a repo's connections */ +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +/** Max connections per repo (caps concurrent queries per repo) */ +const MAX_CONNS_PER_REPO = 8; + +let idleTimer: ReturnType | null = null; + +/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */ +export const realStdoutWrite = process.stdout.write.bind(process.stdout); +let stdoutSilenceCount = 0; +/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */ +let preWarmActive = false; + +/** + * Start the idle cleanup timer (runs every 60s) + */ +function ensureIdleTimer(): void { + if (idleTimer) return; + idleTimer = setInterval(() => { + const now = Date.now(); + for (const [repoId, entry] of pool) { + if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) { + closeOne(repoId); + } + } + }, 60_000); + if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) { + (idleTimer as NodeJS.Timeout).unref(); + } +} + +/** + * Touch a repo to reset its idle timeout. + * Call this during long-running operations to prevent the connection from being closed. + */ +export const touchRepo = (repoId: string): void => { + const entry = pool.get(repoId); + if (entry) { + entry.lastUsed = Date.now(); + } +}; + +/** + * Evict the least-recently-used repo if pool is at capacity + */ +function evictLRU(): void { + if (pool.size < MAX_POOL_SIZE) return; + + let oldestId: string | null = null; + let oldestTime = Infinity; + for (const [id, entry] of pool) { + if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) { + oldestTime = entry.lastUsed; + oldestId = id; + } + } + if (oldestId) { + closeOne(oldestId); + } +} + +/** + * Remove a repo from the pool, close its connections, and release its + * shared Database ref. Only closes the Database when no other repoIds + * reference it (refCount === 0). + */ +function closeOne(repoId: string): void { + const entry = pool.get(repoId); + if (!entry) return; + + entry.closed = true; + + // Close available connections — fire-and-forget with .catch() to prevent + // unhandled rejections. Native close() returns Promise but can crash + // the N-API destructor on macOS/Windows; deferring to process exit lets + // dangerouslyIgnoreUnhandledErrors absorb the crash. + for (const conn of entry.available) { + conn.close().catch(() => {}); + } + entry.available.length = 0; + + // Checked-out connections can't be closed here — they're in-flight. + // The checkin() function detects entry.closed and closes them on return. + + // Only close the Database when no other repoIds reference it. + // External databases (injected via initLbugWithDb) are never closed here — + // the core adapter owns them and handles their lifecycle. + const shared = dbCache.get(entry.dbPath); + if (shared) { + shared.refCount--; + if (shared.refCount === 0) { + if (shared.external) { + // External databases are owned by the core adapter — don't close + // or remove from cache. Keep the entry so future initLbug() calls + // for the same dbPath reuse it instead of hitting a file lock. + shared.refCount = 0; + } else { + shared.db.close().catch(() => {}); + dbCache.delete(entry.dbPath); + } + } + } + + pool.delete(repoId); +} + +/** + * Create a new Connection from a repo's Database. + * Silences stdout to prevent native module output from corrupting MCP stdio. + */ +let activeQueryCount = 0; + +function silenceStdout(): void { + if (stdoutSilenceCount++ === 0) { + process.stdout.write = (() => true) as any; + } +} + +function restoreStdout(): void { + if (--stdoutSilenceCount <= 0) { + stdoutSilenceCount = 0; + process.stdout.write = realStdoutWrite; + } +} + +// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash +// inside createConnection before restoreStdout runs). +// Exempts active queries and pre-warm — these legitimately hold silence for +// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s). +setInterval(() => { + if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) { + stdoutSilenceCount = 0; + process.stdout.write = realStdoutWrite; + } +}, 1000).unref(); + +function createConnection(db: lbug.Database): lbug.Connection { + silenceStdout(); + try { + return new lbug.Connection(db); + } finally { + restoreStdout(); + } +} + +/** Query timeout in milliseconds */ +const QUERY_TIMEOUT_MS = 30_000; +/** Waiter queue timeout in milliseconds */ +const WAITER_TIMEOUT_MS = 15_000; + +const LOCK_RETRY_ATTEMPTS = 3; +const LOCK_RETRY_DELAY_MS = 2000; + +/** Deduplicates concurrent initLbug calls for the same repoId */ +const initPromises = new Map>(); + +/** + * Initialize (or reuse) a Database + connection pool for a specific repo. + * Retries on lock errors (e.g., when `gitnexus analyze` is running). + * + * Concurrent calls for the same repoId are deduplicated — the second caller + * awaits the first's in-progress init rather than starting a redundant one. + */ +export const initLbug = async (repoId: string, dbPath: string): Promise => { + const existing = pool.get(repoId); + if (existing) { + existing.lastUsed = Date.now(); + return; + } + + // Deduplicate concurrent init calls for the same repoId — + // prevents double-init race when multiple parallel tool calls + // trigger initialization for the same repo simultaneously. + const pending = initPromises.get(repoId); + if (pending) return pending; + + const promise = doInitLbug(repoId, dbPath); + initPromises.set(repoId, promise); + try { + await promise; + } finally { + initPromises.delete(repoId); + } +}; + +/** + * Internal init — creates DB, pre-warms connections, loads FTS, then registers pool. + * Pool entry is registered LAST so concurrent executeQuery calls see either + * "not initialized" (and throw) or a fully ready pool — never a half-built one. + */ +async function doInitLbug(repoId: string, dbPath: string): Promise { + // Check if database exists + try { + await fs.stat(dbPath); + } catch { + throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`); + } + + evictLRU(); + + // Reuse an existing native Database if another repoId already opened this path. + // This prevents buffer manager exhaustion from multiple mmap regions on the same file. + let shared = dbCache.get(dbPath); + if (!shared) { + // Open in read-only mode — MCP server never writes to the database. + // This allows multiple MCP server instances to read concurrently, and + // avoids lock conflicts when `gitnexus analyze` is writing. + let lastError: Error | null = null; + for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) { + silenceStdout(); + try { + const db = new lbug.Database( + dbPath, + 0, // bufferManagerSize (default) + false, // enableCompression (default) + true, // readOnly + ); + restoreStdout(); + shared = { db, refCount: 0, ftsLoaded: false }; + dbCache.set(dbPath, shared); + break; + } catch (err: any) { + restoreStdout(); + lastError = err instanceof Error ? err : new Error(String(err)); + const isLockError = + lastError.message.includes('Could not set lock') || lastError.message.includes('lock'); + if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break; + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt)); + } + } + + if (!shared) { + throw new Error( + `LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` + + `Retry later. (${lastError?.message || 'unknown error'})`, + ); + } + } + + shared.refCount++; + const db = shared.db; + + // Pre-create the full pool upfront so createConnection() (which silences + // stdout) is never called lazily during active query execution. + // Mark preWarmActive so the watchdog timer doesn't interfere. + preWarmActive = true; + const available: lbug.Connection[] = []; + try { + for (let i = 0; i < MAX_CONNS_PER_REPO; i++) { + available.push(createConnection(db)); + } + } finally { + preWarmActive = false; + } + + // Load FTS extension once per shared Database. + // Done BEFORE pool registration so no concurrent checkout can grab + // the connection while the async FTS load is in progress. + if (!shared.ftsLoaded) { + try { + await available[0].query('LOAD EXTENSION fts'); + shared.ftsLoaded = true; + } catch { + // Extension may not be installed — FTS queries will fail gracefully + } + } + + // Register pool entry only after all connections are pre-warmed and FTS is + // loaded. Concurrent executeQuery calls see either "not initialized" + // (and throw cleanly) or a fully ready pool — never a half-built one. + pool.set(repoId, { + db, + available, + checkedOut: 0, + waiters: [], + lastUsed: Date.now(), + dbPath, + closed: false, + }); + ensureIdleTimer(); +} + +/** + * Initialize a pool entry from a pre-existing Database object. + * + * Used in tests to avoid the writable→close→read-only cycle that crashes + * on macOS due to N-API destructor segfaults. The pool adapter reuses + * the core adapter's writable Database instead of opening a new read-only one. + * + * The Database is registered in the shared dbCache so closeOne() decrements + * the refCount correctly. If the Database is already cached (e.g. another + * repoId already injected it), the existing entry is reused. + */ +export async function initLbugWithDb( + repoId: string, + existingDb: lbug.Database, + dbPath: string, +): Promise { + const existing = pool.get(repoId); + if (existing) { + existing.lastUsed = Date.now(); + return; + } + + // Register in dbCache with external: true so other initLbug() calls + // for the same dbPath reuse this Database instead of trying to open + // a new one (which would fail with a file lock error). + // closeOne() respects the external flag and skips db.close(). + let shared = dbCache.get(dbPath); + if (!shared) { + shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true }; + dbCache.set(dbPath, shared); + } + shared.refCount++; + + const available: lbug.Connection[] = []; + preWarmActive = true; + try { + for (let i = 0; i < MAX_CONNS_PER_REPO; i++) { + available.push(createConnection(existingDb)); + } + } finally { + preWarmActive = false; + } + + // Load FTS extension if not already loaded on this Database + try { + await available[0].query('LOAD EXTENSION fts'); + } catch { + // Extension may already be loaded or not installed + } + + pool.set(repoId, { + db: existingDb, + available, + checkedOut: 0, + waiters: [], + lastUsed: Date.now(), + dbPath, + closed: false, + }); + ensureIdleTimer(); +} + +/** + * Checkout a connection from the pool. + * Returns an available connection, or creates a new one if under the cap. + * If all connections are busy and at cap, queues the caller until one is returned. + */ +function checkout(entry: PoolEntry): Promise { + // Fast path: grab an available connection + if (entry.available.length > 0) { + entry.checkedOut++; + return Promise.resolve(entry.available.pop()!); + } + + // Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here + // with fewer total connections, something leaked — surface the bug rather + // than silently creating a connection (which would silence stdout mid-query). + const totalConns = entry.available.length + entry.checkedOut; + if (totalConns < MAX_CONNS_PER_REPO) { + throw new Error( + `Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` + + `connections but found ${totalConns} (${entry.available.length} available, ` + + `${entry.checkedOut} checked out)`, + ); + } + + // At capacity — queue the caller with a timeout. + return new Promise((resolve, reject) => { + const waiter = (conn: lbug.Connection) => { + clearTimeout(timer); + resolve(conn); + }; + const timer = setTimeout(() => { + const idx = entry.waiters.indexOf(waiter); + if (idx !== -1) entry.waiters.splice(idx, 1); + reject( + new Error( + `Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`, + ), + ); + }, WAITER_TIMEOUT_MS); + entry.waiters.push(waiter); + }); +} + +/** + * Return a connection to the pool after use. + * If the pool entry was closed while the connection was checked out (e.g. + * LRU eviction), close the orphaned connection instead of returning it. + * If there are queued waiters, hand the connection directly to the next one + * instead of putting it back in the available array (avoids race conditions). + */ +function checkin(entry: PoolEntry, conn: lbug.Connection): void { + if (entry.closed) { + // Pool entry was deleted during checkout — close the orphaned connection + conn.close().catch(() => {}); + return; + } + if (entry.waiters.length > 0) { + // Hand directly to the next waiter — no intermediate available state + const waiter = entry.waiters.shift()!; + waiter(conn); + } else { + entry.checkedOut--; + entry.available.push(conn); + } +} + +/** + * Execute a query on a specific repo's connection pool. + * Automatically checks out a connection, runs the query, and returns it. + */ +/** Race a promise against a timeout */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +export const executeQuery = async (repoId: string, cypher: string): Promise => { + const entry = pool.get(repoId); + if (!entry) { + throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); + } + + if (isWriteQuery(cypher)) { + throw new Error('Write operations are not allowed. The pool adapter is read-only.'); + } + + entry.lastUsed = Date.now(); + + const conn = await checkout(entry); + silenceStdout(); + activeQueryCount++; + try { + const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query'); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const rows = await result.getAll(); + return rows; + } finally { + activeQueryCount--; + restoreStdout(); + checkin(entry, conn); + } +}; + +/** + * Execute a parameterized query on a specific repo's connection pool. + * Uses prepare/execute pattern to prevent Cypher injection. + */ +export const executeParameterized = async ( + repoId: string, + cypher: string, + params: Record, +): Promise => { + const entry = pool.get(repoId); + if (!entry) { + throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); + } + + entry.lastUsed = Date.now(); + + const conn = await checkout(entry); + silenceStdout(); + activeQueryCount++; + try { + const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare'); + if (!stmt.isSuccess()) { + const errMsg = await stmt.getErrorMessage(); + throw new Error(`Prepare failed: ${errMsg}`); + } + const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute'); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const rows = await result.getAll(); + return rows; + } finally { + activeQueryCount--; + restoreStdout(); + checkin(entry, conn); + } +}; + +/** + * Close one or all repo pools. + * If repoId is provided, close only that repo's connections. + * If omitted, close all repos. + */ +export const closeLbug = async (repoId?: string): Promise => { + if (repoId) { + closeOne(repoId); + return; + } + + for (const id of [...pool.keys()]) { + closeOne(id); + } + + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; + } +}; + +/** + * Check if a specific repo's pool is active + */ +export const isLbugReady = (repoId: string): boolean => pool.has(repoId); + +/** Regex to detect write operations in user-supplied Cypher queries. + * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX) + * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in + * read-only mode as defense-in-depth against write procedures. */ +export const CYPHER_WRITE_RE = + /(? executeQuery(repoId, cypher); fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit); functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit); diff --git a/gitnexus/src/core/wiki/graph-queries.ts b/gitnexus/src/core/wiki/graph-queries.ts index f59316827..f8518a3bd 100644 --- a/gitnexus/src/core/wiki/graph-queries.ts +++ b/gitnexus/src/core/wiki/graph-queries.ts @@ -5,7 +5,7 @@ * Uses the MCP-style pooled lbug-adapter for connection management. */ -import { initLbug, executeQuery, closeLbug, touchRepo } from '../../mcp/core/lbug-adapter.js'; +import { initLbug, executeQuery, closeLbug, touchRepo } from '../lbug/pool-adapter.js'; const REPO_ID = '__wiki__'; diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index 9420a73c8..ffbd394f5 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -1,574 +1,5 @@ /** - * LadybugDB Adapter (Connection Pool) - * - * Manages a pool of LadybugDB databases keyed by repoId, each with - * multiple Connection objects for safe concurrent query execution. - * - * LadybugDB Connections are NOT thread-safe — a single Connection - * segfaults if concurrent .query() calls hit it simultaneously. - * This adapter provides a checkout/return connection pool so each - * concurrent query gets its own Connection from the same Database. - * - * @see https://docs.ladybugdb.com/concurrency — multiple Connections - * from the same Database is the officially supported concurrency pattern. + * LadybugDB connection pool — re-exported from core. + * Prefer importing from `../../core/lbug/pool-adapter.js` in new code. */ - -import fs from 'fs/promises'; -import lbug from '@ladybugdb/core'; - -/** Per-repo pool: one Database, many Connections */ -interface PoolEntry { - db: lbug.Database; - /** Available connections ready for checkout */ - available: lbug.Connection[]; - /** Number of connections currently checked out */ - checkedOut: number; - /** Queued waiters for when all connections are busy */ - waiters: Array<(conn: lbug.Connection) => void>; - lastUsed: number; - dbPath: string; - /** Set to true when the pool entry is closed — checkin will close orphaned connections */ - closed: boolean; -} - -const pool = new Map(); - -/** - * Shared Database cache keyed by resolved dbPath. - * Multiple repoIds pointing to the same path share one native Database - * object to avoid exhausting the buffer manager's mmap budget. - */ -interface SharedDB { - db: lbug.Database; - refCount: number; - ftsLoaded: boolean; - /** When true, closeOne skips db.close() — the Database is owned externally. */ - external?: boolean; -} -const dbCache = new Map(); - -/** Max repos in the pool (LRU eviction) */ -const MAX_POOL_SIZE = 5; -/** Idle timeout before closing a repo's connections */ -const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -/** Max connections per repo (caps concurrent queries per repo) */ -const MAX_CONNS_PER_REPO = 8; - -let idleTimer: ReturnType | null = null; - -/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */ -export const realStdoutWrite = process.stdout.write.bind(process.stdout); -let stdoutSilenceCount = 0; -/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */ -let preWarmActive = false; - -/** - * Start the idle cleanup timer (runs every 60s) - */ -function ensureIdleTimer(): void { - if (idleTimer) return; - idleTimer = setInterval(() => { - const now = Date.now(); - for (const [repoId, entry] of pool) { - if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) { - closeOne(repoId); - } - } - }, 60_000); - if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) { - (idleTimer as NodeJS.Timeout).unref(); - } -} - -/** - * Touch a repo to reset its idle timeout. - * Call this during long-running operations to prevent the connection from being closed. - */ -export const touchRepo = (repoId: string): void => { - const entry = pool.get(repoId); - if (entry) { - entry.lastUsed = Date.now(); - } -}; - -/** - * Evict the least-recently-used repo if pool is at capacity - */ -function evictLRU(): void { - if (pool.size < MAX_POOL_SIZE) return; - - let oldestId: string | null = null; - let oldestTime = Infinity; - for (const [id, entry] of pool) { - if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) { - oldestTime = entry.lastUsed; - oldestId = id; - } - } - if (oldestId) { - closeOne(oldestId); - } -} - -/** - * Remove a repo from the pool, close its connections, and release its - * shared Database ref. Only closes the Database when no other repoIds - * reference it (refCount === 0). - */ -function closeOne(repoId: string): void { - const entry = pool.get(repoId); - if (!entry) return; - - entry.closed = true; - - // Close available connections — fire-and-forget with .catch() to prevent - // unhandled rejections. Native close() returns Promise but can crash - // the N-API destructor on macOS/Windows; deferring to process exit lets - // dangerouslyIgnoreUnhandledErrors absorb the crash. - for (const conn of entry.available) { - conn.close().catch(() => {}); - } - entry.available.length = 0; - - // Checked-out connections can't be closed here — they're in-flight. - // The checkin() function detects entry.closed and closes them on return. - - // Only close the Database when no other repoIds reference it. - // External databases (injected via initLbugWithDb) are never closed here — - // the core adapter owns them and handles their lifecycle. - const shared = dbCache.get(entry.dbPath); - if (shared) { - shared.refCount--; - if (shared.refCount === 0) { - if (shared.external) { - // External databases are owned by the core adapter — don't close - // or remove from cache. Keep the entry so future initLbug() calls - // for the same dbPath reuse it instead of hitting a file lock. - shared.refCount = 0; - } else { - shared.db.close().catch(() => {}); - dbCache.delete(entry.dbPath); - } - } - } - - pool.delete(repoId); -} - -/** - * Create a new Connection from a repo's Database. - * Silences stdout to prevent native module output from corrupting MCP stdio. - */ -let activeQueryCount = 0; - -function silenceStdout(): void { - if (stdoutSilenceCount++ === 0) { - process.stdout.write = (() => true) as any; - } -} - -function restoreStdout(): void { - if (--stdoutSilenceCount <= 0) { - stdoutSilenceCount = 0; - process.stdout.write = realStdoutWrite; - } -} - -// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash -// inside createConnection before restoreStdout runs). -// Exempts active queries and pre-warm — these legitimately hold silence for -// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s). -setInterval(() => { - if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) { - stdoutSilenceCount = 0; - process.stdout.write = realStdoutWrite; - } -}, 1000).unref(); - -function createConnection(db: lbug.Database): lbug.Connection { - silenceStdout(); - try { - return new lbug.Connection(db); - } finally { - restoreStdout(); - } -} - -/** Query timeout in milliseconds */ -const QUERY_TIMEOUT_MS = 30_000; -/** Waiter queue timeout in milliseconds */ -const WAITER_TIMEOUT_MS = 15_000; - -const LOCK_RETRY_ATTEMPTS = 3; -const LOCK_RETRY_DELAY_MS = 2000; - -/** Deduplicates concurrent initLbug calls for the same repoId */ -const initPromises = new Map>(); - -/** - * Initialize (or reuse) a Database + connection pool for a specific repo. - * Retries on lock errors (e.g., when `gitnexus analyze` is running). - * - * Concurrent calls for the same repoId are deduplicated — the second caller - * awaits the first's in-progress init rather than starting a redundant one. - */ -export const initLbug = async (repoId: string, dbPath: string): Promise => { - const existing = pool.get(repoId); - if (existing) { - existing.lastUsed = Date.now(); - return; - } - - // Deduplicate concurrent init calls for the same repoId — - // prevents double-init race when multiple parallel tool calls - // trigger initialization for the same repo simultaneously. - const pending = initPromises.get(repoId); - if (pending) return pending; - - const promise = doInitLbug(repoId, dbPath); - initPromises.set(repoId, promise); - try { - await promise; - } finally { - initPromises.delete(repoId); - } -}; - -/** - * Internal init — creates DB, pre-warms connections, loads FTS, then registers pool. - * Pool entry is registered LAST so concurrent executeQuery calls see either - * "not initialized" (and throw) or a fully ready pool — never a half-built one. - */ -async function doInitLbug(repoId: string, dbPath: string): Promise { - // Check if database exists - try { - await fs.stat(dbPath); - } catch { - throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`); - } - - evictLRU(); - - // Reuse an existing native Database if another repoId already opened this path. - // This prevents buffer manager exhaustion from multiple mmap regions on the same file. - let shared = dbCache.get(dbPath); - if (!shared) { - // Open in read-only mode — MCP server never writes to the database. - // This allows multiple MCP server instances to read concurrently, and - // avoids lock conflicts when `gitnexus analyze` is writing. - let lastError: Error | null = null; - for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) { - silenceStdout(); - try { - const db = new lbug.Database( - dbPath, - 0, // bufferManagerSize (default) - false, // enableCompression (default) - true, // readOnly - ); - restoreStdout(); - shared = { db, refCount: 0, ftsLoaded: false }; - dbCache.set(dbPath, shared); - break; - } catch (err: any) { - restoreStdout(); - lastError = err instanceof Error ? err : new Error(String(err)); - const isLockError = - lastError.message.includes('Could not set lock') || lastError.message.includes('lock'); - if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break; - await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt)); - } - } - - if (!shared) { - throw new Error( - `LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` + - `Retry later. (${lastError?.message || 'unknown error'})`, - ); - } - } - - shared.refCount++; - const db = shared.db; - - // Pre-create the full pool upfront so createConnection() (which silences - // stdout) is never called lazily during active query execution. - // Mark preWarmActive so the watchdog timer doesn't interfere. - preWarmActive = true; - const available: lbug.Connection[] = []; - try { - for (let i = 0; i < MAX_CONNS_PER_REPO; i++) { - available.push(createConnection(db)); - } - } finally { - preWarmActive = false; - } - - // Load FTS extension once per shared Database. - // Done BEFORE pool registration so no concurrent checkout can grab - // the connection while the async FTS load is in progress. - if (!shared.ftsLoaded) { - try { - await available[0].query('LOAD EXTENSION fts'); - shared.ftsLoaded = true; - } catch { - // Extension may not be installed — FTS queries will fail gracefully - } - } - - // Register pool entry only after all connections are pre-warmed and FTS is - // loaded. Concurrent executeQuery calls see either "not initialized" - // (and throw cleanly) or a fully ready pool — never a half-built one. - pool.set(repoId, { - db, - available, - checkedOut: 0, - waiters: [], - lastUsed: Date.now(), - dbPath, - closed: false, - }); - ensureIdleTimer(); -} - -/** - * Initialize a pool entry from a pre-existing Database object. - * - * Used in tests to avoid the writable→close→read-only cycle that crashes - * on macOS due to N-API destructor segfaults. The pool adapter reuses - * the core adapter's writable Database instead of opening a new read-only one. - * - * The Database is registered in the shared dbCache so closeOne() decrements - * the refCount correctly. If the Database is already cached (e.g. another - * repoId already injected it), the existing entry is reused. - */ -export async function initLbugWithDb( - repoId: string, - existingDb: lbug.Database, - dbPath: string, -): Promise { - const existing = pool.get(repoId); - if (existing) { - existing.lastUsed = Date.now(); - return; - } - - // Register in dbCache with external: true so other initLbug() calls - // for the same dbPath reuse this Database instead of trying to open - // a new one (which would fail with a file lock error). - // closeOne() respects the external flag and skips db.close(). - let shared = dbCache.get(dbPath); - if (!shared) { - shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true }; - dbCache.set(dbPath, shared); - } - shared.refCount++; - - const available: lbug.Connection[] = []; - preWarmActive = true; - try { - for (let i = 0; i < MAX_CONNS_PER_REPO; i++) { - available.push(createConnection(existingDb)); - } - } finally { - preWarmActive = false; - } - - // Load FTS extension if not already loaded on this Database - try { - await available[0].query('LOAD EXTENSION fts'); - } catch { - // Extension may already be loaded or not installed - } - - pool.set(repoId, { - db: existingDb, - available, - checkedOut: 0, - waiters: [], - lastUsed: Date.now(), - dbPath, - closed: false, - }); - ensureIdleTimer(); -} - -/** - * Checkout a connection from the pool. - * Returns an available connection, or creates a new one if under the cap. - * If all connections are busy and at cap, queues the caller until one is returned. - */ -function checkout(entry: PoolEntry): Promise { - // Fast path: grab an available connection - if (entry.available.length > 0) { - entry.checkedOut++; - return Promise.resolve(entry.available.pop()!); - } - - // Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here - // with fewer total connections, something leaked — surface the bug rather - // than silently creating a connection (which would silence stdout mid-query). - const totalConns = entry.available.length + entry.checkedOut; - if (totalConns < MAX_CONNS_PER_REPO) { - throw new Error( - `Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` + - `connections but found ${totalConns} (${entry.available.length} available, ` + - `${entry.checkedOut} checked out)`, - ); - } - - // At capacity — queue the caller with a timeout. - return new Promise((resolve, reject) => { - const waiter = (conn: lbug.Connection) => { - clearTimeout(timer); - resolve(conn); - }; - const timer = setTimeout(() => { - const idx = entry.waiters.indexOf(waiter); - if (idx !== -1) entry.waiters.splice(idx, 1); - reject( - new Error( - `Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`, - ), - ); - }, WAITER_TIMEOUT_MS); - entry.waiters.push(waiter); - }); -} - -/** - * Return a connection to the pool after use. - * If the pool entry was closed while the connection was checked out (e.g. - * LRU eviction), close the orphaned connection instead of returning it. - * If there are queued waiters, hand the connection directly to the next one - * instead of putting it back in the available array (avoids race conditions). - */ -function checkin(entry: PoolEntry, conn: lbug.Connection): void { - if (entry.closed) { - // Pool entry was deleted during checkout — close the orphaned connection - conn.close().catch(() => {}); - return; - } - if (entry.waiters.length > 0) { - // Hand directly to the next waiter — no intermediate available state - const waiter = entry.waiters.shift()!; - waiter(conn); - } else { - entry.checkedOut--; - entry.available.push(conn); - } -} - -/** - * Execute a query on a specific repo's connection pool. - * Automatically checks out a connection, runs the query, and returns it. - */ -/** Race a promise against a timeout */ -function withTimeout(promise: Promise, ms: number, label: string): Promise { - let timer: ReturnType; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); -} - -export const executeQuery = async (repoId: string, cypher: string): Promise => { - const entry = pool.get(repoId); - if (!entry) { - throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); - } - - if (isWriteQuery(cypher)) { - throw new Error('Write operations are not allowed. The pool adapter is read-only.'); - } - - entry.lastUsed = Date.now(); - - const conn = await checkout(entry); - silenceStdout(); - activeQueryCount++; - try { - const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query'); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - return rows; - } finally { - activeQueryCount--; - restoreStdout(); - checkin(entry, conn); - } -}; - -/** - * Execute a parameterized query on a specific repo's connection pool. - * Uses prepare/execute pattern to prevent Cypher injection. - */ -export const executeParameterized = async ( - repoId: string, - cypher: string, - params: Record, -): Promise => { - const entry = pool.get(repoId); - if (!entry) { - throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); - } - - entry.lastUsed = Date.now(); - - const conn = await checkout(entry); - silenceStdout(); - activeQueryCount++; - try { - const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare'); - if (!stmt.isSuccess()) { - const errMsg = await stmt.getErrorMessage(); - throw new Error(`Prepare failed: ${errMsg}`); - } - const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute'); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - return rows; - } finally { - activeQueryCount--; - restoreStdout(); - checkin(entry, conn); - } -}; - -/** - * Close one or all repo pools. - * If repoId is provided, close only that repo's connections. - * If omitted, close all repos. - */ -export const closeLbug = async (repoId?: string): Promise => { - if (repoId) { - closeOne(repoId); - return; - } - - for (const id of [...pool.keys()]) { - closeOne(id); - } - - if (idleTimer) { - clearInterval(idleTimer); - idleTimer = null; - } -}; - -/** - * Check if a specific repo's pool is active - */ -export const isLbugReady = (repoId: string): boolean => pool.has(repoId); - -/** Regex to detect write operations in user-supplied Cypher queries. - * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX) - * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in - * read-only mode as defense-in-depth against write procedures. */ -export const CYPHER_WRITE_RE = - /(? { if (options?.poolAdapter) { - const poolAdapter = await import('../../src/mcp/core/lbug-adapter.js'); + const poolAdapter = await import('../../src/core/lbug/pool-adapter.js'); await poolAdapter.closeLbug(repoId); } await adapter.closeLbug(); diff --git a/gitnexus/test/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts new file mode 100644 index 000000000..50878ce7d --- /dev/null +++ b/gitnexus/test/unit/group/config-parser.test.ts @@ -0,0 +1,129 @@ +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'; + +const VALID_YAML = ` +version: 1 +name: company +description: "All company microservices" +repos: + hr/hiring/backend: hr-hiring-backend + hr/hiring/ui: hr-hiring-ui +links: + - from: hr/hiring/backend + to: hr/hiring/ui + type: http + contract: "/api/users" + role: provider +packages: + hr/common: + npm: "@hr/common" +detect: + http: true + grpc: false + topics: false + shared_libs: true + embedding_fallback: false +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`; + +describe('parseGroupConfig', () => { + it('parses valid group.yaml', () => { + const config = parseGroupConfig(VALID_YAML); + expect(config.name).toBe('company'); + expect(config.version).toBe(1); + expect(Object.keys(config.repos)).toHaveLength(2); + expect(config.repos['hr/hiring/backend']).toBe('hr-hiring-backend'); + expect(config.links).toHaveLength(1); + expect(config.links[0].type).toBe('http'); + expect(config.links[0].role).toBe('provider'); + expect(config.packages['hr/common'].npm).toBe('@hr/common'); + expect(config.detect.http).toBe(true); + expect(config.detect.grpc).toBe(false); + }); + + it('applies defaults for missing optional fields', () => { + const minimal = ` +version: 1 +name: test +repos: + app: my-app +`; + const config = parseGroupConfig(minimal); + expect(config.description).toBe(''); + expect(config.links).toEqual([]); + expect(config.packages).toEqual({}); + expect(config.detect.http).toBe(true); + expect(config.matching.bm25_threshold).toBe(0.7); + }); + + it('throws on missing required fields', () => { + expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i); + expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i); + expect(() => parseGroupConfig('version: 1\nname: test')).toThrow(/repos.*required/i); + }); + + it('allows empty repos object (fresh group before first add)', () => { + const yaml = `version: 1 +name: new-group +repos: {} +`; + const config = parseGroupConfig(yaml); + expect(Object.keys(config.repos)).toHaveLength(0); + }); + + it('loadGroupConfig reads group.yaml from disk', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-group-load-')); + const yaml = `version: 1 +name: disk-test +repos: + a: repo-a +`; + await fs.writeFile(path.join(dir, 'group.yaml'), yaml, 'utf-8'); + const config = await loadGroupConfig(dir); + expect(config.name).toBe('disk-test'); + expect(config.repos.a).toBe('repo-a'); + }); + + it('throws on invalid version', () => { + expect(() => parseGroupConfig('version: 2\nname: test\nrepos:\n a: b')).toThrow(/version/i); + }); + + it('throws on invalid link role', () => { + const yaml = ` +version: 1 +name: test +repos: + a: repo-a + b: repo-b +links: + - from: a + to: b + type: http + contract: "/api" + role: invalid +`; + expect(() => parseGroupConfig(yaml)).toThrow(/role/i); + }); + + it('throws when link references non-existent repo path', () => { + const yaml = ` +version: 1 +name: test +repos: + a: repo-a +links: + - from: a + to: nonexistent + type: http + contract: "/api" + role: provider +`; + expect(() => parseGroupConfig(yaml)).toThrow(/nonexistent/i); + }); +}); diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts new file mode 100644 index 000000000..bbbe5f664 --- /dev/null +++ b/gitnexus/test/unit/group/matching.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js'; +import type { StoredContract } from '../../../src/core/group/types.js'; + +describe('normalizeContractId', () => { + it('lowercases HTTP method', () => { + expect(normalizeContractId('http::get::/api/users')).toBe('http::GET::/api/users'); + }); + + it('strips trailing slash from HTTP path', () => { + expect(normalizeContractId('http::GET::/api/users/')).toBe('http::GET::/api/users'); + }); + + it('lowercases gRPC package', () => { + expect(normalizeContractId('grpc::Hr.UserService/GetUser')).toBe( + 'grpc::hr.userservice/GetUser', + ); + }); + + it('preserves case for malformed gRPC id with leading slash (no full-string lowercasing)', () => { + expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing'); + }); + + it('trims and lowercases topic', () => { + expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired'); + }); + + it('lowercases lib package coordinates', () => { + expect(normalizeContractId('lib::@Hr/Common::UserDTO')).toBe('lib::@hr/common::userdto'); + }); +}); + +describe('runExactMatch', () => { + const makeContract = ( + id: string, + role: 'provider' | 'consumer', + repo: string, + ): StoredContract => ({ + contractId: id, + type: 'http', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.8, + meta: {}, + repo, + }); + + it('matches provider and consumer with same contract ID', () => { + const contracts: StoredContract[] = [ + makeContract('http::GET::/api/users', 'provider', 'backend'), + makeContract('http::GET::/api/users', 'consumer', 'frontend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('http::GET::/api/users'); + expect(matched[0].matchType).toBe('exact'); + expect(matched[0].confidence).toBe(1.0); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + expect(unmatched).toHaveLength(0); + }); + + it('handles multiple consumers for one provider', () => { + const contracts: StoredContract[] = [ + makeContract('http::GET::/api/users', 'provider', 'backend'), + makeContract('http::GET::/api/users', 'consumer', 'frontend'), + makeContract('http::GET::/api/users', 'consumer', 'bff'), + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(2); + }); + + it('reports unmatched contracts', () => { + const contracts: StoredContract[] = [ + makeContract('http::GET::/api/users', 'provider', 'backend'), + makeContract('http::GET::/api/orphan', 'consumer', 'frontend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + expect(matched).toHaveLength(0); + expect(unmatched).toHaveLength(2); + }); + + it('normalizes contract IDs before matching', () => { + const contracts: StoredContract[] = [ + makeContract('http::GET::/api/users/', 'provider', 'backend'), + makeContract('http::get::/api/users', 'consumer', 'frontend'), + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(1); + }); + + it('does not match contracts within the same repo', () => { + const contracts: StoredContract[] = [ + makeContract('http::GET::/api/users', 'provider', 'backend'), + makeContract('http::GET::/api/users', 'consumer', 'backend'), + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(0); + }); + + it('matches same-repo contracts with different service boundaries', () => { + const contracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/users', 'provider', 'monorepo'), + service: 'services/auth', + }, + { + ...makeContract('http::GET::/api/users', 'consumer', 'monorepo'), + service: 'services/gateway', + }, + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(1); + expect(matched[0].from.repo).toBe('monorepo'); + expect(matched[0].to.repo).toBe('monorepo'); + expect(matched[0].from.service).toBe('services/gateway'); + expect(matched[0].to.service).toBe('services/auth'); + }); + + it('does not match same-repo contracts with same service', () => { + const contracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/users', 'provider', 'monorepo'), + service: 'services/auth', + }, + { + ...makeContract('http::GET::/api/users', 'consumer', 'monorepo'), + service: 'services/auth', + }, + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(0); + }); + + it('does not match same-repo when only one has service', () => { + const contracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/users', 'provider', 'monorepo'), + service: 'services/auth', + }, + makeContract('http::GET::/api/users', 'consumer', 'monorepo'), + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(0); + }); + + it('cross-repo matching works regardless of service field', () => { + const contracts: StoredContract[] = [ + { ...makeContract('http::GET::/api/users', 'provider', 'backend'), service: 'services/auth' }, + { ...makeContract('http::GET::/api/users', 'consumer', 'frontend'), service: 'services/web' }, + ]; + + const { matched } = runExactMatch(contracts); + expect(matched).toHaveLength(1); + expect(matched[0].from.service).toBe('services/web'); + expect(matched[0].to.service).toBe('services/auth'); + }); + + it('matches consumer http::*::path to a concrete provider method on that path', () => { + const contracts: StoredContract[] = [ + makeContract('http::POST::/api/users', 'provider', 'backend'), + makeContract('http::*::/api/users', 'consumer', 'frontend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('http::*::/api/users'); + expect(matched[0].to.repo).toBe('backend'); + expect(unmatched).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/group/storage.test.ts b/gitnexus/test/unit/group/storage.test.ts new file mode 100644 index 000000000..700e91d7c --- /dev/null +++ b/gitnexus/test/unit/group/storage.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { + getGroupDir, + getGroupsBaseDir, + writeContractRegistry, + readContractRegistry, + listGroups, +} from '../../../src/core/group/storage.js'; +import type { ContractRegistry } from '../../../src/core/group/types.js'; + +describe('Group storage', () => { + const tmpDir = path.join(os.tmpdir(), `gitnexus-test-storage-${Date.now()}`); + + beforeEach(() => { + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('getGroupsBaseDir returns ~/.gitnexus/groups/', () => { + const base = getGroupsBaseDir(tmpDir); + expect(base).toBe(path.join(tmpDir, 'groups')); + }); + + it('getGroupDir returns correct path for group name', () => { + const dir = getGroupDir(tmpDir, 'company'); + expect(dir).toBe(path.join(tmpDir, 'groups', 'company')); + }); + + it('writeContractRegistry writes atomically and readContractRegistry reads back', async () => { + const groupDir = path.join(tmpDir, 'groups', 'test-group'); + fs.mkdirSync(groupDir, { recursive: true }); + + const registry: ContractRegistry = { + version: 1, + generatedAt: '2026-03-31T10:00:00Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [], + crossLinks: [], + }; + + await writeContractRegistry(groupDir, registry); + + const filePath = path.join(groupDir, 'contracts.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const loaded = await readContractRegistry(groupDir); + expect(loaded?.version).toBe(1); + expect(loaded?.generatedAt).toBe('2026-03-31T10:00:00Z'); + }); + + it('readContractRegistry returns null when file does not exist', async () => { + const groupDir = path.join(tmpDir, 'groups', 'nonexistent'); + fs.mkdirSync(groupDir, { recursive: true }); + const result = await readContractRegistry(groupDir); + expect(result).toBeNull(); + }); + + it('listGroups returns group names', async () => { + const groupsDir = path.join(tmpDir, 'groups'); + fs.mkdirSync(path.join(groupsDir, 'company'), { recursive: true }); + fs.writeFileSync( + path.join(groupsDir, 'company', 'group.yaml'), + 'version: 1\nname: company\nrepos:\n a: b', + ); + fs.mkdirSync(path.join(groupsDir, 'personal'), { recursive: true }); + fs.writeFileSync( + path.join(groupsDir, 'personal', 'group.yaml'), + 'version: 1\nname: personal\nrepos:\n c: d', + ); + + const groups = await listGroups(tmpDir); + expect(groups.sort()).toEqual(['company', 'personal']); + }); +}); diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts new file mode 100644 index 000000000..df952e598 --- /dev/null +++ b/gitnexus/test/unit/group/types.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import type { + GroupConfig, + ContractType, + ExtractedContract, + CrossLink, + ContractRegistry, + GroupManifestLink, + MatchType, +} from '../../../src/core/group/types.js'; + +describe('Group types', () => { + it('GroupConfig has required fields', () => { + const config: GroupConfig = { + version: 1, + name: 'company', + description: 'All company microservices', + repos: { 'hr/hiring/backend': 'hr-hiring-backend' }, + links: [], + packages: {}, + detect: { + http: true, + grpc: true, + topics: true, + shared_libs: true, + embedding_fallback: true, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + expect(config.version).toBe(1); + expect(config.name).toBe('company'); + }); + + it('ContractRegistry has required structure', () => { + const registry: ContractRegistry = { + version: 1, + generatedAt: '2026-03-31T10:00:00Z', + repoSnapshots: { + 'hr/hiring/backend': { indexedAt: '2026-03-30T21:14:14Z', lastCommit: '5838fb8d' }, + }, + missingRepos: [], + contracts: [], + crossLinks: [], + }; + expect(registry.version).toBe(1); + expect(registry.contracts).toHaveLength(0); + }); + + it('ExtractedContract accepts all contract types', () => { + const types: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom']; + types.forEach((t) => { + const contract: ExtractedContract = { + contractId: `${t}::test`, + type: t, + role: 'provider', + symbolUid: 'uid-123', + symbolRef: { filePath: 'src/test.ts', name: 'testFn' }, + symbolName: 'testFn', + confidence: 1.0, + meta: {}, + }; + expect(contract.type).toBe(t); + }); + }); + + it('CrossLink stores match metadata', () => { + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'backend', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/ctrl.ts', name: 'UserController.list' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + const _m: MatchType = link.matchType; + expect(_m).toBe('exact'); + }); + + it('GroupManifestLink is valid', () => { + const l: GroupManifestLink = { + from: 'a', + to: 'b', + type: 'http', + contract: '/x', + role: 'provider', + }; + expect(l.contract).toBe('/x'); + }); +}); diff --git a/gitnexus/test/unit/staleness.test.ts b/gitnexus/test/unit/staleness.test.ts index 59d3d7b83..b8ea398ff 100644 --- a/gitnexus/test/unit/staleness.test.ts +++ b/gitnexus/test/unit/staleness.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; import { execFileSync } from 'child_process'; -import { checkStaleness } from '../../src/mcp/staleness.js'; +import { checkStaleness } from '../../src/core/git-staleness.js'; // We test checkStaleness with a real git repo (the project itself) // since mocking execFileSync across ESM modules is complex. From e114194c1f2cbf49733228c49f4b5af1c7251c21 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 00:40:12 +0300 Subject: [PATCH 2/5] feat(group): add service boundary detection and contract extractors Service communication detection for microservice monorepos: - ServiceBoundaryDetector: auto-detects service boundaries via markers (package.json, go.mod, Dockerfile, pom.xml, Cargo.toml, build.gradle, pyproject.toml, etc.) - HttpRouteExtractor: graph-assisted (Strategy A) with source-scan fallback (Strategy B) for Spring, Express, Laravel, FastAPI providers and fetch/axios consumers - GrpcExtractor: parses .proto files, detects Go/Java/Python/TS gRPC servers (RegisterXxxServer, @GrpcService, add_XxxServicer_to_server, @GrpcMethod) and clients (NewXxxClient, newBlockingStub, XxxStub) - TopicExtractor: Kafka (@KafkaListener, producer.send), RabbitMQ (@RabbitListener, channel.publish/consume), NATS (nc.Subscribe/Publish) across Java, Node, Go, and Python Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/group/extractors/grpc-extractor.ts | 332 ++++++++++++ .../group/extractors/http-route-extractor.ts | 475 ++++++++++++++++++ .../core/group/extractors/topic-extractor.ts | 277 ++++++++++ .../core/group/service-boundary-detector.ts | 160 ++++++ .../test/unit/group/grpc-extractor.test.ts | 275 ++++++++++ .../unit/group/http-route-extractor.test.ts | 366 ++++++++++++++ .../group/service-boundary-detector.test.ts | 215 ++++++++ .../test/unit/group/topic-extractor.test.ts | 313 ++++++++++++ 8 files changed, 2413 insertions(+) create mode 100644 gitnexus/src/core/group/extractors/grpc-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/http-route-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/topic-extractor.ts create mode 100644 gitnexus/src/core/group/service-boundary-detector.ts create mode 100644 gitnexus/test/unit/group/grpc-extractor.test.ts create mode 100644 gitnexus/test/unit/group/http-route-extractor.test.ts create mode 100644 gitnexus/test/unit/group/service-boundary-detector.test.ts create mode 100644 gitnexus/test/unit/group/topic-extractor.test.ts diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts new file mode 100644 index 000000000..465d1f2bb --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -0,0 +1,332 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function contractId(pkg: string, service: string, method: string): string { + const prefix = pkg ? `${pkg}.${service}` : service; + return `grpc::${prefix}/${method}`; +} + +function serviceOnlyContractId(serviceName: string): string { + return `grpc::${serviceName}/*`; +} + +function makeContract( + cid: string, + role: 'provider' | 'consumer', + filePath: string, + symbolName: string, + confidence: number, + meta: Record, +): ExtractedContract { + return { + contractId: cid, + type: 'grpc', + role, + symbolUid: '', + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, + symbolName, + confidence, + meta: { ...meta, extractionStrategy: 'source_scan' }, + }; +} + +export class GrpcExtractor implements ContractExtractor { + type = 'grpc' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + _dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + const out: ExtractedContract[] = []; + + // Proto files — definitive provider source + const protoFiles = await glob('**/*.proto', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], + nodir: true, + }); + for (const rel of protoFiles) { + const content = readSafe(repoPath, rel); + if (content) out.push(...this.parseProtoFile(content, rel)); + } + + // Source files — server/client detection + const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + for (const rel of sourceFiles) { + const content = readSafe(repoPath, rel); + if (!content) continue; + const ext = path.extname(rel).toLowerCase(); + + if (ext === '.go') { + out.push(...this.scanGoProviders(content, rel)); + out.push(...this.scanGoConsumers(content, rel)); + } else if (ext === '.java') { + out.push(...this.scanJavaProviders(content, rel)); + out.push(...this.scanJavaConsumers(content, rel)); + } else if (ext === '.py') { + out.push(...this.scanPythonProviders(content, rel)); + out.push(...this.scanPythonConsumers(content, rel)); + } else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { + out.push(...this.scanTsProviders(content, rel)); + } + } + + return this.dedupe(out); + } + + private parseProtoFile(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); + const pkg = pkgMatch ? pkgMatch[1] : ''; + + const serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs; + let svcMatch: RegExpExecArray | null; + while ((svcMatch = serviceRe.exec(content)) !== null) { + const serviceName = svcMatch[1]; + const body = svcMatch[2]; + + const rpcRe = /rpc\s+(\w+)\s*\(/g; + let rpcMatch: RegExpExecArray | null; + while ((rpcMatch = rpcRe.exec(body)) !== null) { + const methodName = rpcMatch[1]; + const cid = contractId(pkg, serviceName, methodName); + out.push( + makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, { + package: pkg, + service: serviceName, + method: methodName, + source: 'proto', + }), + ); + } + } + + return out; + } + + private scanGoProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + // pb.RegisterXxxServer( + const registerRe = /\w+\.Register(\w+)Server\s*\(/g; + let m: RegExpExecArray | null; + while ((m = registerRe.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `Register${serviceName}Server`, + 0.8, + { service: serviceName, source: 'go_register' }, + ), + ); + } + + // pb.UnimplementedXxxServer + const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g; + while ((m = unimplRe.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `Unimplemented${serviceName}Server`, + 0.8, + { service: serviceName, source: 'go_unimplemented' }, + ), + ); + } + + return out; + } + + private scanGoConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /\w+\.New(\w+)Client\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'consumer', + filePath, + `New${serviceName}Client`, + 0.7, + { service: serviceName, source: 'go_client' }, + ), + ); + } + return out; + } + + private scanJavaProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + // @GrpcService + if (content.includes('@GrpcService')) { + const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/; + const m = content.match(implBaseRe); + if (m) { + out.push( + makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, { + service: m[1], + source: 'java_grpc_service', + }), + ); + } else { + // Try extracting service name from class name + const classRe = + /class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/; + const cm = content.match(classRe); + if (cm) { + const svcName = cm[2].replace(/Grpc$/, ''); + out.push( + makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, { + service: svcName, + source: 'java_grpc_service', + }), + ); + } + } + } + + // extends XxxImplBase (without @GrpcService) + if (!content.includes('@GrpcService')) { + const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/; + const m = content.match(implRe); + if (m) { + const svcName = m[2] || m[1].replace(/Grpc$/, ''); + out.push( + makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, { + service: svcName, + source: 'java_impl_base', + }), + ); + } + } + + return out; + } + + private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // XxxGrpc.newBlockingStub( or XxxGrpc.newStub( + const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'consumer', + filePath, + `${serviceName}Stub`, + 0.7, + { service: serviceName, source: 'java_stub' }, + ), + ); + } + return out; + } + + private scanPythonProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // add_XxxServicer_to_server( + const re = /add_(\w+?)Servicer_to_server\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `add_${serviceName}Servicer_to_server`, + 0.8, + { service: serviceName, source: 'python_servicer' }, + ), + ); + } + return out; + } + + private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // XxxStub( + const re = /(\w+)Stub\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const name = m[1]; + // Filter out common false positives + if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue; + out.push( + makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, { + service: name, + source: 'python_stub', + }), + ); + } + return out; + } + + private scanTsProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // @GrpcMethod('ServiceName', 'MethodName') + const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + const methodName = m[2]; + const cid = contractId('', serviceName, methodName); + out.push( + makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, { + service: serviceName, + method: methodName, + source: 'ts_grpc_method', + }), + ); + } + return out; + } + + private dedupe(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } +} diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts new file mode 100644 index 000000000..8dfb242bf --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -0,0 +1,475 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +const HANDLES_ROUTE_QUERY = ` +MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) +RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath, + route.name AS routePath, route.id AS routeId, + route.responseKeys AS responseKeys, + r.reason AS routeSource`; + +const FETCHES_QUERY = ` +MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route) +RETURN callerFile.id AS fileId, callerFile.filePath AS filePath, + route.name AS routePath, route.id AS routeId, + r.reason AS fetchReason`; + +const CONTAINS_QUERY = ` +MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym) +WHERE sym.startLine IS NOT NULL +RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels +ORDER BY sym.startLine`; + +export function normalizeHttpPath(p: string): string { + let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, ''); + s = s.replace(/:\w+/g, '{param}'); + s = s.replace(/\{[^}]+\}/g, '{param}'); + s = s.replace(/\[[^\]]+\]/g, '{param}'); + return s; +} + +function methodFromRouteReason(reason: string): string | null { + const r = reason || ''; + if (/GetMapping|decorator-Get/i.test(r)) return 'GET'; + if (/PostMapping|decorator-Post/i.test(r)) return 'POST'; + if (/PutMapping|decorator-Put/i.test(r)) return 'PUT'; + if (/DeleteMapping|decorator-Delete/i.test(r)) return 'DELETE'; + if (/PatchMapping|decorator-Patch/i.test(r)) return 'PATCH'; + return null; +} + +function contractIdFor(method: string, pathNorm: string): string { + return `http::${method.toUpperCase()}::${pathNorm}`; +} + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function pickJavaHandlerName( + content: string, + routePath: string, + httpMethod: string, +): string | null { + const tail = routePath.split('/').filter(Boolean).pop() || ''; + const mapNames: Record = { + GET: 'GetMapping', + POST: 'PostMapping', + PUT: 'PutMapping', + DELETE: 'DeleteMapping', + PATCH: 'PatchMapping', + }; + const ann = mapNames[httpMethod] || 'GetMapping'; + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes(`@${ann}`)) continue; + if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail)) + continue; + for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) { + const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); + if (m) return m[1]; + } + } + return null; +} + +function pickSymbolUid( + rows: Record[], + preferredName: string | null, +): { uid: string; name: string; filePath: string } { + const norm = (x: unknown) => String(x ?? ''); + const labeled = rows.filter((r) => { + const labels = r.labels ?? r[3]; + const s = JSON.stringify(labels); + return s.includes('Method') || s.includes('Function'); + }); + const pool = labeled.length > 0 ? labeled : rows; + if (preferredName) { + const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName); + if (hit) { + return { + uid: norm(hit.uid ?? hit[0]), + name: norm(hit.name ?? hit[1]), + filePath: norm(hit.filePath ?? hit[2]), + }; + } + } + const first = pool[0] || rows[0]; + return { + uid: norm(first?.uid ?? first?.[0]), + name: norm(first?.name ?? first?.[1]), + filePath: norm(first?.filePath ?? first?.[2]), + }; +} + +export class HttpRouteExtractor implements ContractExtractor { + type = 'http' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + dbExecutor: CypherExecutor | null, + repoPath: string, + repo: RepoHandle, + ): Promise { + const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : []; + const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath); + + const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : []; + const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath); + + return [...providers, ...consumers]; + } + + private async extractProvidersGraph( + db: CypherExecutor, + repoPath: string, + ): Promise { + const out: ExtractedContract[] = []; + let rows: Record[]; + try { + rows = await db(HANDLES_ROUTE_QUERY); + } catch { + return []; + } + + for (const row of rows) { + const filePath = String(row.filePath ?? ''); + const routePath = String(row.routePath ?? ''); + const routeSource = String(row.routeSource ?? row.routeReason ?? ''); + let method = methodFromRouteReason(routeSource); + const content = readSafe(repoPath, filePath); + if (!method && content) { + method = this.inferMethodFromFileScan(content, routePath, 'provider'); + } + if (!method) method = 'GET'; + + const pathNorm = normalizeHttpPath(routePath); + const cid = contractIdFor(method, pathNorm); + const handlerName = + content && routePath ? pickJavaHandlerName(content, routePath, method) : null; + + let symbolUid = ''; + let symbolName = path.basename(filePath) || 'handler'; + let symPath = filePath; + const fileId = row.fileId ?? row[0]; + if (fileId) { + try { + const syms = await db(CONTAINS_QUERY, { fileId }); + if (syms.length > 0) { + const picked = pickSymbolUid(syms, handlerName); + symbolUid = picked.uid; + symbolName = picked.name; + symPath = picked.filePath || filePath; + } + } catch { + /* ignore */ + } + } + + out.push({ + contractId: cid, + type: 'http', + role: 'provider', + symbolUid, + symbolRef: { filePath: symPath, name: symbolName }, + symbolName, + confidence: 0.9, + meta: { + method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'graph_assisted', + routeSource, + }, + }); + } + return out; + } + + private inferMethodFromFileScan( + content: string, + routePath: string, + _role: string, + ): string | null { + const tail = routePath.split('/').filter(Boolean).pop() || ''; + for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) { + const mapNames: Record = { + GET: 'GetMapping', + POST: 'PostMapping', + PUT: 'PutMapping', + DELETE: 'DeleteMapping', + PATCH: 'PatchMapping', + }; + if ( + content.includes(`@${mapNames[m]}`) && + (content.includes(tail) || routePath.includes(tail)) + ) { + return m; + } + } + return null; + } + + private async extractProvidersSourceScan(repoPath: string): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanSpringProviders(content, rel)); + out.push(...this.scanExpressProviders(content, rel)); + out.push(...this.scanLaravelProviders(content, rel)); + out.push(...this.scanFastApiProviders(content, rel)); + } + return this.dedupeContracts(out); + } + + private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } + + private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + let classPrefix = ''; + const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); + if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); + + const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + let p = m[2]; + if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`; + const pathNorm = normalizeHttpPath(p); + const sub = content.slice(m.index); + const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); + const name = nameM ? nameM[1] : m[0]; + out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8)); + } + return out; + } + + private scanExpressProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + const pathNorm = normalizeHttpPath(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); + } + return out; + } + + private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + const pathNorm = normalizeHttpPath(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8)); + } + return out; + } + + private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + const pathNorm = normalizeHttpPath(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); + } + return out; + } + + private makeProvider( + filePath: string, + method: string, + pathNorm: string, + name: string, + confidence: number, + ): ExtractedContract { + const cid = contractIdFor(method, pathNorm); + return { + contractId: cid, + type: 'http', + role: 'provider', + symbolUid: '', + symbolRef: { filePath, name }, + symbolName: name, + confidence, + meta: { + method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'source_scan', + }, + }; + } + + private async extractConsumersGraph( + db: CypherExecutor, + repoPath: string, + ): Promise { + const out: ExtractedContract[] = []; + let rows: Record[]; + try { + rows = await db(FETCHES_QUERY); + } catch { + return []; + } + for (const row of rows) { + const filePath = String(row.filePath ?? ''); + const routePath = String(row.routePath ?? ''); + const pathNorm = normalizeHttpPath(routePath); + let method = 'GET'; + const content = readSafe(repoPath, filePath); + if (content) { + const inferred = this.inferFetchMethod(content, pathNorm); + if (inferred) method = inferred; + } + const cid = contractIdFor(method, pathNorm); + let symbolUid = ''; + let symbolName = 'fetch'; + let symPath = filePath; + const fileId = row.fileId ?? row[0]; + if (fileId) { + try { + const syms = await db(CONTAINS_QUERY, { fileId }); + if (syms.length > 0) { + const picked = pickSymbolUid(syms, null); + symbolUid = picked.uid; + symbolName = picked.name; + symPath = picked.filePath || filePath; + } + } catch { + /* ignore */ + } + } + out.push({ + contractId: cid, + type: 'http', + role: 'consumer', + symbolUid, + symbolRef: { filePath: symPath, name: symbolName }, + symbolName, + confidence: 0.9, + meta: { + method, + path: pathNorm, + extractionStrategy: 'graph_assisted', + fetchReason: String(row.fetchReason ?? ''), + }, + }); + } + return out; + } + + private inferFetchMethod(content: string, pathNorm: string): string | null { + const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fetchRe = new RegExp( + `fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`, + 'i', + ); + const m = content.match(fetchRe); + if (m) return m[2].toUpperCase(); + return null; + } + + private async extractConsumersSourceScan(repoPath: string): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**'], + nodir: true, + }); + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanFetchConsumers(content, rel)); + out.push(...this.scanAxiosConsumers(content, rel)); + } + return this.dedupeContracts(out); + } + + private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = + /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const pathNorm = normalizeHttpPath(this.templateToPattern(m[1])); + const method = (m[2] || 'GET').toUpperCase(); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + } + return out; + } + + private templateToPattern(url: string): string { + return url.replace(/\$\{[^}]+\}/g, '{param}'); + } + + private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + const pathNorm = normalizeHttpPath(this.templateToPattern(m[2])); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + } + return out; + } + + private makeConsumer( + filePath: string, + method: string, + pathNorm: string, + confidence: number, + ): ExtractedContract { + return { + contractId: contractIdFor(method, pathNorm), + type: 'http', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath, name: 'fetch' }, + symbolName: 'fetch', + confidence, + meta: { + method, + path: pathNorm, + extractionStrategy: 'source_scan', + }, + }; + } +} diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts new file mode 100644 index 000000000..c27b419bb --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -0,0 +1,277 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +type Broker = 'kafka' | 'rabbitmq' | 'nats'; + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function makeContract( + topicName: string, + role: 'provider' | 'consumer', + filePath: string, + symbolName: string, + confidence: number, + broker: Broker, +): ExtractedContract { + return { + contractId: `topic::${topicName}`, + type: 'topic', + role, + symbolUid: '', + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, + symbolName, + confidence, + meta: { + broker, + topicName, + extractionStrategy: 'source_scan', + }, + }; +} + +interface PatternDef { + regex: RegExp; + role: 'provider' | 'consumer'; + broker: Broker; + confidence: number; + topicGroup: number; + symbolName: string; +} + +// --- Kafka patterns --- +const KAFKA_PATTERNS: PatternDef[] = [ + // Java: @KafkaListener(topics = "xxx") + { + regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'kafkaListener', + }, + // Java: kafkaTemplate.send("xxx" + { + regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi, + role: 'provider', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'kafkaTemplate.send', + }, + // Node: producer.send({ topic: 'xxx' + { + regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'producer.send', + }, + // Node: consumer.subscribe({ topic: 'xxx' + { + regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'consumer.subscribe', + }, + // Go: consumer.ConsumePartition("xxx" + { + regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'ConsumePartition', + }, + // Python: KafkaConsumer('xxx' + { + regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'KafkaConsumer', + }, + // Python: producer.send('xxx' or producer.produce('xxx' + { + regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'producer.send', + }, +]; + +// --- RabbitMQ patterns --- +const RABBITMQ_PATTERNS: PatternDef[] = [ + // Java: @RabbitListener(queues = "xxx") + { + regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'rabbitListener', + }, + // Java: rabbitTemplate.convertAndSend("xxx" + { + regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'rabbitTemplate.convertAndSend', + }, + // Node: channel.consume("xxx" + { + regex: /channel\.consume\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.consume', + }, + // Node: channel.publish("xxx" + { + regex: /channel\.publish\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.publish', + }, + // Node: channel.sendToQueue("xxx" + { + regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.sendToQueue', + }, + // Python: channel.basic_consume(queue='xxx' + { + regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.7, + topicGroup: 1, + symbolName: 'basic_consume', + }, + // Python: channel.basic_publish(exchange='xxx' + { + regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.7, + topicGroup: 1, + symbolName: 'basic_publish', + }, +]; + +// --- NATS patterns --- +const NATS_PATTERNS: PatternDef[] = [ + // Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx" + { + regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'nats', + confidence: 0.8, + topicGroup: 1, + symbolName: 'nc.Subscribe', + }, + // Go/Node: nc.Publish("xxx" or nc.publish("xxx" + { + regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'nats', + confidence: 0.8, + topicGroup: 1, + symbolName: 'nc.Publish', + }, +]; + +const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS]; + +export class TopicExtractor implements ContractExtractor { + type = 'topic' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + _dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanFile(content, rel)); + } + + return this.dedupe(out); + } + + private scanFile(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + for (const pattern of ALL_PATTERNS) { + // Reset regex state for each file + const re = new RegExp(pattern.regex.source, pattern.regex.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const topicName = m[pattern.topicGroup]; + if (!topicName) continue; + out.push( + makeContract( + topicName, + pattern.role, + filePath, + pattern.symbolName, + pattern.confidence, + pattern.broker, + ), + ); + } + } + + return out; + } + + private dedupe(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } +} diff --git a/gitnexus/src/core/group/service-boundary-detector.ts b/gitnexus/src/core/group/service-boundary-detector.ts new file mode 100644 index 000000000..4b42073d3 --- /dev/null +++ b/gitnexus/src/core/group/service-boundary-detector.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface ServiceBoundary { + servicePath: string; + serviceName: string; + markers: string[]; + confidence: number; +} + +const SERVICE_MARKERS = [ + 'package.json', + 'go.mod', + 'Dockerfile', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'Cargo.toml', + 'pyproject.toml', + 'requirements.txt', + 'mix.exs', +] as const; + +const SOURCE_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.go', + '.java', + '.kt', + '.kts', + '.py', + '.pyi', + '.rs', + '.c', + '.cpp', + '.h', + '.hpp', + '.cs', + '.rb', + '.php', + '.swift', + '.dart', + '.ex', + '.exs', + '.erl', + '.proto', +]); + +export async function detectServiceBoundaries(repoPath: string): Promise { + const boundaries: ServiceBoundary[] = []; + await walkForBoundaries(repoPath, repoPath, boundaries); + return boundaries; +} + +async function walkForBoundaries( + dir: string, + repoRoot: string, + results: ServiceBoundary[], +): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + const isRoot = path.resolve(dir) === path.resolve(repoRoot); + + const foundMarkers: string[] = []; + let hasSourceFiles = false; + const subdirs: string[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + + if (entry.isDirectory()) { + subdirs.push(path.join(dir, entry.name)); + } else if (entry.isFile()) { + if (SERVICE_MARKERS.includes(entry.name as (typeof SERVICE_MARKERS)[number])) { + foundMarkers.push(entry.name); + } + const ext = path.extname(entry.name).toLowerCase(); + if (SOURCE_EXTENSIONS.has(ext)) { + hasSourceFiles = true; + } + } + } + + // Check subdirectories for source files if not found at this level + if (!hasSourceFiles && foundMarkers.length > 0) { + hasSourceFiles = await hasSourceFilesInSubdirs(subdirs); + } + + if (!isRoot && foundMarkers.length >= 1 && hasSourceFiles) { + const relativePath = path.relative(repoRoot, dir).replace(/\\/g, '/'); + const serviceName = path.basename(dir); + const confidence = computeConfidence(foundMarkers.length); + + results.push({ + servicePath: relativePath, + serviceName, + markers: foundMarkers, + confidence, + }); + } + + // Recurse into subdirectories + for (const subdir of subdirs) { + await walkForBoundaries(subdir, repoRoot, results); + } +} + +async function hasSourceFilesInSubdirs(subdirs: string[]): Promise { + for (const subdir of subdirs) { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(subdir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (SOURCE_EXTENSIONS.has(ext)) return true; + } + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { + const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]); + if (deeper) return true; + } + } + } + return false; +} + +function computeConfidence(markerCount: number): number { + if (markerCount >= 3) return 1.0; + if (markerCount === 2) return 0.9; + return 0.75; +} + +export function assignService(filePath: string, boundaries: ServiceBoundary[]): string | undefined { + const normalized = filePath.replace(/\\/g, '/'); + + let bestMatch: ServiceBoundary | undefined; + let bestLength = 0; + + for (const boundary of boundaries) { + const prefix = boundary.servicePath + '/'; + if (normalized.startsWith(prefix) && boundary.servicePath.length > bestLength) { + bestMatch = boundary; + bestLength = boundary.servicePath.length; + } + } + + return bestMatch?.servicePath; +} diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts new file mode 100644 index 000000000..c8a3c18e2 --- /dev/null +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('GrpcExtractor', () => { + let tmpDir: string; + let extractor: GrpcExtractor; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-grpc-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + extractor = new GrpcExtractor(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content: string): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/app', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + describe('proto file parsing', () => { + it('test_extract_proto_service_single_rpc_returns_provider', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::auth.AuthService/Login'); + expect(providers[0].confidence).toBe(0.85); + expect(providers[0].symbolRef.filePath).toBe('proto/auth.proto'); + }); + + it('test_extract_proto_service_multiple_rpcs_returns_all', async () => { + writeFile( + 'api/user.proto', + `syntax = "proto3"; +package hr.user.v1; +service UserService { + rpc GetUser (GetUserRequest) returns (UserResponse); + rpc ListUsers (ListUsersRequest) returns (ListUsersResponse); + rpc DeleteUser (DeleteUserRequest) returns (Empty); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(3); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::hr.user.v1.UserService/DeleteUser', + 'grpc::hr.user.v1.UserService/GetUser', + 'grpc::hr.user.v1.UserService/ListUsers', + ]); + }); + + it('test_extract_proto_without_package_uses_service_only', async () => { + writeFile( + 'service.proto', + `syntax = "proto3"; +service HealthCheck { + rpc Check (HealthRequest) returns (HealthResponse); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(1); + expect(contracts[0].contractId).toBe('grpc::HealthCheck/Check'); + }); + }); + + describe('Go server detection', () => { + it('test_extract_go_register_server_returns_provider', async () => { + writeFile( + 'cmd/server/main.go', + `package main + +import pb "example.com/proto/auth" + +func main() { + srv := grpc.NewServer() + pb.RegisterAuthServiceServer(srv, &authServer{}) + srv.Serve(lis) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('grpc::'); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_go_unimplemented_server_returns_provider', async () => { + writeFile( + 'internal/server.go', + `package server + +type authServer struct { + pb.UnimplementedAuthServiceServer +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + }); + }); + + describe('Go client detection', () => { + it('test_extract_go_new_client_returns_consumer', async () => { + writeFile( + 'internal/client.go', + `package client + +import pb "example.com/proto/auth" + +func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient { + return pb.NewAuthServiceClient(conn) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('Java detection', () => { + it('test_extract_java_grpc_service_annotation_returns_provider', async () => { + writeFile( + 'src/main/java/AuthGrpcService.java', + `@GrpcService +public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { + @Override + public void login(LoginRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_java_blocking_stub_returns_consumer', async () => { + writeFile( + 'src/main/java/AuthClient.java', + `public class AuthClient { + private final AuthServiceGrpc.AuthServiceBlockingStub stub; + public AuthClient(ManagedChannel ch) { + this.stub = AuthServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('Python detection', () => { + it('test_extract_python_add_servicer_returns_provider', async () => { + writeFile( + 'server.py', + `import grpc +from proto import auth_pb2_grpc + +def serve(): + server = grpc.server(futures.ThreadPoolExecutor()) + auth_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(), server) + server.start()`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_python_stub_returns_consumer', async () => { + writeFile( + 'client.py', + `import grpc +from proto import auth_pb2_grpc + +channel = grpc.insecure_channel('localhost:50051') +stub = auth_pb2_grpc.AuthServiceStub(channel)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('TypeScript/Node detection', () => { + it('test_extract_ts_grpc_method_decorator_returns_provider', async () => { + writeFile( + 'src/auth.controller.ts', + `import { GrpcMethod } from '@nestjs/microservices'; + +export class AuthController { + @GrpcMethod('AuthService', 'Login') + login(data: LoginRequest): LoginResponse { + return {}; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].contractId).toContain('Login'); + expect(providers[0].confidence).toBe(0.8); + }); + }); + + describe('edge cases', () => { + it('test_extract_empty_repo_returns_empty', async () => { + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_repo_without_grpc_returns_empty', async () => { + writeFile('src/index.ts', 'console.log("hello")'); + writeFile('package.json', '{}'); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts new file mode 100644 index 000000000..2290c806a --- /dev/null +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('HttpRouteExtractor', () => { + const tmpDir = path.join(os.tmpdir(), `gitnexus-http-extract-${Date.now()}`); + let extractor: HttpRouteExtractor; + + beforeEach(() => { + extractor = new HttpRouteExtractor(); + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/backend', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + describe('provider extraction — graph-first (Strategy A)', () => { + it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => { + const dir = path.join(tmpDir, 'graph-first'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +@RestController +@RequestMapping("/api/v2") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } + + @PostMapping("/users") + public User create(@RequestBody User user) { return service.save(user); } +} +`, + ); + + const mockDbExecutor = async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'file-uid-ctrl', + filePath: 'src/controller/UserController.java', + routePath: '/api/v2/users', + routeId: 'route-uid-users', + responseKeys: null, + routeSource: 'decorator-GetMapping', + }, + ]; + } + if (query.includes('CONTAINS')) { + return [ + { + uid: 'uid-ctrl-list', + name: 'list', + filePath: 'src/controller/UserController.java', + labels: ['Method'], + }, + { + uid: 'uid-ctrl-create', + name: 'create', + filePath: 'src/controller/UserController.java', + labels: ['Method'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const getRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(getRoute).toBeDefined(); + expect(getRoute!.confidence).toBe(0.9); + expect(getRoute!.symbolUid).not.toBe('file-uid-ctrl'); + }); + }); + + describe('provider extraction — source-scan fallback (Strategy B)', () => { + it('extracts Spring @GetMapping annotation', async () => { + const dir = path.join(tmpDir, 'spring'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v2") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } + + @PostMapping("/users") + public User create(@RequestBody User user) { return service.save(user); } + + @GetMapping("/users/{id}") + public User getById(@PathVariable Long id) { return service.findById(id); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + + const listRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(listRoute).toBeDefined(); + expect(listRoute!.meta.method).toBe('GET'); + expect(listRoute!.meta.path).toBe('/api/v2/users'); + + const createRoute = providers.find((c) => c.contractId === 'http::POST::/api/v2/users'); + expect(createRoute).toBeDefined(); + + const getByIdRoute = providers.find( + (c) => c.contractId === 'http::GET::/api/v2/users/{param}', + ); + expect(getByIdRoute).toBeDefined(); + }); + + it('extracts Express router.get patterns', async () => { + const dir = path.join(tmpDir, 'express'); + fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/routes/users.ts'), + ` +import { Router } from 'express'; +const router = Router(); + +router.get('/api/users', async (req, res) => { res.json([]); }); +router.post('/api/users', async (req, res) => { res.json({}); }); +router.delete('/api/users/:id', async (req, res) => { res.sendStatus(204); }); + +export default router; +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + }); + }); + + describe('consumer extraction — fetch patterns', () => { + it('extracts fetch() calls', async () => { + const dir = path.join(tmpDir, 'frontend'); + fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/api/users.ts'), + ` +export async function fetchUsers() { + const res = await fetch('/api/users'); + return res.json(); +} + +export async function createUser(data: any) { + const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) }); + return res.json(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(2); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + }); + + it('extracts axios calls', async () => { + const dir = path.join(tmpDir, 'axios-fe'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/api.ts'), + ` +import axios from 'axios'; +export const getUsers = () => axios.get('/api/users'); +export const deleteUser = (id: string) => axios.delete(\`/api/users/\${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/users')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + }); + }); + + describe('provider extraction — Laravel', () => { + it('extracts Laravel Route::get patterns', async () => { + const dir = path.join(tmpDir, 'laravel'); + fs.mkdirSync(path.join(dir, 'routes'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'routes/api.php'), + ` c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::DELETE::/users/{param}')).toBeDefined(); + }); + }); + + describe('provider extraction — FastAPI', () => { + it('extracts FastAPI @app.get decorator patterns', async () => { + const dir = path.join(tmpDir, 'fastapi'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/main.py'), + `from fastapi import FastAPI +app = FastAPI() + +@app.get("/users") +async def list_users(): + return [] + +@app.post("/users") +async def create_user(user: UserCreate): + return user +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(2); + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined(); + }); + }); + + describe('consumer extraction — graph-first (Strategy A)', () => { + it('extracts consumers from FETCHES graph edges', async () => { + const dir = path.join(tmpDir, 'graph-consumers'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/api.ts'), 'export const api = {};'); + + const mockDbExecutor = async (query: string) => { + if (query.includes('HANDLES_ROUTE')) return []; + if (query.includes('FETCHES')) { + return [ + { + fileId: 'file-uid-api', + filePath: 'src/api.ts', + routePath: '/api/users', + routeId: 'route-uid-users', + fetchReason: 'fetch-url-match', + }, + ]; + } + if (query.includes('CONTAINS')) { + return [ + { + uid: 'uid-fn-fetch', + name: 'fetchUsers', + filePath: 'src/api.ts', + labels: ['Function'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].confidence).toBe(0.9); + expect(consumers[0].symbolName).toBe('fetchUsers'); + }); + }); + + describe('edge cases', () => { + it('returns empty for repo with no matching files', async () => { + const dir = path.join(tmpDir, 'empty-repo'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), '# Hello'); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts).toHaveLength(0); + }); + + it('handles graph queries that throw gracefully', async () => { + const dir = path.join(tmpDir, 'graph-error'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`); + + const throwingExecutor = async () => { + throw new Error('DB unavailable'); + }; + + const contracts = await extractor.extract(throwingExecutor, dir, makeRepo(dir)); + // Should fall back to source scan + const providers = contracts.filter((c) => c.role === 'provider'); + expect(providers.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('path normalization', () => { + it('strips trailing slash', async () => { + const dir = path.join(tmpDir, 'trailing'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/router.ts'), + ` +router.get('/api/users/', handler); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const provider = contracts.find((c) => c.role === 'provider'); + expect(provider?.meta.path).toBe('/api/users'); + }); + + it('normalizes path params from multiple syntaxes', async () => { + const dir = path.join(tmpDir, 'params'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/router.ts'), + ` +router.get('/api/users/:id', handler1); +router.get('/api/posts/{postId}', handler2); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + contracts.forEach((c) => { + expect(c.meta.path).not.toContain(':id'); + expect(c.meta.path).not.toContain('{postId}'); + if (typeof c.meta.path === 'string' && c.meta.path.includes('users/')) { + expect(c.meta.path).toContain('{param}'); + } + }); + }); + }); +}); diff --git a/gitnexus/test/unit/group/service-boundary-detector.test.ts b/gitnexus/test/unit/group/service-boundary-detector.test.ts new file mode 100644 index 000000000..4898b05eb --- /dev/null +++ b/gitnexus/test/unit/group/service-boundary-detector.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + detectServiceBoundaries, + assignService, + type ServiceBoundary, +} from '../../../src/core/group/service-boundary-detector.js'; + +describe('ServiceBoundaryDetector', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-sbd-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content = ''): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + describe('detectServiceBoundaries', () => { + it('test_detect_services_with_package_json_and_dockerfile_returns_boundaries', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/Dockerfile', 'FROM node:20'); + writeFile('services/auth/src/index.ts', 'export default {}'); + writeFile('services/orders/package.json', '{}'); + writeFile('services/orders/src/main.ts', 'console.log("ok")'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(2); + const names = boundaries.map((b) => b.serviceName).sort(); + expect(names).toEqual(['auth', 'orders']); + }); + + it('test_detect_root_package_json_excluded_from_boundaries', async () => { + writeFile('package.json', '{}'); + writeFile('src/index.ts', 'export default {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(0); + }); + + it('test_detect_go_mod_marker_returns_boundary', async () => { + writeFile('services/api/go.mod', 'module example.com/api'); + writeFile('services/api/main.go', 'package main'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('api'); + expect(boundaries[0].markers).toContain('go.mod'); + }); + + it('test_detect_pom_xml_marker_returns_boundary', async () => { + writeFile('microservices/billing/pom.xml', ''); + writeFile('microservices/billing/src/Main.java', 'class Main {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('billing'); + expect(boundaries[0].markers).toContain('pom.xml'); + }); + + it('test_detect_dockerfile_marker_returns_boundary', async () => { + writeFile('apps/worker/Dockerfile', 'FROM python:3.12'); + writeFile('apps/worker/requirements.txt', 'flask'); + writeFile('apps/worker/app.py', 'print("worker")'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('worker'); + expect(boundaries[0].markers).toContain('Dockerfile'); + expect(boundaries[0].markers).toContain('requirements.txt'); + }); + + it('test_detect_multiple_markers_increases_confidence', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/Dockerfile', 'FROM node:20'); + writeFile('services/auth/src/index.ts', ''); + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + const auth = boundaries.find((b) => b.serviceName === 'auth')!; + const api = boundaries.find((b) => b.serviceName === 'api')!; + + expect(auth.confidence).toBeGreaterThan(api.confidence); + }); + + it('test_detect_nested_services_returns_deepest_match', async () => { + writeFile('platform/services/auth/package.json', '{}'); + writeFile('platform/services/auth/src/index.ts', ''); + writeFile('platform/package.json', '{}'); + writeFile('platform/src/shared.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + const paths = boundaries.map((b) => b.servicePath).sort(); + + // Both detected; assignService will use the deepest match + expect(paths.length).toBeGreaterThanOrEqual(1); + expect(paths).toContain('platform/services/auth'); + }); + + it('test_detect_cargo_toml_marker_returns_boundary', async () => { + writeFile('crates/parser/Cargo.toml', '[package]'); + writeFile('crates/parser/src/lib.rs', 'pub fn parse() {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('parser'); + expect(boundaries[0].markers).toContain('Cargo.toml'); + }); + + it('test_detect_build_gradle_marker_returns_boundary', async () => { + writeFile('modules/gateway/build.gradle', 'apply plugin: "java"'); + writeFile('modules/gateway/src/Main.java', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('gateway'); + expect(boundaries[0].markers).toContain('build.gradle'); + }); + + it('test_detect_empty_repo_returns_empty', async () => { + const boundaries = await detectServiceBoundaries(tmpDir); + expect(boundaries).toHaveLength(0); + }); + + it('test_detect_pyproject_toml_marker_returns_boundary', async () => { + writeFile('services/ml/pyproject.toml', '[project]'); + writeFile('services/ml/src/model.py', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].markers).toContain('pyproject.toml'); + }); + }); + + describe('assignService', () => { + it('test_assign_file_to_correct_service', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + { + servicePath: 'services/orders', + serviceName: 'orders', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('services/auth/src/index.ts', boundaries)).toBe('services/auth'); + expect(assignService('services/orders/src/main.ts', boundaries)).toBe('services/orders'); + }); + + it('test_assign_file_outside_services_returns_undefined', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('libs/shared/utils.ts', boundaries)).toBeUndefined(); + expect(assignService('README.md', boundaries)).toBeUndefined(); + }); + + it('test_assign_nested_file_uses_deepest_boundary', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'platform', + serviceName: 'platform', + markers: ['package.json'], + confidence: 0.7, + }, + { + servicePath: 'platform/services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('platform/services/auth/src/index.ts', boundaries)).toBe( + 'platform/services/auth', + ); + expect(assignService('platform/shared/utils.ts', boundaries)).toBe('platform'); + }); + + it('test_assign_with_empty_boundaries_returns_undefined', () => { + expect(assignService('src/index.ts', [])).toBeUndefined(); + }); + }); +}); diff --git a/gitnexus/test/unit/group/topic-extractor.test.ts b/gitnexus/test/unit/group/topic-extractor.test.ts new file mode 100644 index 000000000..c6a1161a0 --- /dev/null +++ b/gitnexus/test/unit/group/topic-extractor.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { TopicExtractor } from '../../../src/core/group/extractors/topic-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('TopicExtractor', () => { + let tmpDir: string; + let extractor: TopicExtractor; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-topic-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + extractor = new TopicExtractor(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content: string): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/app', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + describe('Kafka — Java', () => { + it('test_extract_kafka_listener_returns_consumer', async () => { + writeFile( + 'src/EventHandler.java', + `@KafkaListener(topics = "user.created") +public void handleUserCreated(ConsumerRecord record) { + // process +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::user.created'); + expect(consumers[0].confidence).toBe(0.8); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafka_template_send_returns_producer', async () => { + writeFile( + 'src/EventPublisher.java', + `public class EventPublisher { + @Autowired KafkaTemplate template; + public void publish() { + kafkaTemplate.send("user.created", payload); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::user.created'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + }); + + describe('Kafka — Node', () => { + it('test_extract_kafkajs_subscribe_returns_consumer', async () => { + writeFile( + 'src/consumer.ts', + `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true }); +await consumer.run({ eachMessage: async ({ message }) => {} });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::order.placed'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafkajs_producer_send_returns_producer', async () => { + writeFile( + 'src/producer.ts', + `await producer.send({ topic: 'order.placed', messages: [{ value: JSON.stringify(order) }] });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::order.placed'); + }); + }); + + describe('RabbitMQ — Java', () => { + it('test_extract_rabbit_listener_returns_consumer', async () => { + writeFile( + 'src/OrderListener.java', + `@RabbitListener(queues = "order-queue") +public void processOrder(OrderMessage msg) {}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::order-queue'); + expect(consumers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_rabbit_template_send_returns_producer', async () => { + writeFile( + 'src/Publisher.java', + `rabbitTemplate.convertAndSend("order-exchange", "order.new", payload);`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::order-exchange'); + expect(producers[0].meta.broker).toBe('rabbitmq'); + }); + }); + + describe('RabbitMQ — Node', () => { + it('test_extract_amqplib_consume_returns_consumer', async () => { + writeFile( + 'src/worker.ts', + `channel.consume("task-queue", (msg) => { + console.log(msg.content.toString()); +});`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::task-queue'); + expect(consumers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_amqplib_publish_returns_producer', async () => { + writeFile( + 'src/publisher.ts', + `channel.publish("events", "user.signup", Buffer.from(JSON.stringify(data)));`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::events'); + expect(producers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_amqplib_sendToQueue_returns_producer', async () => { + writeFile('src/sender.ts', `channel.sendToQueue("job-queue", Buffer.from(msg));`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::job-queue'); + }); + }); + + describe('NATS', () => { + it('test_extract_nats_subscribe_go_returns_consumer', async () => { + writeFile( + 'cmd/sub.go', + `package main +nc, _ := nats.Connect(nats.DefaultURL) +nc.Subscribe("updates.weather", func(m *nats.Msg) { + fmt.Println(string(m.Data)) +})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::updates.weather'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_nats_publish_go_returns_producer', async () => { + writeFile( + 'cmd/pub.go', + `package main +nc, _ := nats.Connect(nats.DefaultURL) +nc.Publish("updates.weather", []byte("sunny"))`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::updates.weather'); + }); + + it('test_extract_nats_subscribe_node_returns_consumer', async () => { + writeFile( + 'src/sub.ts', + `const sub = nc.subscribe("events.order"); +for await (const msg of sub) { process(msg); }`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::events.order'); + }); + + it('test_extract_nats_publish_node_returns_producer', async () => { + writeFile('src/pub.ts', `nc.publish("events.order", sc.encode(JSON.stringify(order)));`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::events.order'); + }); + }); + + describe('Kafka — Go', () => { + it('test_extract_sarama_consume_returns_consumer', async () => { + writeFile( + 'internal/consumer.go', + `package consumer +partConsumer, _ := consumer.ConsumePartition("inventory.update", 0, sarama.OffsetNewest)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::inventory.update'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + }); + + describe('Kafka — Python', () => { + it('test_extract_kafka_python_subscribe_returns_consumer', async () => { + writeFile( + 'app/consumer.py', + `from kafka import KafkaConsumer +consumer = KafkaConsumer('payment.processed', bootstrap_servers=['localhost:9092'])`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::payment.processed'); + }); + + it('test_extract_kafka_python_producer_send_returns_producer', async () => { + writeFile( + 'app/producer.py', + `from kafka import KafkaProducer +producer = KafkaProducer(bootstrap_servers=['localhost:9092']) +producer.send('payment.processed', value=msg)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::payment.processed'); + }); + }); + + describe('edge cases', () => { + it('test_extract_empty_repo_returns_empty', async () => { + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_repo_without_queues_returns_empty', async () => { + writeFile('src/index.ts', 'console.log("hello")'); + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_multiple_topics_in_one_file', async () => { + writeFile( + 'src/events.ts', + `await producer.send({ topic: 'user.created', messages: [] }); +await producer.send({ topic: 'user.deleted', messages: [] }); +await consumer.subscribe({ topic: 'order.placed' });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toHaveLength(3); + const producers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + expect(producers).toHaveLength(2); + expect(consumers).toHaveLength(1); + }); + }); +}); From 24b25640badb08febe66169fdcf7ce3e43786211 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 00:40:31 +0300 Subject: [PATCH 3/5] feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire extractors into the sync pipeline with service boundary detection. GroupService provides high-level API for all group operations. - Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with service boundary assignment and exact matching - GroupService: groupList, groupSync, groupContracts, groupQuery, groupStatus (groupImpact deferred to cross-repo follow-up PR) - CLI: group create/add/remove/list/sync/contracts/query/status - MCP tools: group_list, group_sync, group_contracts, group_query, group_status - Monorepo fixture: 3 services (auth/orders/gateway) connected via gRPC + Kafka + HTTP — all intra-repo cross-links discovered - Documentation: CLI commands and MCP tools added to both READMEs Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 17 +- gitnexus/README.md | 10 + gitnexus/src/cli/ai-context.ts | 31 +- gitnexus/src/cli/group.ts | 303 +++++++++++++++ gitnexus/src/cli/index.ts | 3 + gitnexus/src/core/group/service.ts | 223 +++++++++++ gitnexus/src/core/group/sync.ts | 185 +++++++++ gitnexus/src/mcp/local/local-backend.ts | 163 +++++++- gitnexus/src/mcp/staleness.ts | 43 +- gitnexus/src/mcp/tools.ts | 76 ++++ .../group/test-backend/src/routes/health.ts | 8 + .../group/test-backend/src/routes/users.ts | 16 + .../group/test-frontend/src/api/users.ts | 18 + .../test-monorepo/services/auth/package.json | 1 + .../services/auth/src/auth.proto | 5 + .../test-monorepo/services/auth/src/events.ts | 3 + .../test-monorepo/services/auth/src/server.ts | 8 + .../services/gateway/package.json | 1 + .../test-monorepo/services/gateway/src/api.ts | 4 + .../services/orders/package.json | 1 + .../services/orders/src/order-service.ts | 7 + .../services/orders/src/routes.ts | 8 + .../test/integration/group/group-cli.test.ts | 52 +++ .../test/integration/group/group-sync.test.ts | 79 ++++ .../integration/group/monorepo-sync.test.ts | 120 ++++++ gitnexus/test/unit/calltool-dispatch.test.ts | 24 +- gitnexus/test/unit/group/group-tools.test.ts | 27 ++ .../test/unit/group/impact-by-uid.test.ts | 25 ++ gitnexus/test/unit/group/service.test.ts | 366 ++++++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 223 +++++++++++ .../unit/impact-batching-grouping.test.ts | 15 +- gitnexus/test/unit/tools.test.ts | 37 +- 32 files changed, 2043 insertions(+), 59 deletions(-) create mode 100644 gitnexus/src/cli/group.ts create mode 100644 gitnexus/src/core/group/service.ts create mode 100644 gitnexus/src/core/group/sync.ts create mode 100644 gitnexus/test/fixtures/group/test-backend/src/routes/health.ts create mode 100644 gitnexus/test/fixtures/group/test-backend/src/routes/users.ts create mode 100644 gitnexus/test/fixtures/group/test-frontend/src/api/users.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts create mode 100644 gitnexus/test/integration/group/group-cli.test.ts create mode 100644 gitnexus/test/integration/group/group-sync.test.ts create mode 100644 gitnexus/test/integration/group/monorepo-sync.test.ts create mode 100644 gitnexus/test/unit/group/group-tools.test.ts create mode 100644 gitnexus/test/unit/group/impact-by-uid.test.ts create mode 100644 gitnexus/test/unit/group/service.test.ts create mode 100644 gitnexus/test/unit/group/sync.test.ts diff --git a/README.md b/README.md index ae00065df..482b496bc 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,21 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate repository wiki from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) gitnexus wiki --base-url # Wiki with custom LLM API base URL + +# Repository groups (multi-repo / monorepo service tracking) +gitnexus group create # Create a repository group +gitnexus group add # Add a repo to a group +gitnexus group remove # Remove a repo from a group +gitnexus group list [name] # List groups, or show one group's config +gitnexus group sync # Extract contracts and match across repos/services +gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group query # Search execution flows across all repos in a group +gitnexus group status # Check staleness of repos in a group ``` ### What Your AI Agent Gets -**7 tools** exposed via MCP: +**16 tools** exposed via MCP (11 per-repo + 5 group): | Tool | What It Does | `repo` Param | | ------------------ | ----------------------------------------------------------------- | -------------- | @@ -221,6 +231,11 @@ gitnexus wiki --base-url # Wiki with custom LLM API base URL | `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional | | `rename` | Multi-file coordinated rename with graph + text search | Optional | | `cypher` | Raw Cypher graph queries | Optional | +| `group_list` | List configured repository groups | — | +| `group_sync` | Extract contracts and match across repos/services | — | +| `group_contracts`| Inspect extracted contracts and cross-links | — | +| `group_query` | Search execution flows across all repos in a group | — | +| `group_status` | Check staleness of repos in a group | — | > When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`. diff --git a/gitnexus/README.md b/gitnexus/README.md index b56e6c219..7e87c93b4 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -164,6 +164,16 @@ gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) + +# Repository groups (multi-repo / monorepo service tracking) +gitnexus group create # Create a repository group +gitnexus group add # Add a repo to a group +gitnexus group remove # Remove a repo from a group +gitnexus group list [name] # List groups, or show one group's config +gitnexus group sync # Extract contracts and match across repos/services +gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group query # Search execution flows across all repos in a group +gitnexus group status # Check staleness of repos in a group ``` ## Remote Embeddings diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 81aaa496d..1c7a95d7a 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -42,10 +42,28 @@ const GITNEXUS_END_MARKER = ''; * - Exact tool commands with parameters — vague directives get ignored * - Self-review checklist — forces model to verify its own work */ +async function findGroupsContainingRegistryName(registryName: string): Promise { + const { listGroups, getDefaultGitnexusDir, getGroupDir } = + await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const names = await listGroups(); + const hits: string[] = []; + for (const g of names) { + try { + const config = await loadGroupConfig(getGroupDir(getDefaultGitnexusDir(), g)); + if (Object.values(config.repos).some((r) => r === registryName)) hits.push(config.name); + } catch { + // skip invalid or unreadable groups + } + } + return hits; +} + function generateGitNexusContent( projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], + groupNames?: string[], ): string { const generatedRows = generatedSkills && generatedSkills.length > 0 @@ -155,7 +173,15 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta > Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. -## CLI +${ + groupNames && groupNames.length > 0 + ? `## Cross-Repo Groups + +This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync \`, \`npx gitnexus group impact --target --repo \`. + +` + : '' +}## CLI ${skillsTable} @@ -305,7 +331,8 @@ export async function generateAIContextFiles( generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions, ): Promise<{ files: string[] }> { - const content = generateGitNexusContent(projectName, stats, generatedSkills); + const groupNames = await findGroupsContainingRegistryName(projectName); + const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames); const createdFiles: string[] = []; if (!options?.skipAgentsMd) { diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts new file mode 100644 index 000000000..73f7978f5 --- /dev/null +++ b/gitnexus/src/cli/group.ts @@ -0,0 +1,303 @@ +// gitnexus/src/cli/group.ts +import { createRequire } from 'node:module'; +import type { Command } from 'commander'; + +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + +export function registerGroupCommands(program: Command): void { + const group = program + .command('group') + .description('Manage repository groups for cross-index impact analysis'); + + group + .command('create ') + .description('Create a new group with template group.yaml') + .option('--force', 'Overwrite existing group') + .action(async (name: string, opts: { force?: boolean }) => { + const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force); + console.log(`Created group "${name}" at ${dir}`); + console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name); + }); + + group + .command('add ') + .description( + 'Add a repo to a group. = hierarchy path (e.g. hr/hiring/backend), = name from registry', + ) + .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 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'); + console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`); + console.log(`Run: gitnexus group sync ${groupName}`); + }); + + group + .command('remove ') + .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 path = await import('node:path'); + const fs = await import('node:fs/promises'); + const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); + const config = await loadGroupConfig(groupDir); + if (!(repoPath in config.repos)) { + console.error(`Repo path "${repoPath}" not found in group "${groupName}"`); + process.exitCode = 1; + return; + } + delete config.repos[repoPath]; + await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + console.log(`Removed "${repoPath}" from group "${groupName}"`); + }); + + group + .command('list [name]') + .description('List all groups or details of one') + .action(async (name?: string) => { + const { listGroups, getDefaultGitnexusDir, getGroupDir } = + await import('../core/group/storage.js'); + if (!name) { + const groups = await listGroups(); + if (groups.length === 0) { + console.log('No groups configured. Create one with: gitnexus group create '); + return; + } + console.log('Groups:'); + groups.forEach((g) => console.log(` ${g}`)); + return; + } + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + console.log(`Group: ${config.name}`); + if (config.description) console.log(`Description: ${config.description}`); + console.log(`\nRepos (${Object.keys(config.repos).length}):`); + for (const [p, id] of Object.entries(config.repos)) { + console.log(` ${p} -> ${id}`); + } + if (config.links.length > 0) { + console.log(`\nManifest links (${config.links.length}):`); + for (const link of config.links) { + console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`); + } + } + }); + + group + .command('status ') + .description('Check staleness of group and repos') + .action(async (name: string) => { + const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } = + await import('../core/group/storage.js'); + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const registry = await readContractRegistry(groupDir); + + console.log( + `Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`, + ); + + const backend = new LocalBackend(); + try { + await backend.init(); + const raw = await backend.getGroupService().groupStatus({ name }); + const st = raw as { + repos?: Record< + string, + { + indexStale: boolean; + contractsStale: boolean; + missing: boolean; + commitsBehind?: number; + } + >; + missingRepos?: string[]; + }; + + console.log(' Repo index / contracts staleness:'); + for (const [repoPath, row] of Object.entries(st.repos || {})) { + if (row.missing) { + console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`); + continue; + } + const idx = row.indexStale + ? `STALE (${row.commitsBehind ?? '?'} commits behind)` + : 'OK '; + const ctr = row.contractsStale ? ' CONTRACTS_STALE' : ''; + console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`); + } + if ((st.missingRepos || []).length > 0) { + console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`); + } + } finally { + await backend.dispose().catch(() => {}); + } + }); + + group + .command('sync ') + .description('Sync Contract Registry — extract contracts and build cross-links') + .option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)') + .option('--exact-only', 'Exact match only') + .option('--allow-stale', 'Skip stale index warnings') + .option('--verbose', 'Show each cross-link detail') + .option('--json', 'JSON output') + .action(async (name: string, opts: Record) => { + const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const { syncGroup } = await import('../core/group/sync.js'); + const { closeLbug } = await import('../core/lbug/pool-adapter.js'); + + try { + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); + } + } finally { + await closeLbug().catch(() => {}); + } + }); + + group + .command('query ') + .description('Search execution flows across all repos in a group') + .option('--subgroup ', 'Limit search scope') + .option('--limit ', 'Max merged results', '5') + .option('--json', 'JSON output') + .action( + async ( + name: string, + queryText: string, + opts: Record, + ) => { + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const limit = parseInt(String(opts.limit ?? '5'), 10) || 5; + const subgroup = opts.subgroup as string | undefined; + const backend = new LocalBackend(); + try { + await backend.init(); + + console.log(`Searching "${queryText}" across group "${name}"...\n`); + + const raw = await backend.getGroupService().groupQuery({ + name, + query: queryText, + limit, + subgroup, + }); + const merged = raw as { + results: Array>; + per_repo: Array<{ repo: string; count: number }>; + }; + + if (opts.json) { + console.log(JSON.stringify(raw, null, 2)); + } else { + console.log(`Results (top ${merged.results.length}):\n`); + for (const p of merged.results) { + const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string; + console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`); + } + if (merged.results.length === 0) { + console.log(' No matching execution flows found.'); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }, + ); + + group + .command('contracts ') + .description('Inspect Contract Registry') + .option('--type ', 'Filter by contract type') + .option('--repo ', 'Filter by repo') + .option('--unmatched', 'Show only unmatched contracts') + .option('--json', 'JSON output') + .action(async (name: string, opts: Record) => { + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const backend = new LocalBackend(); + try { + await backend.init(); + const raw = await backend.getGroupService().groupContracts({ + name, + type: opts.type as string | undefined, + repo: opts.repo as string | undefined, + unmatchedOnly: Boolean(opts.unmatched), + }); + + if (raw && typeof raw === 'object' && 'error' in raw) { + console.error(String((raw as { error: string }).error)); + process.exitCode = 1; + return; + } + + const { contracts, crossLinks } = raw as { + contracts: Array<{ + role: string; + contractId: string; + repo: string; + symbolRef: { name: string }; + }>; + crossLinks: Array<{ + from: { repo: string }; + to: { repo: string }; + matchType: string; + confidence: number; + contractId: string; + }>; + }; + + if (opts.json) { + console.log(JSON.stringify({ contracts, crossLinks }, null, 2)); + } else { + console.log(`Contracts (${contracts.length}):`); + for (const c of contracts) { + console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`); + } + console.log(`\nCross-links (${crossLinks.length}):`); + for (const l of crossLinks) { + console.log( + ` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`, + ); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }); +} diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 805c55968..75940bcbf 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -6,6 +6,7 @@ import { Command } from 'commander'; import { createRequire } from 'node:module'; import { createLazyAction } from './lazy-action.js'; +import { registerGroupCommands } from './group.js'; const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); @@ -148,4 +149,6 @@ program .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); +registerGroupCommands(program); + program.parse(process.argv); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts new file mode 100644 index 000000000..1530cd6dd --- /dev/null +++ b/gitnexus/src/core/group/service.ts @@ -0,0 +1,223 @@ +/** + * Group orchestration shared by MCP (LocalBackend) and CLI. + * DB access is injected via GroupToolPort so this module stays free of LocalBackend private API. + */ + +import { checkStaleness } from '../git-staleness.js'; +import { loadGroupConfig } from './config-parser.js'; +import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js'; +import { syncGroup } from './sync.js'; + +export interface GroupRepoHandle { + id: string; + name: string; + repoPath: string; + storagePath: string; + indexedAt?: string; + lastCommit?: string; +} + +export interface GroupToolPort { + resolveRepo(repoParam?: string): Promise; + impact( + repo: GroupRepoHandle, + params: { + target: string; + direction: 'upstream' | 'downstream'; + maxDepth?: number; + relationTypes?: string[]; + includeTests?: boolean; + minConfidence?: number; + }, + ): Promise; + query( + repo: GroupRepoHandle, + params: { + query: string; + task_context?: string; + goal?: string; + limit?: number; + max_symbols?: number; + include_content?: boolean; + }, + ): Promise; + impactByUid( + repoId: string, + uid: string, + direction: string, + opts: { + maxDepth: number; + relationTypes: string[]; + minConfidence: number; + includeTests: boolean; + }, + ): Promise; +} + +function repoInSubgroup(repoPath: string, subgroup?: string): boolean { + if (!subgroup?.trim()) return true; + const s = subgroup.replace(/\/+$/, ''); + return repoPath === s || repoPath.startsWith(`${s}/`); +} + +export class GroupService { + constructor(private readonly port: GroupToolPort) {} + + async groupList(params: Record): Promise { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + if (!name) { + const groups = await listGroups(); + return { groups }; + } + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + return { + name: config.name, + description: config.description, + repos: config.repos, + links: config.links, + }; + } + + async groupSync(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + const result = await syncGroup(config, { + groupDir, + exactOnly: Boolean(params.exactOnly), + skipEmbeddings: Boolean(params.skipEmbeddings), + allowStale: Boolean(params.allowStale), + verbose: Boolean(params.verbose), + }); + return { + contracts: result.contracts.length, + crossLinks: result.crossLinks.length, + unmatched: result.unmatched.length, + missingRepos: result.missingRepos, + }; + } + + async groupContracts(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const registry = await readContractRegistry(groupDir); + if (!registry) { + return { error: `No contracts.json for group "${name}". Run group_sync first.` }; + } + let contracts = registry.contracts; + if (params.type) contracts = contracts.filter((c) => c.type === params.type); + if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo); + if (params.unmatchedOnly) { + const matchedIds = new Set( + registry.crossLinks.flatMap((l) => [ + `${l.from.repo}::${l.contractId}`, + `${l.to.repo}::${l.contractId}`, + ]), + ); + contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`)); + } + return { contracts, crossLinks: registry.crossLinks }; + } + + async groupQuery(params: Record): Promise { + const name = String(params.name ?? '').trim(); + const queryText = String(params.query ?? '').trim(); + if (!name || !queryText) return { error: 'name and query are required' }; + + const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5; + const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + const perRepo: Array<{ repo: string; score: number; processes: unknown[] }> = []; + for (const [repoPath, registryName] of Object.entries(config.repos)) { + if (!repoInSubgroup(repoPath, subgroup)) continue; + try { + const repoObj = await this.port.resolveRepo(registryName); + const queryResult = (await this.port.query(repoObj, { + query: queryText, + limit, + max_symbols: 10, + include_content: false, + })) as { processes?: Array> }; + const processes = queryResult.processes || []; + const scored = processes.map((p, idx) => ({ + ...p, + _rrf_score: 1 / (idx + 1 + 60), + _repo: repoPath, + })); + perRepo.push({ repo: repoPath, score: 0, processes: scored }); + } catch { + perRepo.push({ repo: repoPath, score: 0, processes: [] }); + } + } + + const allProcesses = perRepo.flatMap((r) => r.processes as Array>); + allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number)); + const topN = allProcesses.slice(0, limit); + + return { + group: name, + query: queryText, + results: topN, + per_repo: perRepo.map((r) => ({ repo: r.repo, count: r.processes.length })), + }; + } + + async groupStatus(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + const registry = await readContractRegistry(groupDir); + + const repoStatuses: Record< + string, + { + indexStale: boolean; + contractsStale: boolean; + missing: boolean; + commitsBehind?: number; + } + > = {}; + + const fsp = await import('node:fs/promises'); + const pathMod = await import('node:path'); + + for (const [repoPath, registryName] of Object.entries(config.repos)) { + try { + const repoObj = await this.port.resolveRepo(registryName); + const metaPath = pathMod.join(repoObj.storagePath, 'meta.json'); + const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}'); + const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string }; + + const staleness = meta.lastCommit + ? checkStaleness(repoObj.repoPath, meta.lastCommit) + : { isStale: true, commitsBehind: -1 }; + + const snapshot = registry?.repoSnapshots[repoPath]; + const contractsStale = + snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot; + + repoStatuses[repoPath] = { + indexStale: staleness.isStale, + contractsStale: Boolean(contractsStale), + missing: false, + commitsBehind: staleness.commitsBehind, + }; + } catch { + repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true }; + } + } + + return { + group: name, + lastSync: registry?.generatedAt || null, + missingRepos: registry?.missingRepos || [], + repos: repoStatuses, + }; + } +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts new file mode 100644 index 000000000..92cd9fe5f --- /dev/null +++ b/gitnexus/src/core/group/sync.ts @@ -0,0 +1,185 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Buffer } from 'node:buffer'; +import { initLbug, closeLbug, executeParameterized } from '../lbug/pool-adapter.js'; +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 { TopicExtractor } from './extractors/topic-extractor.js'; +import { runExactMatch } from './matching.js'; +import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; +import type { CypherExecutor } from './contract-extractor.js'; +import { writeContractRegistry } from './storage.js'; +import type { ContractRegistry } from './types.js'; + +export interface SyncOptions { + extractorOverride?: + | ((repo: RepoHandle) => Promise) + | (() => Promise); + resolveRepoHandle?: (registryName: string, groupPath: string) => Promise; + skipWrite?: boolean; + groupDir?: string; + allowStale?: boolean; + verbose?: boolean; + exactOnly?: boolean; + skipEmbeddings?: boolean; +} + +export interface SyncResult { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + unmatched: StoredContract[]; + missingRepos: string[]; + repoSnapshots: Record; +} + +export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry[]): string { + const base = entry.name.toLowerCase(); + const resolved = path.resolve(entry.path); + for (const other of allEntries) { + if (other.name.toLowerCase() === base && path.resolve(other.path) !== resolved) { + const hash = Buffer.from(entry.path).toString('base64url').slice(0, 6); + return `${base}-${hash}`; + } + } + return base; +} + +function defaultResolveHandle(allEntries: RegistryEntry[]) { + return async (registryName: string, groupPath: string): Promise => { + const e = allEntries.find((en) => en.name === registryName); + if (!e) return null; + const poolId = stableRepoPoolId(e, allEntries); + return { + id: poolId, + path: groupPath, + repoPath: e.path, + storagePath: e.storagePath, + }; + }; +} + +export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise { + const missingRepos: string[] = []; + const repoSnapshots: Record = {}; + let autoContracts: StoredContract[] = []; + let dbExecutors: Map | undefined; + + const eo = opts?.extractorOverride; + if (eo && eo.length === 0) { + autoContracts = await (eo as () => Promise)(); + } else { + const entries = await readRegistry(); + const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); + const httpEx = new HttpRouteExtractor(); + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + dbExecutors = new Map(); + const openPoolIds: string[] = []; + + try { + for (const [groupPath, regName] of Object.entries(config.repos)) { + const handle = await resolve(regName, groupPath); + if (!handle) { + missingRepos.push(groupPath); + continue; + } + + const poolId = handle.id; + const lbugPath = path.join(handle.storagePath, 'lbug'); + try { + await initLbug(poolId, lbugPath); + openPoolIds.push(poolId); + + const executor: CypherExecutor = (query, params) => + executeParameterized(poolId, query, params ?? {}); + + dbExecutors.set(groupPath, executor); + + const boundaries = await detectServiceBoundaries(handle.repoPath); + + if (config.detect.http) { + const extracted = await httpEx.extract(executor, handle.repoPath, handle); + for (const c of extracted) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + + if (config.detect.grpc) { + const extracted = await grpcEx.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) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + + const metaPath = path.join(handle.storagePath, 'meta.json'); + try { + const raw = await fs.readFile(metaPath, 'utf-8'); + const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string }; + repoSnapshots[groupPath] = { + indexedAt: m.indexedAt || '', + lastCommit: m.lastCommit || '', + }; + } catch { + const e = entries.find((en) => en.name === regName); + repoSnapshots[groupPath] = { + indexedAt: e?.indexedAt || '', + lastCommit: e?.lastCommit || '', + }; + } + } catch { + missingRepos.push(groupPath); + } + } + } finally { + for (const id of [...new Set(openPoolIds)]) { + await closeLbug(id).catch(() => {}); + } + } + } + + const { matched, unmatched } = runExactMatch(autoContracts); + const crossLinks: CrossLink[] = matched; + const allContracts: StoredContract[] = autoContracts; + + const registry: ContractRegistry = { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots, + missingRepos, + contracts: allContracts, + crossLinks, + }; + + if (opts?.groupDir && !opts.skipWrite) { + await writeContractRegistry(opts.groupDir, registry); + } + + return { + contracts: allContracts, + crossLinks, + unmatched, + missingRepos, + repoSnapshots, + }; +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 3bf9d511d..8f8b4d94b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -15,7 +15,7 @@ import { closeLbug, isLbugReady, isWriteQuery, -} from '../core/lbug-adapter.js'; +} from '../../core/lbug/pool-adapter.js'; export { isWriteQuery }; // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node // at MCP server startup — crashes on unsupported Node ABI versions (#89) @@ -26,6 +26,7 @@ import { cleanupOldKuzuFiles, type RegistryEntry, } from '../../storage/repo-manager.js'; +import { GroupService, type GroupToolPort } from '../../core/group/service.js'; // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -175,6 +176,28 @@ export class LocalBackend { private initializedRepos: Set = new Set(); private reinitPromises: Map> = new Map(); private lastStalenessCheck: Map = new Map(); + private groupToolSvc: GroupService | null = null; + + /** + * Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers. + */ + getGroupService(): GroupService { + if (!this.groupToolSvc) { + const port: GroupToolPort = { + resolveRepo: (p) => this.resolveRepo(p), + impact: (r, p) => this.impact(r as RepoHandle, p), + query: (r, p) => this.query(r as RepoHandle, p), + impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o), + }; + this.groupToolSvc = new GroupService(port); + } + return this.groupToolSvc; + } + + /** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */ + async dispose(): Promise { + await closeLbug(); + } // ─── Initialization ────────────────────────────────────────────── @@ -430,6 +453,10 @@ export class LocalBackend { return this.listRepos(); } + if (method.startsWith('group_')) { + return this.handleGroupTool(method, params || {}); + } + // Resolve repo from optional param (re-reads registry on miss) const repo = await this.resolveRepo(params?.repo); @@ -1854,9 +1881,6 @@ export class LocalBackend { const includeTests = params.includeTests ?? false; const minConfidence = params.minConfidence ?? 0; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - // Resolve target by name, preferring Class/Interface over Constructor // (fix #480: Java class and constructor share the same name). // labels(n)[0] returns empty string in LadybugDB, so we use explicit @@ -1917,6 +1941,33 @@ export class LocalBackend { if (!sym) return { error: `Target '${target}' not found` }; + return this._runImpactBFS(repo, sym, symType, direction, { + maxDepth, + relationTypes, + includeTests, + minConfidence, + }); + } + + /** + * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol). + */ + private async _runImpactBFS( + repo: RepoHandle, + sym: any, + symType: string, + direction: 'upstream' | 'downstream', + opts: { + maxDepth: number; + relationTypes: string[]; + includeTests: boolean; + minConfidence: number; + }, + ): Promise { + const { maxDepth, relationTypes, includeTests, minConfidence } = opts; + const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); + const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; + const symId = sym.id || sym[0]; const impacted: any[] = []; @@ -2323,6 +2374,110 @@ export class LocalBackend { }; } + /** + * UID-based impact for cross-repo fan-out. Same result shape as `impact`. + * Returns null if the repo is unknown, the UID is missing, or analysis fails. + */ + async impactByUid( + repoId: string, + uid: string, + direction: string, + opts: { + maxDepth: number; + relationTypes: string[]; + minConfidence: number; + includeTests: boolean; + }, + ): Promise { + try { + await this.refreshRepos(); + await this.ensureInitialized(repoId); + } catch { + return null; + } + + const repo = this.repos.get(repoId); + if (!repo) return null; + + const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream'; + + let rows: any[]; + try { + rows = await executeParameterized( + repoId, + `MATCH (n) WHERE n.id = $uid + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type + LIMIT 1`, + { uid }, + ); + } catch { + return null; + } + if (!rows?.length) return null; + + const sym = rows[0]; + const labelRaw = sym.type ?? sym[3]; + const symType = + typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : ''; + + const rawRelTypes = + opts.relationTypes && opts.relationTypes.length > 0 + ? opts.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) + : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + const relationTypes = + rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + + try { + return await this._runImpactBFS(repo, sym, symType, dir, { + maxDepth: opts.maxDepth, + relationTypes, + includeTests: opts.includeTests, + minConfidence: opts.minConfidence, + }); + } catch { + return null; + } + } + + private handleGroupTool(method: string, params: Record): Promise { + switch (method) { + case 'group_list': + return this.groupList(params); + case 'group_sync': + return this.groupSync(params); + case 'group_contracts': + return this.groupContracts(params); + case 'group_query': + return this.groupQuery(params); + case 'group_status': + return this.groupStatus(params); + default: + throw new Error(`Unknown group tool: ${method}`); + } + } + + private async groupList(params: Record): Promise { + return this.getGroupService().groupList(params); + } + + private async groupSync(params: Record): Promise { + return this.getGroupService().groupSync(params); + } + + private async groupContracts(params: Record): Promise { + return this.getGroupService().groupContracts(params); + } + + private async groupQuery(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupQuery(params); + } + + private async groupStatus(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupStatus(params); + } + /** * Fetch Route nodes with their consumers in a single query. * Shared by routeMap and shapeCheck to avoid N+1 query patterns. diff --git a/gitnexus/src/mcp/staleness.ts b/gitnexus/src/mcp/staleness.ts index 3889eac03..811889f3a 100644 --- a/gitnexus/src/mcp/staleness.ts +++ b/gitnexus/src/mcp/staleness.ts @@ -1,43 +1,6 @@ /** - * Staleness Check - * - * Checks if the GitNexus index is behind the current git HEAD. - * Returns a hint for the LLM to call analyze if stale. + * Staleness Check — re-export from core (see `core/git-staleness.ts`). */ -import { execFileSync } from 'child_process'; - -export interface StalenessInfo { - isStale: boolean; - commitsBehind: number; - hint?: string; -} - -/** - * Check how many commits the index is behind HEAD - */ -export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo { - try { - // Get count of commits between lastCommit and HEAD - const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { - cwd: repoPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - - const commitsBehind = parseInt(result, 10) || 0; - - if (commitsBehind > 0) { - return { - isStale: true, - commitsBehind, - hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`, - }; - } - - return { isStale: false, commitsBehind: 0 }; - } catch { - // If git command fails, assume not stale (fail open) - return { isStale: false, commitsBehind: 0 }; - } -} +export type { StalenessInfo } from '../core/git-staleness.js'; +export { checkStaleness } from '../core/git-staleness.js'; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a1d3deb60..c74b19464 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -377,4 +377,80 @@ Returns: single route object when one match, or { routes: [...], total: N } for required: [], }, }, + { + name: 'group_list', + description: `List all configured repository groups, or return details for one group (repos, manifest links). + +WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name. Omit to list all groups.' }, + }, + required: [], + }, + }, + { + name: 'group_sync', + description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links. + +WHEN TO USE: After changing group.yaml or re-indexing member repos.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + skipEmbeddings: { + type: 'boolean', + description: 'Exact + BM25 only (Demo PR: same as default exact path)', + }, + exactOnly: { type: 'boolean', description: 'Exact match only in cascade' }, + }, + required: ['name'], + }, + }, + { + name: 'group_contracts', + description: `Inspect contracts and cross-links from the group's contracts.json. + +WHEN TO USE: Debug cross-repo links after group_sync.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + type: { type: 'string', description: 'Filter by contract type (http, topic, …)' }, + repo: { type: 'string', description: 'Filter by group repo path (e.g. app/backend)' }, + unmatchedOnly: { type: 'boolean', description: 'Only contracts with no cross-link' }, + }, + required: ['name'], + }, + }, + { + name: 'group_query', + description: `Run the query tool across all repos in a group and merge process results via reciprocal rank fusion. + +WHEN TO USE: Semantic / hybrid search across a whole product group.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + query: { type: 'string', description: 'Search query' }, + subgroup: { type: 'string', description: 'Limit to repo paths under this prefix' }, + limit: { type: 'number', description: 'Max merged results (default 5)' }, + }, + required: ['name', 'query'], + }, + }, + { + name: 'group_status', + description: `Report index staleness (commit vs HEAD) and Contract Registry staleness (indexedAt) for each repo in a group. + +WHEN TO USE: Before group_sync or when agents should refresh indexes.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + }, + required: ['name'], + }, + }, ]; diff --git a/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts b/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts new file mode 100644 index 000000000..81de7a0d0 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +const router = Router(); + +router.get('/api/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +export default router; diff --git a/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts b/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts new file mode 100644 index 000000000..602bf1ee3 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +const router = Router(); + +router.get('/api/users', async (req, res) => { + res.json([{ id: 1, name: 'Alice' }]); +}); + +router.post('/api/users', async (req, res) => { + res.json({ id: 2, ...req.body }); +}); + +router.get('/api/users/:id', async (req, res) => { + res.json({ id: req.params.id, name: 'Alice' }); +}); + +export default router; diff --git a/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts b/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts new file mode 100644 index 000000000..0a73412f1 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts @@ -0,0 +1,18 @@ +export async function fetchUsers() { + const res = await fetch('/api/users'); + return res.json(); +} + +export async function createUser(data: { name: string }) { + const res = await fetch('/api/users', { + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + }); + return res.json(); +} + +export async function fetchUser(id: string) { + const res = await fetch(`/api/users/${id}`); + return res.json(); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json new file mode 100644 index 000000000..21f7d53e2 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/auth", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto new file mode 100644 index 000000000..3a9d542c9 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto @@ -0,0 +1,5 @@ +syntax = "proto3"; +package auth; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts new file mode 100644 index 000000000..99a281654 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts @@ -0,0 +1,3 @@ +export async function publishLoginEvent(producer: any, userId: string) { + await producer.send({ topic: 'user.logged-in', messages: [{ value: userId }] }); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts new file mode 100644 index 000000000..79ea2f541 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts @@ -0,0 +1,8 @@ +import { GrpcMethod } from '@nestjs/microservices'; + +export class AuthController { + @GrpcMethod('AuthService', 'Login') + login(data: unknown): unknown { + return { token: 'ok' }; + } +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json new file mode 100644 index 000000000..14e0f8797 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/gateway", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts new file mode 100644 index 000000000..346d962a7 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts @@ -0,0 +1,4 @@ +export async function createOrder(data: unknown) { + const res = await fetch('/api/orders', { method: 'POST' }); + return res.json(); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json new file mode 100644 index 000000000..ab03bf430 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/orders", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts new file mode 100644 index 000000000..34c538dc1 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts @@ -0,0 +1,7 @@ +import { Client } from '@grpc/grpc-js'; + +// Consumes AuthService gRPC +const authClient = new AuthServiceClient('localhost:50051'); + +// Consumes user.logged-in topic +await consumer.subscribe({ topic: 'user.logged-in', fromBeginning: true }); diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts new file mode 100644 index 000000000..579c14159 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +const router = express.Router(); + +router.post('/api/orders', (req, res) => { + res.json({ orderId: '123' }); +}); + +export default router; diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts new file mode 100644 index 000000000..20c9fcdf3 --- /dev/null +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -0,0 +1,52 @@ +/** + * Smoke-test `gitnexus group` CLI via tsx (same pattern as cli-e2e.test.ts). + * Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import os from 'node:os'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../..'); +const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); +const _require = createRequire(import.meta.url); +const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); +const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; + +let tmpHome: string; + +beforeAll(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-cli-')); +}); + +afterAll(() => { + if (tmpHome && fs.existsSync(tmpHome)) { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +function runGroup(args: string[]) { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'group', ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 20000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_HOME: tmpHome }, + }); +} + +describe('group CLI', () => { + it('create + list', () => { + const c = runGroup(['create', 'acme']); + expect(c.status).toBe(0); + expect(c.stdout).toContain('Created group "acme"'); + + const l = runGroup(['list']); + expect(l.status).toBe(0); + expect(l.stdout).toContain('acme'); + }); +}); diff --git a/gitnexus/test/integration/group/group-sync.test.ts b/gitnexus/test/integration/group/group-sync.test.ts new file mode 100644 index 000000000..3cceb200b --- /dev/null +++ b/gitnexus/test/integration/group/group-sync.test.ts @@ -0,0 +1,79 @@ +/** + * Group sync integration — uses `extractorOverride` / parsed YAML only (no LadybugDB). + * Full pipeline with indexed fixture repos is a follow-up (needs `.gitnexus/lbug`). + */ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseGroupConfig } from '../../../src/core/group/config-parser.js'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import type { StoredContract } from '../../../src/core/group/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group'); + +describe('Group sync integration', () => { + it('parses fixture group.yaml', () => { + const yamlContent = fs.readFileSync(path.join(FIXTURES_DIR, 'group.yaml'), 'utf-8'); + const config = parseGroupConfig(yamlContent); + expect(config.name).toBe('test-group'); + expect(config.repos['app/backend']).toBe('test-backend'); + expect(config.repos['app/frontend']).toBe('test-frontend'); + }); + + it('builds cross-links from fixture-shaped contracts via syncGroup', async () => { + const yamlContent = fs.readFileSync(path.join(FIXTURES_DIR, 'group.yaml'), 'utf-8'); + const config = parseGroupConfig(yamlContent); + + const mockContracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-p1', + symbolRef: { filePath: 'src/routes/users.ts', name: 'list' }, + symbolName: 'list', + confidence: 0.9, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/backend', + }, + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-c1', + symbolRef: { filePath: 'src/api/users.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + confidence: 0.85, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/frontend', + }, + { + contractId: 'http::GET::/api/health', + type: 'http', + role: 'provider', + symbolUid: 'uid-h1', + symbolRef: { filePath: 'src/routes/health.ts', name: 'health' }, + symbolName: 'health', + confidence: 0.9, + meta: { method: 'GET', path: '/api/health' }, + repo: 'app/backend', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks.length).toBeGreaterThanOrEqual(1); + const usersLink = result.crossLinks.find((l) => l.contractId.includes('/api/users')); + expect(usersLink).toBeDefined(); + expect(usersLink!.matchType).toBe('exact'); + expect(usersLink!.confidence).toBe(1.0); + + const healthUnmatched = result.unmatched.some((c) => c.contractId.includes('/api/health')); + expect(healthUnmatched).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/group/monorepo-sync.test.ts b/gitnexus/test/integration/group/monorepo-sync.test.ts new file mode 100644 index 000000000..a9dc38cec --- /dev/null +++ b/gitnexus/test/integration/group/monorepo-sync.test.ts @@ -0,0 +1,120 @@ +/** + * Monorepo sync integration — proves that intra-repo service communication + * (gRPC, Kafka topics, HTTP routes) is detected and matched across service + * boundaries within a single monorepo. + * + * Uses real extractors on fixture files (no LadybugDB). + */ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import { TopicExtractor } from '../../../src/core/group/extractors/topic-extractor.js'; +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import { + detectServiceBoundaries, + assignService, +} from '../../../src/core/group/service-boundary-detector.js'; +import { runExactMatch } from '../../../src/core/group/matching.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MONOREPO_DIR = path.resolve(__dirname, '../../fixtures/group/test-monorepo'); + +const REPO_GROUP_PATH = 'platform/monorepo'; +const makeHandle = (): RepoHandle => ({ + id: 'test-monorepo', + path: REPO_GROUP_PATH, + repoPath: MONOREPO_DIR, + storagePath: path.join(MONOREPO_DIR, '.gitnexus'), +}); + +describe('Monorepo sync integration', () => { + it('detects service boundaries in fixture monorepo', async () => { + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + expect(boundaries.length).toBe(3); + const names = boundaries.map((b) => b.serviceName).sort(); + expect(names).toEqual(['auth', 'gateway', 'orders']); + }); + + it('extracts gRPC, topic, and HTTP contracts with service assignments', async () => { + const handle = makeHandle(); + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + const httpEx = new HttpRouteExtractor(); + + const grpcContracts = await grpcEx.extract(null, MONOREPO_DIR, handle); + const topicContracts = await topicEx.extract(null, MONOREPO_DIR, handle); + const httpContracts = await httpEx.extract(null, MONOREPO_DIR, handle); + + // Assign service boundaries and repo + const allContracts: StoredContract[] = [ + ...grpcContracts, + ...topicContracts, + ...httpContracts, + ].map((c) => ({ + ...c, + repo: REPO_GROUP_PATH, + service: assignService(c.symbolRef.filePath, boundaries), + })); + + // Verify service assignments exist + const withService = allContracts.filter((c) => c.service); + expect(withService.length).toBeGreaterThan(0); + + // Verify we have both providers and consumers + const providers = allContracts.filter((c) => c.role === 'provider'); + const consumers = allContracts.filter((c) => c.role === 'consumer'); + expect(providers.length).toBeGreaterThan(0); + expect(consumers.length).toBeGreaterThan(0); + + return allContracts; + }); + + it('produces intra-repo cross-links between monorepo services', async () => { + const handle = makeHandle(); + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + const httpEx = new HttpRouteExtractor(); + + const grpcContracts = await grpcEx.extract(null, MONOREPO_DIR, handle); + const topicContracts = await topicEx.extract(null, MONOREPO_DIR, handle); + const httpContracts = await httpEx.extract(null, MONOREPO_DIR, handle); + + const allContracts: StoredContract[] = [ + ...grpcContracts, + ...topicContracts, + ...httpContracts, + ].map((c) => ({ + ...c, + repo: REPO_GROUP_PATH, + service: assignService(c.symbolRef.filePath, boundaries), + })); + + const { matched, unmatched } = runExactMatch(allContracts); + + // All links should be intra-repo (same repo, different services) + for (const link of matched) { + expect(link.from.repo).toBe(REPO_GROUP_PATH); + expect(link.to.repo).toBe(REPO_GROUP_PATH); + expect(link.from.service).not.toBe(link.to.service); + } + + // Expect at least topic match (user.logged-in: auth→orders) + const topicLink = matched.find((l) => l.contractId.includes('topic::')); + expect(topicLink).toBeDefined(); + expect(topicLink!.type).toBe('topic'); + + // Expect HTTP match (POST /api/orders: orders→gateway) + const httpLink = matched.find((l) => l.contractId.includes('http::')); + expect(httpLink).toBeDefined(); + + // Summary: we should have at least 2 cross-links + expect(matched.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 1827d8ea9..ebccb42a2 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -9,17 +9,29 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend -vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend. +// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js +// re-exports from the same module, so we mock the canonical source. +// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories. +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { initLbug: vi.fn().mockResolvedValue(undefined), executeQuery: vi.fn().mockResolvedValue([]), executeParameterized: vi.fn().mockResolvedValue([]), closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), - }; + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +// Re-export shim must resolve to the same mocks +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; }); vi.mock('../../src/storage/repo-manager.js', () => ({ diff --git a/gitnexus/test/unit/group/group-tools.test.ts b/gitnexus/test/unit/group/group-tools.test.ts new file mode 100644 index 000000000..e58077442 --- /dev/null +++ b/gitnexus/test/unit/group/group-tools.test.ts @@ -0,0 +1,27 @@ +// gitnexus/test/unit/group/group-tools.test.ts +import { describe, it, expect } from 'vitest'; +import { GITNEXUS_TOOLS } from '../../../src/mcp/tools.js'; + +const GROUP_TOOL_NAMES = [ + 'group_list', + 'group_sync', + 'group_contracts', + 'group_query', + 'group_status', +]; + +describe('Group MCP tools', () => { + it('all 5 group tools are registered', () => { + for (const name of GROUP_TOOL_NAMES) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name); + expect(tool, `tool ${name} should be registered`).toBeDefined(); + expect(tool!.description.length).toBeGreaterThan(10); + expect(tool!.inputSchema.type).toBe('object'); + } + }); + + it('group_sync requires name', () => { + const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; + expect(tool.inputSchema.required).toContain('name'); + }); +}); diff --git a/gitnexus/test/unit/group/impact-by-uid.test.ts b/gitnexus/test/unit/group/impact-by-uid.test.ts new file mode 100644 index 000000000..c583851e2 --- /dev/null +++ b/gitnexus/test/unit/group/impact-by-uid.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { LocalBackend } from '../../../src/mcp/local/local-backend.js'; + +/** + * Smoke tests only. Full UID → BFS impact needs an indexed repo and LadybugDB + * (see integration tests under test/integration/lbug-db project). + */ +describe('LocalBackend.impactByUid', () => { + it('method exists on LocalBackend', () => { + const backend = new LocalBackend(); + expect(typeof backend.impactByUid).toBe('function'); + }); + + it('returns null when repo is unknown or graph lookup fails', async () => { + const backend = new LocalBackend(); + await expect( + backend.impactByUid('nonexistent-repo', 'fake-uid-123', 'upstream', { + maxDepth: 3, + relationTypes: ['CALLS', 'IMPORTS'], + minConfidence: 0, + includeTests: false, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts new file mode 100644 index 000000000..e4b10443c --- /dev/null +++ b/gitnexus/test/unit/group/service.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + GroupService, + type GroupToolPort, + type GroupRepoHandle, +} from '../../../src/core/group/service.js'; +import { writeContractRegistry } from '../../../src/core/group/storage.js'; +import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js'; + +function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } { + const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`); + const groupDir = path.join(tmpDir, 'groups', 'test-group'); + fs.mkdirSync(groupDir, { recursive: true }); + + const yaml = `version: 1 +name: test-group +description: Test +repos: + app/backend: test-backend + app/frontend: test-frontend +`; + fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml); + + return { + tmpDir, + groupDir, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + }; +} + +function makePort(overrides: Partial = {}): GroupToolPort { + return { + resolveRepo: vi.fn( + async (name?: string): Promise => ({ + id: name || 'test', + name: name || 'test', + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + }), + ), + impact: vi.fn(async () => ({ symbols: [] })), + query: vi.fn(async () => ({ processes: [] })), + impactByUid: vi.fn(async () => null), + ...overrides, + }; +} + +function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { + return { + contractId: id, + type: 'http', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.8, + meta: {}, + repo, + }; +} + +function makeRegistry(contracts: StoredContract[], crossLinks: CrossLink[] = []): ContractRegistry { + return { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: {}, + missingRepos: [], + contracts, + crossLinks, + }; +} + +describe('GroupService', () => { + describe('groupList', () => { + it('test_groupList_without_name_returns_group_names', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupList({})) as { groups: string[] }; + expect(result.groups).toContain('test-group'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupList_with_name_returns_config_details', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupList({ name: 'test-group' })) as { + name: string; + repos: Record; + }; + expect(result.name).toBe('test-group'); + expect(result.repos['app/backend']).toBe('test-backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupContracts', () => { + it('test_groupContracts_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + + it('test_groupContracts_returns_error_when_no_registry', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string }; + expect(result.error).toContain('No contracts.json'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_returns_all_contracts', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + makeContract('http::GET::/api/users', 'consumer', 'app/frontend'), + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_filters_by_type', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + { + ...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'), + type: 'grpc' as const, + }, + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].type).toBe('grpc'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_filters_by_repo', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + makeContract('http::GET::/api/users', 'consumer', 'app/frontend'), + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].repo).toBe('app/backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_unmatchedOnly_filters_matched', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend'); + const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend'); + const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend'); + const crossLink: CrossLink = { + from: { + repo: 'app/frontend', + symbolUid: 'uid-c', + symbolRef: { filePath: 'f.ts', name: 'fn' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'uid-p', + symbolRef: { filePath: 'f.ts', name: 'fn' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + await writeContractRegistry( + groupDir, + makeRegistry([provider, consumer, orphan], [crossLink]), + ); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].contractId).toBe('http::GET::/api/health'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupSync', () => { + it('test_groupSync_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupSync({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + }); + + describe('groupQuery', () => { + it('test_groupQuery_returns_error_when_params_missing', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupQuery({})) as { error: string }; + expect(result.error).toContain('name and query are required'); + }); + + it('test_groupQuery_merges_results_across_repos', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + query: vi.fn(async () => ({ + processes: [{ name: 'process1', score: 0.9 }], + })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ name: 'test-group', query: 'auth flow' })) as { + group: string; + query: string; + results: unknown[]; + per_repo: Array<{ repo: string; count: number }>; + }; + + expect(result.group).toBe('test-group'); + expect(result.query).toBe('auth flow'); + expect(result.results.length).toBeGreaterThan(0); + expect(result.per_repo).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupQuery_handles_failing_repo_gracefully', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + resolveRepo: vi.fn(async (name?: string) => { + if (name === 'test-backend') throw new Error('not indexed'); + return { id: 'fe', name: 'fe', repoPath: '/tmp', storagePath: '/tmp/.gitnexus' }; + }), + query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ name: 'test-group', query: 'test' })) as { + per_repo: Array<{ repo: string; count: number }>; + }; + + const backendRepo = result.per_repo.find((r) => r.repo === 'app/backend'); + expect(backendRepo?.count).toBe(0); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupQuery_respects_subgroup_filter', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ + name: 'test-group', + query: 'test', + subgroup: 'app/backend', + })) as { per_repo: Array<{ repo: string; count: number }> }; + + expect(result.per_repo).toHaveLength(1); + expect(result.per_repo[0].repo).toBe('app/backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupStatus', () => { + it('test_groupStatus_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupStatus({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + + it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + resolveRepo: vi.fn(async () => { + throw new Error('repo not found'); + }), + }); + + const svc = new GroupService(port); + const result = (await svc.groupStatus({ name: 'test-group' })) as { + group: string; + repos: Record; + }; + + expect(result.group).toBe('test-group'); + expect(result.repos['app/backend'].missing).toBe(true); + expect(result.repos['app/frontend'].missing).toBe(true); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); +}); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts new file mode 100644 index 000000000..f34bfd4d9 --- /dev/null +++ b/gitnexus/test/unit/group/sync.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js'; +import type { GroupConfig, StoredContract, RepoHandle } from '../../../src/core/group/types.js'; +import type { RegistryEntry } from '../../../src/storage/repo-manager.js'; + +describe('syncGroup', () => { + const makeConfig = (repos: Record): GroupConfig => ({ + version: 1, + name: 'test', + description: '', + repos, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + it('returns SyncResult with contracts and cross-links', async () => { + const config = makeConfig({ 'app/backend': 'backend-repo', 'app/frontend': 'frontend-repo' }); + + const mockContracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/ctrl.ts', name: 'UserController.list' }, + symbolName: 'UserController.list', + confidence: 0.8, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/backend', + }, + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + confidence: 0.7, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/frontend', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.contracts).toHaveLength(2); + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('exact'); + expect(result.crossLinks[0].confidence).toBe(1.0); + expect(result.unmatched).toHaveLength(0); + }); + + it('reports missing repos', async () => { + const config = makeConfig({ 'app/backend': 'nonexistent-repo' }); + + const result = await syncGroup(config, { + resolveRepoHandle: async () => null, + skipWrite: true, + }); + + expect(result.missingRepos).toContain('app/backend'); + expect(result.contracts).toHaveLength(0); + }); + + it('handles empty repos config', async () => { + const config = makeConfig({}); + + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + expect(result.contracts).toHaveLength(0); + expect(result.crossLinks).toHaveLength(0); + expect(result.missingRepos).toHaveLength(0); + }); + + it('intra-repo matching works with service field via extractorOverride', async () => { + const config = makeConfig({ 'platform/monorepo': 'monorepo' }); + + const mockContracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/users', 'provider', 'platform/monorepo'), + service: 'services/auth', + }, + { + ...makeContract('http::GET::/api/users', 'consumer', 'platform/monorepo'), + service: 'services/gateway', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].from.service).toBe('services/gateway'); + expect(result.crossLinks[0].to.service).toBe('services/auth'); + }); + + function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { + return { + contractId: id, + type: 'http', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.8, + meta: {}, + repo, + }; + } + + it('per-repo extractorOverride receives repo handle and extracts per repo', async () => { + const config = makeConfig({ + 'app/backend': 'backend-repo', + 'app/frontend': 'frontend-repo', + }); + + const perRepoOverride = async (repo: RepoHandle) => { + if (repo.path === 'app/backend') { + return [makeContract('http::GET::/api/users', 'provider', 'app/backend')]; + } + return [makeContract('http::GET::/api/users', 'consumer', 'app/frontend')]; + }; + + const result = await syncGroup(config, { + extractorOverride: perRepoOverride, + resolveRepoHandle: async (_name, groupPath) => ({ + id: groupPath, + path: groupPath, + repoPath: '/tmp/' + groupPath, + storagePath: '/tmp/' + groupPath + '/.gitnexus', + }), + skipWrite: true, + }); + + // per-repo override goes through the initLbug path which will fail + // but the extractorOverride with arity > 0 triggers the else branch + // At minimum, the function should not throw + expect(result).toBeDefined(); + }); + + it('writes registry to groupDir when skipWrite is false', async () => { + const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + try { + const config = makeConfig({}); + const result = await syncGroup(config, { + extractorOverride: async () => [], + groupDir: tmpDir, + skipWrite: false, + }); + + expect(result.contracts).toHaveLength(0); + + const registryPath = path.join(tmpDir, 'contracts.json'); + expect(fs.existsSync(registryPath)).toBe(true); + + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(registry.version).toBe(1); + expect(registry.contracts).toHaveLength(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('stableRepoPoolId', () => { + it('returns lowercase name when no collision', () => { + const entry: RegistryEntry = { + name: 'MyRepo', + path: '/a/MyRepo', + storagePath: '/a/MyRepo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const all = [entry]; + expect(stableRepoPoolId(entry, all)).toBe('myrepo'); + }); + + it('appends hash suffix on name collision with different path', () => { + const entry1: RegistryEntry = { + name: 'repo', + path: '/a/repo', + storagePath: '/a/repo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const entry2: RegistryEntry = { + name: 'repo', + path: '/b/repo', + storagePath: '/b/repo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const all = [entry1, entry2]; + + const id1 = stableRepoPoolId(entry1, all); + const id2 = stableRepoPoolId(entry2, all); + + expect(id1).toMatch(/^repo-/); + expect(id2).toMatch(/^repo-/); + expect(id1).not.toBe(id2); + }); +}); diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index 2ee1e5b66..2a018d972 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -5,7 +5,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); -// Use the exact import specifier including .js to match runtime imports +// Mock both the canonical source (core/lbug/pool-adapter.js — what local-backend.ts +// imports) and the re-export shim (mcp/core/lbug-adapter.js) so the mocks intercept +// regardless of import path. +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 9a4862ba5..4274716a7 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -2,7 +2,7 @@ * Unit Tests: MCP Tool Definitions * * Tests: GITNEXUS_TOOLS from tools.ts - * - All 7 tools are defined + * - All 16 tools are defined (per-repo + group_*) * - Each tool has valid name, description, inputSchema * - Required fields are correct * - Optional repo parameter is present on tools that need it @@ -10,9 +10,17 @@ import { describe, it, expect } from 'vitest'; import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js'; +const GROUP_TOOLS = new Set([ + 'group_list', + 'group_sync', + 'group_contracts', + 'group_query', + 'group_status', +]); + describe('GITNEXUS_TOOLS', () => { - it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(11); + it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 5 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(16); }); it('contains all expected tool names', () => { @@ -83,16 +91,35 @@ describe('GITNEXUS_TOOLS', () => { expect(listTool.inputSchema.required).toEqual([]); }); - it('all tools except list_repos have optional repo parameter', () => { + it('per-repo tools have optional repo parameter for backend selection', () => { for (const tool of GITNEXUS_TOOLS) { if (tool.name === 'list_repos') continue; + if (GROUP_TOOLS.has(tool.name)) continue; expect(tool.inputSchema.properties.repo).toBeDefined(); expect(tool.inputSchema.properties.repo.type).toBe('string'); - // repo should never be required expect(tool.inputSchema.required).not.toContain('repo'); } }); + it('group_contracts has optional repo filter', () => { + const groupContracts = GITNEXUS_TOOLS.find((t) => t.name === 'group_contracts')!; + expect(groupContracts.inputSchema.properties).toHaveProperty('repo'); + expect(groupContracts.inputSchema.required).not.toContain('repo'); + }); + + it('group tools without backend repo param omit repo property', () => { + for (const name of ['group_list', 'group_status', 'group_sync', 'group_query'] as const) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!; + expect(tool.inputSchema.properties).not.toHaveProperty('repo'); + } + }); + + it('group_query requires name and query', () => { + const groupQuery = GITNEXUS_TOOLS.find((t) => t.name === 'group_query')!; + expect(groupQuery.inputSchema.required).toContain('name'); + expect(groupQuery.inputSchema.required).toContain('query'); + }); + it('detect_changes scope has correct enum values', () => { const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!; const scopeProp = detectTool.inputSchema.properties.scope; From b8109b88e11d907a0b86133061af833c8ee3c30a Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 12:55:18 +0300 Subject: [PATCH 4/5] docs: add design spec and implementation plan for PR #626 HIGH fixes Spec covers 4 HIGH-priority issues from review: path traversal via group name, gRPC proto regex nested braces, service boundary detector directory exclusions, double-close of LadybugDB pools. Plan: 6 tasks with TDD, ordered by complexity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-02-pr626-high-fixes.md | 725 ++++++++++++++++++ .../2026-04-02-pr626-high-fixes-design.md | 175 +++++ 2 files changed, 900 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-pr626-high-fixes.md create mode 100644 docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md diff --git a/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md b/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md new file mode 100644 index 000000000..0c9204e8c --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md @@ -0,0 +1,725 @@ +# PR #626 HIGH-Priority Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge. + +**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed. + +**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs + +**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md` + +**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo. + +--- + +### Task 1: Path Traversal — Validate Group Name + +**Files:** +- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir) +- Test: `gitnexus/test/unit/group/storage.test.ts` + +- [ ] **Step 1: Write failing tests for validateGroupName** + +In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`: + +```typescript + describe('validateGroupName', () => { + it('test_validateGroupName_traversal_path_throws', () => { + expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_slash_in_name_throws', () => { + expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_empty_string_throws', () => { + expect(() => validateGroupName('')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_starts_with_dash_throws', () => { + expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_starts_with_underscore_throws', () => { + expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_dots_throws', () => { + expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_valid_alphanumeric_passes', () => { + expect(() => validateGroupName('my-group_01')).not.toThrow(); + }); + + it('test_validateGroupName_single_char_passes', () => { + expect(() => validateGroupName('A')).not.toThrow(); + }); + + it('test_validateGroupName_all_digits_passes', () => { + expect(() => validateGroupName('123')).not.toThrow(); + }); + }); + + describe('getGroupDir rejects invalid names', () => { + it('test_getGroupDir_traversal_throws', () => { + expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/); + }); + + it('test_getGroupDir_valid_name_returns_path', () => { + const dir = getGroupDir(tmpDir, 'company'); + expect(dir).toBe(path.join(tmpDir, 'groups', 'company')); + }); + }); + + describe('createGroupDir rejects invalid names', () => { + it('test_createGroupDir_traversal_throws', async () => { + await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts` +Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw. + +- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir** + +In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it: + +```typescript +const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +export function validateGroupName(name: string): void { + if (!GROUP_NAME_RE.test(name)) { + throw new Error( + `Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`, + ); + } +} + +export function getGroupDir(gitnexusDir: string, groupName: string): string { + validateGroupName(groupName); + return path.join(gitnexusDir, 'groups', groupName); +} +``` + +`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts +git commit -m "fix(group): validate group name to prevent path traversal + +Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*. +Called in getGroupDir (defense in depth) which covers all CLI entry +points: create, add, remove, status, sync. + +Addresses PR #626 review item 1 (HIGH). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 2: Directory Exclusions in Service Boundary Detector + +**Files:** +- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs) +- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts` + +- [ ] **Step 1: Write failing tests for excluded directories** + +Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`: + +```typescript + it('test_detect_skips_vendor_directory', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/src/index.ts', ''); + // vendor should be skipped — its contents should not create a boundary + writeFile('vendor/some-dep/package.json', '{}'); + writeFile('vendor/some-dep/src/lib.go', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/auth'); + expect(paths).not.toContain('vendor/some-dep'); + }); + + it('test_detect_skips_target_directory', async () => { + writeFile('services/api/go.mod', 'module api'); + writeFile('services/api/main.go', ''); + writeFile('target/classes/Main.java', ''); + writeFile('target/pom.xml', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/api'); + expect(paths).not.toContain('target'); + }); + + it('test_detect_skips_pycache_directory', async () => { + writeFile('services/ml/pyproject.toml', '[project]'); + writeFile('services/ml/model.py', ''); + // __pycache__ with a marker + source files — would be detected as + // a boundary if not excluded, since it has package.json + .py file + writeFile('__pycache__/package.json', '{}'); + writeFile('__pycache__/cached.py', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/ml'); + expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true); + }); + + it('test_detect_skips_dotfile_directories_regression', async () => { + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + writeFile('.hidden/package.json', '{}'); + writeFile('.hidden/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/api'); + expect(paths).not.toContain('.hidden'); + }); + + it('test_detect_does_not_skip_regular_source_directories', async () => { + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('api'); + }); +``` + +- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail** + +Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts` +Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists. + +- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions** + +In `gitnexus/src/core/group/service-boundary-detector.ts`: + +After `SOURCE_EXTENSIONS` (after line 51), add: + +```typescript +const EXCLUDED_DIRS = new Set([ + 'node_modules', + 'vendor', + 'target', + 'build', + 'dist', + '__pycache__', + '.venv', + 'venv', + '.tox', + '.mypy_cache', + '.gradle', + '.mvn', + 'out', + 'bin', +]); +``` + +In `walkForBoundaries`, replace line 78: +```typescript + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; +``` +with: +```typescript + if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue; +``` + +In `hasSourceFilesInSubdirs`, replace line 130: +```typescript + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { +``` +with: +```typescript + if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) { +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts +git commit -m "fix(group): add directory exclusions to service boundary detector + +Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__, +.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin. +Applied in walkForBoundaries and hasSourceFilesInSubdirs. +Replaces inline node_modules check. + +Addresses PR #626 review item 3 (HIGH). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 3: Remove Double-Close of LadybugDB Pools + +**Files:** +- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body) +- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test) +- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source) + +- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts** + +Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`: + +```typescript + it('test_syncGroup_closes_only_opened_pools', async () => { + const config = makeConfig({ + 'app/backend': 'backend-repo', + 'app/frontend': 'frontend-repo', + }); + + const closedIds: string[] = []; + + // Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle + const { vi } = await import('vitest'); + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => { + if (id) closedIds.push(id); + }); + + try { + await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: groupPath.replace(/\//g, '-'), + path: groupPath, + repoPath: '/tmp/' + groupPath, + storagePath: '/tmp/' + groupPath + '/.gitnexus', + }), + skipWrite: true, + }).catch(() => {}); + // Regardless of extraction errors, closeLbug should be called per id + // closeLbug should only receive specific pool ids, never undefined/empty + for (const id of closedIds) { + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + } + // No blanket close (no-arg call) + const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]); + expect(blanketCalls).toHaveLength(0); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + } + }); +``` + +- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)** + +Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts` +Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior. + +- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()** + +Add to `gitnexus/test/integration/group/group-cli.test.ts`: + +```typescript + it('test_sync_command_source_does_not_call_blanket_closeLbug', () => { + const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts'); + const source = fs.readFileSync(cliGroupPath, 'utf-8'); + + // closeLbug() without arguments (blanket close) must not appear. + // closeLbug(id) with argument is fine (that's in sync.ts, not here). + // Match closeLbug() but not closeLbug(someArg) + const blanketClosePattern = /closeLbug\s*\(\s*\)/; + expect(source).not.toMatch(blanketClosePattern); + }); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts` +Expected: FAIL — `closeLbug()` (no args) exists at line 188. + +- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts** + +In `gitnexus/src/cli/group.ts`: + +Remove the `closeLbug` import at line 160: +```typescript + const { closeLbug } = await import('../core/lbug/pool-adapter.js'); +``` + +Replace the try/finally wrapper (lines 162-189): +```typescript + try { + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); + } + } finally { + await closeLbug().catch(() => {}); + } +``` + +Becomes (remove try/finally entirely, since sync.ts handles its own cleanup): +```typescript + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts` +Expected: ALL PASS + +- [ ] **Step 7: Commit** + +```bash +cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts +git commit -m "fix(group): remove blanket closeLbug() from CLI sync command + +sync.ts already closes pools per-id in its finally block. +The blanket closeLbug() in cli/group.ts tears down ALL active pools +including unrelated ones in MCP server context. + +Addresses PR #626 review item 4 (HIGH). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 4: gRPC Proto Regex — Brace-Depth Counter + +**Files:** +- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile) +- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts` + +- [ ] **Step 1: Write failing tests for nested braces in proto services** + +Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`: + +```typescript + it('test_extract_proto_with_google_api_http_nested_braces', async () => { + writeFile( + 'api/gateway.proto', + `syntax = "proto3"; +package gateway.v1; + +import "google/api/annotations.proto"; + +service GatewayService { + rpc GetUser (GetUserRequest) returns (UserResponse) { + option (google.api.http) = { + get: "/v1/users/{user_id}" + }; + } + rpc CreateUser (CreateUserRequest) returns (UserResponse) { + option (google.api.http) = { + post: "/v1/users" + body: "*" + }; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto', + ); + + expect(providers).toHaveLength(2); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::gateway.v1.GatewayService/CreateUser', + 'grpc::gateway.v1.GatewayService/GetUser', + ]); + }); + + it('test_extract_proto_with_multiple_services', async () => { + writeFile( + 'api/multi.proto', + `syntax = "proto3"; +package multi; + +service ServiceA { + rpc MethodA (Req) returns (Res); +} + +service ServiceB { + rpc MethodB1 (Req) returns (Res); + rpc MethodB2 (Req) returns (Res); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto', + ); + + expect(providers).toHaveLength(3); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::multi.ServiceA/MethodA', + 'grpc::multi.ServiceB/MethodB1', + 'grpc::multi.ServiceB/MethodB2', + ]); + }); + + it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => { + writeFile( + 'api/nested.proto', + `syntax = "proto3"; +package nested; + +service DeepService { + rpc DeepMethod (Req) returns (Res) { + option (google.api.http) = { + post: "/v1/deep" + body: "*" + additional_bindings { + get: "/v1/deep/{id}" + } + }; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto', + ); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod'); + }); + + it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => { + writeFile( + 'api/broken.proto', + `syntax = "proto3"; +package broken; + +service IncompleteService { + rpc SomeMethod (Req) returns (Res); + // Missing closing brace — EOF before depth returns to 0 +`, + ); + + // Should not throw; incomplete service is silently skipped + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto', + ); + + // The old regex would find partial match; the new parser should skip it + expect(providers).toHaveLength(0); + }); +``` + +- [ ] **Step 2: Run tests to verify the nested brace test fails** + +Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts` +Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block. + +- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function** + +In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130): + +```typescript + private parseProtoFile(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); + const pkg = pkgMatch ? pkgMatch[1] : ''; + + for (const { name: serviceName, body } of extractServiceBlocks(content)) { + const rpcRe = /rpc\s+(\w+)\s*\(/g; + let rpcMatch: RegExpExecArray | null; + while ((rpcMatch = rpcRe.exec(body)) !== null) { + const methodName = rpcMatch[1]; + const cid = contractId(pkg, serviceName, methodName); + out.push( + makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, { + package: pkg, + service: serviceName, + method: methodName, + source: 'proto', + }), + ); + } + } + + return out; + } +``` + +Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26): + +```typescript +function extractServiceBlocks(content: string): Array<{ name: string; body: string }> { + const results: Array<{ name: string; body: string }> = []; + const headerRe = /service\s+(\w+)\s*\{/g; + let headerMatch: RegExpExecArray | null; + + while ((headerMatch = headerRe.exec(content)) !== null) { + const serviceName = headerMatch[1]; + const bodyStart = headerMatch.index + headerMatch[0].length; + let depth = 1; + let pos = bodyStart; + + while (pos < content.length && depth > 0) { + const ch = content[pos]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + pos++; + } + + // If EOF before depth returns to 0, skip incomplete service + if (depth !== 0) continue; + + // body is between opening { (consumed by regex) and closing } (pos is one past it) + const body = content.slice(bodyStart, pos - 1); + results.push({ name: serviceName, body }); + } + + return results; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts` +Expected: ALL PASS (including existing regression tests) + +- [ ] **Step 5: Commit** + +```bash +cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts +git commit -m "fix(group): replace gRPC proto regex with brace-depth counter + +The serviceRe regex used [^}]* which stopped at the first '}'. +Proto services with google.api.http annotations contain nested {} +blocks, causing methods to be missed. + +New extractServiceBlocks() uses a brace-depth counter (init depth=1 +after opening {, scan char-by-char). Malformed protos with unclosed +braces are silently skipped. + +Addresses PR #626 review item 2 (HIGH). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 5: Run Full Test Suite + +- [ ] **Step 1: Run all group-related tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/` +Expected: ALL PASS + +- [ ] **Step 2: Run full test suite to catch regressions** + +Run: `cd gitnexus && npx vitest run` +Expected: ALL PASS, 0 failures + +- [ ] **Step 3: Run typecheck** + +Run: `cd gitnexus && npx tsc --noEmit` +Expected: No errors + +--- + +### Task 6: CLI Integration Smoke Test + +- [ ] **Step 1: Add CLI smoke test for path traversal** + +Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe: + +```typescript + it('test_create_with_invalid_name_fails', () => { + const result = runGroup(['create', '../../evil']); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Invalid group name'); + }); +``` + +- [ ] **Step 2: Run test** + +Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts` +Expected: ALL PASS + +- [ ] **Step 3: Commit** + +```bash +cd gitnexus && git add test/integration/group/group-cli.test.ts +git commit -m "test(group): add CLI smoke test for path traversal rejection + +Verifies that 'group create ../../evil' fails with Invalid group name. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` diff --git a/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md b/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md new file mode 100644 index 000000000..bdd1774e6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md @@ -0,0 +1,175 @@ +# PR #626 HIGH-Priority Fixes Design + +**Date:** 2026-04-02 +**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking +**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin +**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep + +--- + +## Fix 1: Path Traversal via Group Name + +**File:** `gitnexus/src/core/group/storage.ts` +**Risk:** A group name like `../../etc` creates directories outside the intended path. + +### Solution + +Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`. + +- Call in `createGroupDir` (primary entry point) +- Call in `getGroupDir` (defense in depth) +- Throw descriptive error on invalid names + +**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated. + +### Why regex over path.resolve + startsWith + +- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]` +- Stricter: disallows spaces, dots, Unicode edge cases +- Simpler to reason about + +### Tests + +- `../../evil` throws +- `foo/bar` throws +- Empty string throws +- `my-group_01` passes +- `A` (single char) passes +- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage + +### CLI/API entry points accepting groupName + +All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference: + +| Command | Entry | Calls | +|---------|-------|-------| +| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` | +| `group add` | `cli/group.ts` action | `getGroupDir` | +| `group remove` | `cli/group.ts` action | `getGroupDir` | +| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) | +| `group status` | `cli/group.ts` action | `getGroupDir` | +| `group sync` | `cli/group.ts` action | `getGroupDir` | + +**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope. + +--- + +## Fix 2: gRPC Proto Regex -> Brace-Depth Counter + +**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts` +**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks. + +### Solution + +Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`: + +1. Use regex only to find `service {` start positions (regex consumes the opening `{`) +2. Initialise depth to 1 immediately after the opening `{` +3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body +4. Stop when depth reaches 0 (the matching closing `}`) +5. Return name + body pairs + +Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body. + +**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test. + +**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope. + +### Tests + +- Proto with single service, no nesting (regression) +- Proto with `google.api.http` nested braces inside RPC options +- Proto with multiple services +- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`) +- Malformed proto with unclosed brace (graceful handling) + +--- + +## Fix 3: Directory Exclusions in Service Boundary Detector + +**File:** `gitnexus/src/core/group/service-boundary-detector.ts` +**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`. + +### Solution + +Create `EXCLUDED_DIRS` as a `Set` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example: + +```text +node_modules, vendor, target, build, dist, +__pycache__, .venv, venv, .tox, .mypy_cache, +.gradle, .mvn, out, bin +``` + +(Implement as `new Set([...])` — the list above is the membership, not a string literal.) + +Apply in both: +- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)` +- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)` + +Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`. +Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name. +Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`. + +**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos. + +**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code. + +### Tests + +- Directory named `vendor/` is skipped +- Directory named `target/` is skipped +- Directory named `__pycache__/` is skipped +- Regular source directories are NOT skipped +- Dotfile directories still skipped (regression) + +--- + +## Fix 4: Double-Close of LadybugDB Pools + +**Files:** +- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP) +- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE) + +**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations. + +### Solution + +Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient: + +```typescript +// sync.ts — KEEP: cleans up only pools opened by this sync +finally { + for (const id of [...new Set(openPoolIds)]) { + await closeLbug(id).catch(() => {}); + } +} +``` + +```typescript +// cli/group.ts — REMOVE: blanket close that kills all pools +finally { + await closeLbug().catch(() => {}); // DELETE THIS +} +``` + +Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages. + +### Tests (unit level — mock pool adapter) + +- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids) +- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched +- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test) + +--- + +## Out of Scope + +- JSON -> LadybugDB migration (tracked in #606) +- MEDIUM/LOW issues (items 5-10 from review summary) +- Test gap coverage beyond what's needed for these 4 fixes +- Any refactoring or architectural changes + +## Execution Order + +Fixes are independent — can be implemented in parallel or any order. +Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex). From 5367343889830c69741d01315b9718de932851b4 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 12:55:33 +0300 Subject: [PATCH 5/5] fix(group): address 4 HIGH-priority issues from PR #626 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Path traversal via group name — add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*, called in getGroupDir (defense in depth) 2. gRPC proto regex can't handle nested braces — replace serviceRe with extractServiceBlocks() brace-depth counter (init depth=1, skip malformed protos) 3. Service boundary detector directory exclusions — add EXCLUDED_DIRS set (vendor, target, build, dist, __pycache__, .venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin) replacing inline node_modules 4. Double-close of LadybugDB pools — remove blanket closeLbug() from cli/group.ts; sync.ts per-id cleanup is sufficient Tests: 22 new tests across 5 files. Full suite: 4706 passed, 0 failed. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/group.ts | 45 +++---- .../core/group/extractors/grpc-extractor.ts | 37 +++++- .../core/group/service-boundary-detector.ts | 21 +++- gitnexus/src/core/group/storage.ts | 11 ++ .../test/integration/group/group-cli.test.ts | 16 +++ .../test/unit/group/grpc-extractor.test.ts | 116 ++++++++++++++++++ .../group/service-boundary-detector.test.ts | 65 ++++++++++ gitnexus/test/unit/group/storage.test.ts | 57 +++++++++ gitnexus/test/unit/group/sync.test.ts | 45 +++++++ 9 files changed, 380 insertions(+), 33 deletions(-) diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 73f7978f5..70ca9537a 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -157,35 +157,30 @@ export function registerGroupCommands(program: Command): void { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); const { loadGroupConfig } = await import('../core/group/config-parser.js'); const { syncGroup } = await import('../core/group/sync.js'); - const { closeLbug } = await import('../core/lbug/pool-adapter.js'); - try { - const groupDir = getGroupDir(getDefaultGitnexusDir(), name); - const config = await loadGroupConfig(groupDir); + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); - console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); - const result = await syncGroup(config, { - groupDir, - allowStale: Boolean(opts.allowStale), - verbose: Boolean(opts.verbose), - skipEmbeddings: Boolean(opts.skipEmbeddings), - exactOnly: Boolean(opts.exactOnly), - }); + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); - if (opts.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(`\nMatching cascade:`); - const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); - console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); - console.log(` unmatched: ${result.unmatched.length} contracts`); - console.log( - `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, - ); - } - } finally { - await closeLbug().catch(() => {}); + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); } }); diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index 465d1f2bb..b4cefadc5 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -25,6 +25,36 @@ function serviceOnlyContractId(serviceName: string): string { return `grpc::${serviceName}/*`; } +function extractServiceBlocks(content: string): Array<{ name: string; body: string }> { + const results: Array<{ name: string; body: string }> = []; + // v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2) + const headerRe = /service\s+(\w+)\s*\{/g; + let headerMatch: RegExpExecArray | null; + + while ((headerMatch = headerRe.exec(content)) !== null) { + const serviceName = headerMatch[1]; + const bodyStart = headerMatch.index + headerMatch[0].length; + let depth = 1; + let pos = bodyStart; + + while (pos < content.length && depth > 0) { + const ch = content[pos]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + pos++; + } + + // If EOF before depth returns to 0, skip incomplete service + if (depth !== 0) continue; + + // body is between opening { (consumed by regex) and closing } (pos is one past it) + const body = content.slice(bodyStart, pos - 1); + results.push({ name: serviceName, body }); + } + + return results; +} + function makeContract( cid: string, role: 'provider' | 'consumer', @@ -104,12 +134,7 @@ export class GrpcExtractor implements ContractExtractor { const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); const pkg = pkgMatch ? pkgMatch[1] : ''; - const serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs; - let svcMatch: RegExpExecArray | null; - while ((svcMatch = serviceRe.exec(content)) !== null) { - const serviceName = svcMatch[1]; - const body = svcMatch[2]; - + for (const { name: serviceName, body } of extractServiceBlocks(content)) { const rpcRe = /rpc\s+(\w+)\s*\(/g; let rpcMatch: RegExpExecArray | null; while ((rpcMatch = rpcRe.exec(body)) !== null) { diff --git a/gitnexus/src/core/group/service-boundary-detector.ts b/gitnexus/src/core/group/service-boundary-detector.ts index 4b42073d3..fd4545d80 100644 --- a/gitnexus/src/core/group/service-boundary-detector.ts +++ b/gitnexus/src/core/group/service-boundary-detector.ts @@ -50,6 +50,23 @@ const SOURCE_EXTENSIONS = new Set([ '.proto', ]); +const EXCLUDED_DIRS = new Set([ + 'node_modules', + 'vendor', + 'target', + 'build', + 'dist', + '__pycache__', + '.venv', + 'venv', + '.tox', + '.mypy_cache', + '.gradle', + '.mvn', + 'out', + 'bin', +]); + export async function detectServiceBoundaries(repoPath: string): Promise { const boundaries: ServiceBoundary[] = []; await walkForBoundaries(repoPath, repoPath, boundaries); @@ -75,7 +92,7 @@ async function walkForBoundaries( const subdirs: string[] = []; for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue; if (entry.isDirectory()) { subdirs.push(path.join(dir, entry.name)); @@ -127,7 +144,7 @@ async function hasSourceFilesInSubdirs(subdirs: string[]): Promise { const ext = path.extname(entry.name).toLowerCase(); if (SOURCE_EXTENSIONS.has(ext)) return true; } - if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { + if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) { const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]); if (deeper) return true; } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index 568a2c66c..aa6a781a5 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -14,7 +14,18 @@ export function getGroupsBaseDir(gitnexusDir?: string): string { return path.join(gitnexusDir || getDefaultGitnexusDir(), 'groups'); } +const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +export function validateGroupName(name: string): void { + if (!GROUP_NAME_RE.test(name)) { + throw new Error( + `Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`, + ); + } +} + export function getGroupDir(gitnexusDir: string, groupName: string): string { + validateGroupName(groupName); return path.join(gitnexusDir, 'groups', groupName); } diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts index 20c9fcdf3..02da904dc 100644 --- a/gitnexus/test/integration/group/group-cli.test.ts +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -49,4 +49,20 @@ describe('group CLI', () => { expect(l.status).toBe(0); expect(l.stdout).toContain('acme'); }); + + it('test_create_with_invalid_name_fails', () => { + const result = runGroup(['create', '../../evil']); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Invalid group name'); + }); + + it('test_sync_command_source_does_not_call_blanket_closeLbug', () => { + const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts'); + const source = fs.readFileSync(cliGroupPath, 'utf-8'); + + // closeLbug() without arguments (blanket close) must not appear. + // Match closeLbug() but not closeLbug(someArg) + const blanketClosePattern = /closeLbug\s*\(\s*\)/; + expect(source).not.toMatch(blanketClosePattern); + }); }); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index c8a3c18e2..b4fc63b5c 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -89,6 +89,122 @@ service HealthCheck { expect(contracts).toHaveLength(1); expect(contracts[0].contractId).toBe('grpc::HealthCheck/Check'); }); + + it('test_extract_proto_with_google_api_http_nested_braces', async () => { + writeFile( + 'api/gateway.proto', + `syntax = "proto3"; +package gateway.v1; + +import "google/api/annotations.proto"; + +service GatewayService { + rpc GetUser (GetUserRequest) returns (UserResponse) { + option (google.api.http) = { + get: "/v1/users/{user_id}" + }; + } + rpc CreateUser (CreateUserRequest) returns (UserResponse) { + option (google.api.http) = { + post: "/v1/users" + body: "*" + }; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto', + ); + + expect(providers).toHaveLength(2); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::gateway.v1.GatewayService/CreateUser', + 'grpc::gateway.v1.GatewayService/GetUser', + ]); + }); + + it('test_extract_proto_with_multiple_services', async () => { + writeFile( + 'api/multi.proto', + `syntax = "proto3"; +package multi; + +service ServiceA { + rpc MethodA (Req) returns (Res); +} + +service ServiceB { + rpc MethodB1 (Req) returns (Res); + rpc MethodB2 (Req) returns (Res); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto', + ); + + expect(providers).toHaveLength(3); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::multi.ServiceA/MethodA', + 'grpc::multi.ServiceB/MethodB1', + 'grpc::multi.ServiceB/MethodB2', + ]); + }); + + it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => { + writeFile( + 'api/nested.proto', + `syntax = "proto3"; +package nested; + +service DeepService { + rpc DeepMethod (Req) returns (Res) { + option (google.api.http) = { + post: "/v1/deep" + body: "*" + additional_bindings { + get: "/v1/deep/{id}" + } + }; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto', + ); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod'); + }); + + it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => { + writeFile( + 'api/broken.proto', + `syntax = "proto3"; +package broken; + +service IncompleteService { + rpc SomeMethod (Req) returns (Res); + // Missing closing brace — EOF before depth returns to 0 +`, + ); + + // Should not throw; incomplete service is silently skipped + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto', + ); + + // The old regex would find partial match; the new parser should skip it + expect(providers).toHaveLength(0); + }); }); describe('Go server detection', () => { diff --git a/gitnexus/test/unit/group/service-boundary-detector.test.ts b/gitnexus/test/unit/group/service-boundary-detector.test.ts index 4898b05eb..ea508c937 100644 --- a/gitnexus/test/unit/group/service-boundary-detector.test.ts +++ b/gitnexus/test/unit/group/service-boundary-detector.test.ts @@ -149,6 +149,71 @@ describe('ServiceBoundaryDetector', () => { expect(boundaries).toHaveLength(1); expect(boundaries[0].markers).toContain('pyproject.toml'); }); + + it('test_detect_skips_vendor_directory', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/src/index.ts', ''); + // vendor should be skipped — its contents should not create a boundary + writeFile('vendor/some-dep/package.json', '{}'); + writeFile('vendor/some-dep/src/lib.go', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/auth'); + expect(paths).not.toContain('vendor/some-dep'); + }); + + it('test_detect_skips_target_directory', async () => { + writeFile('services/api/go.mod', 'module api'); + writeFile('services/api/main.go', ''); + writeFile('target/classes/Main.java', ''); + writeFile('target/pom.xml', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/api'); + expect(paths).not.toContain('target'); + }); + + it('test_detect_skips_pycache_directory', async () => { + writeFile('services/ml/pyproject.toml', '[project]'); + writeFile('services/ml/model.py', ''); + // __pycache__ with a marker + source files — would be detected as + // a boundary if not excluded, since it has package.json + .py file + writeFile('__pycache__/package.json', '{}'); + writeFile('__pycache__/cached.py', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/ml'); + expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true); + }); + + it('test_detect_skips_dotfile_directories_regression', async () => { + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + writeFile('.hidden/package.json', '{}'); + writeFile('.hidden/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + const paths = boundaries.map((b) => b.servicePath); + expect(paths).toContain('services/api'); + expect(paths).not.toContain('.hidden'); + }); + + it('test_detect_does_not_skip_regular_source_directories', async () => { + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('api'); + }); }); describe('assignService', () => { diff --git a/gitnexus/test/unit/group/storage.test.ts b/gitnexus/test/unit/group/storage.test.ts index 700e91d7c..8a4500e05 100644 --- a/gitnexus/test/unit/group/storage.test.ts +++ b/gitnexus/test/unit/group/storage.test.ts @@ -8,6 +8,8 @@ import { writeContractRegistry, readContractRegistry, listGroups, + createGroupDir, + validateGroupName, } from '../../../src/core/group/storage.js'; import type { ContractRegistry } from '../../../src/core/group/types.js'; @@ -78,4 +80,59 @@ describe('Group storage', () => { const groups = await listGroups(tmpDir); expect(groups.sort()).toEqual(['company', 'personal']); }); + + describe('validateGroupName', () => { + it('test_validateGroupName_traversal_path_throws', () => { + expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_slash_in_name_throws', () => { + expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_empty_string_throws', () => { + expect(() => validateGroupName('')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_starts_with_dash_throws', () => { + expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_starts_with_underscore_throws', () => { + expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_dots_throws', () => { + expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/); + }); + + it('test_validateGroupName_valid_alphanumeric_passes', () => { + expect(() => validateGroupName('my-group_01')).not.toThrow(); + }); + + it('test_validateGroupName_single_char_passes', () => { + expect(() => validateGroupName('A')).not.toThrow(); + }); + + it('test_validateGroupName_all_digits_passes', () => { + expect(() => validateGroupName('123')).not.toThrow(); + }); + }); + + describe('getGroupDir rejects invalid names', () => { + it('test_getGroupDir_traversal_throws', () => { + expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/); + }); + + it('test_getGroupDir_valid_name_returns_path', () => { + const dir = getGroupDir(tmpDir, 'company'); + expect(dir).toBe(path.join(tmpDir, 'groups', 'company')); + }); + }); + + describe('createGroupDir rejects invalid names', () => { + it('test_createGroupDir_traversal_throws', async () => { + await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/); + }); + }); }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index f34bfd4d9..50c9093b9 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -157,6 +157,51 @@ describe('syncGroup', () => { expect(result).toBeDefined(); }); + it('test_syncGroup_closes_only_opened_pools', async () => { + const config = makeConfig({ + 'app/backend': 'backend-repo', + 'app/frontend': 'frontend-repo', + }); + + const closedIds: string[] = []; + + const { vi } = await import('vitest'); + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => { + if (id) closedIds.push(id); + }); + + try { + await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: groupPath.replace(/\//g, '-'), + path: groupPath, + repoPath: '/tmp/' + groupPath, + storagePath: '/tmp/' + groupPath + '/.gitnexus', + }), + skipWrite: true, + }).catch(() => {}); + + // closeLbug must have been called at least once with specific pool ids + expect(closeSpy.mock.calls.length).toBeGreaterThan(0); + expect(closedIds).toContain('app-backend'); + expect(closedIds).toContain('app-frontend'); + + // Every call must have a truthy string id + for (const id of closedIds) { + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + } + // No blanket close (no-arg or empty-string or undefined) + const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]); + expect(blanketCalls).toHaveLength(0); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + } + }); + it('writes registry to groupDir when skipWrite is false', async () => { const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true });