Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
491 changes: 161 additions & 330 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
84 changes: 84 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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 { 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 = async ({
sourceCode,
filePath,
...options
}: InputOptions): Promise<string> => {
// Create source file from input
const sourceFile = createSourceFileFromInput({
sourceCode,
filePath,
...options,
})

// Check if any interfaces have generic type parameters
const hasGenericInterfaces = sourceFile
.getInterfaces()
.some((i) => i.getTypeParameters().length > 0)

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

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

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

return result
}
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
26 changes: 17 additions & 9 deletions src/parsers/parse-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@ 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()

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: enumName,
initializer: typeboxType,
},
],
})

addStaticTypeAlias(
this.newSourceFile,
typeName,
enumName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}
}
12 changes: 4 additions & 8 deletions src/parsers/parse-function-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import { FunctionDeclaration, ts, VariableDeclarationKind } from 'ts-morph'

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

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

private parseFunctionWithImportFlag(
functionDecl: FunctionDeclaration,
isImported: boolean,
): void {
const functionName = functionDecl.getName()
if (!functionName) {
Expand Down Expand Up @@ -70,10 +69,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 +85,6 @@ export class FunctionDeclarationParser extends BaseParser {
functionName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}
}
34 changes: 10 additions & 24 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,13 +83,12 @@ 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 {
// Create type parameters for the type alias
const typeParamDeclarations = typeParameters.map((typeParam) => {
Expand Down Expand Up @@ -152,12 +138,12 @@ 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),
),
type: staticType,
),
type: staticType,
})
}
}
15 changes: 3 additions & 12 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 @@ -44,7 +36,6 @@ export class TypeAliasParser extends BaseParser {
typeName,
this.newSourceFile.compilerNode,
this.printer,
isExported,
)
}
}
Loading