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
487 changes: 161 additions & 326 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "@daxserver/validation-schema-codegen",
"version": "0.1.0",
"main": "src/ts-morph-codegen.ts",
"module": "src/ts-morph-codegen.ts",
"main": "src/index.ts",
"module": "src/index.ts",
"devDependencies": {
"@eslint/js": "^9.34.0",
"@prettier/sync": "^0.6.1",
Expand Down
77 changes: 77 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
createSourceFileFromInput,
type InputOptions,
} from '@daxserver/validation-schema-codegen/input-handler'
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 { Node, Project, SourceFile, ts } from 'ts-morph'

const createOutputFile = (hasGenericInterfaces: boolean) => {
const newSourceFile = new Project().createSourceFile('output.ts', '', {
overwrite: true,
})

// Add imports
const namedImports = [
'Type',
{
name: 'Static',
isTypeOnly: true,
},
]

if (hasGenericInterfaces) {
namedImports.push({
name: 'TSchema',
isTypeOnly: true,
})
}

newSourceFile.addImportDeclaration({
moduleSpecifier: '@sinclair/typebox',
namedImports,
})

return newSourceFile
}

const printSortedNodes = (sortedTraversedNodes: TraversedNode[], newSourceFile: SourceFile) => {
const printer = new TypeBoxPrinter({
newSourceFile,
printer: ts.createPrinter(),
})

// Process nodes in topological order
for (const traversedNode of sortedTraversedNodes) {
printer.printNode(traversedNode)
}

return newSourceFile.getFullText()
}

export const generateCode = ({ sourceCode, filePath, ...options }: InputOptions): string => {
// Create source file from input
const sourceFile = createSourceFileFromInput({
sourceCode,
filePath,
...options,
})

// Create dependency traversal and start traversal
const dependencyTraversal = new DependencyTraversal()
const traversedNodes = dependencyTraversal.startTraversal(sourceFile)

// Check if any interfaces have generic type parameters
const hasGenericInterfaces = traversedNodes.some(
(t) => Node.isInterfaceDeclaration(t.node) && t.node.getTypeParameters().length > 0,
)

// Create output file with proper imports
const newSourceFile = createOutputFile(hasGenericInterfaces)

// Print sorted nodes to output
const result = printSortedNodes(traversedNodes, newSourceFile)

return result
}
Comment on lines +53 to +77
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add proactive collision detection before printing.
Given parsers emit original names, detect duplicate originalNames across different qualifiedNames and fail fast with guidance (see proposed diff in parse-type-aliases.ts comment). This prevents invalid, duplicate symbol emissions.

🤖 Prompt for AI Agents
In src/index.ts around lines 53 to 77, add a proactive collision detection step
after traversing nodes and before creating the output file/printing: scan
traversedNodes to group by originalName and collect their distinct qualifiedName
values, and if any originalName is associated with more than one qualifiedName
throw a clear, failing error that explains which originalName collides, lists
the differing qualifiedNames and advises renaming or scoping to avoid duplicate
emissions; implement this check synchronously and return/throw before calling
createOutputFile or printSortedNodes so the build fails fast with actionable
guidance.

12 changes: 1 addition & 11 deletions src/parsers/base-parser.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
import { ExportGetableNode, Node, SourceFile, ts } from 'ts-morph'
import { Node, SourceFile, ts } from 'ts-morph'

export interface BaseParserOptions {
newSourceFile: SourceFile
printer: ts.Printer
processedTypes: Set<string>
exportEverything?: boolean
}

export abstract class BaseParser {
protected newSourceFile: SourceFile
protected printer: ts.Printer
protected processedTypes: Set<string>
protected exportEverything: boolean

constructor(options: BaseParserOptions) {
this.newSourceFile = options.newSourceFile
this.printer = options.printer
this.processedTypes = options.processedTypes
this.exportEverything = options.exportEverything ?? false
}

protected getIsExported(node: ExportGetableNode, isImported: boolean = false): boolean {
if (this.exportEverything) {
return true
}
return isImported ? false : node.hasExportKeyword()
}

abstract parse(node: Node): void
Expand Down
27 changes: 18 additions & 9 deletions src/parsers/parse-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,37 @@ import { EnumDeclaration, VariableDeclarationKind } from 'ts-morph'

export class EnumParser extends BaseParser {
parse(enumDeclaration: EnumDeclaration): void {
const typeName = enumDeclaration.getName()
const enumText = enumDeclaration.getText()
const isExported = this.getIsExported(enumDeclaration)
this.newSourceFile.addStatements(isExported ? `export ${enumText}` : enumText)
const enumName = enumDeclaration.getName()
const schemaName = `${enumName}Schema`

this.newSourceFile.addEnum({
name: enumName,
isExported: true,
members: enumDeclaration.getMembers().map((member) => ({
name: member.getName(),
value: member.hasInitializer() ? member.getValue() : undefined,
})),
})

// Generate TypeBox type
const typeboxType = `Type.Enum(${enumName})`

this.newSourceFile.addVariableStatement({
isExported,
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: typeName,
initializer: `Type.Enum(${typeName})`,
name: schemaName,
initializer: typeboxType,
},
],
})

addStaticTypeAlias(
this.newSourceFile,
typeName,
schemaName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}
}
24 changes: 3 additions & 21 deletions src/parsers/parse-function-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,10 @@ import { FunctionDeclaration, ts, VariableDeclarationKind } from 'ts-morph'

