GitNexus/gitnexus/test/unit/group/sync.test.ts
Gergő Magyar 66b44afe8c
fix(group): make degraded links, sync warnings and UID-only impact actually work (#3113)
* feat(group-surface): impact selector pass-through + degraded links + sync hygiene

- @group impact forwards target_uid/file_path/kind through service port
  and cross-impact impactParams (was dead-wired: params accepted at MCP
  boundary then dropped at validation).
- crossLinks with unresolved provider symbols carry degraded: true,
  derived at the persistence boundary after merge/dedupe; sync reports
  'degraded links: N' and per-repo extraction failures instead of
  swallowing them; bridge write failures surface as sync warnings;
  contracts.json passes through dedupeContracts.
- Absolute-URL branch restores %7B/%7D around {param} after URL parsing.
- tests: consumer matrix + wildcard folding + degraded pins (261 new);
  SCHEMA_BUMP pin 47 -> 48 (wildcardImports cache shape); sync.ts NUL
  byte rewritten as text escape (no longer binary to git).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(group): impact selector pass-through, degraded links, sync failure hygiene

- @group impact forwards target_uid/file_path/kind through the service
  port into cross-impact impactParams. These were accepted at the MCP
  boundary and then dropped in validation — a dead wire: disambiguating
  an ambiguous impact target never actually reached the per-member impact.
- Cross-links whose provider endpoint never resolves to a graph symbol are
  marked degraded: true at the single persistence boundary (post
  merge/dedupe, before re-export), counted as SyncResult.degradedLinks,
  and surfaced by the sync summary ('degraded links: N') — the remedy
  (re-analyze the provider repo) is documented on the field.
- Sync failure hygiene: a repo whose per-repo extraction throws records
  its reason in SyncResult.failedRepos (still lands in missingRepos, so
  downstream semantics are unchanged) instead of the old silent swallow
  that could persist half a repo's contracts; operator warnings
  accumulate in SyncResult.warnings.

Tests: cross-impact selector threading, degraded-link marking, per-repo
failure reporting.

* style: prettier

* fix(group): make degraded links, sync warnings and UID-only impact actually work

The three fixes this branch claims were wired at the type and payload level
but never at the boundary that produces the values:

- `degraded` was only ever cleared by the exported `dedupeCrossLinks`, which
  the sync path does not use, so `degradedLinks` was always 0. Derivation now
  lives in one exported `applyDegradedFlag` that both the sync finalize and
  the post-merge re-derivation call.
- The bridge-write catch logged an operator warning and dropped it, leaving
  `warnings` permanently `[]`.
- `@group impact` rejected a UID-only call before it parsed `target_uid`, so
  the documented "re-call with target_uid" disambiguation loop was
  unreachable in group mode even though the selectors were forwarded.
- `failedRepos[].repo` reported the registry display name while the repo
  landed in `unreadableRepos` under its group path, so the two lists could
  not be joined; the JSDoc also pointed at the wrong list.
- Restored the truncated `READ THE RESULT:` heading in the group_sync tool
  description and documented degradedLinks / failedRepos / warnings.

Tests pin each value at the boundary that produces it, including the exact
group_sync wire shape, which previously omitted all three new fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 20:03:53 +00:00

1504 lines
52 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,
graphql: false,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: false,
},
matching: {},
});
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('exact-matches GraphQL root fields across repositories', async () => {
const config = makeConfig({ api: 'api-repo', web: 'web-repo' });
const contracts: StoredContract[] = [
{
...makeContract('graphql::query::widget', 'provider', 'api'),
type: 'graphql',
},
{
...makeContract('graphql::query::widget', 'consumer', 'web'),
type: 'graphql',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => contracts,
skipWrite: true,
});
expect(result.crossLinks).toEqual([
expect.objectContaining({
type: 'graphql',
contractId: 'graphql::query::widget',
matchType: 'exact',
}),
]);
});
it('marks a cross-link degraded when the PROVIDER endpoint carries no resolved symbolUid', async () => {
// A provider whose symbol never resolved (empty symbolUid — e.g. a
// source-scan provider in a repo without graph symbols) still proves the
// service boundary, but cross-impact fan-out cannot anchor the link.
// isUnresolvedEndpoint is evaluated at the persistence boundary so exact,
// wildcard and manifest origins are covered by one truth point.
const config = makeConfig({ 'app/backend': 'backend-repo', 'app/frontend': 'frontend-repo' });
const mk = (role: 'provider' | 'consumer', uid: string): StoredContract => ({
contractId: 'http::GET::/api/users',
type: 'http',
role,
symbolUid: uid,
symbolRef: { filePath: `src/${role}.ts`, name: `fn-${role}` },
symbolName: `fn-${role}`,
confidence: 0.8,
meta: {},
repo: role === 'provider' ? 'app/backend' : 'app/frontend',
});
const degraded = await syncGroup(config, {
extractorOverride: async () => [mk('provider', ''), mk('consumer', 'uid-2')],
skipWrite: true,
});
expect(degraded.crossLinks).toHaveLength(1);
expect(degraded.crossLinks[0].degraded).toBe(true);
expect(degraded.degradedLinks).toBe(1);
// Control: a resolved provider keeps the flag absent (not false) so the
// field stays "carried only when meaningful" in contracts.json.
const resolved = await syncGroup(config, {
extractorOverride: async () => [mk('provider', 'uid-1'), mk('consumer', 'uid-2')],
skipWrite: true,
});
expect(resolved.crossLinks).toHaveLength(1);
expect('degraded' in resolved.crossLinks[0]).toBe(false);
expect(resolved.degradedLinks).toBe(0);
});
it('does NOT mark a manifest synthetic-UID link degraded — the manifest:: uid anchors fan-out', async () => {
// Manifest endpoints fall back to a deterministic
// `manifest::<repo>::<contractId>` uid exactly when the graph holds no
// symbol for them (dangling repo, or a string-dispatch provider with no
// symbol at all). Their `symbolRef.filePath` is empty by construction —
// which trips isUnresolvedEndpoint's field checks — but cross-impact
// anchors the crossing anyway (#2722: preserved with `fanout_status:
// 'not_attempted'` instead of cross=0). So the degraded flag ("cannot
// anchor a fan-out") must stay off: types.ts pins it as "distinct from
// manifest::… synthetic UIDs", which have their own downstream channel.
const links: GroupManifestLink[] = [
{
from: 'app/consumer',
to: 'app/dangling', // not in config.repos → unresolved → synthetic uid
type: 'http',
contract: 'GET::/api/orders',
role: 'consumer',
},
];
const config: GroupConfig = { ...makeConfig({ 'app/consumer': 'consumer-repo' }), links };
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::GET::/api/orders',
);
// The empty filePath is what makes this a regression guard: without the
// manifest:: exemption in isUnresolvedEndpoint the flag fires on it.
expect(result.crossLinks[0].to.symbolRef.filePath).toBe('');
expect('degraded' in result.crossLinks[0]).toBe(false);
expect(result.degradedLinks).toBe(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,
includes: false,
workspace_deps: false,
},
matching: {},
};
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,
includes: false,
workspace_deps: false,
},
matching: {},
};
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,
includes: false,
workspace_deps: workspaceDeps,
},
matching: {},
};
}
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('builds Maven manifest links when independent repositories share a parent POM', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-maven-parent-'));
const parentCoordinates = `<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1</version>
</parent>`;
const childPom = (artifactId: string, dependency = '') => `<project>
${parentCoordinates}
<artifactId>${artifactId}</artifactId>
<dependencies>${dependency}</dependencies>
</project>`;
const sharedDependency =
'<dependency><groupId>com.example</groupId><artifactId>shared-lib</artifactId></dependency>';
writeFileSync(
'parent/pom.xml',
'<project><groupId>com.example</groupId><artifactId>parent</artifactId><packaging>pom</packaging></project>',
);
writeFileSync('shared-lib/pom.xml', childPom('shared-lib'));
writeFileSync('service-a/pom.xml', childPom('service-a', sharedDependency));
writeFileSync(
'service-a/src/main/java/com/example/service/a/App.java',
'package com.example.service.a;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
writeFileSync('service-b/pom.xml', childPom('service-b', sharedDependency));
writeFileSync(
'service-b/src/main/kotlin/com/example/service/b/App.kt',
'package com.example.service.b\nimport com.example.shared.lib.SharedType\nclass App\n',
);
const repoPaths = ['parent', 'shared-lib', 'service-a', 'service-b'];
const mockEntries: RegistryEntry[] = repoPaths.map((repoPath) => ({
name: repoPath,
path: path.join(tmpDir, repoPath),
storagePath: path.join(tmpDir, repoPath, '.gitnexus'),
indexedAt: '',
lastCommit: '',
}));
const repoManager = await import('../../../src/storage/repo-manager.js');
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
const config = makeWsConfig(
{
parent: 'parent',
'libs/shared-lib': 'shared-lib',
'services/service-a': 'service-a',
'services/service-b': 'service-b',
},
true,
);
const result = await syncGroup(config, {
extractorOverride: async () => [],
skipWrite: true,
});
const manifestLinks = result.crossLinks.filter((link) => link.matchType === 'manifest');
expect(
manifestLinks.map((link) => ({
from: link.from.repo,
to: link.to.repo,
contractId: link.contractId,
})),
).toEqual([
{
from: 'services/service-a',
to: 'libs/shared-lib',
contractId: 'custom::shared-lib::SharedType',
},
{
from: 'services/service-b',
to: 'libs/shared-lib',
contractId: 'custom::shared-lib::SharedType',
},
]);
});
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,
includes: false,
workspace_deps: true,
},
matching: {},
};
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,
includes: false,
workspace_deps: false,
},
matching: {},
};
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,
includes: false,
workspace_deps: false,
},
matching: {},
};
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,
includes: false,
workspace_deps: false,
},
matching: {},
};
};
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);
});
});