mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture
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) <noreply@anthropic.com>
This commit is contained in:
parent
e114194c1f
commit
24b25640ba
32 changed files with 2043 additions and 59 deletions
17
README.md
17
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 <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <name> <repo> # Add a repo to a group
|
||||
gitnexus group remove <name> <repo> # Remove a repo from a group
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # 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 <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"})`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <name> <repo> # Add a repo to a group
|
||||
gitnexus group remove <name> <repo> # Remove a repo from a group
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
## Remote Embeddings
|
||||
|
|
|
|||
|
|
@ -42,10 +42,28 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
|||
* - 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<string[]> {
|
||||
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 <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
|
||||
|
||||
`
|
||||
: ''
|
||||
}## 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) {
|
||||
|
|
|
|||
303
gitnexus/src/cli/group.ts
Normal file
303
gitnexus/src/cli/group.ts
Normal file
|
|
@ -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 <name>')
|
||||
.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 <group> <groupPath> <registryName>')
|
||||
.description(
|
||||
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = 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 <group> <path>')
|
||||
.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 <name>');
|
||||
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 <name>')
|
||||
.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 <name>')
|
||||
.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<string, boolean | undefined>) => {
|
||||
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 <name> <query>')
|
||||
.description('Search execution flows across all repos in a group')
|
||||
.option('--subgroup <path>', 'Limit search scope')
|
||||
.option('--limit <n>', 'Max merged results', '5')
|
||||
.option('--json', 'JSON output')
|
||||
.action(
|
||||
async (
|
||||
name: string,
|
||||
queryText: string,
|
||||
opts: Record<string, string | boolean | undefined>,
|
||||
) => {
|
||||
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<Record<string, unknown>>;
|
||||
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 <name>')
|
||||
.description('Inspect Contract Registry')
|
||||
.option('--type <type>', 'Filter by contract type')
|
||||
.option('--repo <repo>', 'Filter by repo')
|
||||
.option('--unmatched', 'Show only unmatched contracts')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
registerGroupCommands(program);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
|
|
|||
223
gitnexus/src/core/group/service.ts
Normal file
223
gitnexus/src/core/group/service.ts
Normal file
|
|
@ -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<GroupRepoHandle>;
|
||||
impact(
|
||||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
target: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
query: string;
|
||||
task_context?: string;
|
||||
goal?: string;
|
||||
limit?: number;
|
||||
max_symbols?: number;
|
||||
include_content?: boolean;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
impactByUid(
|
||||
repoId: string,
|
||||
uid: string,
|
||||
direction: string,
|
||||
opts: {
|
||||
maxDepth: number;
|
||||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
},
|
||||
): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<Record<string, unknown>> };
|
||||
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<Record<string, unknown>>);
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
185
gitnexus/src/core/group/sync.ts
Normal file
185
gitnexus/src/core/group/sync.ts
Normal file
|
|
@ -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<StoredContract[]>)
|
||||
| (() => Promise<StoredContract[]>);
|
||||
resolveRepoHandle?: (registryName: string, groupPath: string) => Promise<RepoHandle | null>;
|
||||
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<string, RepoSnapshot>;
|
||||
}
|
||||
|
||||
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<RepoHandle | null> => {
|
||||
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<SyncResult> {
|
||||
const missingRepos: string[] = [];
|
||||
const repoSnapshots: Record<string, RepoSnapshot> = {};
|
||||
let autoContracts: StoredContract[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} 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<string, CypherExecutor>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string> = new Set();
|
||||
private reinitPromises: Map<string, Promise<void>> = new Map();
|
||||
private lastStalenessCheck: Map<string, number> = 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<void> {
|
||||
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<any> {
|
||||
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<any | null> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupList(params);
|
||||
}
|
||||
|
||||
private async groupSync(params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupSync(params);
|
||||
}
|
||||
|
||||
private async groupContracts(params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupContracts(params);
|
||||
}
|
||||
|
||||
private async groupQuery(params: Record<string, unknown>): Promise<unknown> {
|
||||
await this.refreshRepos();
|
||||
return this.getGroupService().groupQuery(params);
|
||||
}
|
||||
|
||||
private async groupStatus(params: Record<string, unknown>): Promise<unknown> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/group/test-backend/src/routes/health.ts
vendored
Normal file
8
gitnexus/test/fixtures/group/test-backend/src/routes/health.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Router } from 'express';
|
||||
const router = Router();
|
||||
|
||||
router.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
16
gitnexus/test/fixtures/group/test-backend/src/routes/users.ts
vendored
Normal file
16
gitnexus/test/fixtures/group/test-backend/src/routes/users.ts
vendored
Normal file
|
|
@ -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;
|
||||
18
gitnexus/test/fixtures/group/test-frontend/src/api/users.ts
vendored
Normal file
18
gitnexus/test/fixtures/group/test-frontend/src/api/users.ts
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
1
gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json
vendored
Normal file
1
gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@monorepo/auth", "version": "1.0.0" }
|
||||
5
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto
vendored
Normal file
5
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
syntax = "proto3";
|
||||
package auth;
|
||||
service AuthService {
|
||||
rpc Login (LoginRequest) returns (LoginResponse);
|
||||
}
|
||||
3
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts
vendored
Normal file
3
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export async function publishLoginEvent(producer: any, userId: string) {
|
||||
await producer.send({ topic: 'user.logged-in', messages: [{ value: userId }] });
|
||||
}
|
||||
8
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts
vendored
Normal file
8
gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { GrpcMethod } from '@nestjs/microservices';
|
||||
|
||||
export class AuthController {
|
||||
@GrpcMethod('AuthService', 'Login')
|
||||
login(data: unknown): unknown {
|
||||
return { token: 'ok' };
|
||||
}
|
||||
}
|
||||
1
gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json
vendored
Normal file
1
gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@monorepo/gateway", "version": "1.0.0" }
|
||||
4
gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts
vendored
Normal file
4
gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export async function createOrder(data: unknown) {
|
||||
const res = await fetch('/api/orders', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
1
gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json
vendored
Normal file
1
gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@monorepo/orders", "version": "1.0.0" }
|
||||
7
gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts
vendored
Normal file
7
gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts
vendored
Normal file
|
|
@ -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 });
|
||||
8
gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts
vendored
Normal file
8
gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts
vendored
Normal file
|
|
@ -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;
|
||||
52
gitnexus/test/integration/group/group-cli.test.ts
Normal file
52
gitnexus/test/integration/group/group-cli.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
79
gitnexus/test/integration/group/group-sync.test.ts
Normal file
79
gitnexus/test/integration/group/group-sync.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
120
gitnexus/test/integration/group/monorepo-sync.test.ts
Normal file
120
gitnexus/test/integration/group/monorepo-sync.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
27
gitnexus/test/unit/group/group-tools.test.ts
Normal file
27
gitnexus/test/unit/group/group-tools.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
25
gitnexus/test/unit/group/impact-by-uid.test.ts
Normal file
25
gitnexus/test/unit/group/impact-by-uid.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
366
gitnexus/test/unit/group/service.test.ts
Normal file
366
gitnexus/test/unit/group/service.test.ts
Normal file
|
|
@ -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> = {}): GroupToolPort {
|
||||
return {
|
||||
resolveRepo: vi.fn(
|
||||
async (name?: string): Promise<GroupRepoHandle> => ({
|
||||
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<string, string>;
|
||||
};
|
||||
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<string, { missing: boolean }>;
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
223
gitnexus/test/unit/group/sync.test.ts
Normal file
223
gitnexus/test/unit/group/sync.test.ts
Normal file
|
|
@ -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<string, string>): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue