ruch
Version:
Revolutionary React TypeScript CLI with hexagonal architecture & AI-powered development assistance. Create maintainable, scalable applications with domain-driven design and integrated AI tooling.
362 lines (304 loc) • 9.8 kB
text/typescript
import path from 'path';
import { createFileSystem, type FileSystem } from './file-operations';
export interface ImportStatement {
statement: string;
line: number;
from?: string;
imports?: string[];
type: 'import' | 'export';
}
export interface DomainNode {
id: string;
name: string;
type: string;
fileCount: number;
dependencies: string[];
dependents: string[];
files: string[];
}
export interface DependencyEdge {
from: string;
to: string;
type: 'direct-import' | 'indirect-import';
count: number;
metadata: {
files: string[];
};
}
export interface GraphData {
nodes: DomainNode[];
edges: DependencyEdge[];
circularDependencies: string[][];
stats: {
totalDomains: number;
totalDependencies: number;
circularDependencyCount: number;
};
}
export const extractImportStatements = (content: string): ImportStatement[] => {
const lines = content.split('\n');
const imports: ImportStatement[] = [];
const importRegex = /^import\s+(?:type\s+)?(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?['"]([^'"]+)['"];?/;
const exportRegex = /^export\s+(?:\{[^}]*\}\s+from\s+['"]([^'"]+)['"]|.*from\s+['"]([^'"]+)['"])/;
lines.forEach((line, index) => {
const trimmedLine = line.trim();
// Import statements
const importMatch = trimmedLine.match(importRegex);
if (importMatch) {
imports.push({
statement: trimmedLine,
line: index + 1,
from: importMatch[1],
type: 'import'
});
}
// Export statements with from
const exportMatch = trimmedLine.match(exportRegex);
if (exportMatch) {
imports.push({
statement: trimmedLine,
line: index + 1,
from: exportMatch[1] || exportMatch[2],
type: 'export'
});
}
});
return imports;
};
export const extractDomainFromImportPath = (importPath: string, currentDomainPath: string): string | null => {
// Skip non-relative imports (node_modules, etc.)
if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
return null;
}
// Resolve the absolute path
const resolvedPath = path.resolve(path.dirname(currentDomainPath), importPath);
// Check if it's a cross-domain import
const domainMatch = resolvedPath.match(/\/src\/domains\/([^\/]+)/);
if (domainMatch) {
const targetDomain = domainMatch[1];
// Extract current domain from current file path
const currentDomainMatch = currentDomainPath.match(/\/src\/domains\/([^\/]+)/);
const currentDomain = currentDomainMatch && currentDomainMatch[1] ? currentDomainMatch[1] : null;
// Only return if it's a different domain
if (targetDomain !== currentDomain) {
return targetDomain;
}
}
return null;
};
export const scanDomainFiles = async (
fileSystem: FileSystem,
domainPath: string
): Promise<string[]> => {
const files: string[] = [];
const scanDirectory = async (dirPath: string): Promise<void> => {
try {
const entries = await fileSystem.readdir(dirPath);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const stats = await fileSystem.stat(fullPath);
if (stats.isDirectory()) {
await scanDirectory(fullPath);
} else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) {
files.push(fullPath);
}
}
} catch (error) {
// Directory might not exist or be accessible
}
};
await scanDirectory(domainPath);
return files;
};
export const analyzeDomainDependencies = async (
fileSystem: FileSystem,
domainPath: string,
domainName: string
): Promise<{ dependencies: string[], files: string[] }> => {
const files = await scanDomainFiles(fileSystem, domainPath);
const dependencies = new Set<string>();
for (const filePath of files) {
try {
const content = await fileSystem.readFile(filePath, 'utf8') as string;
const imports = extractImportStatements(content);
for (const importStmt of imports) {
if (importStmt.from) {
const targetDomain = extractDomainFromImportPath(importStmt.from, filePath);
if (targetDomain) {
dependencies.add(targetDomain);
}
}
}
} catch (error) {
// File might not be readable
}
}
return {
dependencies: Array.from(dependencies),
files
};
};
export const detectCircularDependencies = (nodes: DomainNode[]): string[][] => {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const dfs = (nodeId: string, path: string[]): void => {
if (recursionStack.has(nodeId)) {
// Found a cycle
const cycleStart = path.indexOf(nodeId);
if (cycleStart !== -1) {
cycles.push(path.slice(cycleStart));
}
return;
}
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
recursionStack.add(nodeId);
const node = nodes.find(n => n.id === nodeId);
if (node) {
for (const dependency of node.dependencies) {
dfs(dependency, [...path, nodeId]);
}
}
recursionStack.delete(nodeId);
};
for (const node of nodes) {
if (!visited.has(node.id)) {
dfs(node.id, []);
}
}
return cycles;
};
const determineDomainType = (node: DomainNode, allNodes: DomainNode[]): string => {
const domainName = node.name.toLowerCase();
// 1. Check explicit naming patterns
if (domainName.includes('shared') || domainName.includes('common') || domainName.includes('lib')) {
return 'shared';
}
if (domainName.includes('core') || domainName.includes('kernel')) {
return 'core';
}
// 2. Analyze dependency patterns
const dependentCount = node.dependents.length;
const dependencyCount = node.dependencies.length;
// Shared domains: used by many, depend on few
if (dependentCount >= 3 && dependencyCount <= 1) {
return 'shared';
}
// Core domains: used by many, no dependencies on other domains (except shared/lib)
const nonLibDependencies = node.dependencies.filter(dep =>
!dep.includes('lib') && !dep.includes('shared') && !dep.includes('common')
);
if (dependentCount >= 2 && nonLibDependencies.length === 0) {
return 'core';
}
// 3. Check for typical core domain names
const coreDomainNames = ['user', 'auth', 'account', 'identity', 'security'];
if (coreDomainNames.some(coreName => domainName.includes(coreName))) {
// Only if it's used by others and doesn't depend on business domains
if (dependentCount > 0 && nonLibDependencies.length === 0) {
return 'core';
}
}
// Default to feature
return 'feature';
};
export const generateGraphData = async (
projectRoot: string,
fileSystem?: FileSystem
): Promise<GraphData> => {
const fs = fileSystem || createFileSystem();
const domainsPath = path.join(projectRoot, 'src', 'domains');
// Check if domains directory exists
const domainsExist = await fs.exists(domainsPath);
if (!domainsExist) {
return {
nodes: [],
edges: [],
circularDependencies: [],
stats: {
totalDomains: 0,
totalDependencies: 0,
circularDependencyCount: 0
}
};
}
// Get all domain directories
const domainEntries = await fs.readdir(domainsPath);
const domainDirs: string[] = [];
for (const entry of domainEntries) {
const entryPath = path.join(domainsPath, entry);
const stats = await fs.stat(entryPath);
if (stats.isDirectory()) {
domainDirs.push(entry);
}
}
// Analyze each domain
const nodes: DomainNode[] = [];
const dependencyMap = new Map<string, Set<string>>();
for (const domainName of domainDirs) {
const domainPath = path.join(domainsPath, domainName);
const analysis = await analyzeDomainDependencies(fs, domainPath, domainName);
const node: DomainNode = {
id: domainName,
name: domainName,
type: 'feature', // Will be determined later based on dependency analysis
fileCount: analysis.files.length,
dependencies: analysis.dependencies,
dependents: [], // Will be filled later
files: analysis.files
};
nodes.push(node);
dependencyMap.set(domainName, new Set(analysis.dependencies));
}
// Calculate dependents
for (const node of nodes) {
for (const dependency of node.dependencies) {
const dependentNode = nodes.find(n => n.id === dependency);
if (dependentNode) {
dependentNode.dependents.push(node.id);
}
}
}
// Determine domain types based on dependency patterns and naming
for (const node of nodes) {
node.type = determineDomainType(node, nodes);
}
// Generate edges
const edges: DependencyEdge[] = [];
const edgeMap = new Map<string, DependencyEdge>();
for (const node of nodes) {
for (const dependency of node.dependencies) {
const edgeKey = `${node.id}->${dependency}`;
if (!edgeMap.has(edgeKey)) {
edgeMap.set(edgeKey, {
from: node.id,
to: dependency,
type: 'direct-import',
count: 1,
metadata: {
files: []
}
});
} else {
const edge = edgeMap.get(edgeKey)!;
edge.count++;
}
}
}
edges.push(...edgeMap.values());
// Detect circular dependencies
const circularDependencies = detectCircularDependencies(nodes);
return {
nodes,
edges,
circularDependencies,
stats: {
totalDomains: nodes.length,
totalDependencies: edges.length,
circularDependencyCount: circularDependencies.length
}
};
};