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.
@@ -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);