@elliots/typical 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/src/transformer.d.ts +6 -0
- package/dist/src/transformer.js +345 -34
- package/dist/src/transformer.js.map +1 -1
- package/package.json +5 -3
- package/src/transformer.ts +430 -42
- package/src/tsc-plugin.ts +1 -1
package/src/transformer.ts
CHANGED
|
@@ -104,29 +104,31 @@ export class TypicalTransformer {
|
|
|
104
104
|
return transformedSourceFile;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
107
|
+
// Apply typia transformation
|
|
108
108
|
const printer = this.ts.createPrinter();
|
|
109
109
|
const transformedCode = printer.printFile(transformedSourceFile);
|
|
110
110
|
|
|
111
|
+
if (process.env.DEBUG) {
|
|
112
|
+
console.log("TYPICAL: Before typia transform (first 500 chars):", transformedCode.substring(0, 500));
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
if (transformedCode.includes("typia.")) {
|
|
112
116
|
try {
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
// Create a new source file with the transformed code, preserving original filename
|
|
117
|
+
// Create a new source file from our transformed code
|
|
116
118
|
const newSourceFile = this.ts.createSourceFile(
|
|
117
|
-
sourceFile.fileName,
|
|
119
|
+
sourceFile.fileName,
|
|
118
120
|
transformedCode,
|
|
119
121
|
sourceFile.languageVersion,
|
|
120
122
|
true
|
|
121
123
|
);
|
|
122
124
|
|
|
123
|
-
// Create a new program with the transformed source file so typia
|
|
125
|
+
// Create a new program with the transformed source file so typia can resolve types
|
|
124
126
|
const compilerOptions = this.program.getCompilerOptions();
|
|
125
127
|
const originalSourceFiles = new Map<string, ts.SourceFile>();
|
|
126
128
|
for (const sf of this.program.getSourceFiles()) {
|
|
127
129
|
originalSourceFiles.set(sf.fileName, sf);
|
|
128
130
|
}
|
|
129
|
-
// Replace the original source file with
|
|
131
|
+
// Replace the original source file with our transformed one
|
|
130
132
|
originalSourceFiles.set(sourceFile.fileName, newSourceFile);
|
|
131
133
|
|
|
132
134
|
const customHost: ts.CompilerHost = {
|
|
@@ -141,7 +143,7 @@ export class TypicalTransformer {
|
|
|
141
143
|
true
|
|
142
144
|
);
|
|
143
145
|
},
|
|
144
|
-
getDefaultLibFileName: () => this.ts.getDefaultLibFilePath(
|
|
146
|
+
getDefaultLibFileName: (opts) => this.ts.getDefaultLibFilePath(opts),
|
|
145
147
|
writeFile: () => {},
|
|
146
148
|
getCurrentDirectory: () => this.ts.sys.getCurrentDirectory(),
|
|
147
149
|
getCanonicalFileName: (fileName) =>
|
|
@@ -158,33 +160,62 @@ export class TypicalTransformer {
|
|
|
158
160
|
customHost
|
|
159
161
|
);
|
|
160
162
|
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
+
// Get the bound source file from the new program (has proper symbol tables)
|
|
164
|
+
const boundSourceFile = newProgram.getSourceFile(sourceFile.fileName);
|
|
165
|
+
if (!boundSourceFile) {
|
|
166
|
+
throw new Error(`Failed to get bound source file: ${sourceFile.fileName}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create typia transformer with the NEW program that has our transformed source
|
|
170
|
+
const typiaTransformerFactory = typiaTransform(
|
|
163
171
|
newProgram,
|
|
164
172
|
{},
|
|
165
173
|
{
|
|
166
174
|
addDiagnostic(diag: ts.Diagnostic) {
|
|
167
|
-
|
|
175
|
+
if (process.env.DEBUG) {
|
|
176
|
+
console.warn("Typia diagnostic:", diag);
|
|
177
|
+
}
|
|
168
178
|
return 0;
|
|
169
179
|
},
|
|
170
180
|
}
|
|
171
181
|
);
|
|
172
182
|
|
|
173
|
-
// Apply
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
[typiaTransformer],
|
|
177
|
-
{ ...compilerOptions, sourceMap: true }
|
|
178
|
-
);
|
|
183
|
+
// Apply typia's transformer to the bound source file
|
|
184
|
+
const typiaNodeTransformer = typiaTransformerFactory(context);
|
|
185
|
+
const typiaTransformed = typiaNodeTransformer(boundSourceFile);
|
|
179
186
|
|
|
180
|
-
if (
|
|
181
|
-
const
|
|
182
|
-
|
|
187
|
+
if (process.env.DEBUG) {
|
|
188
|
+
const afterTypia = printer.printFile(typiaTransformed);
|
|
189
|
+
console.log("TYPICAL: After typia transform (first 500 chars):", afterTypia.substring(0, 500));
|
|
190
|
+
}
|
|
183
191
|
|
|
184
|
-
|
|
192
|
+
// Return the typia-transformed source file.
|
|
193
|
+
// We need to recreate imports as synthetic nodes to prevent import elision,
|
|
194
|
+
// since the imports come from a different program context.
|
|
195
|
+
// Skip type-only imports as they shouldn't appear in JS output.
|
|
196
|
+
const syntheticStatements: ts.Statement[] = [];
|
|
197
|
+
for (const stmt of typiaTransformed.statements) {
|
|
198
|
+
if (this.ts.isImportDeclaration(stmt)) {
|
|
199
|
+
// Skip type-only imports (import type X from "y")
|
|
200
|
+
if (stmt.importClause?.isTypeOnly) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
syntheticStatements.push(this.recreateImportDeclaration(stmt, factory));
|
|
204
|
+
} else {
|
|
205
|
+
syntheticStatements.push(stmt);
|
|
206
|
+
}
|
|
185
207
|
}
|
|
186
208
|
|
|
187
|
-
|
|
209
|
+
// Update the source file with synthetic imports
|
|
210
|
+
transformedSourceFile = factory.updateSourceFile(
|
|
211
|
+
typiaTransformed,
|
|
212
|
+
syntheticStatements,
|
|
213
|
+
typiaTransformed.isDeclarationFile,
|
|
214
|
+
typiaTransformed.referencedFiles,
|
|
215
|
+
typiaTransformed.typeReferenceDirectives,
|
|
216
|
+
typiaTransformed.hasNoDefaultLib,
|
|
217
|
+
typiaTransformed.libReferenceDirectives
|
|
218
|
+
);
|
|
188
219
|
} catch (error) {
|
|
189
220
|
console.warn("Failed to apply typia transformer:", sourceFile.fileName, error);
|
|
190
221
|
}
|
|
@@ -195,6 +226,59 @@ export class TypicalTransformer {
|
|
|
195
226
|
};
|
|
196
227
|
}
|
|
197
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Re-create an import declaration as a fully synthetic node.
|
|
231
|
+
* This prevents TypeScript from trying to look up symbol bindings
|
|
232
|
+
* and eliding the import as "unused".
|
|
233
|
+
*/
|
|
234
|
+
private recreateImportDeclaration(
|
|
235
|
+
importDecl: ts.ImportDeclaration,
|
|
236
|
+
factory: ts.NodeFactory
|
|
237
|
+
): ts.ImportDeclaration {
|
|
238
|
+
let importClause: ts.ImportClause | undefined;
|
|
239
|
+
|
|
240
|
+
if (importDecl.importClause) {
|
|
241
|
+
const clause = importDecl.importClause;
|
|
242
|
+
let namedBindings: ts.NamedImportBindings | undefined;
|
|
243
|
+
|
|
244
|
+
if (clause.namedBindings) {
|
|
245
|
+
if (this.ts.isNamespaceImport(clause.namedBindings)) {
|
|
246
|
+
// import * as foo from "bar"
|
|
247
|
+
namedBindings = factory.createNamespaceImport(
|
|
248
|
+
factory.createIdentifier(clause.namedBindings.name.text)
|
|
249
|
+
);
|
|
250
|
+
} else if (this.ts.isNamedImports(clause.namedBindings)) {
|
|
251
|
+
// import { foo, bar } from "baz"
|
|
252
|
+
const elements = clause.namedBindings.elements.map((el) =>
|
|
253
|
+
factory.createImportSpecifier(
|
|
254
|
+
el.isTypeOnly,
|
|
255
|
+
el.propertyName ? factory.createIdentifier(el.propertyName.text) : undefined,
|
|
256
|
+
factory.createIdentifier(el.name.text)
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
namedBindings = factory.createNamedImports(elements);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
importClause = factory.createImportClause(
|
|
264
|
+
clause.isTypeOnly,
|
|
265
|
+
clause.name ? factory.createIdentifier(clause.name.text) : undefined,
|
|
266
|
+
namedBindings
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const moduleSpecifier = this.ts.isStringLiteral(importDecl.moduleSpecifier)
|
|
271
|
+
? factory.createStringLiteral(importDecl.moduleSpecifier.text)
|
|
272
|
+
: importDecl.moduleSpecifier;
|
|
273
|
+
|
|
274
|
+
return factory.createImportDeclaration(
|
|
275
|
+
importDecl.modifiers,
|
|
276
|
+
importClause,
|
|
277
|
+
moduleSpecifier,
|
|
278
|
+
importDecl.attributes
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
198
282
|
/**
|
|
199
283
|
* Transform a single source file with TypeScript AST
|
|
200
284
|
*/
|
|
@@ -210,10 +294,12 @@ export class TypicalTransformer {
|
|
|
210
294
|
return sourceFile; // Return unchanged for excluded files
|
|
211
295
|
}
|
|
212
296
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
297
|
+
if (!sourceFile.fileName.includes('transformer.test.ts')) {
|
|
298
|
+
// Check if this file has already been transformed by us
|
|
299
|
+
const sourceText = sourceFile.getFullText();
|
|
300
|
+
if (sourceText.includes('__typical_' + 'assert_') || sourceText.includes('__typical_' + 'stringify_') || sourceText.includes('__typical_' + 'parse_')) {
|
|
301
|
+
throw new Error(`File ${sourceFile.fileName} has already been transformed by Typical! Double transformation detected.`);
|
|
302
|
+
}
|
|
217
303
|
}
|
|
218
304
|
|
|
219
305
|
// Reset caches for each file
|
|
@@ -238,6 +324,17 @@ export class TypicalTransformer {
|
|
|
238
324
|
|
|
239
325
|
if (propertyAccess.name.text === "stringify") {
|
|
240
326
|
// For stringify, we need to infer the type from the argument
|
|
327
|
+
// First check if the argument type is 'any' - if so, skip transformation
|
|
328
|
+
if (node.arguments.length > 0) {
|
|
329
|
+
const arg = node.arguments[0];
|
|
330
|
+
const argType = typeChecker.getTypeAtLocation(arg);
|
|
331
|
+
const typeFlags = argType.flags;
|
|
332
|
+
// Skip if type is any (1) or unknown (2)
|
|
333
|
+
if (typeFlags & ts.TypeFlags.Any || typeFlags & ts.TypeFlags.Unknown) {
|
|
334
|
+
return node; // Don't transform JSON.stringify for any/unknown types
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
241
338
|
if (this.config.reusableValidators) {
|
|
242
339
|
// For JSON.stringify, try to infer the type from the argument
|
|
243
340
|
let typeText = "unknown";
|
|
@@ -385,10 +482,32 @@ export class TypicalTransformer {
|
|
|
385
482
|
funcParent = funcParent.parent;
|
|
386
483
|
}
|
|
387
484
|
break;
|
|
485
|
+
} else if (ts.isArrowFunction(parent) && parent.type) {
|
|
486
|
+
// Arrow function with expression body (not block)
|
|
487
|
+
// e.g., (s: string): User => JSON.parse(s)
|
|
488
|
+
targetType = parent.type;
|
|
489
|
+
break;
|
|
388
490
|
}
|
|
389
491
|
parent = parent.parent;
|
|
390
492
|
}
|
|
391
493
|
|
|
494
|
+
// Skip transformation if target type is any or unknown
|
|
495
|
+
const isAnyOrUnknown = targetType && (
|
|
496
|
+
targetType.kind === ts.SyntaxKind.AnyKeyword ||
|
|
497
|
+
targetType.kind === ts.SyntaxKind.UnknownKeyword
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
if (isAnyOrUnknown) {
|
|
501
|
+
// Don't transform JSON.parse for any/unknown types
|
|
502
|
+
return node;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// If we can't determine the target type and there's no explicit type argument,
|
|
506
|
+
// don't transform - we can't validate against an unknown type
|
|
507
|
+
if (!targetType && !node.typeArguments) {
|
|
508
|
+
return node;
|
|
509
|
+
}
|
|
510
|
+
|
|
392
511
|
if (this.config.reusableValidators && targetType) {
|
|
393
512
|
// Use reusable parser - use typeToString
|
|
394
513
|
const targetTypeObj = typeChecker.getTypeFromTypeNode(targetType);
|
|
@@ -446,25 +565,189 @@ export class TypicalTransformer {
|
|
|
446
565
|
return ctx.ts.visitEachChild(node, visit, ctx.context);
|
|
447
566
|
};
|
|
448
567
|
|
|
568
|
+
// Helper functions for flow analysis
|
|
569
|
+
const getRootIdentifier = (expr: ts.Expression): string | undefined => {
|
|
570
|
+
if (ts.isIdentifier(expr)) {
|
|
571
|
+
return expr.text;
|
|
572
|
+
}
|
|
573
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
574
|
+
return getRootIdentifier(expr.expression);
|
|
575
|
+
}
|
|
576
|
+
return undefined;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const containsReference = (expr: ts.Expression, name: string): boolean => {
|
|
580
|
+
if (ts.isIdentifier(expr) && expr.text === name) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
584
|
+
return containsReference(expr.expression, name);
|
|
585
|
+
}
|
|
586
|
+
if (ts.isElementAccessExpression(expr)) {
|
|
587
|
+
return containsReference(expr.expression, name) ||
|
|
588
|
+
containsReference(expr.argumentExpression as ts.Expression, name);
|
|
589
|
+
}
|
|
590
|
+
// Check all children
|
|
591
|
+
let found = false;
|
|
592
|
+
ts.forEachChild(expr, (child) => {
|
|
593
|
+
if (ts.isExpression(child) && containsReference(child, name)) {
|
|
594
|
+
found = true;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
return found;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// Check if a validated variable has been tainted in the function body
|
|
601
|
+
const isTainted = (varName: string, body: ts.Block): boolean => {
|
|
602
|
+
let tainted = false;
|
|
603
|
+
|
|
604
|
+
// First pass: collect aliases (variables that reference properties of varName)
|
|
605
|
+
// e.g., const addr = user.address; -> addr is an alias
|
|
606
|
+
const aliases = new Set<string>([varName]);
|
|
607
|
+
|
|
608
|
+
const collectAliases = (node: ts.Node): void => {
|
|
609
|
+
// Look for: const/let x = varName.property or const/let x = varName
|
|
610
|
+
if (ts.isVariableStatement(node)) {
|
|
611
|
+
for (const decl of node.declarationList.declarations) {
|
|
612
|
+
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
613
|
+
// Check if initializer is rooted at our tracked variable or any existing alias
|
|
614
|
+
const initRoot = getRootIdentifier(decl.initializer);
|
|
615
|
+
if (initRoot && aliases.has(initRoot)) {
|
|
616
|
+
aliases.add(decl.name.text);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
ts.forEachChild(node, collectAliases);
|
|
622
|
+
};
|
|
623
|
+
collectAliases(body);
|
|
624
|
+
|
|
625
|
+
// Helper to check if any alias is involved
|
|
626
|
+
const involvesTrackedVar = (expr: ts.Expression): boolean => {
|
|
627
|
+
const root = getRootIdentifier(expr);
|
|
628
|
+
return root !== undefined && aliases.has(root);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const checkTainting = (node: ts.Node): void => {
|
|
632
|
+
if (tainted) return; // Early exit if already tainted
|
|
633
|
+
|
|
634
|
+
// Reassignment: trackedVar = ...
|
|
635
|
+
if (ts.isBinaryExpression(node) &&
|
|
636
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
637
|
+
ts.isIdentifier(node.left) &&
|
|
638
|
+
aliases.has(node.left.text)) {
|
|
639
|
+
tainted = true;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Property assignment: trackedVar.x = ... or alias.x = ...
|
|
644
|
+
if (ts.isBinaryExpression(node) &&
|
|
645
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
646
|
+
ts.isPropertyAccessExpression(node.left) &&
|
|
647
|
+
involvesTrackedVar(node.left)) {
|
|
648
|
+
tainted = true;
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Element assignment: trackedVar[x] = ... or alias[x] = ...
|
|
653
|
+
if (ts.isBinaryExpression(node) &&
|
|
654
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
655
|
+
ts.isElementAccessExpression(node.left) &&
|
|
656
|
+
involvesTrackedVar(node.left.expression)) {
|
|
657
|
+
tainted = true;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Passed as argument to a function: fn(trackedVar) or fn(alias)
|
|
662
|
+
if (ts.isCallExpression(node)) {
|
|
663
|
+
for (const arg of node.arguments) {
|
|
664
|
+
// Check if any tracked variable or alias appears in the argument
|
|
665
|
+
let hasTrackedRef = false;
|
|
666
|
+
const checkRef = (n: ts.Node): void => {
|
|
667
|
+
if (ts.isIdentifier(n) && aliases.has(n.text)) {
|
|
668
|
+
hasTrackedRef = true;
|
|
669
|
+
}
|
|
670
|
+
ts.forEachChild(n, checkRef);
|
|
671
|
+
};
|
|
672
|
+
checkRef(arg);
|
|
673
|
+
if (hasTrackedRef) {
|
|
674
|
+
tainted = true;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Method call on the variable: trackedVar.method() or alias.method()
|
|
681
|
+
if (ts.isCallExpression(node) &&
|
|
682
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
683
|
+
involvesTrackedVar(node.expression.expression)) {
|
|
684
|
+
tainted = true;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Await expression (async boundary - external code could run)
|
|
689
|
+
if (ts.isAwaitExpression(node)) {
|
|
690
|
+
tainted = true;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
ts.forEachChild(node, checkTainting);
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
checkTainting(body);
|
|
698
|
+
return tainted;
|
|
699
|
+
};
|
|
700
|
+
|
|
449
701
|
const transformFunction = (
|
|
450
702
|
func: ts.FunctionDeclaration | ts.ArrowFunction | ts.MethodDeclaration
|
|
451
703
|
): ts.Node => {
|
|
452
704
|
const body = func.body;
|
|
705
|
+
|
|
706
|
+
// For arrow functions with expression bodies (not blocks),
|
|
707
|
+
// still visit the expression to transform JSON calls etc.
|
|
708
|
+
if (body && !ts.isBlock(body) && ts.isArrowFunction(func)) {
|
|
709
|
+
const visitedBody = ctx.ts.visitNode(body, visit) as ts.Expression;
|
|
710
|
+
if (visitedBody !== body) {
|
|
711
|
+
return ctx.factory.updateArrowFunction(
|
|
712
|
+
func,
|
|
713
|
+
func.modifiers,
|
|
714
|
+
func.typeParameters,
|
|
715
|
+
func.parameters,
|
|
716
|
+
func.type,
|
|
717
|
+
func.equalsGreaterThanToken,
|
|
718
|
+
visitedBody
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
return func;
|
|
722
|
+
}
|
|
723
|
+
|
|
453
724
|
if (!body || !ts.isBlock(body)) return func;
|
|
454
725
|
|
|
726
|
+
// Track validated variables (params and consts with type annotations)
|
|
727
|
+
const validatedVariables = new Map<string, ts.Type>();
|
|
728
|
+
|
|
455
729
|
// Add parameter validation
|
|
456
730
|
const validationStatements: ts.Statement[] = [];
|
|
457
731
|
|
|
458
732
|
func.parameters.forEach((param) => {
|
|
459
733
|
if (param.type) {
|
|
734
|
+
// Skip 'any' and 'unknown' types - no point validating them
|
|
735
|
+
if (param.type.kind === ts.SyntaxKind.AnyKeyword ||
|
|
736
|
+
param.type.kind === ts.SyntaxKind.UnknownKeyword) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
460
740
|
const paramName = ts.isIdentifier(param.name)
|
|
461
741
|
? param.name.text
|
|
462
742
|
: "param";
|
|
463
743
|
const paramIdentifier = ctx.factory.createIdentifier(paramName);
|
|
464
744
|
|
|
745
|
+
// Track this parameter as validated for flow analysis
|
|
746
|
+
const paramType = typeChecker.getTypeFromTypeNode(param.type);
|
|
747
|
+
validatedVariables.set(paramName, paramType);
|
|
748
|
+
|
|
465
749
|
if (this.config.reusableValidators) {
|
|
466
750
|
// Use reusable validators - use typeToString
|
|
467
|
-
const paramType = typeChecker.getTypeFromTypeNode(param.type);
|
|
468
751
|
const typeText = typeChecker.typeToString(paramType);
|
|
469
752
|
const validatorName = this.getOrCreateValidator(
|
|
470
753
|
typeText,
|
|
@@ -504,31 +787,136 @@ export class TypicalTransformer {
|
|
|
504
787
|
// First visit all child nodes (including JSON calls) before adding validation
|
|
505
788
|
const visitedBody = ctx.ts.visitNode(body, visit) as ts.Block;
|
|
506
789
|
|
|
790
|
+
// Also track const declarations with type annotations as validated
|
|
791
|
+
// (the assignment will be validated, and const can't be reassigned)
|
|
792
|
+
const collectConstDeclarations = (node: ts.Node): void => {
|
|
793
|
+
if (ts.isVariableStatement(node)) {
|
|
794
|
+
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
795
|
+
if (isConst) {
|
|
796
|
+
for (const decl of node.declarationList.declarations) {
|
|
797
|
+
if (decl.type && ts.isIdentifier(decl.name)) {
|
|
798
|
+
// Skip any/unknown types
|
|
799
|
+
if (decl.type.kind !== ts.SyntaxKind.AnyKeyword &&
|
|
800
|
+
decl.type.kind !== ts.SyntaxKind.UnknownKeyword) {
|
|
801
|
+
const constType = typeChecker.getTypeFromTypeNode(decl.type);
|
|
802
|
+
validatedVariables.set(decl.name.text, constType);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
ts.forEachChild(node, collectConstDeclarations);
|
|
809
|
+
};
|
|
810
|
+
collectConstDeclarations(visitedBody);
|
|
811
|
+
|
|
507
812
|
// Transform return statements - use explicit type or infer from type checker
|
|
508
813
|
let transformedStatements = visitedBody.statements;
|
|
509
814
|
let returnType = func.type;
|
|
510
815
|
|
|
816
|
+
// Check if this is an async function
|
|
817
|
+
const isAsync = func.modifiers?.some(
|
|
818
|
+
(mod) => mod.kind === ts.SyntaxKind.AsyncKeyword
|
|
819
|
+
);
|
|
820
|
+
|
|
511
821
|
// If no explicit return type, try to infer it from the type checker
|
|
512
822
|
let returnTypeForString: ts.Type | undefined;
|
|
513
823
|
if (!returnType) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
824
|
+
try {
|
|
825
|
+
const signature = typeChecker.getSignatureFromDeclaration(func);
|
|
826
|
+
if (signature) {
|
|
827
|
+
const inferredReturnType = typeChecker.getReturnTypeOfSignature(signature);
|
|
828
|
+
returnType = typeChecker.typeToTypeNode(
|
|
829
|
+
inferredReturnType,
|
|
830
|
+
func,
|
|
831
|
+
ts.NodeBuilderFlags.InTypeAlias
|
|
832
|
+
);
|
|
833
|
+
returnTypeForString = inferredReturnType;
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
// Could not infer signature (e.g., untyped arrow function callback)
|
|
837
|
+
// Skip return type validation for this function
|
|
523
838
|
}
|
|
524
839
|
} else {
|
|
525
840
|
// For explicit return types, get the Type from the TypeNode
|
|
526
841
|
returnTypeForString = typeChecker.getTypeFromTypeNode(returnType);
|
|
527
842
|
}
|
|
528
843
|
|
|
529
|
-
|
|
844
|
+
// For async functions, unwrap Promise<T> to get T
|
|
845
|
+
// The return statement in an async function returns T, not Promise<T>
|
|
846
|
+
if (isAsync && returnType && returnTypeForString) {
|
|
847
|
+
const promiseSymbol = returnTypeForString.getSymbol();
|
|
848
|
+
if (promiseSymbol && promiseSymbol.getName() === "Promise") {
|
|
849
|
+
// Get the type argument of Promise<T>
|
|
850
|
+
const typeArgs = (returnTypeForString as ts.TypeReference).typeArguments;
|
|
851
|
+
if (typeArgs && typeArgs.length > 0) {
|
|
852
|
+
returnTypeForString = typeArgs[0];
|
|
853
|
+
// Also update the TypeNode to match
|
|
854
|
+
if (ts.isTypeReferenceNode(returnType) && returnType.typeArguments && returnType.typeArguments.length > 0) {
|
|
855
|
+
returnType = returnType.typeArguments[0];
|
|
856
|
+
} else {
|
|
857
|
+
// Create a new type node from the unwrapped type
|
|
858
|
+
returnType = typeChecker.typeToTypeNode(
|
|
859
|
+
returnTypeForString,
|
|
860
|
+
func,
|
|
861
|
+
ts.NodeBuilderFlags.InTypeAlias
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Skip 'any' and 'unknown' return types - no point validating them
|
|
869
|
+
const isAnyOrUnknownReturn = returnType && (
|
|
870
|
+
returnType.kind === ts.SyntaxKind.AnyKeyword ||
|
|
871
|
+
returnType.kind === ts.SyntaxKind.UnknownKeyword
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
if (returnType && returnTypeForString && !isAnyOrUnknownReturn) {
|
|
530
875
|
const returnTransformer = (node: ts.Node): ts.Node => {
|
|
531
876
|
if (ts.isReturnStatement(node) && node.expression) {
|
|
877
|
+
// Skip return validation if the expression already contains a __typical _parse_* call
|
|
878
|
+
// since typia.assertParse already validates the parsed data
|
|
879
|
+
const containsTypicalParse = (n: ts.Node): boolean => {
|
|
880
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
|
881
|
+
const name = n.expression.text;
|
|
882
|
+
if (name.startsWith("__typical" + "_parse_")) {
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return ts.forEachChild(n, containsTypicalParse) || false;
|
|
887
|
+
};
|
|
888
|
+
if (containsTypicalParse(node.expression)) {
|
|
889
|
+
return node; // Already validated by parse, skip return validation
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Flow analysis: Skip return validation if returning a validated variable
|
|
893
|
+
// (or property of one) that hasn't been tainted
|
|
894
|
+
const rootVar = getRootIdentifier(node.expression);
|
|
895
|
+
if (rootVar && validatedVariables.has(rootVar)) {
|
|
896
|
+
// Check if the variable has been tainted (mutated, passed to function, etc.)
|
|
897
|
+
if (!isTainted(rootVar, visitedBody)) {
|
|
898
|
+
// Return expression is rooted at a validated, untainted variable
|
|
899
|
+
// For direct returns (identifier) or property access, we can skip validation
|
|
900
|
+
if (ts.isIdentifier(node.expression) || ts.isPropertyAccessExpression(node.expression)) {
|
|
901
|
+
return node; // Skip validation - already validated and untainted
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// For async functions, we need to await the expression before validating
|
|
907
|
+
// because the return expression might be a Promise
|
|
908
|
+
let expressionToValidate = node.expression;
|
|
909
|
+
|
|
910
|
+
if (isAsync) {
|
|
911
|
+
// Check if the expression is already an await expression
|
|
912
|
+
const isAlreadyAwaited = ts.isAwaitExpression(node.expression);
|
|
913
|
+
|
|
914
|
+
if (!isAlreadyAwaited) {
|
|
915
|
+
// Wrap in await: return validate(await expr)
|
|
916
|
+
expressionToValidate = ctx.factory.createAwaitExpression(node.expression);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
532
920
|
if (this.config.reusableValidators) {
|
|
533
921
|
// Use reusable validators - always use typeToString
|
|
534
922
|
const returnTypeText = typeChecker.typeToString(returnTypeForString!);
|
|
@@ -540,7 +928,7 @@ export class TypicalTransformer {
|
|
|
540
928
|
const validatorCall = ctx.factory.createCallExpression(
|
|
541
929
|
ctx.factory.createIdentifier(validatorName),
|
|
542
930
|
undefined,
|
|
543
|
-
[
|
|
931
|
+
[expressionToValidate]
|
|
544
932
|
);
|
|
545
933
|
|
|
546
934
|
return ctx.factory.updateReturnStatement(node, validatorCall);
|
|
@@ -555,7 +943,7 @@ export class TypicalTransformer {
|
|
|
555
943
|
const callExpression = ctx.factory.createCallExpression(
|
|
556
944
|
propertyAccess,
|
|
557
945
|
[returnType],
|
|
558
|
-
[
|
|
946
|
+
[expressionToValidate]
|
|
559
947
|
);
|
|
560
948
|
|
|
561
949
|
return ctx.factory.updateReturnStatement(node, callExpression);
|
|
@@ -703,7 +1091,7 @@ export class TypicalTransformer {
|
|
|
703
1091
|
return this.typeValidators.get(typeText)!.name;
|
|
704
1092
|
}
|
|
705
1093
|
|
|
706
|
-
const validatorName = `
|
|
1094
|
+
const validatorName = `__typical_` + `assert_${this.typeValidators.size}`;
|
|
707
1095
|
this.typeValidators.set(typeText, { name: validatorName, typeNode });
|
|
708
1096
|
return validatorName;
|
|
709
1097
|
}
|
|
@@ -716,7 +1104,7 @@ export class TypicalTransformer {
|
|
|
716
1104
|
return this.typeStringifiers.get(typeText)!.name;
|
|
717
1105
|
}
|
|
718
1106
|
|
|
719
|
-
const stringifierName = `
|
|
1107
|
+
const stringifierName = `__typical_` + `stringify_${this.typeStringifiers.size}`;
|
|
720
1108
|
this.typeStringifiers.set(typeText, { name: stringifierName, typeNode });
|
|
721
1109
|
return stringifierName;
|
|
722
1110
|
}
|
|
@@ -726,7 +1114,7 @@ export class TypicalTransformer {
|
|
|
726
1114
|
return this.typeParsers.get(typeText)!.name;
|
|
727
1115
|
}
|
|
728
1116
|
|
|
729
|
-
const parserName = `
|
|
1117
|
+
const parserName = `__typical_` + `parse_${this.typeParsers.size}`;
|
|
730
1118
|
this.typeParsers.set(typeText, { name: parserName, typeNode });
|
|
731
1119
|
return parserName;
|
|
732
1120
|
}
|
package/src/tsc-plugin.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { loadConfig } from './config.js';
|
|
|
6
6
|
export default function (program: ts.Program, pluginConfig: PluginConfig, { ts: tsInstance }: TransformerExtras) {
|
|
7
7
|
const config = loadConfig();
|
|
8
8
|
const transformer = new TypicalTransformer(config, program, tsInstance);
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
// Create the typical transformer with typia integration
|
|
11
11
|
return transformer.getTransformer(true);
|
|
12
12
|
}
|