export class FunctionDeclarationParser extends BaseParser {
parse(functionDecl: FunctionDeclaration): void {
this.parseWithImportFlag(functionDecl, false)
}

parseWithImportFlag(functionDecl: FunctionDeclaration, isImported: boolean): void {
this.parseFunctionWithImportFlag(functionDecl, isImported)
}

private parseFunctionWithImportFlag(
functionDecl: FunctionDeclaration,
isImported: boolean,
): void {
const functionName = functionDecl.getName()
if (!functionName) {
return
}
if (!functionName) return

if (this.processedTypes.has(functionName)) {
return
}
if (this.processedTypes.has(functionName)) return
this.processedTypes.add(functionName)
Comment on lines +10 to 13
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Dedupe key uses bare name; risks skipping same-named functions from different files

processedTypes keyed by functionName causes false “already processed” when two files export the same name. Plumb a qualified key (e.g., file-hashed) or accept a provided output name from traversal/printing to avoid collisions and incorrect skips.

I recommend updating BaseParser/TypeBoxPrinter to pass a collision-safe outputName (qualified) into parse(), and track processedTypes by that key. Would you like a follow-up patch touching BaseParser + all parsers?


// Get function parameters and return type
Expand Down Expand Up @@ -70,10 +55,8 @@ export class FunctionDeclarationParser extends BaseParser {
this.newSourceFile.compilerNode,
)

const isExported = this.getIsExported(functionDecl, isImported)

this.newSourceFile.addVariableStatement({
isExported,
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
Expand All @@ -88,7 +71,6 @@ export class FunctionDeclarationParser extends BaseParser {
functionName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}
}
35 changes: 9 additions & 26 deletions src/parsers/parse-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,6 @@ import {

export class InterfaceParser extends BaseParser {
parse(interfaceDecl: InterfaceDeclaration): void {
this.parseWithImportFlag(interfaceDecl, false)
}

parseWithImportFlag(interfaceDecl: InterfaceDeclaration, isImported: boolean): void {
this.parseInterfaceWithImportFlag(interfaceDecl, isImported)
}

private parseInterfaceWithImportFlag(
interfaceDecl: InterfaceDeclaration,
isImported: boolean,
): void {
const interfaceName = interfaceDecl.getName()

if (this.processedTypes.has(interfaceName)) {
Expand All @@ -30,17 +19,16 @@ export class InterfaceParser extends BaseParser {
this.processedTypes.add(interfaceName)

const typeParameters = interfaceDecl.getTypeParameters()
const isExported = this.getIsExported(interfaceDecl, isImported)

// Check if interface has type parameters (generic)
if (typeParameters.length > 0) {
this.parseGenericInterface(interfaceDecl, isExported)
this.parseGenericInterface(interfaceDecl)
} else {
this.parseRegularInterface(interfaceDecl, isExported)
this.parseRegularInterface(interfaceDecl)
}
}

private parseRegularInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
private parseRegularInterface(interfaceDecl: InterfaceDeclaration): void {
const interfaceName = interfaceDecl.getName()

// Generate TypeBox type definition
Expand All @@ -52,7 +40,7 @@ export class InterfaceParser extends BaseParser {
)

this.newSourceFile.addVariableStatement({
isExported,
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
Expand All @@ -67,11 +55,10 @@ export class InterfaceParser extends BaseParser {
interfaceName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}

private parseGenericInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
private parseGenericInterface(interfaceDecl: InterfaceDeclaration): void {
const interfaceName = interfaceDecl.getName()
const typeParameters = interfaceDecl.getTypeParameters()

Expand All @@ -85,7 +72,7 @@ export class InterfaceParser extends BaseParser {

// Add the function declaration
this.newSourceFile.addVariableStatement({
isExported,
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
Expand All @@ -96,14 +83,10 @@ export class InterfaceParser extends BaseParser {
})

// Add generic type alias: type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>
this.addGenericTypeAlias(interfaceName, typeParameters, isExported)
this.addGenericTypeAlias(interfaceName, typeParameters)
}
Comment on lines 85 to 87
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Potentially invalid TypeScript: typeof with type arguments

Creating a TypeQuery with type arguments (typeof A<T>) is not valid in TypeScript; many TS versions reject this. This risks broken emitted code for generics.

Please run tsc to confirm. If invalid, switch to a safe pattern like:

  • Emit const A = <T extends TSchema>(...) => ... and type A<T extends TSchema> = Static<ReturnType<ReturnType<typeof A>>>, or
  • Emit a named helper type and map T separately without typeof type args.

I can produce a concrete patch once we confirm the preferred generic emission shape.

Also applies to: 89-145


🏁 Script executed:

#!/bin/bash
fd parse-interfaces.ts -t f

Length of output: 61


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' src/parsers/parse-interfaces.ts

Length of output: 4568


Replace invalid typeof A<T> usage

TypeScript type queries don’t support type arguments (queries accept only plain identifiers) (1library.net); emit a generic helper function (e.g. const AHelper = <T extends TSchema>(…) => …) and then alias type A<T extends TSchema> = Static<ReturnType<typeof AHelper<T>>>.

🤖 Prompt for AI Agents
In src/parsers/parse-interfaces.ts around lines 85 to 87, the code emits an
invalid type query using typeof A<T> which TypeScript does not allow; instead
create a generic helper function/const (e.g. const AHelper = <T extends
TSchema>(...) => /* returns schema builder */) and then emit the alias using the
helper: type A<T extends TSchema> = Static<ReturnType<typeof AHelper>> but with
the generic applied via the helper—i.e. generate the helper declaration with the
same generic params and change addGenericTypeAlias to reference
ReturnType<typeof AHelper> while keeping the generic bounds on the alias; ensure
the helper name is unique and exported if needed so the alias compiles.


private addGenericTypeAlias(
name: string,
typeParameters: TypeParameterDeclaration[],
isExported: boolean,
): void {
private addGenericTypeAlias(name: string, typeParameters: TypeParameterDeclaration[]): void {
// Create type parameters for the type alias
const typeParamDeclarations = typeParameters.map((typeParam) => {
const paramName = typeParam.getName()
Expand Down Expand Up @@ -152,7 +135,7 @@ export class InterfaceParser extends BaseParser {
)

this.newSourceFile.addTypeAlias({
isExported,
isExported: true,
name,
typeParameters: typeParamDeclarations.map((tp) =>
this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode),
Expand Down
22 changes: 4 additions & 18 deletions src/parsers/parse-type-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,12 @@ import { ts, TypeAliasDeclaration, VariableDeclarationKind } from 'ts-morph'

export class TypeAliasParser extends BaseParser {
parse(typeAlias: TypeAliasDeclaration): void {
this.parseWithImportFlag(typeAlias, false)
this.parseWithImportFlag(typeAlias)
}

parseWithImportFlag(typeAlias: TypeAliasDeclaration, isImported: boolean): void {
parseWithImportFlag(typeAlias: TypeAliasDeclaration): void {
const typeName = typeAlias.getName()

if (this.processedTypes.has(typeName)) {
return
}

this.processedTypes.add(typeName)

const typeNode = typeAlias.getTypeNode()
Comment on lines +12 to 15
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Removed processedTypes dedup guard — verify no duplicate emissions.
Since TypeBoxPrinter still wires a processedTypes set, confirm no other parser relies on it and that graph traversal guarantees uniqueness. Otherwise, identical nodes may be printed twice if ever requeued.

Run quick checks:


🏁 Script executed:

#!/bin/bash
# Where else is processedTypes used?
rg -n "processedTypes" -C2

# Ensure TypeAliasParser no longer uses it
rg -n "class TypeAliasParser" -n -C5 src | sed -n '1,160p'

Length of output: 4112


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' src/printer/typebox-printer.ts

Length of output: 2165


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' src/parsers/parse-type-aliases.ts

Length of output: 1379


Add processedTypes guard in TypeAliasParser
In src/parsers/parse-type-aliases.ts, parseWithImportFlag no longer checks or adds to this.processedTypes, so the same alias could be emitted twice. At the top of parseWithImportFlag, add:

if (this.processedTypes.has(typeName)) return;
this.processedTypes.add(typeName);
🤖 Prompt for AI Agents
In src/parsers/parse-type-aliases.ts around lines 12 to 15, parseWithImportFlag
does not check or mark processed types so the same alias can be emitted twice;
add a guard at the top of parseWithImportFlag that returns immediately if
this.processedTypes.has(typeName) and otherwise adds the type name with
this.processedTypes.add(typeName) before proceeding.

const typeboxTypeNode = typeNode ? getTypeBoxType(typeNode) : makeTypeCall('Any')
const typeboxType = this.printer.printNode(
Expand All @@ -26,10 +20,8 @@ export class TypeAliasParser extends BaseParser {
this.newSourceFile.compilerNode,
)

const isExported = this.getIsExported(typeAlias, isImported)

this.newSourceFile.addVariableStatement({
isExported,
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
Expand All @@ -39,12 +31,6 @@ export class TypeAliasParser extends BaseParser {
],
})

addStaticTypeAlias(
this.newSourceFile,
typeName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
addStaticTypeAlias(this.newSourceFile, typeName, this.newSourceFile.compilerNode, this.printer)
}
}
Loading