mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
feat(group): auto-discover Node/TS workspace cross-package contracts
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).
This commit is contained in:
parent
7f8b01d506
commit
a537f1f153
3 changed files with 576 additions and 4 deletions
249
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
249
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, workspaceDeps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanImports(
|
||||
repoPath: string,
|
||||
knownPackages: Set<string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
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 '<pkg>'
|
||||
// Also: import { Foo as Baz } from '<pkg>'
|
||||
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 '<pkg>'
|
||||
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('<pkg>')
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<string, PackageMeta>;
|
||||
}
|
||||
|
||||
export async function extractNodeWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<NodeWorkspaceResult> {
|
||||
const packagesByName = new Map<string, PackageMeta>();
|
||||
const packagesByGroupPath = new Map<string, PackageMeta>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
312
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
312
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
|
|
@ -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 <Button />; }\n",
|
||||
);
|
||||
|
||||
const repos = { 'libs/ui': 'ui-components', 'apps/web': 'web-app' };
|
||||
const repoPaths = new Map([
|
||||
['libs/ui', path.join(tmpDir, 'ui-lib')],
|
||||
['apps/web', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Button');
|
||||
});
|
||||
|
||||
it('handles CommonJS destructured require', async () => {
|
||||
await writeFile(
|
||||
'lib/package.json',
|
||||
JSON.stringify({ name: 'auth-lib', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('lib/src/index.js', 'module.exports = { Authenticator: class {} };\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/package.json',
|
||||
JSON.stringify({
|
||||
name: 'api-svc',
|
||||
version: '1.0.0',
|
||||
dependencies: { 'auth-lib': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'svc/src/handler.js',
|
||||
"const { Authenticator } = require('auth-lib');\n",
|
||||
);
|
||||
|
||||
const repos = { lib: 'auth-lib', svc: 'api-svc' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Authenticator');
|
||||
});
|
||||
|
||||
it('handles scoped package imports with subpaths', async () => {
|
||||
await writeFile(
|
||||
'core/package.json',
|
||||
JSON.stringify({ name: '@acme/core', version: '2.0.0' }),
|
||||
);
|
||||
await writeFile('core/src/models.ts', 'export class User {}\n');
|
||||
|
||||
await writeFile(
|
||||
'web/package.json',
|
||||
JSON.stringify({
|
||||
name: '@acme/web',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@acme/core': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'web/src/routes.ts',
|
||||
"import { User } from '@acme/core/models';\n",
|
||||
);
|
||||
|
||||
const repos = { core: '@acme/core', web: '@acme/web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('User');
|
||||
});
|
||||
|
||||
it('ignores camelCase/snake_case imports (non-type exports)', async () => {
|
||||
await writeFile(
|
||||
'lib/package.json',
|
||||
JSON.stringify({ name: 'utils', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('lib/src/index.ts', 'export function helper() {}\nexport class Formatter {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { utils: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main.ts',
|
||||
"import { helper, Formatter } from 'utils';\n",
|
||||
);
|
||||
|
||||
const repos = { lib: 'utils', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Formatter');
|
||||
});
|
||||
|
||||
it('skips repos without package.json', async () => {
|
||||
await writeFile('rust-app/Cargo.toml', '[package]\nname = "rapp"\nversion = "0.1.0"\n');
|
||||
await writeFile('rust-app/src/main.rs', 'fn main() {}\n');
|
||||
|
||||
const repos = { app: 'rapp' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'rust-app')]]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredPackages.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/package.json',
|
||||
JSON.stringify({ name: 'shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('lib/src/index.ts', 'export class Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { shared: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/a.ts', "import { Config } from 'shared';\n");
|
||||
await writeFile('app/src/b.ts', "import { Config } from 'shared';\n");
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles aliased imports (import { Foo as Bar })', async () => {
|
||||
await writeFile(
|
||||
'lib/package.json',
|
||||
JSON.stringify({ name: 'models', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('lib/src/index.ts', 'export class Entity {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { models: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main.ts',
|
||||
"import { Entity as BaseEntity } from 'models';\n",
|
||||
);
|
||||
|
||||
const repos = { lib: 'models', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Entity');
|
||||
});
|
||||
|
||||
it('handles multiple packages importing from the same provider', async () => {
|
||||
await writeFile(
|
||||
'shared/package.json',
|
||||
JSON.stringify({ name: '@org/shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('shared/src/index.ts', 'export class Schema {}\n');
|
||||
|
||||
await writeFile(
|
||||
'api/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/api',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('api/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
await writeFile(
|
||||
'worker/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/worker',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('worker/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
const repos = {
|
||||
libs: '@org/shared',
|
||||
api: '@org/api',
|
||||
worker: '@org/worker',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs', path.join(tmpDir, 'shared')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
['worker', path.join(tmpDir, 'worker')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const targets = result.links.map((l) => l.to).sort();
|
||||
expect(targets).toEqual(['api', 'worker']);
|
||||
expect(result.links.every((l) => l.contract === 'Schema')).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue