mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
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>
269 lines
8.5 KiB
TypeScript
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');
|
|
});
|
|
});
|