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.
- package/.claude/settings.local.json +9 -0
- package/CONTRIBUTING.md +430 -0
- package/README.md +444 -64
- package/dist/ICommandOptions.js +3 -0
- package/dist/ICommandOptions.js.map +1 -0
- package/dist/SchemaGenerator.integration.test.js +323 -0
- package/dist/SchemaGenerator.integration.test.js.map +1 -0
- package/dist/SchemaGenerator.js +120 -0
- package/dist/SchemaGenerator.js.map +1 -0
- package/dist/SchemaGenerator.test.js +132 -0
- package/dist/SchemaGenerator.test.js.map +1 -0
- package/dist/errors/index.js +95 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/index.test.js +232 -0
- package/dist/errors/index.test.js.map +1 -0
- package/dist/getPosixPath.js +13 -0
- package/dist/getPosixPath.js.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.js.map +1 -0
- package/dist/services/CodeGenerator.js +305 -0
- package/dist/services/CodeGenerator.js.map +1 -0
- package/dist/services/FileDiscovery.js +121 -0
- package/dist/services/FileDiscovery.js.map +1 -0
- package/dist/services/FileDiscovery.test.js +184 -0
- package/dist/services/FileDiscovery.test.js.map +1 -0
- package/dist/services/SchemaProcessor.js +182 -0
- package/dist/services/SchemaProcessor.js.map +1 -0
- package/dist/services/SchemaProcessor.test.js +395 -0
- package/dist/services/SchemaProcessor.test.js.map +1 -0
- package/dist/services/SchemaWriter.js +76 -0
- package/dist/services/SchemaWriter.js.map +1 -0
- package/dist/services/SchemaWriter.test.js +255 -0
- package/dist/services/SchemaWriter.test.js.map +1 -0
- package/dist/test/basic-scenario/types.jsonschema.js +3 -0
- package/dist/test/basic-scenario/types.jsonschema.js.map +1 -0
- package/dist/test/duplicate-symbols-different-implementation/IBaseType.js +3 -0
- package/dist/test/duplicate-symbols-different-implementation/IBaseType.js.map +1 -0
- package/dist/test/duplicate-symbols-different-implementation/IBaseTypeDefinitionReplicated.js +3 -0
- package/dist/test/duplicate-symbols-different-implementation/IBaseTypeDefinitionReplicated.js.map +1 -0
- package/dist/test/duplicate-symbols-different-implementation/IBasicTypesA.jsonschema.js +3 -0
- package/dist/test/duplicate-symbols-different-implementation/IBasicTypesA.jsonschema.js.map +1 -0
- package/dist/test/duplicate-symbols-different-implementation/IBasicTypesB.jsonschema.js +3 -0
- package/dist/test/duplicate-symbols-different-implementation/IBasicTypesB.jsonschema.js.map +1 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBaseType.js +3 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBaseType.js.map +1 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBaseTypeDefinitionReplicated.js +3 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBaseTypeDefinitionReplicated.js.map +1 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesA.jsonschema.js +3 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesA.jsonschema.js.map +1 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesB.jsonschema.js +3 -0
- package/dist/test/duplicate-symbols-identitcal-implementation/IBasicTypesB.jsonschema.js.map +1 -0
- package/dist/test/output/duplicate-symbols-identitcal-implementation/ValidationType.js +3 -0
- package/dist/test/output/duplicate-symbols-identitcal-implementation/ValidationType.js.map +1 -0
- package/dist/test/output/duplicate-symbols-identitcal-implementation/isValidSchema.js +49 -0
- package/dist/test/output/duplicate-symbols-identitcal-implementation/isValidSchema.js.map +1 -0
- package/dist/utils/ProgressReporter.js +67 -0
- package/dist/utils/ProgressReporter.js.map +1 -0
- package/dist/utils/ProgressReporter.test.js +267 -0
- package/dist/utils/ProgressReporter.test.js.map +1 -0
- package/dist/writeLine.js +12 -0
- package/dist/writeLine.js.map +1 -0
- package/package.json +2 -2
- package/src/ICommandOptions.ts +7 -0
- package/src/SchemaGenerator.integration.test.ts +411 -0
- package/src/SchemaGenerator.test.ts +7 -0
- package/src/SchemaGenerator.ts +112 -298
- package/src/errors/index.test.ts +319 -0
- package/src/errors/index.ts +92 -0
- package/src/index.ts +7 -0
- package/src/services/CodeGenerator.ts +352 -0
- package/src/services/FileDiscovery.test.ts +216 -0
- package/src/services/FileDiscovery.ts +137 -0
- package/src/services/SchemaProcessor.test.ts +464 -0
- package/src/services/SchemaProcessor.ts +173 -0
- package/src/services/SchemaWriter.test.ts +304 -0
- package/src/services/SchemaWriter.ts +75 -0
- package/src/utils/ProgressReporter.test.ts +357 -0
- 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
|
+
}
|