Merge pull request #626 from ivkond/feat/intra-repo-service-tracking-clean

[group] Intra-repo service communication tracking
This commit is contained in:
Abhigyan Patwari 2026-04-03 17:04:24 +05:30 • committed by GitHub
commit a2774c3930
63 changed files with 7343 additions and 638 deletions

View file

@ -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"})`.

View file

@ -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) <noreply@anthropic.com>"
```
---
### 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', '<project/>');
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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```

View file

@ -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 <Name> {` 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<string>` (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).

View file

@ -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

View file

@ -21,6 +21,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",
@ -46,6 +47,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",
@ -1993,6 +1995,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",
@ -2286,6 +2295,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",
@ -3567,6 +3582,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",

View file

@ -57,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 +92,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",

View file

@ -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) {

298
gitnexus/src/cli/group.ts Normal file
View file

@ -0,0 +1,298 @@
// 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 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)`,
);
}
});
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(() => {});
}
});
}

View file

@ -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);

View file

@ -93,7 +93,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
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();

View file

@ -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 };
}
}

View file

@ -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<string, unknown>;
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<string, string>;
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<string, unknown>;
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<string, Record<string, string>>) || {};
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<GroupConfig> {
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);
}

View file

@ -0,0 +1,16 @@
import type { ContractType, ExtractedContract, RepoHandle } from './types.js';
export interface ContractExtractor {
type: ContractType;
canExtract(repo: RepoHandle): Promise<boolean>;
extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]>;
}
export type CypherExecutor = (
query: string,
params?: Record<string, unknown>,
) => Promise<Record<string, unknown>[]>;

View file

@ -0,0 +1,357 @@
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 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',
filePath: string,
symbolName: string,
confidence: number,
meta: Record<string, unknown>,
): 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<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
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] : '';
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;
}
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<string>();
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;
}
}

View file

@ -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<string, string> = {
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<string, unknown>[],
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<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]> {
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<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
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<string, string> = {
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<ExtractedContract[]> {
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<string>();
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<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
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<ExtractedContract[]> {
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',
},
};
}
}

View file

@ -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<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
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<string>();
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;
}
}

View file

@ -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, StoredContract[]>): 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<string, StoredContract[]>();
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<string>();
const matchedProviderIds = new Set<string>();
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 };
}

View file

@ -0,0 +1,177 @@
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',
]);
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<ServiceBoundary[]> {
const boundaries: ServiceBoundary[] = [];
await walkForBoundaries(repoPath, repoPath, boundaries);
return boundaries;
}
async function walkForBoundaries(
dir: string,
repoRoot: string,
results: ServiceBoundary[],
): Promise<void> {
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('.') || EXCLUDED_DIRS.has(entry.name)) 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<boolean> {
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('.') && !EXCLUDED_DIRS.has(entry.name)) {
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;
}

View 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,
};
}
}

View file

