From 05636bf681adaef00a4086ca284e93b3de5d14a7 Mon Sep 17 00:00:00 2001 From: sahalterion Date: Fri, 3 Apr 2026 15:11:42 -0700 Subject: [PATCH] feat(group): add code-level dependency detection between repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../group/extractors/code-dep-extractor.ts | 191 +++++++++++++ .../core/group/extractors/import-scanner.ts | 181 ++++++++++++ .../core/group/extractors/manifest-reader.ts | 85 ++++++ gitnexus/src/core/group/sync.ts | 42 +++ .../integration/group/code-dep-sync.test.ts | 269 ++++++++++++++++++ .../unit/group/code-dep-extractor.test.ts | 254 +++++++++++++++++ .../test/unit/group/import-scanner.test.ts | 263 +++++++++++++++++ .../test/unit/group/manifest-reader.test.ts | 217 ++++++++++++++ 8 files changed, 1502 insertions(+) create mode 100644 gitnexus/src/core/group/extractors/code-dep-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/import-scanner.ts create mode 100644 gitnexus/src/core/group/extractors/manifest-reader.ts create mode 100644 gitnexus/test/integration/group/code-dep-sync.test.ts create mode 100644 gitnexus/test/unit/group/code-dep-extractor.test.ts create mode 100644 gitnexus/test/unit/group/import-scanner.test.ts create mode 100644 gitnexus/test/unit/group/manifest-reader.test.ts diff --git a/gitnexus/src/core/group/extractors/code-dep-extractor.ts b/gitnexus/src/core/group/extractors/code-dep-extractor.ts new file mode 100644 index 000000000..9a18b3d6b --- /dev/null +++ b/gitnexus/src/core/group/extractors/code-dep-extractor.ts @@ -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, + /** This repo's own package name (used for provider contracts) */ + private readonly ownPackageName: string | null, + ) {} + + async canExtract(repo: RepoHandle): Promise { + // 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 { + 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 { + if (!this.ownPackageName || !dbExecutor) return []; + + let rows: Record[]; + try { + rows = await dbExecutor(EXPORTED_SYMBOLS_QUERY); + } catch { + return []; + } + + const contracts: ExtractedContract[] = []; + const seen = new Set(); + + 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 { + // 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(); + + 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; + } +} diff --git a/gitnexus/src/core/group/extractors/import-scanner.ts b/gitnexus/src/core/group/extractors/import-scanner.ts new file mode 100644 index 000000000..13914f5e3 --- /dev/null +++ b/gitnexus/src/core/group/extractors/import-scanner.ts @@ -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, +): 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, +): Promise { + 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; +} diff --git a/gitnexus/src/core/group/extractors/manifest-reader.ts b/gitnexus/src/core/group/extractors/manifest-reader.ts new file mode 100644 index 000000000..df6d1c1ec --- /dev/null +++ b/gitnexus/src/core/group/extractors/manifest-reader.ts @@ -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; + try { + parsed = JSON.parse(content) as Record; + } catch { + return null; + } + + const packageName = typeof parsed.name === 'string' ? parsed.name.trim() : ''; + if (!packageName) return null; + + const deps = new Set(); + + 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)) { + 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, + resolveRepoPath: (registryName: string) => string | null, +): Map { + const packageMap = new Map(); + + 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[] { + const manifest = readNpmManifest(repoPath); + if (!manifest) return []; + + return manifest.dependencies.filter((dep) => packageMap.has(dep)); +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index bd2590ecd..20cf09650 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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(); const openPoolIds: string[] = []; + // Build package map for code-dep extraction (auto-discover when config.packages is empty) + let packageMap = new Map(); + 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'); diff --git a/gitnexus/test/integration/group/code-dep-sync.test.ts b/gitnexus/test/integration/group/code-dep-sync.test.ts new file mode 100644 index 000000000..0a8499209 --- /dev/null +++ b/gitnexus/test/integration/group/code-dep-sync.test.ts @@ -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 { + 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'); + }); +}); diff --git a/gitnexus/test/unit/group/code-dep-extractor.test.ts b/gitnexus/test/unit/group/code-dep-extractor.test.ts new file mode 100644 index 000000000..f57e25c36 --- /dev/null +++ b/gitnexus/test/unit/group/code-dep-extractor.test.ts @@ -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[]): 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'); + }); + }); +}); diff --git a/gitnexus/test/unit/group/import-scanner.test.ts b/gitnexus/test/unit/group/import-scanner.test.ts new file mode 100644 index 000000000..f38e4d9a6 --- /dev/null +++ b/gitnexus/test/unit/group/import-scanner.test.ts @@ -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); + }); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-reader.test.ts b/gitnexus/test/unit/group/manifest-reader.test.ts new file mode 100644 index 000000000..2f4365bb5 --- /dev/null +++ b/gitnexus/test/unit/group/manifest-reader.test.ts @@ -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): 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([]); + }); + }); +});