From a537f1f153724edf6a9924f87830f29dbb3a7a23 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Fri, 1 May 2026 19:41:17 +0200 Subject: [PATCH] feat(group): auto-discover Node/TS workspace cross-package contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan package.json dependencies and ES/CJS imports to find PascalCase type exports crossing workspace package boundaries. Same pipeline as Rust workspace extractor — emits GroupManifestLink[] with type:custom. Supports: ES named imports, default imports, CommonJS destructured require, scoped packages (@org/pkg), subpath imports, aliased imports. Filters to PascalCase names only (types/classes, not functions). --- .../extractors/node-workspace-extractor.ts | 249 ++++++++++++++ gitnexus/src/core/group/sync.ts | 19 +- .../group/node-workspace-extractor.test.ts | 312 ++++++++++++++++++ 3 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/node-workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/node-workspace-extractor.test.ts diff --git a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts new file mode 100644 index 000000000..b8f4dfcb1 --- /dev/null +++ b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts @@ -0,0 +1,249 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; + +interface PackageMeta { + name: string; + groupPath: string; + repoPath: string; + workspaceDeps: string[]; +} + +interface ImportedSymbol { + packageName: string; + symbolName: string; + filePath: string; +} + +async function parsePackageManifest( + repoPath: string, +): Promise<{ name: string; workspaceDeps: string[] } | null> { + const pkgPath = path.join(repoPath, 'package.json'); + let content: string; + try { + content = await fs.readFile(pkgPath, 'utf-8'); + } catch { + return null; + } + + let pkg: Record; + try { + pkg = JSON.parse(content); + } catch { + return null; + } + + const name = typeof pkg.name === 'string' ? pkg.name : ''; + if (!name) return null; + + const deps: string[] = []; + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + const section = pkg[field]; + if (section && typeof section === 'object') { + deps.push(...Object.keys(section as Record)); + } + } + + return { name, workspaceDeps: [...new Set(deps)] }; +} + +async function scanImports( + repoPath: string, + knownPackages: Set, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findSourceFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + // ES import: import { Foo, Bar } from '' + // Also: import { Foo as Baz } from '' + const esImportRegex = + /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm; + let match; + while ((match = esImportRegex.exec(content)) !== null) { + const importClause = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + const symbols = parseImportClause(importClause); + for (const sym of symbols) { + if (isExportedName(sym)) { + results.push({ packageName: pkgName, symbolName: sym, filePath: relFile }); + } + } + } + + // ES import default: import Foo from '' + const defaultImportRegex = + /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm; + while ((match = defaultImportRegex.exec(content)) !== null) { + const symbolName = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + if (isExportedName(symbolName)) { + results.push({ packageName: pkgName, symbolName, filePath: relFile }); + } + } + + // CommonJS: const { Foo, Bar } = require('') + const cjsRegex = + /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm; + while ((match = cjsRegex.exec(content)) !== null) { + const importClause = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + const symbols = parseImportClause(importClause); + for (const sym of symbols) { + if (isExportedName(sym)) { + results.push({ packageName: pkgName, symbolName: sym, filePath: relFile }); + } + } + } + } + + return results; +} + +function resolvePackageName(modulePath: string): string | null { + if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null; + // Scoped: @scope/pkg or @scope/pkg/sub + if (modulePath.startsWith('@')) { + const parts = modulePath.split('/'); + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + return null; + } + // Unscoped: pkg or pkg/sub + return modulePath.split('/')[0]; +} + +function parseImportClause(clause: string): string[] { + return clause + .split(',') + .map((s) => { + const trimmed = s.trim(); + // Handle `Foo as Bar` — use the original export name + const asMatch = trimmed.match(/^(\S+)\s+as\s+/); + return asMatch ? asMatch[1] : trimmed; + }) + .filter(Boolean); +} + +function isExportedName(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); +} + +async function findSourceFiles(repoPath: string): Promise { + const results: string[] = []; + const IGNORE = new Set([ + 'node_modules', + '.git', + '.gitnexus', + 'dist', + 'build', + 'coverage', + '.next', + '.nuxt', + 'vendor', + ]); + const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (IGNORE.has(entry.name)) continue; + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), childRel); + } else { + const ext = path.extname(entry.name); + if (EXTENSIONS.has(ext)) { + results.push(childRel); + } + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface NodeWorkspaceResult { + links: GroupManifestLink[]; + discoveredPackages: Map; +} + +export async function extractNodeWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const packagesByName = new Map(); + const packagesByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parsePackageManifest(repoPath); + if (!manifest) continue; + + const meta: PackageMeta = { + name: manifest.name, + groupPath, + repoPath, + workspaceDeps: manifest.workspaceDeps, + }; + packagesByName.set(manifest.name, meta); + packagesByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, pkg] of packagesByGroupPath) { + const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d)); + if (groupPkgDeps.length === 0) continue; + + const knownPackages = new Set(groupPkgDeps); + const imports = await scanImports(pkg.repoPath, knownPackages); + + for (const imp of imports) { + const providerPkg = packagesByName.get(imp.packageName); + if (!providerPkg) continue; + + const key = `${pkg.groupPath}→${providerPkg.groupPath}::${imp.symbolName}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerPkg.groupPath, + to: pkg.groupPath, + type: 'custom', + contract: imp.symbolName, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredPackages: packagesByGroupPath }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index a9ecb51f4..918811128 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -9,6 +9,7 @@ import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js'; +import { extractNodeWorkspaceLinks } from './extractors/node-workspace-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -193,12 +194,22 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis if (e) repoPaths.set(groupPath, e.path); } - const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors); - if (wsResult.links.length > 0) { - allLinks = [...allLinks, ...wsResult.links]; + const rustResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (rustResult.links.length > 0) { + allLinks = [...allLinks, ...rustResult.links]; if (opts?.verbose) { console.log( - ` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`, + ` workspace-deps: discovered ${rustResult.links.length} cross-crate links from ${rustResult.discoveredCrates.size} Rust crates`, + ); + } + } + + const nodeResult = await extractNodeWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (nodeResult.links.length > 0) { + allLinks = [...allLinks, ...nodeResult.links]; + if (opts?.verbose) { + console.log( + ` workspace-deps: discovered ${nodeResult.links.length} cross-package links from ${nodeResult.discoveredPackages.size} Node packages`, ); } } diff --git a/gitnexus/test/unit/group/node-workspace-extractor.test.ts b/gitnexus/test/unit/group/node-workspace-extractor.test.ts new file mode 100644 index 000000000..adfe11915 --- /dev/null +++ b/gitnexus/test/unit/group/node-workspace-extractor.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractNodeWorkspaceLinks } from '../../../src/core/group/extractors/node-workspace-extractor.js'; + +describe('NodeWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-node-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + it('discovers cross-package ES imports', async () => { + await writeFile( + 'pkg-a/package.json', + JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }), + ); + await writeFile('pkg-a/src/index.ts', 'export class Config {}\nexport class Logger {}\n'); + + await writeFile( + 'pkg-b/package.json', + JSON.stringify({ + name: '@myorg/api', + version: '1.0.0', + dependencies: { '@myorg/shared': 'workspace:*' }, + }), + ); + await writeFile( + 'pkg-b/src/server.ts', + "import { Config } from '@myorg/shared';\nconst c = new Config();\n", + ); + + const repos = { + 'libs/shared': '@myorg/shared', + 'services/api': '@myorg/api', + }; + const repoPaths = new Map([ + ['libs/shared', path.join(tmpDir, 'pkg-a')], + ['services/api', path.join(tmpDir, 'pkg-b')], + ]); + + const result = await extractNodeWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'libs/shared', + to: 'services/api', + type: 'custom', + contract: 'Config', + role: 'provider', + }); + }); + + it('handles default imports (PascalCase)', async () => { + await writeFile( + 'ui-lib/package.json', + JSON.stringify({ name: 'ui-components', version: '1.0.0' }), + ); + await writeFile('ui-lib/src/index.ts', 'export default class Button {}\n'); + + await writeFile( + 'app/package.json', + JSON.stringify({ + name: 'web-app', + version: '1.0.0', + dependencies: { 'ui-components': '^1.0.0' }, + }), + ); + await writeFile( + 'app/src/page.tsx', + "import Button from 'ui-components';\nexport default function Page() { return