From b6144951c5b1109e38e2dc5240777e2e2e2c017e Mon Sep 17 00:00:00 2001 From: DaxServer Date: Thu, 28 Aug 2025 21:06:41 +0200 Subject: [PATCH 1/2] feat: Improve dependency traversal for interface inheritance This commit introduces several improvements to the dependency traversal logic, specifically handling interface inheritance scenarios: 1. Correctly extracts dependencies from interfaces that inherit from other interfaces, ensuring the correct order of dependencies. 2. Handles multiple interface inheritance, where an interface extends multiple other interfaces. 3. Supports nested interface inheritance, where an interface extends an interface that extends another interface. These changes ensure that the dependency traversal algorithm can accurately identify and order the dependencies for complex interface inheritance structures, which is crucial for generating correct code. --- ARCHITECTURE.md | 28 +- bun.lock | 11 + package.json | 3 + src/index.ts | 26 +- src/traverse/dependency-traversal.ts | 98 ++-- src/traverse/node-graph.ts | 34 +- src/utils/graph-visualizer.ts | 424 ++++++++++++++++++ tests/traverse/circular-dependency.test.ts | 64 +++ tests/traverse/dependency-extraction.test.ts | 155 +++++++ tests/traverse/dependency-ordering.test.ts | 64 ++- ... dependency-traversal.integration.test.ts} | 2 +- ... dependency-traversal.performance.test.ts} | 0 .../interface-inheritance-dependency.test.ts | 118 +++++ tests/traverse/simple-dependency.test.ts | 39 ++ 14 files changed, 1002 insertions(+), 64 deletions(-) create mode 100644 src/utils/graph-visualizer.ts create mode 100644 tests/traverse/circular-dependency.test.ts create mode 100644 tests/traverse/dependency-extraction.test.ts rename tests/traverse/{dependency-collector.integration.test.ts => dependency-traversal.integration.test.ts} (99%) rename tests/traverse/{dependency-collector.performance.test.ts => dependency-traversal.performance.test.ts} (100%) create mode 100644 tests/traverse/interface-inheritance-dependency.test.ts create mode 100644 tests/traverse/simple-dependency.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 812944d..9fa1c98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,9 +70,35 @@ The dependency system consists of three main components: 1. **Local Type Collection**: Adds all types from the main source file 2. **Import Processing**: Recursively processes import declarations -3. **Dependency Extraction**: Analyzes type references to build dependency graph +3. **Dependency Extraction**: Analyzes type references to build dependency graph for all supported TypeScript constructs: + - **Type Aliases**: Extracts dependencies from type alias declarations + - **Interfaces**: Analyzes interface property types and heritage clauses + - **Enums**: Processes enum member value dependencies + - **Functions**: Extracts dependencies from parameter types, return types, and type parameters 4. **Topological Sorting**: Returns nodes in proper dependency order +#### Graph Visualization + +The system includes interactive graph visualization capabilities through the `GraphVisualizer` utility: + +- **Sigma.js Integration**: Uses ES6 modules with unpkg CDN for interactive HTML-based graph visualization +- **Custom Node Shapes**: Implements WebGL-based custom node programs for different TypeScript constructs: + - **Diamond**: Interface types (rotated square program) + - **Square**: Type alias declarations + - **Triangle**: Enum declarations + - **Star**: Function declarations + - **Circle**: Default/other types +- **ForceAtlas2 Layout**: Single optimized layout algorithm for automatic node positioning with gravity and scaling controls +- **Enhanced Node Differentiation**: + - **Size Variation**: Different node sizes based on type and importance (main code vs imported) + - **Color Coding**: Type-specific colors with intensity variation based on import nesting level + - **Shape Mapping**: Direct visual shape differentiation using custom WebGL node programs + - **Visual Legend**: CSS-styled legend showing actual shape representations +- **Import Nesting Visualization**: Color intensity reflects import depth, with main code having brightest colors +- **Interactive Features**: Click events for node details, zoom/pan capabilities, and hover tooltips +- **Export Options**: Generates standalone HTML files with embedded visualization and complete styling +- **WebGL Programs**: Custom node renderers extending `@sigma/node-square` and `@sigma/node-border` packages + ### Parser System The parser system is built around a base class architecture in : diff --git a/bun.lock b/bun.lock index 1a9b785..9897a66 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,11 @@ "": { "name": "new-bun-project", "dependencies": { + "@sigma/node-border": "^3.0.0", + "@sigma/node-square": "^3.0.0", "graphology": "^0.26.0", "graphology-dag": "^0.4.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphology-traversal": "^0.3.1", }, "devDependencies": { @@ -74,6 +77,10 @@ "@prettier/sync": ["@prettier/sync@0.6.1", "", { "dependencies": { "make-synchronized": "^0.8.0" }, "peerDependencies": { "prettier": "*" } }, "sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw=="], + "@sigma/node-border": ["@sigma/node-border@3.0.0", "", { "peerDependencies": { "sigma": ">=3.0.0-beta.17" } }, "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ=="], + + "@sigma/node-square": ["@sigma/node-square@3.0.0", "", { "peerDependencies": { "sigma": ">=3.0.0-beta.17" } }, "sha512-hPX2oWo7WeaSe6M3D56AXsrLyg3F+7N/YsodaJh4Sw3KTce0GAFVWWPZZklu9CITz0xi3kEmlCGulqAH0cVG2w=="], + "@sinclair/typebox": ["@sinclair/typebox@0.33.22", "", {}, "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ=="], "@sinclair/typebox-codegen": ["@sinclair/typebox-codegen@0.11.1", "", { "dependencies": { "@sinclair/typebox": "^0.33.1", "prettier": "^2.8.7", "typescript": "^5.4.5" } }, "sha512-Bckbrf1sJFTIVD88PvI0vWUfE3Sh/6pwu6Jov+6xyMrEqnabOxEFAmPSDWjB1FGPL5C1/HfdScwa1imwAtGi9w=="], @@ -208,6 +215,8 @@ "graphology-indices": ["graphology-indices@0.17.0", "", { "dependencies": { "graphology-utils": "^2.4.2", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ=="], + "graphology-layout-forceatlas2": ["graphology-layout-forceatlas2@0.10.1", "", { "dependencies": { "graphology-utils": "^2.1.0" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ=="], + "graphology-traversal": ["graphology-traversal@0.3.1", "", { "dependencies": { "graphology-indices": "^0.17.0", "graphology-utils": "^2.0.0" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-lGLrLKEDKtNgAKgHVhVftKf3cb/nuWwuVPQZHXRnN90JWn0RSjco/s+NB2ARSlMapEMlbnPgv6j++427yTnU3Q=="], "graphology-types": ["graphology-types@0.24.8", "", {}, "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q=="], @@ -304,6 +313,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "sigma": ["sigma@3.0.2", "", { "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" } }, "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/package.json b/package.json index 4e3b711..c082523 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,11 @@ }, "type": "module", "dependencies": { + "@sigma/node-border": "^3.0.0", + "@sigma/node-square": "^3.0.0", "graphology": "^0.26.0", "graphology-dag": "^0.4.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphology-traversal": "^0.3.1" } } diff --git a/src/index.ts b/src/index.ts index 3c8441f..f726978 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { import { TypeBoxPrinter } from '@daxserver/validation-schema-codegen/printer/typebox-printer' import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' +import type { VisualizationOptions } from '@daxserver/validation-schema-codegen/utils/graph-visualizer' import { Node, Project, SourceFile, ts } from 'ts-morph' const createOutputFile = (hasGenericInterfaces: boolean) => { @@ -50,13 +51,26 @@ const printSortedNodes = (sortedTraversedNodes: TraversedNode[], newSourceFile: return newSourceFile.getFullText() } -export const generateCode = ({ sourceCode, filePath, ...options }: InputOptions): string => { +export interface CodeGenerationOptions extends InputOptions { + visualize?: boolean + visualizationOptions?: VisualizationOptions +} + +export const generateVisualization = async (options: CodeGenerationOptions): Promise => { // Create source file from input - const sourceFile = createSourceFileFromInput({ - sourceCode, - filePath, - ...options, - }) + const sourceFile = createSourceFileFromInput(options) + + // Create dependency traversal and start traversal + const dependencyTraversal = new DependencyTraversal() + dependencyTraversal.startTraversal(sourceFile) + + // Generate visualization + return await dependencyTraversal.visualizeGraph(options.visualizationOptions) +} + +export const generateCode = (options: InputOptions): string => { + // Create source file from input + const sourceFile = createSourceFileFromInput(options) // Create dependency traversal and start traversal const dependencyTraversal = new DependencyTraversal() diff --git a/src/traverse/dependency-traversal.ts b/src/traverse/dependency-traversal.ts index 82d5d48..da1d069 100644 --- a/src/traverse/dependency-traversal.ts +++ b/src/traverse/dependency-traversal.ts @@ -2,12 +2,19 @@ import { FileGraph } from '@daxserver/validation-schema-codegen/traverse/file-gr import { NodeGraph } from '@daxserver/validation-schema-codegen/traverse/node-graph' import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' import { generateQualifiedNodeName } from '@daxserver/validation-schema-codegen/utils/generate-qualified-name' +import { + GraphVisualizer, + type VisualizationOptions, +} from '@daxserver/validation-schema-codegen/utils/graph-visualizer' import { topologicalSort } from 'graphology-dag' import { + EnumDeclaration, + FunctionDeclaration, ImportDeclaration, InterfaceDeclaration, Node, SourceFile, + SyntaxKind, TypeAliasDeclaration, TypeReferenceNode, } from 'ts-morph' @@ -113,32 +120,29 @@ export class DependencyTraversal { * Extract dependencies for all nodes in the graph */ extractDependencies(): void { - // Extract dependencies for all nodes in the graph for (const nodeId of this.nodeGraph.nodes()) { const nodeData = this.nodeGraph.getNode(nodeId) + let nodeToAnalyze: Node | undefined + if (nodeData.type === 'typeAlias') { const typeAlias = nodeData.node as TypeAliasDeclaration - const typeNode = typeAlias.getTypeNode() - if (!typeNode) continue + nodeToAnalyze = typeAlias.getTypeNode() + } else if (nodeData.type === 'interface') { + nodeToAnalyze = nodeData.node as InterfaceDeclaration + } else if (nodeData.type === 'enum') { + nodeToAnalyze = nodeData.node as EnumDeclaration + } else if (nodeData.type === 'function') { + nodeToAnalyze = nodeData.node as FunctionDeclaration + } - const typeReferences = this.extractTypeReferences(typeNode) + if (!nodeToAnalyze) continue - // Add edges for dependencies - for (const referencedType of typeReferences) { - if (this.nodeGraph.hasNode(referencedType)) { - this.nodeGraph.addDependency(referencedType, nodeId) - } - } - } else if (nodeData.type === 'interface') { - const interfaceDecl = nodeData.node as InterfaceDeclaration - const typeReferences = this.extractTypeReferences(interfaceDecl) + const typeReferences = this.extractTypeReferences(nodeToAnalyze) - // Add edges for dependencies - for (const referencedType of typeReferences) { - if (this.nodeGraph.hasNode(referencedType)) { - this.nodeGraph.addDependency(referencedType, nodeId) - } + for (const referencedType of typeReferences) { + if (this.nodeGraph.hasNode(referencedType)) { + this.nodeGraph.addDependency(referencedType, nodeId) } } } @@ -229,23 +233,37 @@ export class DependencyTraversal { } /** - * Get nodes in dependency order (dependencies first) - * Retrieved from the graph, not from SourceFile + * Get nodes in dependency order from graph + * Handles circular dependencies gracefully by falling back to simple node order */ getNodesToPrint(): TraversedNode[] { try { - // Use topological sort to ensure dependencies are printed first - const sortedNodeIds = topologicalSort(this.nodeGraph) - return sortedNodeIds.map((nodeId: string) => this.nodeGraph.getNodeAttributes(nodeId)) - } catch { + return topologicalSort(this.nodeGraph).map((nodeId: string) => this.nodeGraph.getNode(nodeId)) + } catch (error) { // Handle circular dependencies by returning nodes in insertion order - // This ensures dependencies are still processed before dependents when possible - return Array.from(this.nodeGraph.nodes()).map((nodeId: string) => - this.nodeGraph.getNodeAttributes(nodeId), - ) + if (error instanceof Error && error.message.includes('not acyclic')) { + return Array.from(this.nodeGraph.nodes()).map((nodeId: string) => + this.nodeGraph.getNode(nodeId), + ) + } + throw error } } + /** + * Generate HTML visualization of the dependency graph + */ + async visualizeGraph(options: VisualizationOptions = {}): Promise { + return GraphVisualizer.generateVisualization(this.nodeGraph, options) + } + + /** + * Get the node graph for debugging purposes + */ + getNodeGraph(): NodeGraph { + return this.nodeGraph + } + private extractTypeReferences(node: Node): string[] { const references: string[] = [] const visited = new Set() @@ -266,7 +284,29 @@ export class DependencyTraversal { } } - return + // Continue traversing to find type arguments + } + + // Handle interface inheritance (extends clauses) + if (Node.isInterfaceDeclaration(node)) { + const interfaceDecl = node as InterfaceDeclaration + const heritageClauses = interfaceDecl.getHeritageClauses() + + for (const heritageClause of heritageClauses) { + if (heritageClause.getToken() !== SyntaxKind.ExtendsKeyword) continue + + for (const typeNode of heritageClause.getTypeNodes()) { + const typeName = typeNode.getText() + + for (const qualifiedName of this.nodeGraph.nodes()) { + const nodeData = this.nodeGraph.getNode(qualifiedName) + if (nodeData.originalName === typeName) { + references.push(qualifiedName) + break + } + } + } + } } node.forEachChild(traverse) diff --git a/src/traverse/node-graph.ts b/src/traverse/node-graph.ts index 4a048ff..ed687cc 100644 --- a/src/traverse/node-graph.ts +++ b/src/traverse/node-graph.ts @@ -22,36 +22,18 @@ export class NodeGraph extends DirectedGraph { return this.getNodeAttributes(qualifiedName) as TraversedNode } - /** - * Remove unused imported nodes that have no outgoing edges - * Never removes root imports (types directly imported from the main file) - */ - removeUnusedImportedNodes(): void { - const nodesToRemove: string[] = [] - - for (const nodeId of this.nodes()) { - const nodeData = this.getNodeAttributes(nodeId) - if (nodeData?.isImported && !nodeData?.isMainCode) { - // Check if this imported type has any outgoing edges (other nodes depend on it) - const outgoingEdges = this.outboundNeighbors(nodeId) - if (outgoingEdges.length === 0) { - nodesToRemove.push(nodeId) - } - } - } - - // Remove unused imported types - for (const nodeId of nodesToRemove) { - this.dropNode(nodeId) - } - } - /** * Add dependency edge between two nodes */ addDependency(fromNode: string, toNode: string): void { - if (this.hasNode(fromNode) && this.hasNode(toNode)) { - this.addDirectedEdge(fromNode, toNode) + if ( + !this.hasNode(fromNode) || + !this.hasNode(toNode) || + this.hasDirectedEdge(fromNode, toNode) + ) { + return } + + this.addDirectedEdge(fromNode, toNode) } } diff --git a/src/utils/graph-visualizer.ts b/src/utils/graph-visualizer.ts new file mode 100644 index 0000000..8978050 --- /dev/null +++ b/src/utils/graph-visualizer.ts @@ -0,0 +1,424 @@ +import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' +import type { DirectedGraph } from 'graphology' +import Graph from 'graphology' +import forceAtlas2 from 'graphology-layout-forceatlas2' + +export interface VisualizationOptions { + outputPath?: string + title?: string +} + +export interface GraphNode { + key: string + label: string + x?: number + y?: number + size: number + color: string + title?: string +} + +export interface GraphEdge { + key: string + source: string + target: string + color?: string + size?: number +} + +/** + * Converts a graphology DirectedGraph to HTML visualization + */ +export class GraphVisualizer { + /** + * Generate HTML visualization for the dependency graph + */ + static async generateVisualization( + nodeGraph: DirectedGraph, + options: VisualizationOptions = {}, + ): Promise { + const { outputPath = './dependency-graph.html', title = 'TypeScript Dependency Graph' } = + options + + const { nodes, edges } = this.convertGraphToSigmaData(nodeGraph) + + // Always apply ForceAtlas2 layout + this.applyForceAtlas2Layout(nodes) + + const htmlContent = this.generateHTMLContent(nodes, edges, title) + + const file = Bun.file(outputPath) + await file.write(htmlContent) + + return outputPath + } + + /** + * Convert graphology graph to sigma.js compatible data + */ + private static convertGraphToSigmaData(graph: DirectedGraph): { + nodes: GraphNode[] + edges: GraphEdge[] + } { + const nodes: GraphNode[] = [] + const edges: GraphEdge[] = [] + + // Convert nodes + for (const nodeId of graph.nodes()) { + const nodeData = graph.getNodeAttributes(nodeId) + + const node: GraphNode = { + key: nodeId, + label: `${nodeData.type}: ${nodeData.originalName}`, + size: this.getNodeSize(nodeData), + color: this.getEnhancedNodeColor(nodeData), + } + + nodes.push(node) + } + + // Convert edges + for (const edge of graph.edges()) { + const [source, target] = graph.extremities(edge) + + edges.push({ + key: `${source}-${target}`, + source: source, + target: target, + color: '#848484', + size: 2, + }) + } + + return { nodes, edges } + } + + /** + * Apply ForceAtlas2 layout to nodes + */ + private static applyForceAtlas2Layout(nodes: GraphNode[]): void { + // Create a temporary graph for layout calculation + const tempGraph = new Graph() + + // Add nodes with initial circular positions + nodes.forEach((node, index) => { + const angle = (2 * Math.PI * index) / nodes.length + const radius = Math.min(300, Math.max(100, nodes.length * 8)) + const x = Math.cos(angle) * radius + const y = Math.sin(angle) * radius + + tempGraph.addNode(node.key, { + x, + y, + size: node.size, + }) + }) + + // Apply ForceAtlas2 layout + const settings = forceAtlas2.inferSettings(tempGraph) + const positions = forceAtlas2(tempGraph, { + iterations: 50, + settings: { + ...settings, + gravity: 1, + scalingRatio: 10, + }, + }) + + // Update node positions + nodes.forEach((node) => { + const position = positions[node.key] + if (position) { + node.x = position.x + node.y = position.y + } + }) + } + + /** + * Get node size based on node type and properties + */ + private static getNodeSize(nodeData: TraversedNode): number { + const baseSize = 12 + + if (nodeData.isMainCode) { + return baseSize + 6 // Larger for main code + } + + switch (nodeData.type) { + case 'interface': + case 'typeAlias': + return baseSize + 4 + case 'enum': + return baseSize + 3 + case 'function': + return baseSize + 2 + default: + return baseSize + } + } + + /** + * Get enhanced node color with intensity based on import nesting level + */ + private static getEnhancedNodeColor(nodeData: TraversedNode): string { + const nestingLevel = this.calculateImportNestingLevel(nodeData) + const intensity = Math.min(1, 0.4 + nestingLevel * 0.15) + + let baseColor: { r: number; g: number; b: number } + + if (nodeData.isMainCode) { + baseColor = { r: 76, g: 175, b: 80 } // Green + } else { + switch (nodeData.type) { + case 'interface': + baseColor = { r: 33, g: 150, b: 243 } // Blue + break + case 'typeAlias': + baseColor = { r: 156, g: 39, b: 176 } // Purple + break + case 'enum': + baseColor = { r: 244, g: 67, b: 54 } // Red + break + case 'function': + baseColor = { r: 121, g: 85, b: 72 } // Brown + break + default: + baseColor = { r: 96, g: 125, b: 139 } // Blue Grey + } + } + + // Apply intensity + const r = Math.round(baseColor.r * intensity) + const g = Math.round(baseColor.g * intensity) + const b = Math.round(baseColor.b * intensity) + + return `rgb(${r}, ${g}, ${b})` + } + + /** + * Calculate import nesting level for color intensity + */ + private static calculateImportNestingLevel(nodeData: TraversedNode): number { + if (nodeData.isMainCode) { + return 4 // Highest intensity for main code + } + + if (nodeData.isImported) { + // Simple heuristic: count path depth + const filePath = nodeData.node.getSourceFile().getFilePath() + const pathDepth = filePath.split('/').length + return Math.max(1, Math.min(3, pathDepth - 3)) + } + + return 2 // Default level + } + + /** + * Generate complete HTML content with sigma.js + */ + private static generateHTMLContent( + nodes: GraphNode[], + edges: GraphEdge[], + title: string, + ): string { + return ` + + + ${title} + + + + + + +
+ +
+

