From 17294656b041bfa42bdb1e0273d773768fecbbb8 Mon Sep 17 00:00:00 2001 From: sahalterion Date: Fri, 3 Apr 2026 16:10:52 -0700 Subject: [PATCH] feat(group): support explicit --repos paths in auto-discover Add --repos flag to `group auto-discover` so users can pass explicit repo paths instead of scanning a directory: gitnexus group auto-discover --repos /path/to/repo1 /path/to/repo2 Also removes unused buildPackageMap() and findSiblingDependencies() functions, and cleans up redundant inline comments across extractors. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/group.ts | 41 ++++-- .../group/extractors/code-dep-extractor.ts | 4 - .../core/group/extractors/import-scanner.ts | 22 +-- .../core/group/extractors/manifest-reader.ts | 37 ----- gitnexus/src/core/group/service.ts | 131 ++++++++++++------ .../integration/group/auto-discover.test.ts | 2 +- .../test/unit/group/manifest-reader.test.ts | 98 ++----------- 7 files changed, 131 insertions(+), 204 deletions(-) diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 8267e6de8..5be70d279 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -24,34 +24,51 @@ export function registerGroupCommands(program: Command): void { group .command('auto-discover [directory]') .description( - 'Auto-discover indexed repos in a directory and create a group with code-level dependency detection', + 'Auto-discover indexed repos in a directory, or create a group from explicit repo paths', ) .option('--name ', 'Group name', 'workspace') + .option('--repos ', 'Explicit repo paths (instead of scanning a directory)') .option('--force', 'Overwrite existing group') .option('--skip-sync', 'Create group without running sync') .option('--json', 'JSON output') .action( async ( directory: string | undefined, - opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean }, + opts: { name: string; repos?: string[]; force?: boolean; skipSync?: boolean; json?: boolean }, ) => { const pathMod = await import('node:path'); const { LocalBackend } = await import('../mcp/local/local-backend.js'); - const resolvedDir = pathMod.resolve(directory || process.cwd()); const backend = new LocalBackend(); try { await backend.init(); - console.log( - `Discovering indexed repos in ${resolvedDir}...\n`, - ); - const raw = await backend.getGroupService().groupDiscover({ - directory: resolvedDir, - name: opts.name, - force: Boolean(opts.force), - skipSync: Boolean(opts.skipSync), - }); + let raw: unknown; + if (opts.repos && opts.repos.length > 0) { + // Explicit repo paths mode + const resolvedPaths = opts.repos.map((p) => pathMod.resolve(p)); + console.log( + `Creating group from ${resolvedPaths.length} repos...\n`, + ); + raw = await backend.getGroupService().groupDiscover({ + repoPaths: resolvedPaths, + name: opts.name, + force: Boolean(opts.force), + skipSync: Boolean(opts.skipSync), + }); + } else { + // Directory scan mode + const resolvedDir = pathMod.resolve(directory || process.cwd()); + console.log( + `Discovering indexed repos in ${resolvedDir}...\n`, + ); + raw = await backend.getGroupService().groupDiscover({ + directory: resolvedDir, + name: opts.name, + force: Boolean(opts.force), + skipSync: Boolean(opts.skipSync), + }); + } const result = raw as { error?: string; diff --git a/gitnexus/src/core/group/extractors/code-dep-extractor.ts b/gitnexus/src/core/group/extractors/code-dep-extractor.ts index 9a18b3d6b..1ce7e19bc 100644 --- a/gitnexus/src/core/group/extractors/code-dep-extractor.ts +++ b/gitnexus/src/core/group/extractors/code-dep-extractor.ts @@ -112,7 +112,6 @@ export class CodeDepExtractor implements ContractExtractor { 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; @@ -137,7 +136,6 @@ export class CodeDepExtractor implements ContractExtractor { } 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}`; @@ -161,7 +159,6 @@ export class CodeDepExtractor implements ContractExtractor { }); } } 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; @@ -183,7 +180,6 @@ export class CodeDepExtractor implements ContractExtractor { }, }); } - // 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 index 13914f5e3..c6c460712 100644 --- a/gitnexus/src/core/group/extractors/import-scanner.ts +++ b/gitnexus/src/core/group/extractors/import-scanner.ts @@ -17,18 +17,9 @@ export interface ScannedImport { 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; @@ -93,17 +84,16 @@ export function scanFileForImports( 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 isNamespaceImport = !!match[1]; + const isDefaultImport = !!match[2]; + const namedFromDefault = match[3] || ''; + const namedOnly = match[4] || ''; const importedSymbols = [ ...parseNamedImports(namedFromDefault), ...parseNamedImports(namedOnly), ]; - // Side-effect import (match[6]) — no symbols const isSideEffect = !!match[6]; results.push({ @@ -125,8 +115,8 @@ export function scanFileForImports( 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 defaultName = match[1]; + const destructured = match[2]; const importedSymbols = destructured ? parseNamedImports(destructured) : []; const isDefaultImport = !!defaultName; diff --git a/gitnexus/src/core/group/extractors/manifest-reader.ts b/gitnexus/src/core/group/extractors/manifest-reader.ts index df6d1c1ec..bd21bf706 100644 --- a/gitnexus/src/core/group/extractors/manifest-reader.ts +++ b/gitnexus/src/core/group/extractors/manifest-reader.ts @@ -46,40 +46,3 @@ export function readNpmManifest(repoPath: string): ManifestInfo | null { 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/service.ts b/gitnexus/src/core/group/service.ts index 2f54e1b4f..d268ab6ea 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -682,19 +682,12 @@ export class GroupService { */ async groupDiscover(params: Record): Promise { const directory = typeof params.directory === 'string' ? params.directory.trim() : ''; + const repoPaths = Array.isArray(params.repoPaths) ? (params.repoPaths as string[]) : []; const groupName = typeof params.name === 'string' ? params.name.trim() : 'workspace'; const force = Boolean(params.force); const skipSync = Boolean(params.skipSync); - if (!directory) return { error: 'directory is required' }; - - // List subdirectories and find indexed repos - let entries: import('node:fs').Dirent[]; - try { - entries = await fsp.readdir(directory, { withFileTypes: true }); - } catch { - return { error: `Cannot read directory: ${directory}` }; - } + if (!directory && repoPaths.length === 0) return { error: 'directory or repoPaths is required' }; const repos: Record = {}; const packages: Record> = {}; @@ -704,51 +697,101 @@ export class GroupService { packageName: string | null; }> = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (repoPaths.length > 0) { + // Explicit repo paths mode + for (const repoPath of repoPaths) { + const resolvedPath = path.resolve(repoPath); + const metaPath = path.join(resolvedPath, '.gitnexus', 'meta.json'); - const repoPath = path.join(directory, entry.name); - const metaPath = path.join(repoPath, '.gitnexus', 'meta.json'); - - let metaExists = false; - try { - await fsp.access(metaPath); - metaExists = true; - } catch { - // Not indexed - } - if (!metaExists) continue; - - // Find the registry name for this repo - let registryName: string | null = null; - try { - const repoHandle = await this.port.resolveRepo(entry.name); - registryName = repoHandle.name; - } catch { - // Try resolving by path + let metaExists = false; try { - const repoHandle = await this.port.resolveRepo(repoPath); + await fsp.access(metaPath); + metaExists = true; + } catch { + // Not indexed + } + if (!metaExists) { + return { error: `Repo at ${resolvedPath} is not indexed. Run 'gitnexus analyze' there first.` }; + } + + const dirName = path.basename(resolvedPath); + let registryName: string | null = null; + try { + const repoHandle = await this.port.resolveRepo(dirName); registryName = repoHandle.name; } catch { - // Use directory name as fallback - registryName = entry.name; + try { + const repoHandle = await this.port.resolveRepo(resolvedPath); + registryName = repoHandle.name; + } catch { + registryName = dirName; + } } + + repos[dirName] = registryName; + + const manifest = readNpmManifest(resolvedPath); + discoveredRepos.push({ + name: registryName, + path: resolvedPath, + packageName: manifest?.packageName ?? null, + }); + } + } else { + // Directory scan mode + let entries: import('node:fs').Dirent[]; + try { + entries = await fsp.readdir(directory, { withFileTypes: true }); + } catch { + return { error: `Cannot read directory: ${directory}` }; } - const groupPath = entry.name; - repos[groupPath] = registryName; + for (const entry of entries) { + if (!entry.isDirectory()) continue; - // Read package manifest for auto-discovery - const manifest = readNpmManifest(repoPath); - discoveredRepos.push({ - name: registryName, - path: repoPath, - packageName: manifest?.packageName ?? null, - }); - } + const repoPath = path.join(directory, entry.name); + const metaPath = path.join(repoPath, '.gitnexus', 'meta.json'); + + let metaExists = false; + try { + await fsp.access(metaPath); + metaExists = true; + } catch { + // Not indexed + } + if (!metaExists) continue; + + // Find the registry name for this repo + let registryName: string | null = null; + try { + const repoHandle = await this.port.resolveRepo(entry.name); + registryName = repoHandle.name; + } catch { + // Try resolving by path + try { + const repoHandle = await this.port.resolveRepo(repoPath); + registryName = repoHandle.name; + } catch { + // Use directory name as fallback + registryName = entry.name; + } + } + + const groupPath = entry.name; + repos[groupPath] = registryName; + + // Read package manifest for auto-discovery + const manifest = readNpmManifest(repoPath); + discoveredRepos.push({ + name: registryName, + path: repoPath, + packageName: manifest?.packageName ?? null, + }); + } + } // end else (directory scan mode) if (Object.keys(repos).length === 0) { - return { error: `No indexed repos found in ${directory}. Run 'gitnexus analyze' in each repo first.` }; + return { error: `No indexed repos found. Run 'gitnexus analyze' in each repo first.` }; } // Build packages mapping from discovered manifests diff --git a/gitnexus/test/integration/group/auto-discover.test.ts b/gitnexus/test/integration/group/auto-discover.test.ts index 93e735fb8..73c72b0c2 100644 --- a/gitnexus/test/integration/group/auto-discover.test.ts +++ b/gitnexus/test/integration/group/auto-discover.test.ts @@ -175,7 +175,7 @@ console.log(formatDate(new Date()));`, it('returns error when directory param is missing', async () => { const service = new GroupService(makeMockPort()); const result = (await service.groupDiscover({})) as { error: string }; - expect(result.error).toBe('directory is required'); + expect(result.error).toBe('directory or repoPaths is required'); }); it('skips non-indexed subdirectories', async () => { diff --git a/gitnexus/test/unit/group/manifest-reader.test.ts b/gitnexus/test/unit/group/manifest-reader.test.ts index 2f4365bb5..7579e3cc8 100644 --- a/gitnexus/test/unit/group/manifest-reader.test.ts +++ b/gitnexus/test/unit/group/manifest-reader.test.ts @@ -2,11 +2,7 @@ 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'; +import { readNpmManifest } from '../../../src/core/group/extractors/manifest-reader.js'; describe('manifest-reader', () => { let tmpDir: string; @@ -118,65 +114,8 @@ describe('manifest-reader', () => { }); }); - 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', () => { + describe('readNpmManifest — dependency list for sibling detection', () => { + it('lists all dependency names for cross-repo matching', () => { writePackageJson('.', { name: '@acme/web-app', dependencies: { @@ -186,32 +125,11 @@ describe('manifest-reader', () => { }, }); - 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([]); + const result = readNpmManifest(tmpDir); + expect(result).not.toBeNull(); + expect(result!.dependencies).toContain('@acme/shared'); + expect(result!.dependencies).toContain('@acme/ui-kit'); + expect(result!.dependencies).toContain('lodash'); }); }); });