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) <noreply@anthropic.com>
This commit is contained in:
sahalterion 2026-04-03 16:10:52 -07:00
parent a33c26e298
commit 17294656b0
7 changed files with 131 additions and 204 deletions

View file

@ -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 <name>', 'Group name', 'workspace')
.option('--repos <paths...>', '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;

View file

@ -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;

View file

@ -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;

View file

@ -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<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));
}

View file

@ -682,19 +682,12 @@ export class GroupService {
*/
async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
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<string, string> = {};
const packages: Record<string, Record<string, string>> = {};
@ -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

View file

@ -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 () => {

View file

@ -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');
});
});
});