@ -0,0 +1,109 @@
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');
}
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);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,
): Promise<void> {
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<ContractRegistry | null> {
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<string[]> {
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<string> {
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;
}

View 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,
};
}

View file

@ -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<string, string>;
links: GroupManifestLink[];
packages: Record<string, Record<string, string>>;
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<string, unknown>;
/** 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<string, RepoSnapshot>;
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<string, unknown[]>;
affected_processes: string[];
}
export interface OutOfScopeLink {
from: string;
to: string;
contractId: string;
confidence: number;
}

View file

@ -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<string, PoolEntry>();
/**
* 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<string, SharedDB>();
/** 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<typeof setInterval> | 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<void> 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<string, Promise<void>>();
/**
* 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<void> => {
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<void> {
// 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<void> {
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<lbug.Connection> {
// 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<lbug.Connection>((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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, 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<any[]> => {
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<string, any>,
): Promise<any[]> => {
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<void> => {
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 =
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}

View file

@ -73,7 +73,7 @@ export const searchFTSFromLbug = async (
// Use MCP connection pool via dynamic import
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
// The MCP pool supports multiple connections, but FTS is best run serially.
const { executeQuery } = await import('../../mcp/core/lbug-adapter.js');
const { executeQuery } = await import('../lbug/pool-adapter.js');
const executor = (cypher: string) => executeQuery(repoId, cypher);
fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);

View file

@ -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__';

View file

@ -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<string, PoolEntry>();
/**
* 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<string, SharedDB>();
/** 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<typeof setInterval> | 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<void> 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<string, Promise<void>>();
/**
* 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<void> => {
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<void> {
// 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<void> {
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<lbug.Connection> {
// 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<lbug.Connection>((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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, 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<any[]> => {
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<string, any>,
): Promise<any[]> => {
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<void> => {
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 =
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
export * from '../../core/lbug/pool-adapter.js';

View file

@ -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.

View file

@ -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';

View file

@ -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'],
},
},
];

View file

@ -21,7 +21,7 @@ import {
closeLbug,
withLbugDb,
} from '../core/lbug/lbug-adapter.js';
import { isWriteQuery } from '../mcp/core/lbug-adapter.js';
import { isWriteQuery } from '../core/lbug/pool-adapter.js';
import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared';
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';

24
gitnexus/test/fixtures/group/group.yaml vendored Normal file
View file

@ -0,0 +1,24 @@
# Test group for integration / fixture-driven tests
version: 1
name: test-group
description: "Test group for integration tests"
repos:
app/backend: test-backend
app/frontend: test-frontend
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

View 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;

View 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;

View 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();
}

View file

@ -0,0 +1 @@
{ "name": "@monorepo/auth", "version": "1.0.0" }

View file

@ -0,0 +1,5 @@
syntax = "proto3";
package auth;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}

View file

@ -0,0 +1,3 @@
export async function publishLoginEvent(producer: any, userId: string) {
await producer.send({ topic: 'user.logged-in', messages: [{ value: userId }] });
}

View file

@ -0,0 +1,8 @@
import { GrpcMethod } from '@nestjs/microservices';
export class AuthController {
@GrpcMethod('AuthService', 'Login')
login(data: unknown): unknown {
return { token: 'ok' };
}
}

View file

@ -0,0 +1 @@
{ "name": "@monorepo/gateway", "version": "1.0.0" }

View file

@ -0,0 +1,4 @@
export async function createOrder(data: unknown) {
const res = await fetch('/api/orders', { method: 'POST' });
return res.json();
}

View file

@ -0,0 +1 @@
{ "name": "@monorepo/orders", "version": "1.0.0" }

View 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 });

View 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;

View file

@ -128,13 +128,13 @@ export function withTestLbugDB(
if (options?.poolAdapter) {
const coreDb = adapter.getDatabase();
if (!coreDb) throw new Error('withTestLbugDB: core adapter has no open Database');
const { initLbugWithDb } = await import('../../src/mcp/core/lbug-adapter.js');
const { initLbugWithDb } = await import('../../src/core/lbug/pool-adapter.js');
await initLbugWithDb(repoId, coreDb, dbPath);
}
const cleanup = async () => {
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();

View file

@ -0,0 +1,68 @@
/**
* 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');
});
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);
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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', () => ({

View file

@ -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);
});
});

View 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');
});
});

View file

@ -0,0 +1,391 @@
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');
});
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', () => {
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<LoginResponse> 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);
});
});
});

View file

@ -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<User> 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<User> 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'),
`<?php
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
`,
);
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::/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}');
}
});
});
});
});

View 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();
});
});

View file

@ -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);
});
});

View file

@ -0,0 +1,280 @@
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', '<project/>');
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');
});
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', '<project/>');
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', () => {
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();
});
});
});

View 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();
}
});
});
});

View file

@ -0,0 +1,138 @@
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,
createGroupDir,
validateGroupName,
} 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']);
});
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/);
});
});
});

View file

@ -0,0 +1,268 @@
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('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 });
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);
});
});

View file

@ -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<String, String> 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<String, String> 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);
});
});
});

View file

@ -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');
});
});

View file

@ -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 {

View file

@ -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.

View file

@ -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;