mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(lbug): pin repos to exempt them from automatic pool eviction [#2189] Add a pinnedRepos set and pinRepo/unpinRepo to the LadybugDB pool adapter. evictLRU and the idle-timeout sweep skip pinned repos; closeOne clears the pin on teardown so explicit close always wins and pins never leak across operations. Behavior is byte-identical when nothing is pinned. Bounded multi-repo callers (group sync) can now keep more than MAX_POOL_SIZE repos resident through deferred cross-repo resolution. * fix(group): pin repos during sync so >MAX_POOL_SIZE groups resolve [#2189] syncGroup now pins each repo immediately after initLbug and releases the pin (unpin then close) in the finally. This keeps every group member resident through the deferred manifest/workspace resolution that runs after the init loop, so cross-links anchor to real graph symbols instead of falling back to synthetic UIDs when a group has more than MAX_POOL_SIZE repos. Release is unpin-before-close plus closeOne's own pin-clear, so pins never leak across syncs in the long-lived MCP server even on error. * style(test): apply prettier formatting to #2189 test files * fix(review): apply autofix feedback Clarify the pinRepo docstring: the pin does not survive teardown (closeOne clears it) and the repoId must match the key passed to initLbug. Addresses a code-review finding that the prior 'or later holds' wording contradicted closeOne's unconditional pin-clear. * refactor(lbug): reference-count pool pins so overlapping holders are safe [#2189] Change pinnedRepos from Set<string> to Map<string,number>. pinRepo increments the lease count; unpinRepo decrements and deletes the key at 0 (flooring at zero, unknown-id no-op). evictLRU, the idle sweep, and closeOne are transparent to the swap (has()/delete() keep their semantics: skip while count>=1, force-clear on teardown). A boolean Set could not represent two simultaneous holders, so the first release wrongly cleared a pin another holder still needed — the concurrent overlapping group_sync teardown race from the PR #2191 review (Finding 1). Reference counts let two windows of one sync, or two concurrent syncs sharing a repo, coexist safely: the repo stays exempt until the last lease releases. * refactor(lbug): pinRepo returns a leak-proof release disposer [#2189] pinRepo now returns a release() disposer (mirroring addPoolCloseListener) that releases its own lease exactly once — a double-call is a guarded no-op, so it can never over-decrement a sibling holder's reference count. Callers can use the leak-proof pattern `const release = pinRepo(id); try { … } finally { release(); }`. unpinRepo stays exported for explicit pairing. Addresses the PR #2191 review's P3: the exported pin primitive had no built-in pairing, so a caller that forgot to unpin would disable eviction for a repo permanently. * refactor(group): windowed manifest resolution bounds sync pool residency [#2189] Replace whole-sync pinning with windowed deferred resolution. The init loop extracts contracts without pinning (repos evict naturally); manifest links are pre-sorted and partitioned into windows whose referenced in-group repos number <= getMaxResidentRepos(), and each window re-inits + leases only its own repos, resolves, then RELEASES the leases (not closeLbug — released repos stay evictable for the LRU, which avoids stomping a concurrent MCP reader). Peak per-sync pool residency is now bounded by getMaxResidentRepos() distinct repos regardless of group size, removing the unbounded-mmap crash risk the PR #2191 review flagged (Finding 3) — without a new magic-number threshold (it reuses MAX_POOL_SIZE via an intent-named accessor). #2189 stays fixed: each window resolves against live, freshly-leased pools, so cross-links anchor to real graph symbols. partitionManifestWindows is a pure, unit-tested function (every link in exactly one window — the contract-dedup invariant). New sync-windowed-resolution.test.ts asserts the partition bound and, through the real pool, that concurrently-open Databases never exceed the resident cap for a group larger than it. Rewrote the sync.test.ts pinning block (init loop no longer pins; per-window lease/release; release-not-close).
1329 lines
46 KiB
TypeScript
1329 lines
46 KiB
TypeScript
import { describe, it, expect, vi, afterEach } 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 { cleanupTempDir } from '../../helpers/test-db.js';
|
|
import { _captureLogger } from '../../../src/core/logger.js';
|
|
import type {
|
|
GroupConfig,
|
|
StoredContract,
|
|
RepoHandle,
|
|
GroupManifestLink,
|
|
} 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,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: 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_does_not_force_close_pools (release-not-close, #2191 review)', async () => {
|
|
// Post windowed-resolution refactor, syncGroup releases its eviction leases
|
|
// and lets the pool's LRU reclaim repos — it does NOT call closeLbug. This
|
|
// avoids tearing down a pool entry a concurrent MCP reader may share.
|
|
const config = makeConfig({
|
|
'app/backend': 'backend-repo',
|
|
'app/frontend': 'frontend-repo',
|
|
});
|
|
|
|
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').mockResolvedValue(undefined);
|
|
|
|
try {
|
|
await syncGroup(config, {
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: groupPath.replace(/\//g, '-'),
|
|
path: groupPath,
|
|
repoPath: '/tmp/' + groupPath,
|
|
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
|
}),
|
|
skipWrite: true,
|
|
}).catch(() => {});
|
|
|
|
// No closeLbug — repos are left evictable for the LRU to reclaim.
|
|
expect(closeSpy.mock.calls.length).toBe(0);
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
closeSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('manifest links in config.links produce cross-links with matchType manifest', async () => {
|
|
const links: GroupManifestLink[] = [
|
|
{
|
|
from: 'app/consumer',
|
|
to: 'app/provider',
|
|
type: 'http',
|
|
contract: 'GET::/api/orders',
|
|
role: 'consumer',
|
|
},
|
|
];
|
|
|
|
const config: GroupConfig = {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos: { 'app/consumer': 'consumer-repo', 'app/provider': 'provider-repo' },
|
|
links,
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
// ManifestExtractor should inject 2 contracts (provider + consumer) and 1 cross-link
|
|
expect(result.contracts).toHaveLength(2);
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(1);
|
|
expect(manifestLinks[0].contractId).toBe('http::GET::/api/orders');
|
|
expect(manifestLinks[0].from.repo).toBe('app/consumer');
|
|
expect(manifestLinks[0].to.repo).toBe('app/provider');
|
|
expect(manifestLinks[0].confidence).toBe(1.0);
|
|
|
|
// With no DB executors available, UIDs fall back to the deterministic
|
|
// synthetic form `manifest::<repo>::<contractId>`.
|
|
expect(manifestLinks[0].from.symbolUid).toBe('manifest::app/consumer::http::GET::/api/orders');
|
|
expect(manifestLinks[0].to.symbolUid).toBe('manifest::app/provider::http::GET::/api/orders');
|
|
|
|
// Manifest contracts also participate in runExactMatch; we must not emit a
|
|
// duplicate matchType:'exact' cross-link for the same endpoint pair.
|
|
const exactForSameContract = result.crossLinks.filter(
|
|
(cl) => cl.matchType === 'exact' && cl.contractId === 'http::GET::/api/orders',
|
|
);
|
|
expect(exactForSameContract).toHaveLength(0);
|
|
expect(result.crossLinks).toHaveLength(1);
|
|
});
|
|
|
|
it('runs thrift wildcard matching after exact matching and returns wildcard remaining', async () => {
|
|
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
|
const provider: StoredContract = {
|
|
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'provider',
|
|
symbolUid: 'uid-provider-place-order',
|
|
symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' },
|
|
symbolName: 'OrderService.PlaceOrder',
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo: 'app/provider',
|
|
};
|
|
const consumer: StoredContract = {
|
|
contractId: 'thrift::OrderService/*',
|
|
type: 'thrift',
|
|
role: 'consumer',
|
|
symbolUid: 'uid-consumer-order-service',
|
|
symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' },
|
|
symbolName: 'OrderClient',
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo: 'app/consumer',
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [provider, consumer],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(1);
|
|
expect(result.crossLinks[0].matchType).toBe('wildcard');
|
|
expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*');
|
|
expect(result.crossLinks[0].from.repo).toBe('app/consumer');
|
|
expect(result.crossLinks[0].to.repo).toBe('app/provider');
|
|
expect(result.unmatched).toEqual([provider]);
|
|
});
|
|
|
|
it('keeps wildcard thrift links to multiple extracted IDL provider methods', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-wildcard-'));
|
|
fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'idl', 'order.thrift'),
|
|
`namespace java billing.v1
|
|
|
|
service OrderService {
|
|
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
|
OrderResponse GetOrder(1: string orderId)
|
|
}`,
|
|
);
|
|
|
|
try {
|
|
const { ThriftExtractor } =
|
|
await import('../../../src/core/group/extractors/thrift-extractor.js');
|
|
const extractedProviders = (
|
|
await new ThriftExtractor().extract(null, tmpDir, {
|
|
id: 'provider-repo',
|
|
path: 'app/provider',
|
|
repoPath: tmpDir,
|
|
storagePath: path.join(tmpDir, '.gitnexus'),
|
|
})
|
|
)
|
|
.filter((c) => c.role === 'provider')
|
|
.map(
|
|
(c): StoredContract => ({
|
|
...c,
|
|
repo: 'app/provider',
|
|
}),
|
|
);
|
|
|
|
const consumer: StoredContract = {
|
|
contractId: 'thrift::OrderService/*',
|
|
type: 'thrift',
|
|
role: 'consumer',
|
|
symbolUid: 'manifest::app/consumer::thrift::OrderService/*',
|
|
symbolRef: { filePath: 'group.yaml', name: 'OrderService' },
|
|
symbolName: 'OrderService',
|
|
confidence: 1,
|
|
meta: {},
|
|
repo: 'app/consumer',
|
|
};
|
|
|
|
const result = await syncGroup(makeConfig({}), {
|
|
extractorOverride: async () => [...extractedProviders, consumer],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(2);
|
|
expect(result.crossLinks.map((cl) => cl.to.symbolRef.name).sort()).toEqual([
|
|
'OrderService.GetOrder',
|
|
'OrderService.PlaceOrder',
|
|
]);
|
|
expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('matches weak thrift method consumers to namespace-qualified providers during sync', async () => {
|
|
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
|
const provider: StoredContract = {
|
|
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'provider',
|
|
symbolUid: 'uid-provider-place-order',
|
|
symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' },
|
|
symbolName: 'OrderService.PlaceOrder',
|
|
confidence: 0.85,
|
|
meta: {},
|
|
repo: 'app/provider',
|
|
};
|
|
const consumer: StoredContract = {
|
|
contractId: 'thrift::OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'consumer',
|
|
symbolUid: 'uid-consumer-place-order',
|
|
symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' },
|
|
symbolName: 'orderService.PlaceOrder',
|
|
confidence: 0.45,
|
|
meta: {},
|
|
repo: 'app/consumer',
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [provider, consumer],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(1);
|
|
expect(result.crossLinks[0].matchType).toBe('exact');
|
|
expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/PlaceOrder');
|
|
expect(result.crossLinks[0].from.repo).toBe('app/consumer');
|
|
expect(result.crossLinks[0].to.repo).toBe('app/provider');
|
|
expect(result.unmatched).toHaveLength(0);
|
|
});
|
|
|
|
it('keeps exact thrift links to extracted IDL and Java providers for same method', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-exact-'));
|
|
fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, 'src', 'main', 'java', 'example'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'idl', 'order.thrift'),
|
|
`namespace java billing.v1
|
|
|
|
service OrderService {
|
|
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
|
}`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'src', 'main', 'java', 'example', 'IfaceOrderHandler.java'),
|
|
`package example;
|
|
|
|
class IfaceOrderHandler implements OrderService.Iface {
|
|
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
|
return new PlaceOrderResponse();
|
|
}
|
|
}`,
|
|
);
|
|
|
|
try {
|
|
const { ThriftExtractor } =
|
|
await import('../../../src/core/group/extractors/thrift-extractor.js');
|
|
const extractedProviders = (
|
|
await new ThriftExtractor().extract(null, tmpDir, {
|
|
id: 'provider-repo',
|
|
path: 'app/provider',
|
|
repoPath: tmpDir,
|
|
storagePath: path.join(tmpDir, '.gitnexus'),
|
|
})
|
|
)
|
|
.filter((c) => c.role === 'provider')
|
|
.map(
|
|
(c): StoredContract => ({
|
|
...c,
|
|
repo: 'app/provider',
|
|
}),
|
|
);
|
|
|
|
const consumer: StoredContract = {
|
|
contractId: 'thrift::OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'consumer',
|
|
symbolUid: [
|
|
'source-scan::thrift',
|
|
'consumer',
|
|
'OrderService/PlaceOrder',
|
|
'src/BillingWorkflow.java',
|
|
'orderService.PlaceOrder',
|
|
].join('::'),
|
|
symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' },
|
|
symbolName: 'orderService.PlaceOrder',
|
|
confidence: 0.45,
|
|
meta: {},
|
|
repo: 'app/consumer',
|
|
};
|
|
|
|
const result = await syncGroup(makeConfig({}), {
|
|
extractorOverride: async () => [...extractedProviders, consumer],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(2);
|
|
expect(result.crossLinks.map((cl) => cl.to.symbolRef.filePath).sort()).toEqual([
|
|
'idl/order.thrift',
|
|
'src/main/java/example/IfaceOrderHandler.java',
|
|
]);
|
|
expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('extracts thrift contracts during real sync when thrift detection is enabled', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-'));
|
|
const storageDir = path.join(tmpDir, '.gitnexus');
|
|
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'src'), { recursive: true });
|
|
fs.mkdirSync(storageDir, { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, 'services', 'billing', 'package.json'), '{}');
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'services', 'billing', 'src', 'BillingWorkflow.java'),
|
|
'package example; class BillingWorkflow {}',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'),
|
|
`namespace java billing.v1
|
|
|
|
service OrderService {
|
|
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
|
}`,
|
|
);
|
|
|
|
const config = makeConfig({ 'services/billing': 'billing-repo' });
|
|
config.detect.http = false;
|
|
config.detect.thrift = true;
|
|
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
|
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
|
|
|
|
try {
|
|
const result = await syncGroup(config, {
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'billing-repo',
|
|
path: groupPath,
|
|
repoPath: tmpDir,
|
|
storagePath: storageDir,
|
|
}),
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.missingRepos).toHaveLength(0);
|
|
expect(result.contracts).toHaveLength(1);
|
|
expect(result.contracts[0]).toMatchObject({
|
|
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'provider',
|
|
repo: 'services/billing',
|
|
service: 'services/billing',
|
|
symbolRef: {
|
|
filePath: 'services/billing/idl/order.thrift',
|
|
name: 'OrderService.PlaceOrder',
|
|
},
|
|
});
|
|
expect(initSpy).toHaveBeenCalledWith('billing-repo', path.join(storageDir, 'lbug'));
|
|
// syncGroup no longer force-closes pools (release-not-close, #2191 review);
|
|
// repos are left evictable for the LRU. Assert no teardown call here.
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
closeSpy.mockRestore();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not extract thrift contracts during real sync when thrift detection is disabled', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-off-'));
|
|
const storageDir = path.join(tmpDir, '.gitnexus');
|
|
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true });
|
|
fs.mkdirSync(storageDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'),
|
|
`namespace java billing.v1
|
|
|
|
service OrderService {
|
|
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
|
}`,
|
|
);
|
|
|
|
const config = makeConfig({ 'services/billing': 'billing-repo' });
|
|
config.detect.http = false;
|
|
config.detect.thrift = false;
|
|
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
|
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
|
|
|
|
try {
|
|
const result = await syncGroup(config, {
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'billing-repo',
|
|
path: groupPath,
|
|
repoPath: tmpDir,
|
|
storagePath: storageDir,
|
|
}),
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.missingRepos).toHaveLength(0);
|
|
expect(result.contracts).toHaveLength(0);
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
closeSpy.mockRestore();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not extract include contracts during real sync when includes detection is disabled', async () => {
|
|
// PR #1156 Codex follow-up: ce-code-review T1 — verifies the gate at
|
|
// sync.ts:174 honors `detect.includes: false`. Mirrors the existing
|
|
// thrift-off pattern at sync.test.ts:545.
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-includes-off-'));
|
|
const storageDir = path.join(tmpDir, '.gitnexus');
|
|
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
|
fs.mkdirSync(storageDir, { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, 'src', 'view.h'), '#pragma once\nclass View {};');
|
|
|
|
const config = makeConfig({ 'app/cpp-lib': 'cpp-lib-repo' });
|
|
config.detect.http = false;
|
|
config.detect.grpc = false;
|
|
config.detect.thrift = false;
|
|
config.detect.topics = false;
|
|
config.detect.includes = false;
|
|
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
|
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
|
|
|
|
try {
|
|
const result = await syncGroup(config, {
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: 'cpp-lib-repo',
|
|
path: groupPath,
|
|
repoPath: tmpDir,
|
|
storagePath: storageDir,
|
|
}),
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.missingRepos).toHaveLength(0);
|
|
expect(result.contracts.filter((c) => c.type === 'include')).toHaveLength(0);
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
closeSpy.mockRestore();
|
|
await cleanupTempDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
it('dedupes duplicate wildcard cross-links during sync', async () => {
|
|
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
|
const provider: StoredContract = {
|
|
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
|
type: 'thrift',
|
|
role: 'provider',
|
|
symbolUid: 'uid-provider-place-order',
|
|
symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' },
|
|
symbolName: 'OrderService.PlaceOrder',
|
|
confidence: 0.9,
|
|
meta: {},
|
|
repo: 'app/provider',
|
|
};
|
|
const duplicateProvider: StoredContract = {
|
|
...provider,
|
|
confidence: 0.7,
|
|
};
|
|
const consumer: StoredContract = {
|
|
contractId: 'thrift::OrderService/*',
|
|
type: 'thrift',
|
|
role: 'consumer',
|
|
symbolUid: 'uid-consumer-order-service',
|
|
symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' },
|
|
symbolName: 'OrderClient',
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo: 'app/consumer',
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [provider, duplicateProvider, consumer],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(1);
|
|
expect(result.crossLinks[0].matchType).toBe('wildcard');
|
|
});
|
|
|
|
it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => {
|
|
const links: GroupManifestLink[] = [
|
|
{
|
|
from: 'app/known',
|
|
to: 'app/dangling', // not present in config.repos
|
|
type: 'http',
|
|
contract: 'POST::/api/missing',
|
|
role: 'consumer',
|
|
},
|
|
];
|
|
|
|
const config: GroupConfig = {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos: { 'app/known': 'known-repo' },
|
|
links,
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
|
|
const cap = _captureLogger();
|
|
try {
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(1);
|
|
expect(result.crossLinks[0].matchType).toBe('manifest');
|
|
expect(result.crossLinks[0].to.symbolUid).toBe(
|
|
'manifest::app/dangling::http::POST::/api/missing',
|
|
);
|
|
expect(cap.records().some((r) => String(r.msg ?? '').includes('app/dangling'))).toBe(true);
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('writes registry to groupDir when skipWrite is false', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-write-'));
|
|
|
|
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 {
|
|
// syncGroup now writes bridge.lbug + WAL/shadow sidecars when
|
|
// skipWrite is false. On Windows, LadybugDB's checkpoint thread can
|
|
// briefly outlive closeBridgeDb, holding a Win32 lock on the file.
|
|
// cleanupTempDir tolerates the documented Windows-native lock codes
|
|
// (EBUSY/EPERM/EACCES/ENOTEMPTY) with bounded retries.
|
|
await cleanupTempDir(tmpDir);
|
|
}
|
|
});
|
|
|
|
describe('workspace_deps integration', () => {
|
|
let tmpDir: string;
|
|
|
|
function makeWsConfig(repos: Record<string, string>, workspaceDeps: boolean): GroupConfig {
|
|
return {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos,
|
|
links: [],
|
|
packages: {},
|
|
detect: {
|
|
http: false,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: workspaceDeps,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
}
|
|
|
|
function writeFileSync(relPath: string, content: string) {
|
|
const absPath = path.join(tmpDir, relPath);
|
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
fs.writeFileSync(absPath, content, 'utf-8');
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('workspace_deps: true discovers Rust crate links through syncGroup', async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-'));
|
|
|
|
writeFileSync(
|
|
'crate-a/Cargo.toml',
|
|
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
|
);
|
|
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
|
|
|
writeFileSync(
|
|
'crate-b/Cargo.toml',
|
|
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
|
);
|
|
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
|
|
|
const mockEntries: RegistryEntry[] = [
|
|
{
|
|
name: 'mathlex',
|
|
path: path.join(tmpDir, 'crate-a'),
|
|
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
{
|
|
name: 'thales',
|
|
path: path.join(tmpDir, 'crate-b'),
|
|
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
];
|
|
|
|
const repoManager = await import('../../../src/storage/repo-manager.js');
|
|
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
|
|
|
const config = makeWsConfig({ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' }, true);
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(1);
|
|
expect(manifestLinks[0].contractId).toBe('custom::mathlex::Expression');
|
|
expect(manifestLinks[0].from.repo).toBe('engine/thales');
|
|
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
|
|
});
|
|
|
|
it('workspace_deps: false skips workspace extraction entirely', async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-off-'));
|
|
|
|
writeFileSync(
|
|
'crate-a/Cargo.toml',
|
|
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
|
);
|
|
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
|
|
|
writeFileSync(
|
|
'crate-b/Cargo.toml',
|
|
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
|
);
|
|
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
|
|
|
const repoManager = await import('../../../src/storage/repo-manager.js');
|
|
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue([]);
|
|
|
|
const config = makeWsConfig(
|
|
{ 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
|
|
false,
|
|
);
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
expect(result.crossLinks).toHaveLength(0);
|
|
expect(result.contracts).toHaveLength(0);
|
|
});
|
|
|
|
it('discovered workspace links merge with explicit manifest links', async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-merge-'));
|
|
|
|
writeFileSync(
|
|
'crate-a/Cargo.toml',
|
|
'[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n',
|
|
);
|
|
writeFileSync('crate-a/src/lib.rs', 'pub struct Expression {}\n');
|
|
|
|
writeFileSync(
|
|
'crate-b/Cargo.toml',
|
|
'[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n',
|
|
);
|
|
writeFileSync('crate-b/src/main.rs', 'use mathlex::Expression;\n');
|
|
|
|
const mockEntries: RegistryEntry[] = [
|
|
{
|
|
name: 'mathlex',
|
|
path: path.join(tmpDir, 'crate-a'),
|
|
storagePath: path.join(tmpDir, 'crate-a', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
{
|
|
name: 'thales',
|
|
path: path.join(tmpDir, 'crate-b'),
|
|
storagePath: path.join(tmpDir, 'crate-b', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
];
|
|
|
|
const repoManager = await import('../../../src/storage/repo-manager.js');
|
|
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
|
|
|
const explicitLinks: GroupManifestLink[] = [
|
|
{
|
|
from: 'parser/mathlex',
|
|
to: 'engine/thales',
|
|
type: 'http',
|
|
contract: 'GET::/api/parse',
|
|
role: 'provider',
|
|
},
|
|
];
|
|
|
|
const config: GroupConfig = {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos: { 'parser/mathlex': 'mathlex', 'engine/thales': 'thales' },
|
|
links: explicitLinks,
|
|
packages: {},
|
|
detect: {
|
|
http: false,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: true,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(2);
|
|
|
|
const contractIds = manifestLinks.map((cl) => cl.contractId);
|
|
expect(contractIds).toContain('http::GET::/api/parse');
|
|
expect(contractIds).toContain('custom::mathlex::Expression');
|
|
});
|
|
|
|
it('discovers Node workspace links through syncGroup orchestrator', async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-node-'));
|
|
|
|
writeFileSync('shared/package.json', '{"name": "@myorg/shared", "version": "1.0.0"}');
|
|
writeFileSync('shared/src/index.ts', 'export class Config {}\n');
|
|
|
|
writeFileSync(
|
|
'app/package.json',
|
|
'{"name": "@myorg/app", "version": "1.0.0", "dependencies": {"@myorg/shared": "workspace:*"}}',
|
|
);
|
|
writeFileSync('app/src/index.ts', "import { Config } from '@myorg/shared';\n");
|
|
|
|
const mockEntries: RegistryEntry[] = [
|
|
{
|
|
name: 'shared',
|
|
path: path.join(tmpDir, 'shared'),
|
|
storagePath: path.join(tmpDir, 'shared', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
{
|
|
name: 'app',
|
|
path: path.join(tmpDir, 'app'),
|
|
storagePath: path.join(tmpDir, 'app', '.gitnexus'),
|
|
indexedAt: '',
|
|
lastCommit: '',
|
|
},
|
|
];
|
|
|
|
const repoManager = await import('../../../src/storage/repo-manager.js');
|
|
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
|
|
|
const config = makeWsConfig({ 'pkg/shared': 'shared', 'pkg/app': 'app' }, true);
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(1);
|
|
const nodeLink = manifestLinks.find(
|
|
(cl) => cl.contractId === 'custom::@myorg/shared::Config',
|
|
);
|
|
expect(nodeLink).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('manifest symbol resolution runs before closeLbug (issue #1802)', async () => {
|
|
const links: GroupManifestLink[] = [
|
|
{
|
|
from: 'svc/orders',
|
|
to: 'svc/payments',
|
|
type: 'http',
|
|
contract: 'GET::/api/checkout',
|
|
role: 'consumer',
|
|
},
|
|
];
|
|
|
|
const config: GroupConfig = {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos: { 'svc/orders': 'orders-repo', 'svc/payments': 'payments-repo' },
|
|
links,
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
|
|
let closeLbugCalled = false;
|
|
let manifestResolvedWhilePoolOpen = false;
|
|
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
|
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async () => {
|
|
closeLbugCalled = true;
|
|
});
|
|
const execSpy = vi
|
|
.spyOn(poolAdapter, 'executeParameterized')
|
|
.mockImplementation(
|
|
async (_poolId: string, query: string, _params: Record<string, unknown>) => {
|
|
if (query.includes('HANDLES_ROUTE')) {
|
|
manifestResolvedWhilePoolOpen = !closeLbugCalled;
|
|
}
|
|
return [
|
|
{ uid: 'real-uid-checkout', name: 'CheckoutHandler', filePath: 'src/checkout.ts' },
|
|
];
|
|
},
|
|
);
|
|
|
|
try {
|
|
const result = await syncGroup(config, {
|
|
resolveRepoHandle: async (_name, groupPath) => ({
|
|
id: groupPath.replace(/\//g, '-'),
|
|
path: groupPath,
|
|
repoPath: '/tmp/' + groupPath,
|
|
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
|
}),
|
|
skipWrite: true,
|
|
});
|
|
|
|
// Manifest symbol resolution runs against live (leased) pools.
|
|
expect(manifestResolvedWhilePoolOpen).toBe(true);
|
|
|
|
// The manifest cross-link must use the real UID from the DB, not synthetic
|
|
// — the #2189 fix, now via windowed resolution (the svc/orders↔svc/payments
|
|
// link forms one window whose repos are re-inited + leased for resolution).
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(1);
|
|
expect(manifestLinks[0].to.symbolUid).toBe('real-uid-checkout');
|
|
expect(manifestLinks[0].to.symbolUid).not.toContain('manifest::');
|
|
|
|
// syncGroup no longer force-closes pools (release-not-close, #2191 review).
|
|
expect(closeLbugCalled).toBe(false);
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
closeSpy.mockRestore();
|
|
execSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('extractorOverride no-DB path still produces synthetic manifest UIDs', async () => {
|
|
const links: GroupManifestLink[] = [
|
|
{
|
|
from: 'svc/orders',
|
|
to: 'svc/payments',
|
|
type: 'http',
|
|
contract: 'GET::/api/checkout',
|
|
role: 'consumer',
|
|
},
|
|
];
|
|
|
|
const config: GroupConfig = {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos: { 'svc/orders': 'orders-repo', 'svc/payments': 'payments-repo' },
|
|
links,
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
|
|
const result = await syncGroup(config, {
|
|
extractorOverride: async () => [],
|
|
skipWrite: true,
|
|
});
|
|
|
|
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
|
expect(manifestLinks).toHaveLength(1);
|
|
expect(manifestLinks[0].from.symbolUid).toBe('manifest::svc/orders::http::GET::/api/checkout');
|
|
expect(manifestLinks[0].to.symbolUid).toBe('manifest::svc/payments::http::GET::/api/checkout');
|
|
});
|
|
});
|
|
|
|
// Lifecycle wiring for issue #2189: syncGroup must pin every repo it
|
|
// initializes (so a group larger than MAX_POOL_SIZE survives deferred
|
|
// manifest/workspace resolution) and release those pins on completion AND on
|
|
// error. The eviction-survival MECHANISM itself is proven against real
|
|
// evictLRU in test/unit/lbug-pool-pinning.test.ts; these tests prove the sync
|
|
// loop drives that mechanism correctly. (A full end-to-end proof through the
|
|
// real pool — real symbolUid instead of synthetic after >5 repos — would
|
|
// require a real or fully-native-mocked LadybugDB stack; mechanism + wiring
|
|
// coverage stands in for it here.)
|
|
describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review)', () => {
|
|
const groupConfig = (count: number, links: GroupManifestLink[] = []): GroupConfig => {
|
|
const repos: Record<string, string> = {};
|
|
for (let i = 1; i <= count; i++) repos[`app/repo-${i}`] = `repo-${i}`;
|
|
return {
|
|
version: 1,
|
|
name: 'test',
|
|
description: '',
|
|
repos,
|
|
links,
|
|
packages: {},
|
|
detect: {
|
|
http: true,
|
|
grpc: false,
|
|
thrift: false,
|
|
topics: false,
|
|
shared_libs: false,
|
|
embedding_fallback: false,
|
|
workspace_deps: false,
|
|
},
|
|
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
|
};
|
|
};
|
|
|
|
const okHandle = async (_name: string, groupPath: string): Promise<RepoHandle> => ({
|
|
id: groupPath.replace(/\//g, '-'),
|
|
path: groupPath,
|
|
repoPath: '/tmp/' + groupPath,
|
|
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
|
});
|
|
|
|
const httpLink = (from: string, to: string): GroupManifestLink => ({
|
|
from,
|
|
to,
|
|
type: 'http',
|
|
contract: 'GET::/api/x',
|
|
role: 'consumer',
|
|
});
|
|
|
|
// pinRepo now returns a release disposer; the spy returns a tracked spy fn so
|
|
// tests can assert every acquired lease was released.
|
|
const setupPoolSpies = async () => {
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
const releaseSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
|
const execSpy = vi.spyOn(poolAdapter, 'executeParameterized').mockResolvedValue([]);
|
|
const pinSpy = vi.spyOn(poolAdapter, 'pinRepo').mockImplementation(() => {
|
|
const release = vi.fn();
|
|
releaseSpies.push(release);
|
|
return release;
|
|
});
|
|
const restore = () => {
|
|
initSpy.mockRestore();
|
|
execSpy.mockRestore();
|
|
pinSpy.mockRestore();
|
|
};
|
|
return { releaseSpies, initSpy, execSpy, pinSpy, restore };
|
|
};
|
|
|
|
it('pins only the repos referenced by manifest links, not the whole group', async () => {
|
|
const { pinSpy, restore } = await setupPoolSpies();
|
|
try {
|
|
await syncGroup(groupConfig(8, [httpLink('app/repo-1', 'app/repo-2')]), {
|
|
resolveRepoHandle: okHandle,
|
|
skipWrite: true,
|
|
});
|
|
const pinnedIds = pinSpy.mock.calls.map((c) => c[0]).sort();
|
|
// Only the windowed (link-referenced) repos are leased — bounded residency,
|
|
// not the whole 8-repo group.
|
|
expect(pinnedIds).toEqual(['app-repo-1', 'app-repo-2']);
|
|
expect(pinnedIds).not.toContain('app-repo-3');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it('does not pin during the init loop when there are no manifest links', async () => {
|
|
const { pinSpy, restore } = await setupPoolSpies();
|
|
try {
|
|
await syncGroup(groupConfig(8, []), { resolveRepoHandle: okHandle, skipWrite: true });
|
|
// The init loop extracts contracts without pinning; with no links there
|
|
// are no resolution windows, so nothing is ever pinned.
|
|
expect(pinSpy.mock.calls.length).toBe(0);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it('releases every window lease on successful completion', async () => {
|
|
const { releaseSpies, restore } = await setupPoolSpies();
|
|
try {
|
|
await syncGroup(
|
|
groupConfig(8, [
|
|
httpLink('app/repo-1', 'app/repo-2'),
|
|
httpLink('app/repo-7', 'app/repo-8'),
|
|
]),
|
|
{ resolveRepoHandle: okHandle, skipWrite: true },
|
|
);
|
|
expect(releaseSpies.length).toBeGreaterThan(0);
|
|
for (const release of releaseSpies) expect(release).toHaveBeenCalled();
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it('releases the window leases even when resolution throws mid-window', async () => {
|
|
const { ManifestExtractor } =
|
|
await import('../../../src/core/group/extractors/manifest-extractor.js');
|
|
const { releaseSpies, restore } = await setupPoolSpies();
|
|
const manifestSpy = vi
|
|
.spyOn(ManifestExtractor.prototype, 'extractFromManifest')
|
|
.mockRejectedValue(new Error('resolution boom'));
|
|
try {
|
|
await expect(
|
|
syncGroup(groupConfig(8, [httpLink('app/repo-1', 'app/repo-2')]), {
|
|
resolveRepoHandle: okHandle,
|
|
skipWrite: true,
|
|
}),
|
|
).rejects.toThrow('resolution boom');
|
|
// The window's finally released its acquired leases despite the throw.
|
|
expect(releaseSpies.length).toBeGreaterThan(0);
|
|
for (const release of releaseSpies) expect(release).toHaveBeenCalled();
|
|
} finally {
|
|
restore();
|
|
manifestSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not pin a repo that fails to resolve (no pool handle)', async () => {
|
|
const { pinSpy, restore } = await setupPoolSpies();
|
|
try {
|
|
await syncGroup(groupConfig(3, [httpLink('app/repo-1', 'app/repo-2')]), {
|
|
resolveRepoHandle: async (_name, groupPath) =>
|
|
groupPath === 'app/repo-2' ? null : okHandle(_name, groupPath),
|
|
skipWrite: true,
|
|
});
|
|
const pinnedIds = pinSpy.mock.calls.map((c) => c[0]);
|
|
// repo-2 has no handle (resolve returned null) → not in knownRepos →
|
|
// never windowed, never leased; repo-1 (resolved) is.
|
|
expect(pinnedIds).toContain('app-repo-1');
|
|
expect(pinnedIds).not.toContain('app-repo-2');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it('releases an already-acquired lease when a later init in the same window throws', async () => {
|
|
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
|
const releaseSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
// Throw on the SECOND init of app-repo-2 — the first is the init-loop
|
|
// extraction; the second is the window re-init. This isolates the failure
|
|
// to window setup, after app-repo-1's lease was already acquired.
|
|
const initCounts = new Map<string, number>();
|
|
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockImplementation(async (id: string) => {
|
|
const n = (initCounts.get(id) ?? 0) + 1;
|
|
initCounts.set(id, n);
|
|
if (id === 'app-repo-2' && n === 2) throw new Error('window init boom');
|
|
});
|
|
const execSpy = vi.spyOn(poolAdapter, 'executeParameterized').mockResolvedValue([]);
|
|
const pinSpy = vi.spyOn(poolAdapter, 'pinRepo').mockImplementation(() => {
|
|
const release = vi.fn();
|
|
releaseSpies.push(release);
|
|
return release;
|
|
});
|
|
try {
|
|
await expect(
|
|
syncGroup(groupConfig(2, [httpLink('app/repo-1', 'app/repo-2')]), {
|
|
resolveRepoHandle: okHandle,
|
|
skipWrite: true,
|
|
}),
|
|
).rejects.toThrow('window init boom');
|
|
// Exactly one lease was acquired (app-repo-1) before app-repo-2's init
|
|
// threw, and the window finally released it — no leaked lease.
|
|
expect(releaseSpies.length).toBe(1);
|
|
expect(releaseSpies[0]).toHaveBeenCalled();
|
|
} finally {
|
|
initSpy.mockRestore();
|
|
execSpy.mockRestore();
|
|
pinSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|