Node Types:

+
+ + Interface +
+
+ + Type Alias +
+
+ + Enum +
+
+ + Function +
+
+ + Other +
+
+

Color Intensity & Size:

+
+ + Main Code (Brightest & Larger) +
+
+ + Imported (Varies by nesting) +
+
+
+ + + +` + } +} diff --git a/tests/traverse/circular-dependency.test.ts b/tests/traverse/circular-dependency.test.ts new file mode 100644 index 0000000..203fa9e --- /dev/null +++ b/tests/traverse/circular-dependency.test.ts @@ -0,0 +1,64 @@ +import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' +import { describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('Circular dependency issues', () => { + test('should handle forward references correctly', () => { + const project = new Project() + + // Reproduce the exact issue from wikibase entity.d.ts + const sourceFile = project.createSourceFile( + 'test.ts', + ` + export type Entities = Record + export type SimplifiedEntities = Record + + export interface EntityInfo { + id: T + } + + export type EntityId = ItemId | PropertyId + export type ItemId = \`Q\${number}\` + export type PropertyId = \`P\${number}\` + + export interface Item extends EntityInfo { + type: 'item' + } + + export interface Property extends EntityInfo { + type: 'property' + } + + export type Entity = Property | Item + export type SimplifiedEntity = Property | Item + `, + ) + + const traversal = new DependencyTraversal() + const result = traversal.startTraversal(sourceFile) + + // EntityId should come before Entities + const entityIdIndex = result.findIndex((node) => node.originalName === 'EntityId') + const entitiesIndex = result.findIndex((node) => node.originalName === 'Entities') + + expect(entityIdIndex).toBeGreaterThanOrEqual(0) + expect(entitiesIndex).toBeGreaterThanOrEqual(0) + expect(entityIdIndex).toBeLessThan(entitiesIndex) + + // Entity should come before Entities + const entityIndex = result.findIndex((node) => node.originalName === 'Entity') + expect(entityIndex).toBeGreaterThanOrEqual(0) + expect(entityIndex).toBeLessThan(entitiesIndex) + + // SimplifiedEntity should come before SimplifiedEntities + const simplifiedEntityIndex = result.findIndex( + (node) => node.originalName === 'SimplifiedEntity', + ) + const simplifiedEntitiesIndex = result.findIndex( + (node) => node.originalName === 'SimplifiedEntities', + ) + expect(simplifiedEntityIndex).toBeGreaterThanOrEqual(0) + expect(simplifiedEntitiesIndex).toBeGreaterThanOrEqual(0) + expect(simplifiedEntityIndex).toBeLessThan(simplifiedEntitiesIndex) + }) +}) diff --git a/tests/traverse/dependency-extraction.test.ts b/tests/traverse/dependency-extraction.test.ts new file mode 100644 index 0000000..d3879e4 --- /dev/null +++ b/tests/traverse/dependency-extraction.test.ts @@ -0,0 +1,155 @@ +import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' +import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' +import { createSourceFile } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +const getNodeName = (traversedNode: TraversedNode): string => { + return traversedNode.originalName +} + +describe('Dependency Extraction', () => { + let project: Project + let traverser: DependencyTraversal + + beforeEach(() => { + project = new Project() + traverser = new DependencyTraversal() + }) + + test('should extract dependencies from enums', () => { + createSourceFile( + project, + ` + export type Status = 'active' | 'inactive'; + `, + 'status.ts', + ) + + const mainFile = createSourceFile( + project, + ` + import { Status } from "./status"; + + enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'inactive' + } + + type UserWithStatus = { + id: string; + status: Status; + }; + `, + 'main.ts', + ) + + traverser.startTraversal(mainFile) + const dependencies = traverser.getNodesToPrint() + + // Should have Status, UserStatus, and UserWithStatus + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('Status') + expect(typeNames).toContain('UserStatus') + expect(typeNames).toContain('UserWithStatus') + + // Status should come before UserWithStatus in topological order + const statusIndex = dependencies.findIndex((d) => getNodeName(d) === 'Status') + const userWithStatusIndex = dependencies.findIndex((d) => getNodeName(d) === 'UserWithStatus') + expect(statusIndex).toBeLessThan(userWithStatusIndex) + }) + + test('should extract dependencies from functions', () => { + createSourceFile( + project, + ` + export type User = { + id: string; + name: string; + }; + `, + 'user.ts', + ) + + const mainFile = createSourceFile( + project, + ` + import { User } from "./user"; + + function createUser(name: string): User { + return { id: '1', name }; + } + + type UserFactory = { + create: typeof createUser; + }; + `, + 'main.ts', + ) + + traverser.startTraversal(mainFile) + const dependencies = traverser.getNodesToPrint() + + // Should have User, createUser, and UserFactory + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('User') + expect(typeNames).toContain('createUser') + expect(typeNames).toContain('UserFactory') + + // User should come before createUser in topological order + const userIndex = dependencies.findIndex((d) => getNodeName(d) === 'User') + const createUserIndex = dependencies.findIndex((d) => getNodeName(d) === 'createUser') + expect(userIndex).toBeLessThan(createUserIndex) + }) + + test('should extract dependencies from enum with type references', () => { + createSourceFile( + project, + ` + export type Color = 'red' | 'green' | 'blue'; + `, + 'color.ts', + ) + + const mainFile = createSourceFile( + project, + ` + import { Color } from "./color"; + + enum Theme { + LIGHT = 'light', + DARK = 'dark' + } + + type ThemedColor = { + color: Color; + theme: Theme; + }; + `, + 'main.ts', + ) + + traverser.startTraversal(mainFile) + const dependencies = traverser.getNodesToPrint() + + // Should have Color, Theme, and ThemedColor + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('Color') + expect(typeNames).toContain('Theme') + expect(typeNames).toContain('ThemedColor') + + // Both Color and Theme should come before ThemedColor + const colorIndex = dependencies.findIndex((d) => getNodeName(d) === 'Color') + const themeIndex = dependencies.findIndex((d) => getNodeName(d) === 'Theme') + const themedColorIndex = dependencies.findIndex((d) => getNodeName(d) === 'ThemedColor') + + expect(colorIndex).toBeLessThan(themedColorIndex) + expect(themeIndex).toBeLessThan(themedColorIndex) + }) +}) diff --git a/tests/traverse/dependency-ordering.test.ts b/tests/traverse/dependency-ordering.test.ts index dd0e1ac..dcff9bd 100644 --- a/tests/traverse/dependency-ordering.test.ts +++ b/tests/traverse/dependency-ordering.test.ts @@ -1,8 +1,9 @@ +import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' import { formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' import { beforeEach, describe, expect, test } from 'bun:test' import { Project } from 'ts-morph' -describe('Dependency Ordering Bug', () => { +describe('Dependency ordering', () => { let project: Project beforeEach(() => { @@ -89,4 +90,65 @@ describe('Dependency Ordering Bug', () => { `), ) }) + + test('should order dependencies correctly - referenced types before referencing types', () => { + const project = new Project() + + // Create a simple test case where TypeB depends on TypeA + const sourceFile = project.createSourceFile( + 'test.ts', + ` + export type TypeB = TypeA + export type TypeA = string + `, + ) + + const traversal = new DependencyTraversal() + const result = traversal.startTraversal(sourceFile) + + // TypeA should come before TypeB in the result + const typeAIndex = result.findIndex((node) => node.originalName === 'TypeA') + const typeBIndex = result.findIndex((node) => node.originalName === 'TypeB') + + expect(typeAIndex).toBeGreaterThanOrEqual(0) + expect(typeBIndex).toBeGreaterThanOrEqual(0) + expect(typeAIndex).toBeLessThan(typeBIndex) + }) + + test('should handle complex dependency chains', () => { + const project = new Project() + + // Create a more complex dependency chain + const sourceFile = project.createSourceFile( + 'test.ts', + ` + export type EntityInfo = { + id: EntityId + name: string + } + + export type EntityId = string + + export type Entities = Record + + export type Entity = { + info: EntityInfo + type: string + } + `, + ) + + const traversal = new DependencyTraversal() + const result = traversal.startTraversal(sourceFile) + + // EntityId should come before EntityInfo, EntityInfo should come before Entity, Entity should come before Entities + const entityIdIndex = result.findIndex((node) => node.originalName === 'EntityId') + const entityInfoIndex = result.findIndex((node) => node.originalName === 'EntityInfo') + const entityIndex = result.findIndex((node) => node.originalName === 'Entity') + const entitiesIndex = result.findIndex((node) => node.originalName === 'Entities') + + expect(entityIdIndex).toBeLessThan(entityInfoIndex) + expect(entityInfoIndex).toBeLessThan(entityIndex) + expect(entityIdIndex).toBeLessThan(entitiesIndex) + }) }) diff --git a/tests/traverse/dependency-collector.integration.test.ts b/tests/traverse/dependency-traversal.integration.test.ts similarity index 99% rename from tests/traverse/dependency-collector.integration.test.ts rename to tests/traverse/dependency-traversal.integration.test.ts index d340c10..8bcb4bd 100644 --- a/tests/traverse/dependency-collector.integration.test.ts +++ b/tests/traverse/dependency-traversal.integration.test.ts @@ -8,7 +8,7 @@ const getNodeName = (traversedNode: TraversedNode): string => { return traversedNode.originalName } -describe('DependencyCollector', () => { +describe('Dependency Traversal', () => { let project: Project let traverser: DependencyTraversal diff --git a/tests/traverse/dependency-collector.performance.test.ts b/tests/traverse/dependency-traversal.performance.test.ts similarity index 100% rename from tests/traverse/dependency-collector.performance.test.ts rename to tests/traverse/dependency-traversal.performance.test.ts diff --git a/tests/traverse/interface-inheritance-dependency.test.ts b/tests/traverse/interface-inheritance-dependency.test.ts new file mode 100644 index 0000000..0a1f4ce --- /dev/null +++ b/tests/traverse/interface-inheritance-dependency.test.ts @@ -0,0 +1,118 @@ +import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' +import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' +import { createSourceFile } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +const getNodeName = (traversedNode: TraversedNode): string => { + return traversedNode.originalName +} + +describe('Interface Inheritance Dependencies', () => { + let project: Project + let traverser: DependencyTraversal + + beforeEach(() => { + project = new Project() + traverser = new DependencyTraversal() + }) + + test('should extract dependencies from interface inheritance', () => { + const sourceFile = createSourceFile( + project, + ` + interface SimplifySnakOptions { + entityPrefix?: string + } + + type SimplifySnaksOptions = SimplifySnakOptions + + interface SimplifyClaimsOptions extends SimplifySnaksOptions { + keepQualifiers?: boolean + } + `, + ) + + traverser.startTraversal(sourceFile) + const dependencies = traverser.getNodesToPrint() + + // Should have all three types + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('SimplifySnakOptions') + expect(typeNames).toContain('SimplifySnaksOptions') + expect(typeNames).toContain('SimplifyClaimsOptions') + + // SimplifySnakOptions should come before SimplifySnaksOptions + const snakOptionsIndex = dependencies.findIndex((d) => getNodeName(d) === 'SimplifySnakOptions') + const snaksOptionsIndex = dependencies.findIndex( + (d) => getNodeName(d) === 'SimplifySnaksOptions', + ) + expect(snakOptionsIndex).toBeLessThan(snaksOptionsIndex) + + // SimplifySnaksOptions should come before SimplifyClaimsOptions + const claimsOptionsIndex = dependencies.findIndex( + (d) => getNodeName(d) === 'SimplifyClaimsOptions', + ) + expect(snaksOptionsIndex).toBeLessThan(claimsOptionsIndex) + }) + + test('should handle multiple interface inheritance', () => { + const sourceFile = createSourceFile( + project, + ` + interface A { a: string } + interface B { b: number } + interface C extends A, B { c: boolean } + `, + ) + + traverser.startTraversal(sourceFile) + const dependencies = traverser.getNodesToPrint() + + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('A') + expect(typeNames).toContain('B') + expect(typeNames).toContain('C') + + // Both A and B should come before C + const aIndex = dependencies.findIndex((d) => getNodeName(d) === 'A') + const bIndex = dependencies.findIndex((d) => getNodeName(d) === 'B') + const cIndex = dependencies.findIndex((d) => getNodeName(d) === 'C') + + expect(aIndex).toBeLessThan(cIndex) + expect(bIndex).toBeLessThan(cIndex) + }) + + test('should handle nested interface inheritance', () => { + const sourceFile = createSourceFile( + project, + ` + interface A { a: string } + interface B extends A { b: number } + interface C extends B { c: boolean } + `, + ) + + traverser.startTraversal(sourceFile) + const dependencies = traverser.getNodesToPrint() + + expect(dependencies).toHaveLength(3) + + const typeNames = dependencies.map((d) => getNodeName(d)) + expect(typeNames).toContain('A') + expect(typeNames).toContain('B') + expect(typeNames).toContain('C') + + // Should be in order: A, B, C + const aIndex = dependencies.findIndex((d) => getNodeName(d) === 'A') + const bIndex = dependencies.findIndex((d) => getNodeName(d) === 'B') + const cIndex = dependencies.findIndex((d) => getNodeName(d) === 'C') + + expect(aIndex).toBeLessThan(bIndex) + expect(bIndex).toBeLessThan(cIndex) + }) +}) diff --git a/tests/traverse/simple-dependency.test.ts b/tests/traverse/simple-dependency.test.ts new file mode 100644 index 0000000..f726372 --- /dev/null +++ b/tests/traverse/simple-dependency.test.ts @@ -0,0 +1,39 @@ +import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' +import { describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('Simple dependency ordering', () => { + test('should order simple dependencies correctly', () => { + const project = new Project() + + // Create a simple test case with clear dependencies + const sourceFile = project.createSourceFile( + 'test.ts', + ` + export type UserId = string + export type User = { + id: UserId + name: string + } + export type Users = Record + `, + ) + + const traversal = new DependencyTraversal() + const result = traversal.startTraversal(sourceFile) + + // UserId should come before User + const userIdIndex = result.findIndex((node) => node.originalName === 'UserId') + const userIndex = result.findIndex((node) => node.originalName === 'User') + const usersIndex = result.findIndex((node) => node.originalName === 'Users') + + expect(userIdIndex).toBeGreaterThanOrEqual(0) + expect(userIndex).toBeGreaterThanOrEqual(0) + expect(usersIndex).toBeGreaterThanOrEqual(0) + + // Check correct ordering + expect(userIdIndex).toBeLessThan(userIndex) + expect(userIndex).toBeLessThan(usersIndex) + expect(userIdIndex).toBeLessThan(usersIndex) + }) +}) From 0e7c2110f99e9aca768ab2f0a525f69432dc740e Mon Sep 17 00:00:00 2001 From: DaxServer Date: Fri, 29 Aug 2025 14:43:20 +0200 Subject: [PATCH 2/2] fix: several bugs --- bun.lock | 8 ---- package.json | 20 ++++----- src/index.ts | 1 - src/traverse/dependency-traversal.ts | 42 +++++++++++-------- src/traverse/node-graph.ts | 1 + src/utils/graph-visualizer.ts | 26 ++++++++---- tests/traverse/dependency-extraction.test.ts | 3 ++ tests/traverse/dependency-ordering.test.ts | 6 +-- .../interface-inheritance-dependency.test.ts | 9 ++-- 9 files changed, 58 insertions(+), 58 deletions(-) diff --git a/bun.lock b/bun.lock index 9897a66..ecf4051 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,6 @@ "": { "name": "new-bun-project", "dependencies": { - "@sigma/node-border": "^3.0.0", - "@sigma/node-square": "^3.0.0", "graphology": "^0.26.0", "graphology-dag": "^0.4.1", "graphology-layout-forceatlas2": "^0.10.1", @@ -77,10 +75,6 @@ "@prettier/sync": ["@prettier/sync@0.6.1", "", { "dependencies": { "make-synchronized": "^0.8.0" }, "peerDependencies": { "prettier": "*" } }, "sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw=="], - "@sigma/node-border": ["@sigma/node-border@3.0.0", "", { "peerDependencies": { "sigma": ">=3.0.0-beta.17" } }, "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ=="], - - "@sigma/node-square": ["@sigma/node-square@3.0.0", "", { "peerDependencies": { "sigma": ">=3.0.0-beta.17" } }, "sha512-hPX2oWo7WeaSe6M3D56AXsrLyg3F+7N/YsodaJh4Sw3KTce0GAFVWWPZZklu9CITz0xi3kEmlCGulqAH0cVG2w=="], - "@sinclair/typebox": ["@sinclair/typebox@0.33.22", "", {}, "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ=="], "@sinclair/typebox-codegen": ["@sinclair/typebox-codegen@0.11.1", "", { "dependencies": { "@sinclair/typebox": "^0.33.1", "prettier": "^2.8.7", "typescript": "^5.4.5" } }, "sha512-Bckbrf1sJFTIVD88PvI0vWUfE3Sh/6pwu6Jov+6xyMrEqnabOxEFAmPSDWjB1FGPL5C1/HfdScwa1imwAtGi9w=="], @@ -313,8 +307,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "sigma": ["sigma@3.0.2", "", { "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" } }, "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/package.json b/package.json index c082523..60c5db8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,17 @@ { "name": "@daxserver/validation-schema-codegen", "version": "0.1.0", + "description": "Codegen for validation schemas", + "private": true, "main": "src/index.ts", "module": "src/index.ts", + "type": "module", + "dependencies": { + "graphology": "^0.26.0", + "graphology-dag": "^0.4.1", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-traversal": "^0.3.1" + }, "devDependencies": { "@eslint/js": "^9.34.0", "@prettier/sync": "^0.6.1", @@ -26,20 +35,9 @@ "peerDependencies": { "typescript": "~5.9.2" }, - "description": "Codegen for validation schemas", - "private": true, "scripts": { "format": "prettier --cache --write .", "typecheck": "tsc --noEmit", "lint": "eslint" - }, - "type": "module", - "dependencies": { - "@sigma/node-border": "^3.0.0", - "@sigma/node-square": "^3.0.0", - "graphology": "^0.26.0", - "graphology-dag": "^0.4.1", - "graphology-layout-forceatlas2": "^0.10.1", - "graphology-traversal": "^0.3.1" } } diff --git a/src/index.ts b/src/index.ts index f726978..ea4644e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,6 @@ const printSortedNodes = (sortedTraversedNodes: TraversedNode[], newSourceFile: } export interface CodeGenerationOptions extends InputOptions { - visualize?: boolean visualizationOptions?: VisualizationOptions } diff --git a/src/traverse/dependency-traversal.ts b/src/traverse/dependency-traversal.ts index da1d069..b1406a2 100644 --- a/src/traverse/dependency-traversal.ts +++ b/src/traverse/dependency-traversal.ts @@ -6,7 +6,7 @@ import { GraphVisualizer, type VisualizationOptions, } from '@daxserver/validation-schema-codegen/utils/graph-visualizer' -import { topologicalSort } from 'graphology-dag' +import { hasCycle, topologicalSort } from 'graphology-dag' import { EnumDeclaration, FunctionDeclaration, @@ -16,7 +16,6 @@ import { SourceFile, SyntaxKind, TypeAliasDeclaration, - TypeReferenceNode, } from 'ts-morph' /** @@ -237,17 +236,11 @@ export class DependencyTraversal { * Handles circular dependencies gracefully by falling back to simple node order */ getNodesToPrint(): TraversedNode[] { - try { - return topologicalSort(this.nodeGraph).map((nodeId: string) => this.nodeGraph.getNode(nodeId)) - } catch (error) { - // Handle circular dependencies by returning nodes in insertion order - if (error instanceof Error && error.message.includes('not acyclic')) { - return Array.from(this.nodeGraph.nodes()).map((nodeId: string) => - this.nodeGraph.getNode(nodeId), - ) - } - throw error - } + const nodes = hasCycle(this.nodeGraph) + ? Array.from(this.nodeGraph.nodes()) + : topologicalSort(this.nodeGraph) + + return nodes.map((nodeId: string) => this.nodeGraph.getNode(nodeId)) } /** @@ -273,8 +266,7 @@ export class DependencyTraversal { visited.add(node) if (Node.isTypeReference(node)) { - const typeRefNode = node as TypeReferenceNode - const typeName = typeRefNode.getTypeName().getText() + const typeName = node.getTypeName().getText() for (const qualifiedName of this.nodeGraph.nodes()) { const nodeData = this.nodeGraph.getNode(qualifiedName) @@ -283,14 +275,28 @@ export class DependencyTraversal { break } } + } - // Continue traversing to find type arguments + // Handle typeof expressions (TypeQuery nodes) + if (Node.isTypeQuery(node)) { + const exprName = node.getExprName() + + if (Node.isIdentifier(exprName) || Node.isQualifiedName(exprName)) { + const typeName = exprName.getText() + + for (const qualifiedName of this.nodeGraph.nodes()) { + const nodeData = this.nodeGraph.getNode(qualifiedName) + if (nodeData.originalName === typeName) { + references.push(qualifiedName) + break + } + } + } } // Handle interface inheritance (extends clauses) if (Node.isInterfaceDeclaration(node)) { - const interfaceDecl = node as InterfaceDeclaration - const heritageClauses = interfaceDecl.getHeritageClauses() + const heritageClauses = node.getHeritageClauses() for (const heritageClause of heritageClauses) { if (heritageClause.getToken() !== SyntaxKind.ExtendsKeyword) continue diff --git a/src/traverse/node-graph.ts b/src/traverse/node-graph.ts index ed687cc..4136200 100644 --- a/src/traverse/node-graph.ts +++ b/src/traverse/node-graph.ts @@ -29,6 +29,7 @@ export class NodeGraph extends DirectedGraph { if ( !this.hasNode(fromNode) || !this.hasNode(toNode) || + fromNode === toNode || this.hasDirectedEdge(fromNode, toNode) ) { return diff --git a/src/utils/graph-visualizer.ts b/src/utils/graph-visualizer.ts index 8978050..7fb5e43 100644 --- a/src/utils/graph-visualizer.ts +++ b/src/utils/graph-visualizer.ts @@ -2,6 +2,8 @@ import type { TraversedNode } from '@daxserver/validation-schema-codegen/travers import type { DirectedGraph } from 'graphology' import Graph from 'graphology' import forceAtlas2 from 'graphology-layout-forceatlas2' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' export interface VisualizationOptions { outputPath?: string @@ -43,13 +45,16 @@ export class GraphVisualizer { const { nodes, edges } = this.convertGraphToSigmaData(nodeGraph) // Always apply ForceAtlas2 layout - this.applyForceAtlas2Layout(nodes) + this.applyForceAtlas2Layout(nodes, edges) const htmlContent = this.generateHTMLContent(nodes, edges, title) const file = Bun.file(outputPath) await file.write(htmlContent) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, htmlContent, 'utf8') + return outputPath } @@ -96,7 +101,7 @@ export class GraphVisualizer { /** * Apply ForceAtlas2 layout to nodes */ - private static applyForceAtlas2Layout(nodes: GraphNode[]): void { + private static applyForceAtlas2Layout(nodes: GraphNode[], edges: GraphEdge[]): void { // Create a temporary graph for layout calculation const tempGraph = new Graph() @@ -114,6 +119,16 @@ export class GraphVisualizer { }) }) + // Add edges to influence layout + edges.forEach((e) => { + if (tempGraph.hasNode(e.source) && tempGraph.hasNode(e.target)) { + const key = `${e.source}->${e.target}` + if (!tempGraph.hasEdge(key)) { + tempGraph.addEdgeWithKey(key, e.source, e.target) + } + } + }) + // Apply ForceAtlas2 layout const settings = forceAtlas2.inferSettings(tempGraph) const positions = forceAtlas2(tempGraph, { @@ -204,13 +219,6 @@ export class GraphVisualizer { return 4 // Highest intensity for main code } - if (nodeData.isImported) { - // Simple heuristic: count path depth - const filePath = nodeData.node.getSourceFile().getFilePath() - const pathDepth = filePath.split('/').length - return Math.max(1, Math.min(3, pathDepth - 3)) - } - return 2 // Default level } diff --git a/tests/traverse/dependency-extraction.test.ts b/tests/traverse/dependency-extraction.test.ts index d3879e4..59782bf 100644 --- a/tests/traverse/dependency-extraction.test.ts +++ b/tests/traverse/dependency-extraction.test.ts @@ -103,7 +103,10 @@ describe('Dependency Extraction', () => { // User should come before createUser in topological order const userIndex = dependencies.findIndex((d) => getNodeName(d) === 'User') const createUserIndex = dependencies.findIndex((d) => getNodeName(d) === 'createUser') + const userFactoryIndex = dependencies.findIndex((d) => getNodeName(d) === 'UserFactory') + expect(userIndex).toBeLessThan(createUserIndex) + expect(createUserIndex).toBeLessThan(userFactoryIndex) }) test('should extract dependencies from enum with type references', () => { diff --git a/tests/traverse/dependency-ordering.test.ts b/tests/traverse/dependency-ordering.test.ts index dcff9bd..62472bb 100644 --- a/tests/traverse/dependency-ordering.test.ts +++ b/tests/traverse/dependency-ordering.test.ts @@ -92,8 +92,6 @@ describe('Dependency ordering', () => { }) test('should order dependencies correctly - referenced types before referencing types', () => { - const project = new Project() - // Create a simple test case where TypeB depends on TypeA const sourceFile = project.createSourceFile( 'test.ts', @@ -116,8 +114,6 @@ describe('Dependency ordering', () => { }) test('should handle complex dependency chains', () => { - const project = new Project() - // Create a more complex dependency chain const sourceFile = project.createSourceFile( 'test.ts', @@ -149,6 +145,6 @@ describe('Dependency ordering', () => { expect(entityIdIndex).toBeLessThan(entityInfoIndex) expect(entityInfoIndex).toBeLessThan(entityIndex) - expect(entityIdIndex).toBeLessThan(entitiesIndex) + expect(entityIndex).toBeLessThan(entitiesIndex) }) }) diff --git a/tests/traverse/interface-inheritance-dependency.test.ts b/tests/traverse/interface-inheritance-dependency.test.ts index 0a1f4ce..b0b0b2f 100644 --- a/tests/traverse/interface-inheritance-dependency.test.ts +++ b/tests/traverse/interface-inheritance-dependency.test.ts @@ -33,8 +33,7 @@ describe('Interface Inheritance Dependencies', () => { `, ) - traverser.startTraversal(sourceFile) - const dependencies = traverser.getNodesToPrint() + const dependencies = traverser.startTraversal(sourceFile) // Should have all three types expect(dependencies).toHaveLength(3) @@ -68,8 +67,7 @@ describe('Interface Inheritance Dependencies', () => { `, ) - traverser.startTraversal(sourceFile) - const dependencies = traverser.getNodesToPrint() + const dependencies = traverser.startTraversal(sourceFile) expect(dependencies).toHaveLength(3) @@ -97,8 +95,7 @@ describe('Interface Inheritance Dependencies', () => { `, ) - traverser.startTraversal(sourceFile) - const dependencies = traverser.getNodesToPrint() + const dependencies = traverser.startTraversal(sourceFile) expect(dependencies).toHaveLength(3)