mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
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>
This commit is contained in:
parent
dafda284bc
commit
05636bf681
8 changed files with 1502 additions and 0 deletions
191
gitnexus/src/core/group/extractors/code-dep-extractor.ts
Normal file
191
gitnexus/src/core/group/extractors/code-dep-extractor.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readNpmManifest } from './manifest-reader.js';
|
||||
import { scanRepoForImports, type ScannedImport } from './import-scanner.js';
|
||||
|
||||
const EXPORTED_SYMBOLS_QUERY = `
|
||||
MATCH (n)
|
||||
WHERE n.isExported = true AND n.name IS NOT NULL AND n.name <> ''
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath`;
|
||||
|
||||
/**
|
||||
* Extractor for code-level dependencies between repos in a group.
|
||||
*
|
||||
* Detects when repo B imports symbols from repo A's npm package by:
|
||||
* 1. Querying LadybugDB for exported symbols (providers)
|
||||
* 2. Scanning source files for import statements matching sibling packages (consumers)
|
||||
*
|
||||
* Uses the existing `lib` contract type and matching pipeline.
|
||||
*/
|
||||
export class CodeDepExtractor implements ContractExtractor {
|
||||
type = 'lib' as const;
|
||||
|
||||
constructor(
|
||||
/** Maps npm package name → group path (e.g. '@acme/shared' → 'libs/shared') */
|
||||
private readonly packageMap: Map<string, string>,
|
||||
/** This repo's own package name (used for provider contracts) */
|
||||
private readonly ownPackageName: string | null,
|
||||
) {}
|
||||
|
||||
async canExtract(repo: RepoHandle): Promise<boolean> {
|
||||
// Can extract if the repo has a package.json (for providers)
|
||||
// or if there are sibling packages to check imports against (for consumers)
|
||||
const manifest = readNpmManifest(repo.repoPath);
|
||||
return manifest !== null || this.packageMap.size > 0;
|
||||
}
|
||||
|
||||
async extract(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const providers = await this.extractProviders(dbExecutor);
|
||||
const consumers = await this.extractConsumers(repoPath);
|
||||
return [...providers, ...consumers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract provider contracts: exported symbols from this repo that other repos can import.
|
||||
*/
|
||||
private async extractProviders(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
): Promise<ExtractedContract[]> {
|
||||
if (!this.ownPackageName || !dbExecutor) return [];
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await dbExecutor(EXPORTED_SYMBOLS_QUERY);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contracts: ExtractedContract[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const name = String(row.name ?? '');
|
||||
const uid = String(row.uid ?? '');
|
||||
const filePath = String(row.filePath ?? '');
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const contractId = `lib::${this.ownPackageName}::${name}`;
|
||||
if (seen.has(contractId)) continue;
|
||||
seen.add(contractId);
|
||||
|
||||
contracts.push({
|
||||
contractId,
|
||||
type: 'lib',
|
||||
role: 'provider',
|
||||
symbolUid: uid,
|
||||
symbolRef: { filePath, name },
|
||||
symbolName: name,
|
||||
confidence: 0.9,
|
||||
meta: {
|
||||
packageName: this.ownPackageName,
|
||||
extractionStrategy: 'graph_exported_symbols',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contracts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract consumer contracts: imports from sibling packages in this repo's source files.
|
||||
*/
|
||||
private async extractConsumers(repoPath: string): Promise<ExtractedContract[]> {
|
||||
// Only scan for packages that are in the packageMap (sibling repos)
|
||||
const targetPackages = new Set(this.packageMap.keys());
|
||||
if (targetPackages.size === 0) return [];
|
||||
|
||||
const scannedImports = await scanRepoForImports(repoPath, targetPackages);
|
||||
return this.importsToContracts(scannedImports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scanned imports into ExtractedContract entries.
|
||||
*/
|
||||
private importsToContracts(imports: ScannedImport[]): ExtractedContract[] {
|
||||
const contracts: ExtractedContract[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const imp of imports) {
|
||||
if (imp.isNamespaceImport) {
|
||||
// import * as X from 'pkg' — wildcard consumer
|
||||
const contractId = `lib::${imp.packageName}::*`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
contracts.push({
|
||||
contractId,
|
||||
type: 'lib',
|
||||
role: 'consumer',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: imp.filePath, name: '*' },
|
||||
symbolName: '*',
|
||||
confidence: 0.7,
|
||||
meta: {
|
||||
packageName: imp.packageName,
|
||||
subpath: imp.subpath,
|
||||
importType: 'namespace',
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (imp.importedSymbols.length > 0) {
|
||||
// Named imports — one contract per symbol
|
||||
for (const symbol of imp.importedSymbols) {
|
||||
const contractId = `lib::${imp.packageName}::${symbol}`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
contracts.push({
|
||||
contractId,
|
||||
type: 'lib',
|
||||
role: 'consumer',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: imp.filePath, name: symbol },
|
||||
symbolName: symbol,
|
||||
confidence: 0.9,
|
||||
meta: {
|
||||
packageName: imp.packageName,
|
||||
subpath: imp.subpath,
|
||||
importType: 'named',
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (imp.isDefaultImport) {
|
||||
// Default import — uses the package's default export
|
||||
const contractId = `lib::${imp.packageName}::default`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
contracts.push({
|
||||
contractId,
|
||||
type: 'lib',
|
||||
role: 'consumer',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: imp.filePath, name: 'default' },
|
||||
symbolName: 'default',
|
||||
confidence: 0.8,
|
||||
meta: {
|
||||
packageName: imp.packageName,
|
||||
subpath: imp.subpath,
|
||||
importType: 'default',
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Side-effect imports (no symbols, no default) are skipped — no symbol contract to create
|
||||
}
|
||||
|
||||
return contracts;
|
||||
}
|
||||
}
|
||||
181
gitnexus/src/core/group/extractors/import-scanner.ts
Normal file
181
gitnexus/src/core/group/extractors/import-scanner.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
|
||||
export interface ScannedImport {
|
||||
/** The npm package name that was imported (e.g. '@acme/shared-utils') */
|
||||
packageName: string;
|
||||
/** Individual symbol names imported (empty for default/namespace imports) */
|
||||
importedSymbols: string[];
|
||||
/** File path relative to repo root */
|
||||
filePath: string;
|
||||
/** Subpath after the package name (e.g. '/utils' from '@acme/shared-utils/utils') */
|
||||
subpath: string | undefined;
|
||||
/** Whether this is a namespace import (import * as X) */
|
||||
isNamespaceImport: boolean;
|
||||
/** Whether this is a default import */
|
||||
isDefaultImport: boolean;
|
||||
}
|
||||
|
||||
// ES import patterns
|
||||
// Named: import { Foo, Bar } from 'pkg'
|
||||
// Default: import Foo from 'pkg'
|
||||
// Namespace: import * as Foo from 'pkg'
|
||||
// Side-effect: import 'pkg'
|
||||
// Re-export: export { Foo } from 'pkg'
|
||||
const ES_IMPORT_RE =
|
||||
/(?:import\s+(?:(\*\s+as\s+\w+)\s+from|(\w+)(?:\s*,\s*\{([^}]*)\})?\s+from|(?:type\s+)?\{([^}]*)\}\s+from)\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]|export\s+(?:type\s+)?\{[^}]*\}\s+from\s+['"]([^'"]+)['"])/g;
|
||||
|
||||
// CommonJS require patterns
|
||||
// const { Foo } = require('pkg')
|
||||
// const Foo = require('pkg')
|
||||
const CJS_REQUIRE_RE =
|
||||
/(?:const|let|var)\s+(?:(\w+)|\{([^}]*)\})\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
|
||||
/**
|
||||
* Parse a destructured import list like "Foo, Bar as Baz, type Qux" into symbol names.
|
||||
*/
|
||||
function parseNamedImports(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.map((s) => {
|
||||
// Remove 'type ' prefix for type-only imports
|
||||
const withoutType = s.replace(/^type\s+/, '');
|
||||
// Handle 'Foo as Bar' — we want the original name 'Foo'
|
||||
const asIdx = withoutType.indexOf(' as ');
|
||||
return asIdx >= 0 ? withoutType.substring(0, asIdx).trim() : withoutType.trim();
|
||||
})
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a package specifier into package name and optional subpath.
|
||||
* Handles scoped packages: '@scope/pkg/sub' → ['@scope/pkg', '/sub']
|
||||
* And unscoped: 'pkg/sub' → ['pkg', '/sub']
|
||||
*/
|
||||
function splitPackageSpecifier(specifier: string): { packageName: string; subpath?: string } {
|
||||
if (specifier.startsWith('@')) {
|
||||
// Scoped: @scope/name or @scope/name/sub/path
|
||||
const parts = specifier.split('/');
|
||||
const packageName = parts.slice(0, 2).join('/');
|
||||
const subpath = parts.length > 2 ? '/' + parts.slice(2).join('/') : undefined;
|
||||
return { packageName, subpath };
|
||||
}
|
||||
|
||||
const slashIdx = specifier.indexOf('/');
|
||||
if (slashIdx < 0) return { packageName: specifier };
|
||||
|
||||
const packageName = specifier.substring(0, slashIdx);
|
||||
const subpath = specifier.substring(slashIdx);
|
||||
return { packageName, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single file's source code for imports matching any of the target packages.
|
||||
*/
|
||||
export function scanFileForImports(
|
||||
content: string,
|
||||
filePath: string,
|
||||
targetPackages: Set<string>,
|
||||
): ScannedImport[] {
|
||||
const results: ScannedImport[] = [];
|
||||
|
||||
// ES imports
|
||||
let match: RegExpExecArray | null;
|
||||
const esRe = new RegExp(ES_IMPORT_RE.source, ES_IMPORT_RE.flags);
|
||||
while ((match = esRe.exec(content)) !== null) {
|
||||
const specifier = match[5] || match[6] || match[7];
|
||||
if (!specifier) continue;
|
||||
|
||||
const { packageName, subpath } = splitPackageSpecifier(specifier);
|
||||
if (!targetPackages.has(packageName)) continue;
|
||||
|
||||
const isNamespaceImport = !!match[1]; // import * as X
|
||||
const isDefaultImport = !!match[2]; // import X from
|
||||
const namedFromDefault = match[3] || ''; // import X, { Y, Z } from
|
||||
const namedOnly = match[4] || ''; // import { Y, Z } from
|
||||
|
||||
const importedSymbols = [
|
||||
...parseNamedImports(namedFromDefault),
|
||||
...parseNamedImports(namedOnly),
|
||||
];
|
||||
|
||||
// Side-effect import (match[6]) — no symbols
|
||||
const isSideEffect = !!match[6];
|
||||
|
||||
results.push({
|
||||
packageName,
|
||||
importedSymbols,
|
||||
filePath,
|
||||
subpath,
|
||||
isNamespaceImport,
|
||||
isDefaultImport: isDefaultImport && !isSideEffect,
|
||||
});
|
||||
}
|
||||
|
||||
// CommonJS require
|
||||
const cjsRe = new RegExp(CJS_REQUIRE_RE.source, CJS_REQUIRE_RE.flags);
|
||||
while ((match = cjsRe.exec(content)) !== null) {
|
||||
const specifier = match[3];
|
||||
if (!specifier) continue;
|
||||
|
||||
const { packageName, subpath } = splitPackageSpecifier(specifier);
|
||||
if (!targetPackages.has(packageName)) continue;
|
||||
|
||||
const defaultName = match[1]; // const X = require(...)
|
||||
const destructured = match[2]; // const { X, Y } = require(...)
|
||||
|
||||
const importedSymbols = destructured ? parseNamedImports(destructured) : [];
|
||||
const isDefaultImport = !!defaultName;
|
||||
|
||||
results.push({
|
||||
packageName,
|
||||
importedSymbols,
|
||||
filePath,
|
||||
subpath,
|
||||
isNamespaceImport: false,
|
||||
isDefaultImport,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all source files in a repo for imports matching target packages.
|
||||
*/
|
||||
export async function scanRepoForImports(
|
||||
repoPath: string,
|
||||
targetPackages: Set<string>,
|
||||
): Promise<ScannedImport[]> {
|
||||
if (targetPackages.size === 0) return [];
|
||||
|
||||
const files = await glob('**/*.{ts,tsx,js,jsx,mjs,cjs}', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.gitnexus/**'],
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
const results: ScannedImport[] = [];
|
||||
|
||||
for (const rel of files) {
|
||||
const abs = path.resolve(repoPath, rel);
|
||||
const base = path.resolve(repoPath);
|
||||
const relToBase = path.relative(base, abs);
|
||||
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(...scanFileForImports(content, rel, targetPackages));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
85
gitnexus/src/core/group/extractors/manifest-reader.ts
Normal file
85
gitnexus/src/core/group/extractors/manifest-reader.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface ManifestInfo {
|
||||
packageName: string;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an npm/yarn package.json and extract the package name and dependency names.
|
||||
* Returns null if no package.json exists or if it has no name field.
|
||||
*/
|
||||
export function readNpmManifest(repoPath: string): ManifestInfo | null {
|
||||
const pkgPath = path.join(repoPath, 'package.json');
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(pkgPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageName = typeof parsed.name === 'string' ? parsed.name.trim() : '';
|
||||
if (!packageName) return null;
|
||||
|
||||
const deps = new Set<string>();
|
||||
|
||||
const depFields = ['dependencies', 'devDependencies', 'peerDependencies'] as const;
|
||||
for (const field of depFields) {
|
||||
const section = parsed[field];
|
||||
if (section && typeof section === 'object' && !Array.isArray(section)) {
|
||||
for (const key of Object.keys(section as Record<string, unknown>)) {
|
||||
deps.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packageName,
|
||||
dependencies: [...deps],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a package map from a set of repos: maps npm package name → group path.
|
||||
* Only includes repos that have a valid package.json with a name field.
|
||||
*/
|
||||
export function buildPackageMap(
|
||||
repos: Record<string, string>,
|
||||
resolveRepoPath: (registryName: string) => string | null,
|
||||
): Map<string, string> {
|
||||
const packageMap = new Map<string, string>();
|
||||
|
||||
for (const [groupPath, registryName] of Object.entries(repos)) {
|
||||
const repoPath = resolveRepoPath(registryName);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
if (manifest) {
|
||||
packageMap.set(manifest.packageName, groupPath);
|
||||
}
|
||||
}
|
||||
|
||||
return packageMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which sibling packages a repo depends on.
|
||||
* Returns the subset of packageMap keys that appear in this repo's dependencies.
|
||||
*/
|
||||
export function findSiblingDependencies(
|
||||
repoPath: string,
|
||||
packageMap: Map<string, string>,
|
||||
): string[] {
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
if (!manifest) return [];
|
||||
|
||||
return manifest.dependencies.filter((dep) => packageMap.has(dep));
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
|||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { CodeDepExtractor } from './extractors/code-dep-extractor.js';
|
||||
import { readNpmManifest } from './extractors/manifest-reader.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
|
|
@ -97,6 +99,32 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
dbExecutors = new Map<string, CypherExecutor>();
|
||||
const openPoolIds: string[] = [];
|
||||
|
||||
// Build package map for code-dep extraction (auto-discover when config.packages is empty)
|
||||
let packageMap = new Map<string, string>();
|
||||
if (config.detect.shared_libs) {
|
||||
const hasExplicitPackages =
|
||||
Object.keys(config.packages).length > 0 &&
|
||||
Object.values(config.packages).some((v) => Object.keys(v).length > 0);
|
||||
if (hasExplicitPackages) {
|
||||
// Flatten nested packages: { repoPath: { pkgName: groupPath } }
|
||||
for (const pkgMapping of Object.values(config.packages)) {
|
||||
for (const [pkgName, groupPath] of Object.entries(pkgMapping)) {
|
||||
packageMap.set(pkgName, groupPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-discover: read each repo's manifest to build the map
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const entry = entries.find((e) => e.name === regName);
|
||||
if (!entry) continue;
|
||||
const manifest = readNpmManifest(entry.path);
|
||||
if (manifest) {
|
||||
packageMap.set(manifest.packageName, groupPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const handle = await resolve(regName, groupPath);
|
||||
|
|
@ -151,6 +179,20 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
if (config.detect.shared_libs && packageMap.size > 0) {
|
||||
const manifest = readNpmManifest(handle.repoPath);
|
||||
const ownPkgName = manifest?.packageName ?? null;
|
||||
const codeDepEx = new CodeDepExtractor(packageMap, ownPkgName);
|
||||
const extracted = await codeDepEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metaPath = path.join(handle.storagePath, 'meta.json');
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, 'utf-8');
|
||||
|
|
|
|||
269
gitnexus/test/integration/group/code-dep-sync.test.ts
Normal file
269
gitnexus/test/integration/group/code-dep-sync.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
254
gitnexus/test/unit/group/code-dep-extractor.test.ts
Normal file
254
gitnexus/test/unit/group/code-dep-extractor.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { CodeDepExtractor } from '../../../src/core/group/extractors/code-dep-extractor.js';
|
||||
import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js';
|
||||
import type { RepoHandle } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('CodeDepExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-codedep-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFile(relPath: string, content: string): void {
|
||||
const full = path.join(tmpDir, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
const makeRepo = (repoPath: string): RepoHandle => ({
|
||||
id: 'test-repo',
|
||||
path: 'test/app',
|
||||
repoPath,
|
||||
storagePath: path.join(repoPath, '.gitnexus'),
|
||||
});
|
||||
|
||||
const makeMockExecutor = (rows: Record<string, unknown>[]): CypherExecutor => {
|
||||
return vi.fn().mockResolvedValue(rows);
|
||||
};
|
||||
|
||||
describe('canExtract', () => {
|
||||
it('returns true when repo has package.json', async () => {
|
||||
writeFile('package.json', JSON.stringify({ name: '@acme/shared' }));
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const result = await extractor.canExtract(makeRepo(tmpDir));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when packageMap is non-empty', async () => {
|
||||
const extractor = new CodeDepExtractor(new Map([['@acme/other', 'libs/other']]), null);
|
||||
const result = await extractor.canExtract(makeRepo(tmpDir));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no package.json and no packageMap', async () => {
|
||||
const extractor = new CodeDepExtractor(new Map(), null);
|
||||
const result = await extractor.canExtract(makeRepo(tmpDir));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider extraction', () => {
|
||||
it('creates provider contracts from exported symbols', async () => {
|
||||
writeFile('package.json', JSON.stringify({ name: '@acme/shared' }));
|
||||
|
||||
const executor = makeMockExecutor([
|
||||
{ uid: 'fn-1', name: 'formatDate', filePath: 'src/utils.ts' },
|
||||
{ uid: 'class-1', name: 'Logger', filePath: 'src/logger.ts' },
|
||||
]);
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(2);
|
||||
|
||||
expect(providers[0].contractId).toBe('lib::@acme/shared::formatDate');
|
||||
expect(providers[0].type).toBe('lib');
|
||||
expect(providers[0].role).toBe('provider');
|
||||
expect(providers[0].symbolUid).toBe('fn-1');
|
||||
expect(providers[0].symbolRef).toEqual({ filePath: 'src/utils.ts', name: 'formatDate' });
|
||||
expect(providers[0].confidence).toBe(0.9);
|
||||
|
||||
expect(providers[1].contractId).toBe('lib::@acme/shared::Logger');
|
||||
expect(providers[1].symbolUid).toBe('class-1');
|
||||
});
|
||||
|
||||
it('deduplicates provider contracts by contractId', async () => {
|
||||
const executor = makeMockExecutor([
|
||||
{ uid: 'fn-1', name: 'formatDate', filePath: 'src/utils.ts' },
|
||||
{ uid: 'fn-2', name: 'formatDate', filePath: 'src/index.ts' },
|
||||
]);
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].symbolUid).toBe('fn-1'); // first wins
|
||||
});
|
||||
|
||||
it('skips providers when no ownPackageName', async () => {
|
||||
const executor = makeMockExecutor([
|
||||
{ uid: 'fn-1', name: 'formatDate', filePath: 'src/utils.ts' },
|
||||
]);
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), null);
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips providers when no dbExecutor', async () => {
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles db query failure gracefully', async () => {
|
||||
const executor = vi.fn().mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips rows with empty names', async () => {
|
||||
const executor = makeMockExecutor([
|
||||
{ uid: 'fn-1', name: '', filePath: 'src/utils.ts' },
|
||||
{ uid: 'fn-2', name: 'validFn', filePath: 'src/utils.ts' },
|
||||
]);
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), '@acme/shared');
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].symbolName).toBe('validFn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumer extraction', () => {
|
||||
it('creates consumer contracts from named imports', async () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
writeFile('src/app.ts', `import { formatDate, Logger } from '@acme/shared';`);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(consumers).toHaveLength(2);
|
||||
|
||||
expect(consumers[0].contractId).toBe('lib::@acme/shared::formatDate');
|
||||
expect(consumers[0].type).toBe('lib');
|
||||
expect(consumers[0].role).toBe('consumer');
|
||||
expect(consumers[0].symbolRef.filePath).toBe('src/app.ts');
|
||||
expect(consumers[0].confidence).toBe(0.9);
|
||||
|
||||
expect(consumers[1].contractId).toBe('lib::@acme/shared::Logger');
|
||||
});
|
||||
|
||||
it('creates wildcard consumer for namespace imports', async () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
writeFile('src/app.ts', `import * as Shared from '@acme/shared';`);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(consumers).toHaveLength(1);
|
||||
expect(consumers[0].contractId).toBe('lib::@acme/shared::*');
|
||||
expect(consumers[0].confidence).toBe(0.7);
|
||||
expect(consumers[0].meta.importType).toBe('namespace');
|
||||
});
|
||||
|
||||
it('creates default consumer for default imports', async () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
writeFile('src/app.ts', `import SharedLib from '@acme/shared';`);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(consumers).toHaveLength(1);
|
||||
expect(consumers[0].contractId).toBe('lib::@acme/shared::default');
|
||||
expect(consumers[0].confidence).toBe(0.8);
|
||||
expect(consumers[0].meta.importType).toBe('default');
|
||||
});
|
||||
|
||||
it('includes subpath in meta', async () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
writeFile('src/app.ts', `import { helper } from '@acme/shared/utils';`);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(consumers).toHaveLength(1);
|
||||
expect(consumers[0].meta.subpath).toBe('/utils');
|
||||
});
|
||||
|
||||
it('returns empty consumers when no packageMap entries', async () => {
|
||||
writeFile('src/app.ts', `import { Foo } from '@acme/shared';`);
|
||||
|
||||
const extractor = new CodeDepExtractor(new Map(), null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
expect(contracts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates consumers from same file', async () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
writeFile(
|
||||
'src/app.ts',
|
||||
`
|
||||
import { formatDate } from '@acme/shared';
|
||||
import { formatDate } from '@acme/shared/utils';
|
||||
`,
|
||||
);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, null);
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
// Same symbol from same file — deduped by contractId|filePath
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(consumers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined extraction', () => {
|
||||
it('returns both providers and consumers', async () => {
|
||||
const packageMap = new Map([['@acme/ui-kit', 'libs/ui-kit']]);
|
||||
writeFile('package.json', JSON.stringify({ name: '@acme/shared' }));
|
||||
writeFile('src/app.ts', `import { Button } from '@acme/ui-kit';`);
|
||||
|
||||
const executor = makeMockExecutor([
|
||||
{ uid: 'fn-1', name: 'formatDate', filePath: 'src/utils.ts' },
|
||||
]);
|
||||
|
||||
const extractor = new CodeDepExtractor(packageMap, '@acme/shared');
|
||||
const contracts = await extractor.extract(executor, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].contractId).toBe('lib::@acme/shared::formatDate');
|
||||
|
||||
expect(consumers).toHaveLength(1);
|
||||
expect(consumers[0].contractId).toBe('lib::@acme/ui-kit::Button');
|
||||
});
|
||||
});
|
||||
});
|
||||
263
gitnexus/test/unit/group/import-scanner.test.ts
Normal file
263
gitnexus/test/unit/group/import-scanner.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
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 {
|
||||
scanFileForImports,
|
||||
scanRepoForImports,
|
||||
} from '../../../src/core/group/extractors/import-scanner.js';
|
||||
|
||||
describe('import-scanner', () => {
|
||||
const targets = new Set(['@acme/shared', '@acme/ui-kit', 'simple-lib']);
|
||||
|
||||
describe('scanFileForImports', () => {
|
||||
it('detects named ES imports', () => {
|
||||
const code = `import { formatDate, Logger } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].importedSymbols).toEqual(['formatDate', 'Logger']);
|
||||
expect(results[0].isNamespaceImport).toBe(false);
|
||||
expect(results[0].isDefaultImport).toBe(false);
|
||||
expect(results[0].subpath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects default ES imports', () => {
|
||||
const code = `import SharedLib from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].isDefaultImport).toBe(true);
|
||||
expect(results[0].importedSymbols).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects default + named imports', () => {
|
||||
const code = `import SharedLib, { formatDate, Logger } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isDefaultImport).toBe(true);
|
||||
expect(results[0].importedSymbols).toEqual(['formatDate', 'Logger']);
|
||||
});
|
||||
|
||||
it('detects namespace imports', () => {
|
||||
const code = `import * as Shared from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].isNamespaceImport).toBe(true);
|
||||
expect(results[0].importedSymbols).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects side-effect imports', () => {
|
||||
const code = `import '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].importedSymbols).toEqual([]);
|
||||
expect(results[0].isDefaultImport).toBe(false);
|
||||
});
|
||||
|
||||
it('detects re-exports', () => {
|
||||
const code = `export { formatDate } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/index.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
});
|
||||
|
||||
it('detects CommonJS destructured require', () => {
|
||||
const code = `const { formatDate, Logger } = require('@acme/shared');`;
|
||||
const results = scanFileForImports(code, 'src/app.js', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].importedSymbols).toEqual(['formatDate', 'Logger']);
|
||||
expect(results[0].isDefaultImport).toBe(false);
|
||||
});
|
||||
|
||||
it('detects CommonJS default require', () => {
|
||||
const code = `const shared = require('@acme/shared');`;
|
||||
const results = scanFileForImports(code, 'src/app.js', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].isDefaultImport).toBe(true);
|
||||
expect(results[0].importedSymbols).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts subpath for scoped packages', () => {
|
||||
const code = `import { helper } from '@acme/shared/utils';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[0].subpath).toBe('/utils');
|
||||
expect(results[0].importedSymbols).toEqual(['helper']);
|
||||
});
|
||||
|
||||
it('extracts subpath for unscoped packages', () => {
|
||||
const code = `import { helper } from 'simple-lib/utils/helpers';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('simple-lib');
|
||||
expect(results[0].subpath).toBe('/utils/helpers');
|
||||
});
|
||||
|
||||
it('ignores imports from non-target packages', () => {
|
||||
const code = `
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import lodash from 'lodash';
|
||||
`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores relative imports', () => {
|
||||
const code = `
|
||||
import { foo } from './utils';
|
||||
import { bar } from '../shared';
|
||||
`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles aliased imports (as keyword)', () => {
|
||||
const code = `import { formatDate as fmt, Logger as Log } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
// We keep the original name, not the alias
|
||||
expect(results[0].importedSymbols).toEqual(['formatDate', 'Logger']);
|
||||
});
|
||||
|
||||
it('handles type-only imports', () => {
|
||||
const code = `import type { Config } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
});
|
||||
|
||||
it('handles inline type imports', () => {
|
||||
const code = `import { type Config, formatDate } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].importedSymbols).toEqual(['Config', 'formatDate']);
|
||||
});
|
||||
|
||||
it('handles multiple imports from different packages', () => {
|
||||
const code = `
|
||||
import { formatDate } from '@acme/shared';
|
||||
import { Button } from '@acme/ui-kit';
|
||||
import React from 'react';
|
||||
`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].packageName).toBe('@acme/shared');
|
||||
expect(results[1].packageName).toBe('@acme/ui-kit');
|
||||
});
|
||||
|
||||
it('handles multiple imports from the same package', () => {
|
||||
const code = `
|
||||
import { formatDate } from '@acme/shared';
|
||||
import { Logger } from '@acme/shared/logging';
|
||||
`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].subpath).toBeUndefined();
|
||||
expect(results[1].subpath).toBe('/logging');
|
||||
});
|
||||
|
||||
it('handles single-quoted imports', () => {
|
||||
const code = `import { Foo } from '@acme/shared';`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles double-quoted imports', () => {
|
||||
const code = `import { Foo } from "@acme/shared";`;
|
||||
const results = scanFileForImports(code, 'src/app.ts', targets);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty for empty file', () => {
|
||||
const results = scanFileForImports('', 'src/empty.ts', targets);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanRepoForImports', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-import-scan-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFile(relPath: string, content: string): void {
|
||||
const full = path.join(tmpDir, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
it('scans all source files recursively', async () => {
|
||||
writeFile('src/app.ts', `import { formatDate } from '@acme/shared';`);
|
||||
writeFile('src/utils/helper.ts', `import { Logger } from '@acme/shared';`);
|
||||
|
||||
const results = await scanRepoForImports(tmpDir, targets);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.filePath).sort()).toEqual([
|
||||
'src/app.ts',
|
||||
'src/utils/helper.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores node_modules', async () => {
|
||||
writeFile('node_modules/@acme/other/index.ts', `import { Foo } from '@acme/shared';`);
|
||||
writeFile('src/app.ts', `import { Bar } from '@acme/shared';`);
|
||||
|
||||
const results = await scanRepoForImports(tmpDir, targets);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('src/app.ts');
|
||||
});
|
||||
|
||||
it('returns empty for empty target set', async () => {
|
||||
writeFile('src/app.ts', `import { Foo } from '@acme/shared';`);
|
||||
const results = await scanRepoForImports(tmpDir, new Set());
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles .js, .jsx, .tsx, .mjs, .cjs files', async () => {
|
||||
writeFile('src/a.js', `const { Foo } = require('@acme/shared');`);
|
||||
writeFile('src/b.jsx', `import { Bar } from '@acme/shared';`);
|
||||
writeFile('src/c.tsx', `import { Baz } from '@acme/shared';`);
|
||||
writeFile('src/d.mjs', `import { Qux } from '@acme/shared';`);
|
||||
writeFile('src/e.cjs', `const { Quux } = require('@acme/shared');`);
|
||||
|
||||
const results = await scanRepoForImports(tmpDir, targets);
|
||||
expect(results).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('ignores non-JS files', async () => {
|
||||
writeFile('src/readme.md', `import { Foo } from '@acme/shared';`);
|
||||
writeFile('src/config.yaml', `import { Foo } from '@acme/shared';`);
|
||||
|
||||
const results = await scanRepoForImports(tmpDir, targets);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
217
gitnexus/test/unit/group/manifest-reader.test.ts
Normal file
217
gitnexus/test/unit/group/manifest-reader.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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 {
|
||||
readNpmManifest,
|
||||
buildPackageMap,
|
||||
findSiblingDependencies,
|
||||
} from '../../../src/core/group/extractors/manifest-reader.js';
|
||||
|
||||
describe('manifest-reader', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-manifest-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writePackageJson(dir: string, content: Record<string, unknown>): void {
|
||||
const full = path.join(tmpDir, dir);
|
||||
fs.mkdirSync(full, { recursive: true });
|
||||
fs.writeFileSync(path.join(full, 'package.json'), JSON.stringify(content, null, 2));
|
||||
}
|
||||
|
||||
describe('readNpmManifest', () => {
|
||||
it('reads package name and dependencies from package.json', () => {
|
||||
writePackageJson('.', {
|
||||
name: '@acme/shared-utils',
|
||||
version: '1.0.0',
|
||||
dependencies: { lodash: '^4.17.0', express: '^4.18.0' },
|
||||
devDependencies: { vitest: '^1.0.0' },
|
||||
});
|
||||
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packageName).toBe('@acme/shared-utils');
|
||||
expect(result!.dependencies).toContain('lodash');
|
||||
expect(result!.dependencies).toContain('express');
|
||||
expect(result!.dependencies).toContain('vitest');
|
||||
});
|
||||
|
||||
it('reads scoped package names correctly', () => {
|
||||
writePackageJson('.', { name: '@org/my-lib', dependencies: {} });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packageName).toBe('@org/my-lib');
|
||||
});
|
||||
|
||||
it('reads unscoped package names', () => {
|
||||
writePackageJson('.', { name: 'simple-lib', dependencies: { react: '*' } });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packageName).toBe('simple-lib');
|
||||
expect(result!.dependencies).toEqual(['react']);
|
||||
});
|
||||
|
||||
it('returns null when no package.json exists', () => {
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when package.json has no name field', () => {
|
||||
writePackageJson('.', { version: '1.0.0', dependencies: { foo: '1.0.0' } });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when package.json name is empty string', () => {
|
||||
writePackageJson('.', { name: '', dependencies: {} });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when package.json name is whitespace', () => {
|
||||
writePackageJson('.', { name: ' ', dependencies: {} });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed JSON', () => {
|
||||
const pkgDir = tmpDir;
|
||||
fs.writeFileSync(path.join(pkgDir, 'package.json'), '{ invalid json }');
|
||||
const result = readNpmManifest(pkgDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty dependencies when no dep fields exist', () => {
|
||||
writePackageJson('.', { name: 'no-deps' });
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes peerDependencies', () => {
|
||||
writePackageJson('.', {
|
||||
name: 'with-peers',
|
||||
peerDependencies: { react: '>=18.0.0' },
|
||||
});
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dependencies).toContain('react');
|
||||
});
|
||||
|
||||
it('deduplicates dependencies across fields', () => {
|
||||
writePackageJson('.', {
|
||||
name: 'dedup-test',
|
||||
dependencies: { react: '^18.0.0' },
|
||||
peerDependencies: { react: '>=18.0.0' },
|
||||
});
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
const reactCount = result!.dependencies.filter((d) => d === 'react').length;
|
||||
expect(reactCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackageMap', () => {
|
||||
it('maps package names to group paths', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
const repoB = path.join(tmpDir, 'repo-b');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
fs.mkdirSync(repoB, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/shared', dependencies: {} }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoB, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/web-app', dependencies: {} }),
|
||||
);
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a', 'apps/web': 'repo-b' };
|
||||
const resolve = (name: string) => {
|
||||
if (name === 'repo-a') return repoA;
|
||||
if (name === 'repo-b') return repoB;
|
||||
return null;
|
||||
};
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.get('@acme/shared')).toBe('libs/shared');
|
||||
expect(map.get('@acme/web-app')).toBe('apps/web');
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it('skips repos that cannot be resolved', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/shared', dependencies: {} }),
|
||||
);
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a', 'apps/missing': 'repo-missing' };
|
||||
const resolve = (name: string) => (name === 'repo-a' ? repoA : null);
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('@acme/shared')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips repos without package.json', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
// No package.json written
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a' };
|
||||
const resolve = () => repoA;
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSiblingDependencies', () => {
|
||||
it('finds sibling packages in dependencies', () => {
|
||||
writePackageJson('.', {
|
||||
name: '@acme/web-app',
|
||||
dependencies: {
|
||||
'@acme/shared': '^1.0.0',
|
||||
'@acme/ui-kit': '^2.0.0',
|
||||
lodash: '^4.17.0',
|
||||
},
|
||||
});
|
||||
|
||||
const packageMap = new Map([
|
||||
['@acme/shared', 'libs/shared'],
|
||||
['@acme/ui-kit', 'libs/ui-kit'],
|
||||
]);
|
||||
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toContain('@acme/shared');
|
||||
expect(siblings).toContain('@acme/ui-kit');
|
||||
expect(siblings).not.toContain('lodash');
|
||||
});
|
||||
|
||||
it('returns empty when no siblings found', () => {
|
||||
writePackageJson('.', {
|
||||
name: '@acme/standalone',
|
||||
dependencies: { lodash: '^4.17.0' },
|
||||
});
|
||||
|
||||
const packageMap = new Map([['@acme/other', 'libs/other']]);
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty when no package.json', () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue