ts-runtime-validation 1.6.16 → 1.7.0

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.
Files changed (79) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/CONTRIBUTING.md +430 -0
  3. package/README.md +444 -64
  4. package/dist/ICommandOptions.js +3 -0
  5. package/dist/ICommandOptions.js.map +1 -0
  6. package/dist/SchemaGenerator.integration.test.js +323 -0
  7. package/dist/SchemaGenerator.integration.test.js.map +1 -0
  8. package/dist/SchemaGenerator.js +120 -0
  9. package/dist/SchemaGenerator.js.map +1 -0
  10. package/dist/SchemaGenerator.test.js +132 -0
  11. package/dist/SchemaGenerator.test.js.map +1 -0
  12. package/dist/errors/index.js +95 -0
  13. package/dist/errors/index.js.map +1 -0
  14. package/dist/errors/index.test.js +232 -0
  15. package/dist/errors/index.test.js.map +1 -0
  16. package/dist/getPosixPath.js +13 -0
  17. package/dist/getPosixPath.js.map +1 -0
  18. package/dist/index.js +27 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/lib.js.map +1 -0
  21. package/dist/services/CodeGenerator.js +305 -0
  22. package/dist/services/CodeGenerator.js.map +1 -0
  23. package/dist/services/FileDiscovery.js +121 -0
  24. package/dist/services/FileDiscovery.js.map +1 -0
  25. package/dist/services/FileDiscovery.test.js +184 -0
  26. package/dist/services/FileDiscovery.test.js.map +1 -0
  27. package/dist/services/SchemaProcessor.js +182 -0
  28. package/dist/services/SchemaProcessor.js.map +1 -0
  29. package/dist/services/SchemaProcessor.test.js +395 -0
  30. package/dist/services/SchemaProcessor.test.js.map +1 -0
  31. package/dist/services/SchemaWriter.js +76 -0
  32. package/dist/services/SchemaWriter.js.map +1 -0
  33. package/dist/services/SchemaWriter.test.js +255 -0
  34. package/dist/services/SchemaWriter.test.js.map +1 -0
  35. package/dist/test/basic-scenario/types.jsonschema.js +3 -0
  36. package/dist/test/basic-scenario/types.jsonschema.js.map +1 -0
  37. package/dist/test/duplicate-symbols-different-implementation/IBaseType.js +3 -0
  38. package/dist/test/duplicate-symbols-different-implementation/IBaseType.js.map +1 -0
  39. package/dist/test/duplicate-symbols-different-implementation/IBaseTypeDefinitionReplicated.js +3 -0
  40. package/dist/test/duplicate-symbols-different-implementation/IBaseTypeDefinitionReplicated.js.map +1 -0
  41. package/dist/test/duplicate-symbols-different-implementation/IBasicTypesA.jsonschema.js +3 -0
  42. package/dist/test/duplicate-symbols-different-implementation/IBasicTypesA.jsonschema.js.map +1 -0
  43. package/dist/test/duplicate-symbols-different-implementation/IBasicTypesB.jsonschema.js +3 -0
  44. package/dist/test/duplicate-symbols-different-implementation/IBasicTypesB.jsonschema.js.map +1 -0
  45. package/dist/test/duplicate-symbols-identitcal-implementation/IBaseType.js +3 -0
  46. package/dist/test/duplicate-symbols-identitcal-implementation/IBaseType.js.map +1 -0
  47. package/dist/test/duplicate-symbols-identitcal-implementation/IBaseTypeDefinitionReplicated.js +3 -0
  48. package/dist/test/duplicate-symbols-identitcal-implementation/IBaseTypeDefinitionReplicated.js.map +1 -0
  49. package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesA.jsonschema.js +3 -0
  50. package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesA.jsonschema.js.map +1 -0
  51. package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesB.jsonschema.js +3 -0
  52. package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesB.jsonschema.js.map +1 -0
  53. package/dist/test/output/duplicate-symbols-identitcal-implementation/ValidationType.js +3 -0
  54. package/dist/test/output/duplicate-symbols-identitcal-implementation/ValidationType.js.map +1 -0
  55. package/dist/test/output/duplicate-symbols-identitcal-implementation/isValidSchema.js +49 -0
  56. package/dist/test/output/duplicate-symbols-identitcal-implementation/isValidSchema.js.map +1 -0
  57. package/dist/utils/ProgressReporter.js +67 -0
  58. package/dist/utils/ProgressReporter.js.map +1 -0
  59. package/dist/utils/ProgressReporter.test.js +267 -0
  60. package/dist/utils/ProgressReporter.test.js.map +1 -0
  61. package/dist/writeLine.js +12 -0
  62. package/dist/writeLine.js.map +1 -0
  63. package/package.json +2 -2
  64. package/src/ICommandOptions.ts +7 -0
  65. package/src/SchemaGenerator.integration.test.ts +411 -0
  66. package/src/SchemaGenerator.test.ts +7 -0
  67. package/src/SchemaGenerator.ts +112 -298
  68. package/src/errors/index.test.ts +319 -0
  69. package/src/errors/index.ts +92 -0
  70. package/src/index.ts +7 -0
  71. package/src/services/CodeGenerator.ts +352 -0
  72. package/src/services/FileDiscovery.test.ts +216 -0
  73. package/src/services/FileDiscovery.ts +137 -0
  74. package/src/services/SchemaProcessor.test.ts +464 -0
  75. package/src/services/SchemaProcessor.ts +173 -0
  76. package/src/services/SchemaWriter.test.ts +304 -0
  77. package/src/services/SchemaWriter.ts +75 -0
  78. package/src/utils/ProgressReporter.test.ts +357 -0
  79. package/src/utils/ProgressReporter.ts +76 -0
@@ -0,0 +1,352 @@
1
+ import {
2
+ Project,
3
+ IndentationText,
4
+ NewLineKind,
5
+ QuoteKind,
6
+ StructureKind,
7
+ VariableDeclarationKind,
8
+ CodeBlockWriter,
9
+ ProjectOptions,
10
+ SourceFileCreateOptions,
11
+ } from "ts-morph";
12
+ import { Schema } from "ts-json-schema-generator";
13
+ import path from "path";
14
+ import { getPosixPath } from "../getPosixPath";
15
+ import { CodeGenerationError } from "../errors";
16
+
17
+ export interface CodeGeneratorOptions {
18
+ outputPath: string;
19
+ minify?: boolean;
20
+ treeShaking?: boolean;
21
+ lazyLoad?: boolean;
22
+ }
23
+
24
+ interface TypeInfo {
25
+ symbol: string;
26
+ importPath: string;
27
+ isInterface: boolean;
28
+ }
29
+
30
+ const defaultTsMorphProjectSettings: ProjectOptions = {
31
+ manipulationSettings: {
32
+ indentationText: IndentationText.FourSpaces,
33
+ newLineKind: NewLineKind.LineFeed,
34
+ quoteKind: QuoteKind.Double,
35
+ usePrefixAndSuffixTextForRename: false,
36
+ useTrailingCommas: true,
37
+ },
38
+ };
39
+
40
+ const defaultCreateFileOptions: SourceFileCreateOptions = {
41
+ overwrite: true,
42
+ };
43
+
44
+ export class CodeGenerator {
45
+ private project: Project;
46
+
47
+ constructor(private options: CodeGeneratorOptions) {
48
+ this.project = new Project(defaultTsMorphProjectSettings);
49
+ }
50
+
51
+ public async generateSchemaDefinition(
52
+ schemaMap: Map<string, Schema>,
53
+ outputFile: string
54
+ ): Promise<void> {
55
+ try {
56
+ const typeInfos = this.extractTypeInfo(schemaMap);
57
+ await this.writeSchemaDefinitionFile(typeInfos, outputFile);
58
+ } catch (error) {
59
+ throw new CodeGenerationError(
60
+ `Failed to generate schema definition: ${error instanceof Error ? error.message : String(error)}`
61
+ );
62
+ }
63
+ }
64
+
65
+ public async generateValidatorFunction(outputFile: string): Promise<void> {
66
+ try {
67
+ const sourceFile = this.project.createSourceFile(outputFile, {}, defaultCreateFileOptions);
68
+
69
+ if (this.options.lazyLoad) {
70
+ this.generateLazyValidator(sourceFile);
71
+ } else {
72
+ this.generateStandardValidator(sourceFile);
73
+ }
74
+
75
+ await this.project.save();
76
+ } catch (error) {
77
+ throw new CodeGenerationError(
78
+ `Failed to generate validator function: ${error instanceof Error ? error.message : String(error)}`
79
+ );
80
+ }
81
+ }
82
+
83
+ public async generateValidationTypes(
84
+ schemaMap: Map<string, Schema>,
85
+ outputFile: string
86
+ ): Promise<void> {
87
+ try {
88
+ const typeInfos = this.extractTypeInfo(schemaMap);
89
+ await this.writeValidationTypesFile(typeInfos, outputFile);
90
+ } catch (error) {
91
+ throw new CodeGenerationError(
92
+ `Failed to generate validation types: ${error instanceof Error ? error.message : String(error)}`
93
+ );
94
+ }
95
+ }
96
+
97
+ private extractTypeInfo(schemaMap: Map<string, Schema>): TypeInfo[] {
98
+ const readerProject = new Project(defaultTsMorphProjectSettings);
99
+ const typeInfos: TypeInfo[] = [];
100
+
101
+ schemaMap.forEach((schema, filePath) => {
102
+ const dir = path.dirname(filePath);
103
+ const fileWithoutExtension = path.parse(filePath).name;
104
+ const relativeFilePath = path.relative(this.options.outputPath, dir);
105
+ const relativeImportPath = `${relativeFilePath}/${fileWithoutExtension}`;
106
+ const defs = schema.definitions ?? {};
107
+
108
+ const readerSourceFile = readerProject.addSourceFileAtPath(filePath);
109
+
110
+ Object.keys(defs).forEach((symbol) => {
111
+ const typeAlias = readerSourceFile.getTypeAlias(symbol);
112
+ const typeInterface = readerSourceFile.getInterface(symbol);
113
+
114
+ if (typeAlias || typeInterface) {
115
+ typeInfos.push({
116
+ symbol,
117
+ importPath: getPosixPath(relativeImportPath),
118
+ isInterface: !!typeInterface
119
+ });
120
+ }
121
+ });
122
+ });
123
+
124
+ return typeInfos;
125
+ }
126
+
127
+ private async writeSchemaDefinitionFile(
128
+ typeInfos: TypeInfo[],
129
+ outputFile: string
130
+ ): Promise<void> {
131
+ const sourceFile = this.project.createSourceFile(outputFile, {}, defaultCreateFileOptions);
132
+
133
+ if (this.options.treeShaking) {
134
+ this.generateTreeShakingImports(sourceFile, typeInfos);
135
+ } else {
136
+ this.generateStandardImports(sourceFile, typeInfos);
137
+ }
138
+
139
+ sourceFile.addVariableStatement({
140
+ isExported: true,
141
+ declarationKind: VariableDeclarationKind.Const,
142
+ declarations: [
143
+ {
144
+ name: "schemas",
145
+ type: "Record<keyof ISchema, string>",
146
+ initializer: (writer: CodeBlockWriter) => {
147
+ writer.writeLine(`{`);
148
+ typeInfos.forEach(({ symbol }) => {
149
+ writer.writeLine(`["#/definitions/${symbol}"] : "${symbol}",`);
150
+ });
151
+ writer.writeLine(`}`);
152
+ },
153
+ },
154
+ ],
155
+ });
156
+
157
+ sourceFile.addInterface({
158
+ kind: StructureKind.Interface,
159
+ name: "ISchema",
160
+ isExported: true,
161
+ properties: typeInfos.map(({ symbol }) => ({
162
+ name: `readonly ["#/definitions/${symbol}"]`,
163
+ type: symbol
164
+ })),
165
+ });
166
+
167
+ await this.project.save();
168
+ }
169
+
170
+ private generateTreeShakingImports(sourceFile: any, typeInfos: TypeInfo[]): void {
171
+ const importMap = new Map<string, string[]>();
172
+
173
+ typeInfos.forEach(({ symbol, importPath }) => {
174
+ const existing = importMap.get(importPath) || [];
175
+ existing.push(symbol);
176
+ importMap.set(importPath, existing);
177
+ });
178
+
179
+ importMap.forEach((symbols, importPath) => {
180
+ sourceFile.addImportDeclaration({
181
+ namedImports: symbols,
182
+ moduleSpecifier: importPath
183
+ });
184
+ });
185
+ }
186
+
187
+ private generateStandardImports(sourceFile: any, typeInfos: TypeInfo[]): void {
188
+ const importMap = new Map<string, string[]>();
189
+
190
+ typeInfos.forEach(({ symbol, importPath }) => {
191
+ const existing = importMap.get(importPath) || [];
192
+ existing.push(symbol);
193
+ importMap.set(importPath, existing);
194
+ });
195
+
196
+ importMap.forEach((symbols, importPath) => {
197
+ sourceFile.addImportDeclaration({
198
+ namedImports: symbols,
199
+ moduleSpecifier: importPath
200
+ });
201
+ });
202
+ }
203
+
204
+ private generateLazyValidator(sourceFile: any): void {
205
+ sourceFile.addImportDeclaration({
206
+ namespaceImport: "schema",
207
+ moduleSpecifier: "./validation.schema.json"
208
+ });
209
+ sourceFile.addImportDeclaration({
210
+ namedImports: ["ISchema", "schemas"],
211
+ moduleSpecifier: "./SchemaDefinition"
212
+ });
213
+
214
+ sourceFile.addVariableStatement({
215
+ isExported: true,
216
+ declarationKind: VariableDeclarationKind.Let,
217
+ declarations: [
218
+ {
219
+ name: "validator",
220
+ type: "any",
221
+ initializer: "null"
222
+ }
223
+ ]
224
+ });
225
+
226
+ sourceFile.addFunction({
227
+ name: "getValidator",
228
+ isExported: false,
229
+ statements: (writer: CodeBlockWriter) => {
230
+ writer.writeLine(`if (!validator) {`);
231
+ writer.writeLine(` const Ajv = require("ajv");`);
232
+ writer.writeLine(` validator = new Ajv({ allErrors: true });`);
233
+ writer.writeLine(` validator.compile(schema);`);
234
+ writer.writeLine(`}`);
235
+ writer.writeLine(`return validator;`);
236
+ }
237
+ });
238
+
239
+ sourceFile.addVariableStatement({
240
+ declarationKind: VariableDeclarationKind.Const,
241
+ isExported: true,
242
+ declarations: [
243
+ {
244
+ name: "isValidSchema",
245
+ initializer: (writer: CodeBlockWriter) => {
246
+ writer.writeLine(`<T extends keyof typeof schemas>(data: unknown, schemaKeyRef: T): data is ISchema[T] => {`);
247
+ writer.writeLine(`const v = getValidator();`);
248
+ writer.writeLine(`v.validate(schemaKeyRef as string, data);`);
249
+ writer.writeLine(`return Boolean(v.errors) === false;`);
250
+ writer.writeLine(`}`);
251
+ },
252
+ },
253
+ ],
254
+ });
255
+ }
256
+
257
+ private generateStandardValidator(sourceFile: any): void {
258
+ sourceFile.addImportDeclaration({
259
+ namespaceImport: "schema",
260
+ moduleSpecifier: "./validation.schema.json"
261
+ });
262
+ sourceFile.addImportDeclaration({
263
+ defaultImport: "Ajv",
264
+ moduleSpecifier: "ajv"
265
+ });
266
+ sourceFile.addImportDeclaration({
267
+ namedImports: ["ISchema", "schemas"],
268
+ moduleSpecifier: "./SchemaDefinition"
269
+ });
270
+
271
+ sourceFile.addVariableStatement({
272
+ isExported: true,
273
+ declarationKind: VariableDeclarationKind.Const,
274
+ declarations: [
275
+ {
276
+ name: "validator",
277
+ initializer: (writer: CodeBlockWriter) => {
278
+ writer.writeLine(`new Ajv({ allErrors: true });`);
279
+ writer.writeLine(`validator.compile(schema)`);
280
+ },
281
+ },
282
+ ],
283
+ });
284
+
285
+ sourceFile.addVariableStatement({
286
+ declarationKind: VariableDeclarationKind.Const,
287
+ isExported: true,
288
+ declarations: [
289
+ {
290
+ name: "isValidSchema",
291
+ initializer: (writer: CodeBlockWriter) => {
292
+ writer.writeLine(`<T extends keyof typeof schemas>(data: unknown, schemaKeyRef: T): data is ISchema[T] => {`);
293
+ writer.writeLine(`validator.validate(schemaKeyRef as string, data);`);
294
+ writer.writeLine(`return Boolean(validator.errors) === false;`);
295
+ writer.writeLine(`}`);
296
+ },
297
+ },
298
+ ],
299
+ });
300
+ }
301
+
302
+ private async writeValidationTypesFile(
303
+ typeInfos: TypeInfo[],
304
+ outputFile: string
305
+ ): Promise<void> {
306
+ const sourceFile = this.project.createSourceFile(outputFile, {}, defaultCreateFileOptions);
307
+
308
+ const importMap = new Map<string, string[]>();
309
+ typeInfos.forEach(({ symbol, importPath }) => {
310
+ const existing = importMap.get(importPath) || [];
311
+ existing.push(symbol);
312
+ importMap.set(importPath, existing);
313
+ });
314
+
315
+ importMap.forEach((symbols, importPath) => {
316
+ const declaration = sourceFile.addImportDeclaration({
317
+ moduleSpecifier: importPath
318
+ });
319
+ symbols.forEach((symbol) => {
320
+ declaration.addNamedImport({
321
+ name: symbol,
322
+ alias: `_${symbol}`
323
+ });
324
+ });
325
+ });
326
+
327
+ if (this.options.treeShaking) {
328
+ typeInfos.forEach(({ symbol }) => {
329
+ sourceFile.addTypeAlias({
330
+ name: symbol,
331
+ type: `_${symbol}`,
332
+ isExported: true
333
+ });
334
+ });
335
+ } else {
336
+ const namespace = sourceFile.addModule({
337
+ name: "ValidationType",
338
+ isExported: true,
339
+ });
340
+
341
+ typeInfos.forEach(({ symbol }) => {
342
+ namespace.addTypeAlias({
343
+ name: symbol,
344
+ type: `_${symbol}`,
345
+ isExported: true
346
+ });
347
+ });
348
+ }
349
+
350
+ await this.project.save();
351
+ }
352
+ }
@@ -0,0 +1,216 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { FileDiscovery } from "./FileDiscovery";
4
+ import { FileDiscoveryError } from "../errors";
5
+
6
+ const testDir = path.resolve(__dirname, "../test-tmp/file-discovery");
7
+ const cacheDir = path.resolve(testDir, ".cache");
8
+
9
+ const createTestFile = async (filePath: string, content: string = "test content") => {
10
+ const fullPath = path.resolve(testDir, filePath);
11
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
12
+ await fs.promises.writeFile(fullPath, content);
13
+ return fullPath;
14
+ };
15
+
16
+ const cleanup = async () => {
17
+ if (fs.existsSync(testDir)) {
18
+ await fs.promises.rm(testDir, { recursive: true, force: true });
19
+ }
20
+ };
21
+
22
+ beforeEach(cleanup);
23
+ afterAll(cleanup);
24
+
25
+ describe("FileDiscovery", () => {
26
+ describe("discoverFiles", () => {
27
+ it("should find files matching glob pattern", async () => {
28
+ await createTestFile("types/user.jsonschema.ts", "export interface User {}");
29
+ await createTestFile("types/product.jsonschema.ts", "export interface Product {}");
30
+ await createTestFile("types/order.ts", "export interface Order {}");
31
+
32
+ const discovery = new FileDiscovery({
33
+ glob: "*.jsonschema.ts",
34
+ rootPath: testDir
35
+ });
36
+
37
+ const files = await discovery.discoverFiles();
38
+
39
+ expect(files).toHaveLength(2);
40
+ expect(files.map(f => path.basename(f.path))).toEqual(
41
+ expect.arrayContaining(["user.jsonschema.ts", "product.jsonschema.ts"])
42
+ );
43
+ });
44
+
45
+ it("should handle nested directory patterns", async () => {
46
+ await createTestFile("api/v1/user.jsonschema.ts", "export interface User {}");
47
+ await createTestFile("api/v2/product.jsonschema.ts", "export interface Product {}");
48
+ await createTestFile("utils/helper.ts", "export const helper = () => {}");
49
+
50
+ const discovery = new FileDiscovery({
51
+ glob: "**/*.jsonschema.ts",
52
+ rootPath: testDir
53
+ });
54
+
55
+ const files = await discovery.discoverFiles();
56
+
57
+ expect(files).toHaveLength(2);
58
+ expect(files.every(f => f.path.includes(".jsonschema.ts"))).toBe(true);
59
+ });
60
+
61
+ it("should throw error when no files found", async () => {
62
+ const discovery = new FileDiscovery({
63
+ glob: "*.jsonschema.ts",
64
+ rootPath: testDir
65
+ });
66
+
67
+ await expect(discovery.discoverFiles()).rejects.toThrow(FileDiscoveryError);
68
+ });
69
+
70
+ it("should throw error for invalid directory", async () => {
71
+ const discovery = new FileDiscovery({
72
+ glob: "*.jsonschema.ts",
73
+ rootPath: "/nonexistent/path"
74
+ });
75
+
76
+ await expect(discovery.discoverFiles()).rejects.toThrow(FileDiscoveryError);
77
+ });
78
+ });
79
+
80
+ describe("caching", () => {
81
+ it("should generate file hashes when caching enabled", async () => {
82
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
83
+
84
+ const discovery = new FileDiscovery({
85
+ glob: "*.jsonschema.ts",
86
+ rootPath: testDir,
87
+ cacheEnabled: true,
88
+ cachePath: cacheDir
89
+ });
90
+
91
+ const files = await discovery.discoverFiles();
92
+
93
+ expect(files).toHaveLength(1);
94
+ expect(files[0].hash).toBeDefined();
95
+ expect(files[0].lastModified).toBeDefined();
96
+ expect(typeof files[0].hash).toBe("string");
97
+ });
98
+
99
+ it("should save and load cache correctly", async () => {
100
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
101
+
102
+ const discovery = new FileDiscovery({
103
+ glob: "*.jsonschema.ts",
104
+ rootPath: testDir,
105
+ cacheEnabled: true,
106
+ cachePath: cacheDir
107
+ });
108
+
109
+ // First run - should create cache
110
+ const files1 = await discovery.discoverFiles();
111
+ expect(fs.existsSync(path.join(cacheDir, "file-hashes.json"))).toBe(true);
112
+
113
+ // Second run - should load from cache
114
+ const discovery2 = new FileDiscovery({
115
+ glob: "*.jsonschema.ts",
116
+ rootPath: testDir,
117
+ cacheEnabled: true,
118
+ cachePath: cacheDir
119
+ });
120
+
121
+ const files2 = await discovery2.discoverFiles();
122
+ expect(files1[0].hash).toBe(files2[0].hash);
123
+ });
124
+
125
+ it("should detect file changes", async () => {
126
+ const filePath = await createTestFile("user.jsonschema.ts", "export interface User {}");
127
+
128
+ const discovery = new FileDiscovery({
129
+ glob: "*.jsonschema.ts",
130
+ rootPath: testDir,
131
+ cacheEnabled: true,
132
+ cachePath: cacheDir
133
+ });
134
+
135
+ const files1 = await discovery.discoverFiles();
136
+ const originalHash = files1[0].hash!;
137
+
138
+ // Modify file
139
+ await fs.promises.writeFile(filePath, "export interface User { name: string; }");
140
+
141
+ const files2 = await discovery.discoverFiles();
142
+ const newHash = files2[0].hash!;
143
+
144
+ expect(discovery.hasFileChanged(filePath, originalHash)).toBe(true);
145
+ expect(originalHash).not.toBe(newHash);
146
+ });
147
+
148
+ it("should clear cache correctly", async () => {
149
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
150
+
151
+ const discovery = new FileDiscovery({
152
+ glob: "*.jsonschema.ts",
153
+ rootPath: testDir,
154
+ cacheEnabled: true,
155
+ cachePath: cacheDir
156
+ });
157
+
158
+ await discovery.discoverFiles();
159
+ expect(fs.existsSync(path.join(cacheDir, "file-hashes.json"))).toBe(true);
160
+
161
+ discovery.clearCache();
162
+ expect(fs.existsSync(path.join(cacheDir, "file-hashes.json"))).toBe(false);
163
+ });
164
+
165
+ it("should handle corrupted cache gracefully", async () => {
166
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
167
+
168
+ // Create corrupted cache file
169
+ await fs.promises.mkdir(cacheDir, { recursive: true });
170
+ await fs.promises.writeFile(path.join(cacheDir, "file-hashes.json"), "invalid json");
171
+
172
+ const discovery = new FileDiscovery({
173
+ glob: "*.jsonschema.ts",
174
+ rootPath: testDir,
175
+ cacheEnabled: true,
176
+ cachePath: cacheDir
177
+ });
178
+
179
+ // Should not throw error and should work normally
180
+ const files = await discovery.discoverFiles();
181
+ expect(files).toHaveLength(1);
182
+ });
183
+ });
184
+
185
+ describe("file patterns", () => {
186
+ it("should support multiple extensions", async () => {
187
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
188
+ await createTestFile("product.jsonschema.tsx", "export interface Product {}");
189
+ await createTestFile("order.types.ts", "export interface Order {}");
190
+
191
+ const discovery = new FileDiscovery({
192
+ glob: "*.{jsonschema.ts,jsonschema.tsx}",
193
+ rootPath: testDir
194
+ });
195
+
196
+ const files = await discovery.discoverFiles();
197
+ expect(files).toHaveLength(2);
198
+ });
199
+
200
+ it("should exclude files not matching pattern", async () => {
201
+ await createTestFile("user.jsonschema.ts", "export interface User {}");
202
+ await createTestFile("user.test.ts", "test file");
203
+ await createTestFile("user.spec.ts", "spec file");
204
+ await createTestFile("README.md", "readme");
205
+
206
+ const discovery = new FileDiscovery({
207
+ glob: "*.jsonschema.ts",
208
+ rootPath: testDir
209
+ });
210
+
211
+ const files = await discovery.discoverFiles();
212
+ expect(files).toHaveLength(1);
213
+ expect(files[0].path).toContain("user.jsonschema.ts");
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,137 @@
1
+ import { fdir } from "fdir";
2
+ import picomatch from "picomatch";
3
+ import path from "path";
4
+ import fs from "fs";
5
+ import crypto from "crypto";
6
+ import { FileDiscoveryError } from "../errors";
7
+
8
+ export interface FileDiscoveryOptions {
9
+ glob: string;
10
+ rootPath: string;
11
+ cacheEnabled?: boolean;
12
+ cachePath?: string;
13
+ }
14
+
15
+ export interface FileInfo {
16
+ path: string;
17
+ hash?: string;
18
+ lastModified?: Date;
19
+ }
20
+
21
+ export class FileDiscovery {
22
+ private cacheFile: string;
23
+ private fileCache: Map<string, string> = new Map();
24
+
25
+ constructor(private options: FileDiscoveryOptions) {
26
+ this.cacheFile = path.join(
27
+ options.cachePath || ".ts-runtime-validation-cache",
28
+ "file-hashes.json"
29
+ );
30
+ if (options.cacheEnabled) {
31
+ this.loadCache();
32
+ }
33
+ }
34
+
35
+ public async discoverFiles(): Promise<FileInfo[]> {
36
+ const { glob, rootPath } = this.options;
37
+
38
+ try {
39
+ const api = new fdir({
40
+ includeBasePath: true,
41
+ includeDirs: false,
42
+ filters: [
43
+ (filePath) => {
44
+ return picomatch.isMatch(filePath, glob, { contains: true });
45
+ },
46
+ ],
47
+ }).crawl(rootPath);
48
+
49
+ const files = await api.withPromise();
50
+
51
+ if (files.length === 0) {
52
+ throw new FileDiscoveryError(
53
+ `No files found matching pattern: ${glob} in ${rootPath}`
54
+ );
55
+ }
56
+
57
+ return this.options.cacheEnabled
58
+ ? await this.enrichWithCacheInfo(files)
59
+ : files.map(path => ({ path }));
60
+ } catch (error) {
61
+ if (error instanceof FileDiscoveryError) {
62
+ throw error;
63
+ }
64
+ throw new FileDiscoveryError(
65
+ `Failed to discover files: ${error instanceof Error ? error.message : String(error)}`
66
+ );
67
+ }
68
+ }
69
+
70
+ private async enrichWithCacheInfo(files: string[]): Promise<FileInfo[]> {
71
+ const enrichedFiles = await Promise.all(
72
+ files.map(async (filePath) => {
73
+ const stats = await fs.promises.stat(filePath);
74
+ const hash = await this.getFileHash(filePath);
75
+ return {
76
+ path: filePath,
77
+ hash,
78
+ lastModified: stats.mtime
79
+ };
80
+ })
81
+ );
82
+
83
+ await this.saveCache(enrichedFiles);
84
+ return enrichedFiles;
85
+ }
86
+
87
+ private async getFileHash(filePath: string): Promise<string> {
88
+ const content = await fs.promises.readFile(filePath, 'utf-8');
89
+ return crypto.createHash('md5').update(content).digest('hex');
90
+ }
91
+
92
+ public hasFileChanged(filePath: string, currentHash: string): boolean {
93
+ const cachedHash = this.fileCache.get(filePath);
94
+ return cachedHash !== currentHash;
95
+ }
96
+
97
+ private loadCache(): void {
98
+ try {
99
+ if (fs.existsSync(this.cacheFile)) {
100
+ const cacheData = JSON.parse(
101
+ fs.readFileSync(this.cacheFile, 'utf-8')
102
+ );
103
+ this.fileCache = new Map(Object.entries(cacheData));
104
+ }
105
+ } catch (error) {
106
+ console.warn('Failed to load cache, starting fresh');
107
+ this.fileCache.clear();
108
+ }
109
+ }
110
+
111
+ private async saveCache(files: FileInfo[]): Promise<void> {
112
+ const cacheDir = path.dirname(this.cacheFile);
113
+ if (!fs.existsSync(cacheDir)) {
114
+ await fs.promises.mkdir(cacheDir, { recursive: true });
115
+ }
116
+
117
+ const cacheData: Record<string, string> = {};
118
+ files.forEach(file => {
119
+ if (file.hash) {
120
+ cacheData[file.path] = file.hash;
121
+ this.fileCache.set(file.path, file.hash);
122
+ }
123
+ });
124
+
125
+ await fs.promises.writeFile(
126
+ this.cacheFile,
127
+ JSON.stringify(cacheData, null, 2)
128
+ );
129
+ }
130
+
131
+ public clearCache(): void {
132
+ this.fileCache.clear();
133
+ if (fs.existsSync(this.cacheFile)) {
134
+ fs.unlinkSync(this.cacheFile);
135
+ }
136
+ }
137
+ }