Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mcfile name="parsers" path="src/parsers"></mcfile>:
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
25 changes: 19 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<string> => {
// 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()
Expand Down
118 changes: 82 additions & 36 deletions src/traverse/dependency-traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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<string> {
return GraphVisualizer.generateVisualization(this.nodeGraph, options)
}

/**
* Get the node graph for debugging purposes
*/
getNodeGraph(): NodeGraph {
return this.nodeGraph
}

private extractTypeReferences(node: Node): string[] {
Expand All @@ -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)
Expand All @@ -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
}
}
}
}
Comment on lines +281 to +295
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Normalize typeof references.

exprName.getText() may be ns.Symbol; strip qualification to match originalName.

-        if (Node.isIdentifier(exprName) || Node.isQualifiedName(exprName)) {
-          const typeName = exprName.getText()
+        if (Node.isIdentifier(exprName) || Node.isQualifiedName(exprName)) {
+          const typeName = exprName.getText().split('.').pop()!
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
}
}
}
if (Node.isTypeQuery(node)) {
const exprName = node.getExprName()
if (Node.isIdentifier(exprName) || Node.isQualifiedName(exprName)) {
// Strip namespace qualification so it matches nodeData.originalName
const typeName = exprName.getText().split('.').pop()!
for (const qualifiedName of this.nodeGraph.nodes()) {
const nodeData = this.nodeGraph.getNode(qualifiedName)
if (nodeData.originalName === typeName) {
references.push(qualifiedName)
break
}
}
}
}
🤖 Prompt for AI Agents
In src/traverse/dependency-traversal.ts around lines 281-295, the
typeof/typeQuery handling compares exprName.getText() directly to
nodeData.originalName which fails for qualified names like "ns.Symbol";
normalize by extracting the unqualified identifier before comparing (e.g. take
the last segment after the final "." or the right-most part of a QualifiedName)
so that "ns.Symbol" becomes "Symbol", then use that normalized typeName to match
nodeData.originalName and push the qualifiedName when equal.


// 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)
Expand Down
35 changes: 9 additions & 26 deletions src/traverse/node-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,19 @@ export class NodeGraph extends DirectedGraph<TraversedNode> {
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)
}
}
Loading