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..ecf4051 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "graphology": "^0.26.0", "graphology-dag": "^0.4.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphology-traversal": "^0.3.1", }, "devDependencies": { @@ -208,6 +209,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=="], diff --git a/package.json b/package.json index 4e3b711..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,17 +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": { - "graphology": "^0.26.0", - "graphology-dag": "^0.4.1", - "graphology-traversal": "^0.3.1" } } diff --git a/src/index.ts b/src/index.ts index 3c8441f..ea4644e 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,25 @@ const printSortedNodes = (sortedTraversedNodes: TraversedNode[], newSourceFile: return newSourceFile.getFullText() } -export const generateCode = ({ sourceCode, filePath, ...options }: InputOptions): string => { +export interface CodeGenerationOptions extends InputOptions { + 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..b1406a2 100644 --- a/src/traverse/dependency-traversal.ts +++ b/src/traverse/dependency-traversal.ts @@ -2,14 +2,20 @@ 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 { topologicalSort } from 'graphology-dag' import { + GraphVisualizer, + type VisualizationOptions, +} from '@daxserver/validation-schema-codegen/utils/graph-visualizer' +import { hasCycle, topologicalSort } from 'graphology-dag' +import { + EnumDeclaration, + FunctionDeclaration, ImportDeclaration, InterfaceDeclaration, Node, SourceFile, + SyntaxKind, TypeAliasDeclaration, - TypeReferenceNode, } from 'ts-morph' /** @@ -113,32 +119,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,21 +232,29 @@ 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 { - // 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), - ) - } + const nodes = hasCycle(this.nodeGraph) + ? Array.from(this.nodeGraph.nodes()) + : topologicalSort(this.nodeGraph) + + return nodes.map((nodeId: string) => this.nodeGraph.getNode(nodeId)) + } + + /** + * 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[] { @@ -255,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) @@ -265,8 +275,44 @@ export class DependencyTraversal { break } } + } - return + // 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 heritageClauses = node.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..4136200 100644 --- a/src/traverse/node-graph.ts +++ b/src/traverse/node-graph.ts @@ -22,36 +22,19 @@ 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) || + fromNode === 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..7fb5e43 --- /dev/null +++ b/src/utils/graph-visualizer.ts @@ -0,0 +1,432 @@ +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' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +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, 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 + } + + /** + * 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[], edges: GraphEdge[]): 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, + }) + }) + + // 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, { + 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 + } + + 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..59782bf --- /dev/null +++ b/tests/traverse/dependency-extraction.test.ts @@ -0,0 +1,158 @@ +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') + 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', () => { + 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..62472bb 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,61 @@ describe('Dependency Ordering Bug', () => { `), ) }) + + test('should order dependencies correctly - referenced types before referencing types', () => { + // 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', () => { + // 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(entityIndex).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..b0b0b2f --- /dev/null +++ b/tests/traverse/interface-inheritance-dependency.test.ts @@ -0,0 +1,115 @@ +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 + } + `, + ) + + const dependencies = traverser.startTraversal(sourceFile) + + // 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 } + `, + ) + + const dependencies = traverser.startTraversal(sourceFile) + + 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 } + `, + ) + + const dependencies = traverser.startTraversal(sourceFile) + + 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) + }) +})