ts-runtime-validation 1.8.1 → 1.8.3
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 +38 -38
- package/dist/SchemaGenerator.js +37 -3
- package/dist/SchemaGenerator.js.map +1 -1
- package/dist/services/FileDiscovery.js +33 -2
- package/dist/services/FileDiscovery.js.map +1 -1
- package/dist/services/SchemaProcessor.js +77 -1
- package/dist/services/SchemaProcessor.js.map +1 -1
- package/dist/services/SchemaProcessor.test.js +197 -0
- package/dist/services/SchemaProcessor.test.js.map +1 -1
- package/package.json +1 -1
- package/src/SchemaGenerator.ts +44 -7
- package/src/services/FileDiscovery.ts +38 -6
- package/src/services/SchemaProcessor.test.ts +234 -0
- package/src/services/SchemaProcessor.ts +103 -2
|
@@ -414,6 +414,240 @@ describe("SchemaProcessor", () => {
|
|
|
414
414
|
});
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
describe("single-pass processing", () => {
|
|
418
|
+
it("should use single-pass when glob and rootPath are provided with multiple files", async () => {
|
|
419
|
+
const file1 = await createTestFile("user.jsonschema.ts", `
|
|
420
|
+
export interface IUser {
|
|
421
|
+
id: string;
|
|
422
|
+
name: string;
|
|
423
|
+
}
|
|
424
|
+
`);
|
|
425
|
+
|
|
426
|
+
const file2 = await createTestFile("product.jsonschema.ts", `
|
|
427
|
+
export interface IProduct {
|
|
428
|
+
id: string;
|
|
429
|
+
title: string;
|
|
430
|
+
price: number;
|
|
431
|
+
}
|
|
432
|
+
`);
|
|
433
|
+
|
|
434
|
+
const processor = new SchemaProcessor({
|
|
435
|
+
additionalProperties: false,
|
|
436
|
+
parallel: false,
|
|
437
|
+
verbose: false,
|
|
438
|
+
glob: "*.jsonschema.ts",
|
|
439
|
+
rootPath: testDir
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const files: FileInfo[] = [
|
|
443
|
+
{ path: file1 },
|
|
444
|
+
{ path: file2 }
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
const schemaMap = await processor.processFiles(files);
|
|
448
|
+
|
|
449
|
+
expect(schemaMap.size).toBe(2);
|
|
450
|
+
expect(schemaMap.has(file1)).toBe(true);
|
|
451
|
+
expect(schemaMap.has(file2)).toBe(true);
|
|
452
|
+
|
|
453
|
+
// Both files should have all definitions (single-pass partitioning)
|
|
454
|
+
const schema1 = schemaMap.get(file1)!;
|
|
455
|
+
const schema2 = schemaMap.get(file2)!;
|
|
456
|
+
expect(schema1.definitions!.IUser).toBeDefined();
|
|
457
|
+
expect(schema1.definitions!.IProduct).toBeDefined();
|
|
458
|
+
expect(schema2.definitions!.IUser).toBeDefined();
|
|
459
|
+
expect(schema2.definitions!.IProduct).toBeDefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("should fall back to per-file processing when duplicate type names exist", async () => {
|
|
463
|
+
const file1 = await createTestFile("a/types.jsonschema.ts", `
|
|
464
|
+
export interface IDuplicate {
|
|
465
|
+
id: string;
|
|
466
|
+
name: string;
|
|
467
|
+
}
|
|
468
|
+
`);
|
|
469
|
+
|
|
470
|
+
const file2 = await createTestFile("b/types.jsonschema.ts", `
|
|
471
|
+
export interface IDuplicate {
|
|
472
|
+
id: string;
|
|
473
|
+
email: string;
|
|
474
|
+
}
|
|
475
|
+
`);
|
|
476
|
+
|
|
477
|
+
const processor = new SchemaProcessor({
|
|
478
|
+
additionalProperties: false,
|
|
479
|
+
parallel: false,
|
|
480
|
+
verbose: false,
|
|
481
|
+
glob: "*.jsonschema.ts",
|
|
482
|
+
rootPath: testDir
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const files: FileInfo[] = [
|
|
486
|
+
{ path: file1 },
|
|
487
|
+
{ path: file2 }
|
|
488
|
+
];
|
|
489
|
+
|
|
490
|
+
const schemaMap = await processor.processFiles(files);
|
|
491
|
+
|
|
492
|
+
// Falls back to per-file: each file only has its own definitions
|
|
493
|
+
const schema1 = schemaMap.get(file1)!;
|
|
494
|
+
const schema2 = schemaMap.get(file2)!;
|
|
495
|
+
expect(schema1.definitions!.IDuplicate).toBeDefined();
|
|
496
|
+
expect(schema2.definitions!.IDuplicate).toBeDefined();
|
|
497
|
+
|
|
498
|
+
// The schemas should differ (different implementations)
|
|
499
|
+
expect((schema1.definitions!.IDuplicate as any).properties.name).toBeDefined();
|
|
500
|
+
expect((schema2.definitions!.IDuplicate as any).properties.email).toBeDefined();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("should not use single-pass for a single file", async () => {
|
|
504
|
+
const file1 = await createTestFile("user.jsonschema.ts", `
|
|
505
|
+
export interface IUser {
|
|
506
|
+
id: string;
|
|
507
|
+
name: string;
|
|
508
|
+
}
|
|
509
|
+
`);
|
|
510
|
+
|
|
511
|
+
const processor = new SchemaProcessor({
|
|
512
|
+
additionalProperties: false,
|
|
513
|
+
parallel: false,
|
|
514
|
+
verbose: false,
|
|
515
|
+
glob: "*.jsonschema.ts",
|
|
516
|
+
rootPath: testDir
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const files: FileInfo[] = [{ path: file1 }];
|
|
520
|
+
const schemaMap = await processor.processFiles(files);
|
|
521
|
+
|
|
522
|
+
expect(schemaMap.size).toBe(1);
|
|
523
|
+
const schema = schemaMap.get(file1)!;
|
|
524
|
+
expect(schema.definitions!.IUser).toBeDefined();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("should not use single-pass when glob is not provided", async () => {
|
|
528
|
+
const file1 = await createTestFile("user.jsonschema.ts", `
|
|
529
|
+
export interface IUser {
|
|
530
|
+
id: string;
|
|
531
|
+
name: string;
|
|
532
|
+
}
|
|
533
|
+
`);
|
|
534
|
+
|
|
535
|
+
const file2 = await createTestFile("product.jsonschema.ts", `
|
|
536
|
+
export interface IProduct {
|
|
537
|
+
id: string;
|
|
538
|
+
title: string;
|
|
539
|
+
}
|
|
540
|
+
`);
|
|
541
|
+
|
|
542
|
+
const processor = new SchemaProcessor({
|
|
543
|
+
additionalProperties: false,
|
|
544
|
+
parallel: false,
|
|
545
|
+
verbose: false
|
|
546
|
+
// no glob or rootPath
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const files: FileInfo[] = [
|
|
550
|
+
{ path: file1 },
|
|
551
|
+
{ path: file2 }
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
const schemaMap = await processor.processFiles(files);
|
|
555
|
+
|
|
556
|
+
expect(schemaMap.size).toBe(2);
|
|
557
|
+
// Per-file: each file only has its own definitions
|
|
558
|
+
const schema1 = schemaMap.get(file1)!;
|
|
559
|
+
expect(schema1.definitions!.IUser).toBeDefined();
|
|
560
|
+
expect(schema1.definitions!.IProduct).toBeUndefined();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("should include referenced types from non-schema files in single-pass output", async () => {
|
|
564
|
+
await createTestFile("shared/BaseType.ts", `
|
|
565
|
+
export interface IBaseType {
|
|
566
|
+
id: string;
|
|
567
|
+
}
|
|
568
|
+
`);
|
|
569
|
+
|
|
570
|
+
const file1 = await createTestFile("a.jsonschema.ts", `
|
|
571
|
+
import { IBaseType } from "./shared/BaseType";
|
|
572
|
+
export interface ITypeA {
|
|
573
|
+
base: IBaseType;
|
|
574
|
+
}
|
|
575
|
+
`);
|
|
576
|
+
|
|
577
|
+
const file2 = await createTestFile("b.jsonschema.ts", `
|
|
578
|
+
import { IBaseType } from "./shared/BaseType";
|
|
579
|
+
export interface ITypeB {
|
|
580
|
+
base: IBaseType;
|
|
581
|
+
}
|
|
582
|
+
`);
|
|
583
|
+
|
|
584
|
+
const processor = new SchemaProcessor({
|
|
585
|
+
additionalProperties: false,
|
|
586
|
+
parallel: false,
|
|
587
|
+
verbose: false,
|
|
588
|
+
glob: "*.jsonschema.ts",
|
|
589
|
+
rootPath: testDir
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const files: FileInfo[] = [
|
|
593
|
+
{ path: file1 },
|
|
594
|
+
{ path: file2 }
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
const schemaMap = await processor.processFiles(files);
|
|
598
|
+
const mergedSchema = processor.mergeSchemas(schemaMap);
|
|
599
|
+
|
|
600
|
+
// IBaseType should appear in the merged output even though
|
|
601
|
+
// it's not directly in a .jsonschema.ts file
|
|
602
|
+
expect(mergedSchema.definitions!.IBaseType).toBeDefined();
|
|
603
|
+
expect(mergedSchema.definitions!.ITypeA).toBeDefined();
|
|
604
|
+
expect(mergedSchema.definitions!.ITypeB).toBeDefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should produce same merged schema whether using single-pass or per-file", async () => {
|
|
608
|
+
const file1 = await createTestFile("user.jsonschema.ts", `
|
|
609
|
+
export interface IUser {
|
|
610
|
+
id: string;
|
|
611
|
+
name: string;
|
|
612
|
+
}
|
|
613
|
+
`);
|
|
614
|
+
|
|
615
|
+
const file2 = await createTestFile("product.jsonschema.ts", `
|
|
616
|
+
export interface IProduct {
|
|
617
|
+
id: string;
|
|
618
|
+
title: string;
|
|
619
|
+
price: number;
|
|
620
|
+
}
|
|
621
|
+
`);
|
|
622
|
+
|
|
623
|
+
const files: FileInfo[] = [
|
|
624
|
+
{ path: file1 },
|
|
625
|
+
{ path: file2 }
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
// Single-pass (with glob/rootPath)
|
|
629
|
+
const singlePassProcessor = new SchemaProcessor({
|
|
630
|
+
additionalProperties: false,
|
|
631
|
+
parallel: false,
|
|
632
|
+
glob: "*.jsonschema.ts",
|
|
633
|
+
rootPath: testDir
|
|
634
|
+
});
|
|
635
|
+
const singlePassMap = await singlePassProcessor.processFiles(files);
|
|
636
|
+
const singlePassMerged = singlePassProcessor.mergeSchemas(singlePassMap);
|
|
637
|
+
|
|
638
|
+
// Per-file (without glob/rootPath)
|
|
639
|
+
const perFileProcessor = new SchemaProcessor({
|
|
640
|
+
additionalProperties: false,
|
|
641
|
+
parallel: false
|
|
642
|
+
});
|
|
643
|
+
const perFileMap = await perFileProcessor.processFiles(files);
|
|
644
|
+
const perFileMerged = perFileProcessor.mergeSchemas(perFileMap);
|
|
645
|
+
|
|
646
|
+
// Merged schemas should be identical
|
|
647
|
+
expect(singlePassMerged).toStrictEqual(perFileMerged);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
417
651
|
describe("error handling", () => {
|
|
418
652
|
it("should handle missing files gracefully", async () => {
|
|
419
653
|
const processor = new SchemaProcessor({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as tsj from "ts-json-schema-generator";
|
|
2
2
|
import { Config, Schema } from "ts-json-schema-generator";
|
|
3
3
|
import assert from "assert";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { Project } from "ts-morph";
|
|
4
6
|
import { SchemaGenerationError, DuplicateSymbolError } from "../errors";
|
|
5
7
|
import { FileInfo } from "./FileDiscovery";
|
|
6
8
|
|
|
@@ -9,6 +11,8 @@ export interface SchemaProcessorOptions {
|
|
|
9
11
|
tsconfigPath?: string;
|
|
10
12
|
parallel?: boolean;
|
|
11
13
|
verbose?: boolean;
|
|
14
|
+
glob?: string;
|
|
15
|
+
rootPath?: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export interface ProcessingResult {
|
|
@@ -21,8 +25,8 @@ export class SchemaProcessor {
|
|
|
21
25
|
constructor(private options: SchemaProcessorOptions) {}
|
|
22
26
|
|
|
23
27
|
public async processFiles(files: FileInfo[]): Promise<Map<string, Schema>> {
|
|
24
|
-
const { parallel = true, verbose = false } = this.options;
|
|
25
|
-
|
|
28
|
+
const { parallel = true, verbose = false, glob, rootPath } = this.options;
|
|
29
|
+
|
|
26
30
|
if (verbose) {
|
|
27
31
|
console.log(`Processing ${files.length} files...`);
|
|
28
32
|
}
|
|
@@ -30,6 +34,17 @@ export class SchemaProcessor {
|
|
|
30
34
|
// Sort files by path to ensure consistent processing order
|
|
31
35
|
const sortedFiles = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
32
36
|
|
|
37
|
+
// Use single-pass processing when possible (avoids creating N TypeScript programs)
|
|
38
|
+
if (glob && rootPath && sortedFiles.length > 1) {
|
|
39
|
+
try {
|
|
40
|
+
return await this.processSinglePass(sortedFiles, rootPath, glob);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (verbose) {
|
|
43
|
+
console.log(`Single-pass processing failed, falling back to per-file processing: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
const results = parallel
|
|
34
49
|
? await this.processInParallel(sortedFiles)
|
|
35
50
|
: await this.processSequentially(sortedFiles);
|
|
@@ -37,6 +52,92 @@ export class SchemaProcessor {
|
|
|
37
52
|
return this.consolidateSchemas(results);
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
private async processSinglePass(
|
|
56
|
+
files: FileInfo[],
|
|
57
|
+
rootPath: string,
|
|
58
|
+
glob: string
|
|
59
|
+
): Promise<Map<string, Schema>> {
|
|
60
|
+
const { additionalProperties, tsconfigPath, verbose } = this.options;
|
|
61
|
+
|
|
62
|
+
// Pre-check: scan all TS files in rootPath for duplicate exported type names.
|
|
63
|
+
// If duplicates exist, per-file processing is needed to detect schema conflicts.
|
|
64
|
+
if (this.hasDuplicateTypeNames(rootPath)) {
|
|
65
|
+
throw new Error("Duplicate type names detected across source files");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
69
|
+
const globPattern = path.join(resolvedRoot, "**", glob);
|
|
70
|
+
|
|
71
|
+
if (verbose) {
|
|
72
|
+
console.log(`Single-pass processing with pattern: ${globPattern}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const config: Config = {
|
|
76
|
+
path: globPattern,
|
|
77
|
+
type: "*",
|
|
78
|
+
additionalProperties,
|
|
79
|
+
encodeRefs: false,
|
|
80
|
+
sortProps: true,
|
|
81
|
+
...(tsconfigPath ? { tsconfig: tsconfigPath } : {}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const schemaGenerator = tsj.createGenerator(config);
|
|
85
|
+
const fullSchema = schemaGenerator.createSchema(config.type);
|
|
86
|
+
|
|
87
|
+
return this.partitionSchemaByFile(files, fullSchema);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private hasDuplicateTypeNames(rootPath: string): boolean {
|
|
91
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
92
|
+
const allTsFiles = project.addSourceFilesAtPaths(
|
|
93
|
+
path.join(path.resolve(rootPath), "**/*.{ts,tsx}")
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
|
|
98
|
+
for (const sf of allTsFiles) {
|
|
99
|
+
const names: string[] = [];
|
|
100
|
+
for (const t of sf.getTypeAliases()) {
|
|
101
|
+
if (t.isExported()) names.push(t.getName());
|
|
102
|
+
}
|
|
103
|
+
for (const i of sf.getInterfaces()) {
|
|
104
|
+
if (i.isExported()) names.push(i.getName());
|
|
105
|
+
}
|
|
106
|
+
for (const e of sf.getEnums()) {
|
|
107
|
+
if (e.isExported()) names.push(e.getName());
|
|
108
|
+
}
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (seen.has(name)) return true;
|
|
111
|
+
seen.add(name);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private partitionSchemaByFile(
|
|
119
|
+
files: FileInfo[],
|
|
120
|
+
fullSchema: Schema
|
|
121
|
+
): Map<string, Schema> {
|
|
122
|
+
const defs = fullSchema.definitions ?? {};
|
|
123
|
+
const schemaVersion = fullSchema.$schema || "http://json-schema.org/draft-07/schema#";
|
|
124
|
+
const schemaMap = new Map<string, Schema>();
|
|
125
|
+
|
|
126
|
+
if (Object.keys(defs).length === 0) return schemaMap;
|
|
127
|
+
|
|
128
|
+
// Since we've verified no duplicate type names exist, assign all definitions
|
|
129
|
+
// to each file. Downstream code (extractTypeInfo) uses ts-morph to correctly
|
|
130
|
+
// attribute each definition to its actual source file.
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
schemaMap.set(file.path, {
|
|
133
|
+
$schema: schemaVersion,
|
|
134
|
+
definitions: { ...defs },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return schemaMap;
|
|
139
|
+
}
|
|
140
|
+
|
|
40
141
|
private async processInParallel(files: FileInfo[]): Promise<ProcessingResult[]> {
|
|
41
142
|
const promises = files.map(file => this.processFile(file));
|
|
42
143
|
const results = await Promise.allSettled(promises);
|