GitNexus/gitnexus/test/integration/group/code-dep-sync.test.ts
sahalterion 05636bf681 feat(group): add code-level dependency detection between repos
Add CodeDepExtractor that detects when repo B imports symbols from
repo A's npm package. Wires into the existing group sync pipeline
using the already-declared `lib` contract type and `shared_libs`
detection flag.

New files:
- manifest-reader.ts: reads package.json for name + dependencies
- import-scanner.ts: scans source for ES/CJS imports matching sibling packages
- code-dep-extractor.ts: ContractExtractor producing lib::{pkg}::{symbol} contracts

Auto-discovers package mappings when config.packages is empty by reading
each repo's package.json name and checking cross-dependencies.

Includes 58 unit tests and 3 integration tests covering all extraction
paths, edge cases, and the full sync → match → CrossLink pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 06:27:17 -07:00

269 lines
8.5 KiB
TypeScript

/**
* Integration test for code-level dependency detection across repos.
*
* Tests the full pipeline:
* 1. Fixture repos with package.json (shared-utils exports, web-app imports)
* 2. CodeDepExtractor produces lib contracts (provider + consumer)
* 3. syncGroup matches them into CrossLinks with type 'lib'
*
* Uses extractorOverride for provider contracts (no LadybugDB needed),
* and real source scanning for consumer contracts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { syncGroup } from '../../../src/core/group/sync.js';
import type { GroupConfig, StoredContract } from '../../../src/core/group/types.js';
describe('Code-dep sync integration', () => {
let tmpDir: string;
let sharedDir: string;
let webAppDir: string;
beforeEach(() => {
tmpDir = path.join(os.tmpdir(), `gitnexus-codedep-integ-${Date.now()}`);
sharedDir = path.join(tmpDir, 'shared-utils');
webAppDir = path.join(tmpDir, 'web-app');
// Create shared-utils repo
fs.mkdirSync(path.join(sharedDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(sharedDir, 'package.json'),
JSON.stringify({
name: '@test/shared-utils',
version: '1.0.0',
dependencies: {},
}),
);
fs.writeFileSync(
path.join(sharedDir, 'src', 'utils.ts'),
`export function formatDate(d: Date): string { return d.toISOString(); }
export class Logger { log(msg: string) { console.log(msg); } }
export const VERSION = '1.0.0';`,
);
// Create web-app repo
fs.mkdirSync(path.join(webAppDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(webAppDir, 'package.json'),
JSON.stringify({
name: '@test/web-app',
version: '2.0.0',
dependencies: { '@test/shared-utils': '^1.0.0', react: '^18.0.0' },
}),
);
fs.writeFileSync(
path.join(webAppDir, 'src', 'app.ts'),
`import { formatDate, Logger } from '@test/shared-utils';
import React from 'react';
const logger = new Logger();
console.log(formatDate(new Date()));`,
);
fs.writeFileSync(
path.join(webAppDir, 'src', 'utils.ts'),
`import { VERSION } from '@test/shared-utils';
export function getVersion() { return VERSION; }`,
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeConfig(overrides?: Partial<GroupConfig>): GroupConfig {
return {
version: 1,
name: 'test-workspace',
description: 'Code-dep integration test',
repos: { 'libs/shared': 'shared-utils', 'apps/web': 'web-app' },
links: [],
packages: {},
detect: {
http: false,
grpc: false,
topics: false,
shared_libs: true,
embedding_fallback: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
...overrides,
};
}
it('matches lib contracts between provider and consumer repos', async () => {
const config = makeConfig();
// Mock contracts simulating what CodeDepExtractor would produce:
// Provider side: exported symbols from shared-utils
// Consumer side: imports in web-app
const mockContracts: StoredContract[] = [
// Providers from shared-utils
{
contractId: 'lib::@test/shared-utils::formatDate',
type: 'lib',
role: 'provider',
symbolUid: 'fn-formatDate',
symbolRef: { filePath: 'src/utils.ts', name: 'formatDate' },
symbolName: 'formatDate',
confidence: 0.9,
meta: { packageName: '@test/shared-utils' },
repo: 'libs/shared',
},
{
contractId: 'lib::@test/shared-utils::Logger',
type: 'lib',
role: 'provider',
symbolUid: 'class-Logger',
symbolRef: { filePath: 'src/utils.ts', name: 'Logger' },
symbolName: 'Logger',
confidence: 0.9,
meta: { packageName: '@test/shared-utils' },
repo: 'libs/shared',
},
{
contractId: 'lib::@test/shared-utils::VERSION',
type: 'lib',
role: 'provider',
symbolUid: 'const-VERSION',
symbolRef: { filePath: 'src/utils.ts', name: 'VERSION' },
symbolName: 'VERSION',
confidence: 0.9,
meta: { packageName: '@test/shared-utils' },
repo: 'libs/shared',
},
// Consumers from web-app
{
contractId: 'lib::@test/shared-utils::formatDate',
type: 'lib',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/app.ts', name: 'formatDate' },
symbolName: 'formatDate',
confidence: 0.9,
meta: { packageName: '@test/shared-utils', importType: 'named' },
repo: 'apps/web',
},
{
contractId: 'lib::@test/shared-utils::Logger',
type: 'lib',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/app.ts', name: 'Logger' },
symbolName: 'Logger',
confidence: 0.9,
meta: { packageName: '@test/shared-utils', importType: 'named' },
repo: 'apps/web',
},
{
contractId: 'lib::@test/shared-utils::VERSION',
type: 'lib',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/utils.ts', name: 'VERSION' },
symbolName: 'VERSION',
confidence: 0.9,
meta: { packageName: '@test/shared-utils', importType: 'named' },
repo: 'apps/web',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => mockContracts,
skipWrite: true,
});
// Should produce 3 cross-links: formatDate, Logger, VERSION
expect(result.crossLinks).toHaveLength(3);
// All cross-links should be lib type
for (const link of result.crossLinks) {
expect(link.type).toBe('lib');
expect(link.matchType).toBe('exact');
expect(link.confidence).toBe(1.0);
}
// Verify from/to directions
const formatDateLink = result.crossLinks.find((l) =>
l.contractId.includes('formatDate'),
);
expect(formatDateLink).toBeDefined();
expect(formatDateLink!.from.repo).toBe('apps/web'); // consumer
expect(formatDateLink!.to.repo).toBe('libs/shared'); // provider
const loggerLink = result.crossLinks.find((l) => l.contractId.includes('Logger'));
expect(loggerLink).toBeDefined();
expect(loggerLink!.from.repo).toBe('apps/web');
expect(loggerLink!.to.repo).toBe('libs/shared');
// No unmatched lib contracts (all 3 matched)
const unmatchedLib = result.unmatched.filter((c) => c.type === 'lib');
expect(unmatchedLib).toHaveLength(0);
});
it('handles unmatched lib contracts (consumer with no provider)', async () => {
const config = makeConfig();
const mockContracts: StoredContract[] = [
// Consumer wants something that no provider offers
{
contractId: 'lib::@test/shared-utils::nonExistent',
type: 'lib',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/app.ts', name: 'nonExistent' },
symbolName: 'nonExistent',
confidence: 0.9,
meta: {},
repo: 'apps/web',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => mockContracts,
skipWrite: true,
});
expect(result.crossLinks).toHaveLength(0);
expect(result.unmatched).toHaveLength(1);
expect(result.unmatched[0].contractId).toBe('lib::@test/shared-utils::nonExistent');
});
it('lib matching uses normalized contractId (case-insensitive)', async () => {
const config = makeConfig();
const mockContracts: StoredContract[] = [
{
contractId: 'lib::@test/shared-utils::formatDate',
type: 'lib',
role: 'provider',
symbolUid: 'fn-1',
symbolRef: { filePath: 'src/utils.ts', name: 'formatDate' },
symbolName: 'formatDate',
confidence: 0.9,
meta: {},
repo: 'libs/shared',
},
{
contractId: 'lib::@test/shared-utils::formatDate',
type: 'lib',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/app.ts', name: 'formatDate' },
symbolName: 'formatDate',
confidence: 0.9,
meta: {},
repo: 'apps/web',
},
];
const result = await syncGroup(config, {
extractorOverride: async () => mockContracts,
skipWrite: true,
});
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].type).toBe('lib');
});
});