@valbuild/cli 0.92.1 → 0.94.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 (26) hide show
  1. package/cli/dist/valbuild-cli-cli.cjs.dev.js +415 -160
  2. package/cli/dist/valbuild-cli-cli.cjs.prod.js +415 -160
  3. package/cli/dist/valbuild-cli-cli.esm.js +413 -158
  4. package/package.json +5 -5
  5. package/src/__fixtures__/basic/content/basic-errors.val.ts +7 -0
  6. package/src/__fixtures__/basic/content/basic-files.val.ts +14 -0
  7. package/src/__fixtures__/basic/content/basic-gallery-2.val.ts +17 -0
  8. package/src/__fixtures__/basic/content/basic-gallery-fail-on-non-unique-dir.val.ts +17 -0
  9. package/src/__fixtures__/basic/content/basic-gallery-missing-tracked.val.ts +17 -0
  10. package/src/__fixtures__/basic/content/basic-gallery-wrong-metadata.val.ts +17 -0
  11. package/src/__fixtures__/basic/content/basic-gallery.val.ts +18 -0
  12. package/src/__fixtures__/basic/content/basic-image-from-galleries.val.ts +15 -0
  13. package/src/__fixtures__/basic/content/basic-image-from-gallery.val.ts +12 -0
  14. package/src/__fixtures__/basic/content/basic-image.val.ts +7 -0
  15. package/src/__fixtures__/basic/content/basic-valid.val.ts +7 -0
  16. package/src/__fixtures__/basic/public/val/files/tracked.txt +1 -0
  17. package/src/__fixtures__/basic/public/val/files/untracked.txt +1 -0
  18. package/src/__fixtures__/basic/public/val/image.png +0 -0
  19. package/src/__fixtures__/basic/public/val/images/image.png +0 -0
  20. package/src/__fixtures__/basic/public/val/images2/image.png +0 -0
  21. package/src/__fixtures__/basic/public/val/images3/image.png +0 -0
  22. package/src/__fixtures__/basic/tsconfig.json +12 -0
  23. package/src/__fixtures__/basic/val.config.ts +5 -0
  24. package/src/runValidation.test.ts +386 -0
  25. package/src/runValidation.ts +1096 -0
  26. package/src/validate.ts +131 -887
@@ -0,0 +1,1096 @@
1
+ import path from "path";
2
+ import {
3
+ createFixPatch,
4
+ createService,
5
+ getPersonalAccessTokenPath,
6
+ parsePersonalAccessTokenFile,
7
+ Service,
8
+ IValFSHost,
9
+ } from "@valbuild/server";
10
+ import {
11
+ FILE_REF_PROP,
12
+ Internal,
13
+ ModuleFilePath,
14
+ ModulePath,
15
+ SerializedFileSchema,
16
+ SerializedImageSchema,
17
+ SourcePath,
18
+ ValidationFix,
19
+ } from "@valbuild/core";
20
+ import {
21
+ filterRoutesByPatterns,
22
+ validateRoutePatterns,
23
+ type SerializedRegExpPattern,
24
+ } from "@valbuild/shared/internal";
25
+ import { getFileExt } from "./utils/getFileExt";
26
+ import ts from "typescript";
27
+ import nodeFs from "fs";
28
+
29
+ export type { IValFSHost };
30
+
31
+ export type IValRemote = {
32
+ remoteHost: string;
33
+ getSettings(
34
+ projectName: string,
35
+ options: { pat: string },
36
+ ): Promise<
37
+ | {
38
+ success: true;
39
+ data: {
40
+ publicProjectId: string;
41
+ remoteFileBuckets: { bucket: string }[];
42
+ };
43
+ }
44
+ | { success: false; message: string }
45
+ >;
46
+ uploadFile(
47
+ project: string,
48
+ bucket: string,
49
+ fileHash: string,
50
+ fileExt: string | undefined,
51
+ fileBuffer: Buffer,
52
+ options: { pat: string },
53
+ ): Promise<{ success: true } | { success: false; error: string }>;
54
+ };
55
+
56
+ const textEncoder = new TextEncoder();
57
+
58
+ // Types for handler system
59
+ export type ValModule = Awaited<ReturnType<Service["get"]>>;
60
+
61
+ export type ValidationError = {
62
+ message: string;
63
+ value?: unknown;
64
+ fixes?: ValidationFix[];
65
+ };
66
+
67
+ // Cache types for avoiding redundant service.get() calls
68
+ export type KeyOfCache = Map<
69
+ string, // moduleFilePath + modulePath key
70
+ { source: unknown; schema: { type: string } | undefined }
71
+ >;
72
+ export type RouterModulesCache = {
73
+ loaded: boolean;
74
+ modules: Record<string, Record<string, unknown>>;
75
+ };
76
+
77
+ export type FixHandlerContext = {
78
+ sourcePath: SourcePath;
79
+ validationError: ValidationError;
80
+ valModule: ValModule;
81
+ projectRoot: string;
82
+ fix: boolean;
83
+ service: Service;
84
+ valFiles: string[];
85
+ moduleFilePath: ModuleFilePath;
86
+ file: string;
87
+ fs: IValFSHost;
88
+ // Shared state
89
+ remoteFiles: Record<
90
+ SourcePath,
91
+ { ref: string; metadata?: Record<string, unknown> }
92
+ >;
93
+ publicProjectId?: string;
94
+ remoteFileBuckets?: string[];
95
+ remoteFilesCounter: number;
96
+ remote: IValRemote;
97
+ project: string | undefined;
98
+ // Caches for validation
99
+ keyOfCache: KeyOfCache;
100
+ routerModulesCache: RouterModulesCache;
101
+ };
102
+
103
+ export type FixHandlerResult = {
104
+ success: boolean;
105
+ errorMessage?: string;
106
+ shouldApplyPatch?: boolean;
107
+ // Updated shared state
108
+ publicProjectId?: string;
109
+ remoteFileBuckets?: string[];
110
+ remoteFilesCounter?: number;
111
+ // Events to emit
112
+ events?: ValidationEvent[];
113
+ };
114
+
115
+ export type FixHandler = (ctx: FixHandlerContext) => Promise<FixHandlerResult>;
116
+
117
+ export type ValidationEvent =
118
+ | { type: "file-valid"; file: string; durationMs: number }
119
+ | {
120
+ type: "file-error-count";
121
+ file: string;
122
+ errorCount: number;
123
+ durationMs: number;
124
+ }
125
+ | { type: "validation-error"; sourcePath: string; message: string }
126
+ | {
127
+ type: "validation-fixable-error";
128
+ sourcePath: string;
129
+ message: string;
130
+ fixable: boolean;
131
+ }
132
+ | { type: "unknown-fix"; sourcePath: string; fixes: string[] }
133
+ | { type: "fix-applied"; file: string; sourcePath: string }
134
+ | { type: "fatal-error"; file: string; message: string }
135
+ | { type: "remote-uploading"; ref: string }
136
+ | { type: "remote-uploaded"; ref: string }
137
+ | { type: "remote-already-uploaded"; filePath: string }
138
+ | { type: "remote-downloading"; sourcePath: string }
139
+ | { type: "summary-errors"; count: number }
140
+ | { type: "summary-success" };
141
+
142
+ // Handler functions
143
+ export async function handleFileMetadata(
144
+ ctx: FixHandlerContext,
145
+ ): Promise<FixHandlerResult> {
146
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
147
+ ctx.sourcePath,
148
+ );
149
+
150
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
151
+ return {
152
+ success: false,
153
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
154
+ };
155
+ }
156
+
157
+ const fileSource = Internal.resolvePath(
158
+ modulePath,
159
+ ctx.valModule.source,
160
+ ctx.valModule.schema,
161
+ );
162
+
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ const fileRefProp = (fileSource.source as any)?.[FILE_REF_PROP];
165
+ if (!fileRefProp) {
166
+ return {
167
+ success: false,
168
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
169
+ };
170
+ }
171
+
172
+ const filePath = path.join(ctx.projectRoot, fileRefProp);
173
+ if (!ctx.fs.fileExists(filePath)) {
174
+ return {
175
+ success: false,
176
+ errorMessage: `File ${filePath} does not exist`,
177
+ };
178
+ }
179
+
180
+ return { success: true, shouldApplyPatch: true };
181
+ }
182
+
183
+ export async function handleKeyOfCheck(
184
+ ctx: FixHandlerContext,
185
+ ): Promise<FixHandlerResult> {
186
+ if (
187
+ !ctx.validationError.value ||
188
+ typeof ctx.validationError.value !== "object" ||
189
+ !("key" in ctx.validationError.value) ||
190
+ !("sourcePath" in ctx.validationError.value)
191
+ ) {
192
+ return {
193
+ success: false,
194
+ errorMessage: `Unexpected error in ${ctx.sourcePath}: ${ctx.validationError.message} (Expected value to be an object with 'key' and 'sourcePath' properties - this is likely a bug in Val)`,
195
+ };
196
+ }
197
+
198
+ const { key, sourcePath } = ctx.validationError.value as {
199
+ key: unknown;
200
+ sourcePath: unknown;
201
+ };
202
+
203
+ if (typeof key !== "string") {
204
+ return {
205
+ success: false,
206
+ errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'key' to be a string - this is likely a bug in Val)`,
207
+ };
208
+ }
209
+
210
+ if (typeof sourcePath !== "string") {
211
+ return {
212
+ success: false,
213
+ errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'sourcePath' to be a string - this is likely a bug in Val)`,
214
+ };
215
+ }
216
+
217
+ const res = await checkKeyIsValid(
218
+ key,
219
+ sourcePath,
220
+ ctx.service,
221
+ ctx.keyOfCache,
222
+ );
223
+ if (res.error) {
224
+ return {
225
+ success: false,
226
+ errorMessage: res.message,
227
+ };
228
+ }
229
+
230
+ return { success: true };
231
+ }
232
+
233
+ export async function handleRemoteFileUpload(
234
+ ctx: FixHandlerContext,
235
+ ): Promise<FixHandlerResult> {
236
+ if (!ctx.fix) {
237
+ return {
238
+ success: false,
239
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`,
240
+ };
241
+ }
242
+
243
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
244
+ ctx.sourcePath,
245
+ );
246
+
247
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
248
+ return {
249
+ success: false,
250
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
251
+ };
252
+ }
253
+
254
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(
255
+ modulePath,
256
+ ctx.valModule.source,
257
+ ctx.valModule.schema,
258
+ );
259
+
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
261
+ const fileRefProp = (resolvedRemoteFileAtSourcePath.source as any)?.[
262
+ FILE_REF_PROP
263
+ ];
264
+ if (!fileRefProp) {
265
+ return {
266
+ success: false,
267
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
268
+ };
269
+ }
270
+
271
+ const filePath = path.join(ctx.projectRoot, fileRefProp);
272
+ if (!ctx.fs.fileExists(filePath)) {
273
+ return {
274
+ success: false,
275
+ errorMessage: `File ${filePath} does not exist`,
276
+ };
277
+ }
278
+
279
+ const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
280
+ if (!ctx.fs.fileExists(patFile)) {
281
+ return {
282
+ success: false,
283
+ errorMessage: `File: ${path.join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`,
284
+ };
285
+ }
286
+
287
+ const patFileContent = ctx.fs.readFile(patFile);
288
+ if (patFileContent === undefined) {
289
+ return {
290
+ success: false,
291
+ errorMessage: `Could not read personal access token file at ${patFile}`,
292
+ };
293
+ }
294
+
295
+ const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
296
+ if (!parsedPatFile.success) {
297
+ return {
298
+ success: false,
299
+ errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`,
300
+ };
301
+ }
302
+ const { pat } = parsedPatFile.data;
303
+
304
+ if (ctx.remoteFiles[ctx.sourcePath]) {
305
+ return {
306
+ success: true,
307
+ events: [{ type: "remote-already-uploaded", filePath }],
308
+ };
309
+ }
310
+
311
+ if (!resolvedRemoteFileAtSourcePath.schema) {
312
+ return {
313
+ success: false,
314
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`,
315
+ };
316
+ }
317
+
318
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
319
+ const fileSourceMetadata = Internal.isFile(actualRemoteFileSource)
320
+ ? actualRemoteFileSource.metadata
321
+ : undefined;
322
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
323
+
324
+ if (!resolveRemoteFileSchema) {
325
+ return {
326
+ success: false,
327
+ errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`,
328
+ };
329
+ }
330
+
331
+ const projectName = ctx.project;
332
+ let publicProjectId = ctx.publicProjectId;
333
+ let remoteFileBuckets = ctx.remoteFileBuckets;
334
+ let remoteFilesCounter = ctx.remoteFilesCounter;
335
+
336
+ if (!publicProjectId || !remoteFileBuckets) {
337
+ if (!projectName) {
338
+ return {
339
+ success: false,
340
+ errorMessage:
341
+ "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable",
342
+ };
343
+ }
344
+ const settingsRes = await ctx.remote.getSettings(projectName, { pat });
345
+ if (!settingsRes.success) {
346
+ return {
347
+ success: false,
348
+ errorMessage: `Could not get public project id: ${settingsRes.message}.`,
349
+ };
350
+ }
351
+ publicProjectId = settingsRes.data.publicProjectId;
352
+ remoteFileBuckets = settingsRes.data.remoteFileBuckets.map((b) => b.bucket);
353
+ }
354
+
355
+ if (!publicProjectId) {
356
+ return {
357
+ success: false,
358
+ errorMessage: "Could not get public project id",
359
+ };
360
+ }
361
+
362
+ if (!projectName) {
363
+ return {
364
+ success: false,
365
+ errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`,
366
+ };
367
+ }
368
+
369
+ if (
370
+ resolveRemoteFileSchema.type !== "image" &&
371
+ resolveRemoteFileSchema.type !== "file"
372
+ ) {
373
+ return {
374
+ success: false,
375
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`,
376
+ };
377
+ }
378
+
379
+ remoteFilesCounter += 1;
380
+ const bucket =
381
+ remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
382
+
383
+ if (!bucket) {
384
+ return {
385
+ success: false,
386
+ errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`,
387
+ };
388
+ }
389
+
390
+ const fileBuffer = ctx.fs.readBuffer(filePath);
391
+ if (fileBuffer === undefined) {
392
+ return {
393
+ success: false,
394
+ errorMessage: `Error reading file: ${filePath}`,
395
+ };
396
+ }
397
+
398
+ const relativeFilePath = path
399
+ .relative(ctx.projectRoot, filePath)
400
+ .split(path.sep)
401
+ .join("/") as `public/val/${string}`;
402
+
403
+ if (!relativeFilePath.startsWith("public/val/")) {
404
+ return {
405
+ success: false,
406
+ errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`,
407
+ };
408
+ }
409
+
410
+ const fileHash = Internal.remote.getFileHash(fileBuffer);
411
+ const coreVersion = Internal.VERSION.core || "unknown";
412
+ const fileExt = getFileExt(filePath);
413
+ const schema = resolveRemoteFileSchema as
414
+ | SerializedImageSchema
415
+ | SerializedFileSchema;
416
+ const metadata = fileSourceMetadata;
417
+ const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
418
+ publicProjectId,
419
+ coreVersion,
420
+ bucket,
421
+ validationHash: Internal.remote.getValidationHash(
422
+ coreVersion,
423
+ schema,
424
+ fileExt,
425
+ metadata,
426
+ fileHash,
427
+ textEncoder,
428
+ ),
429
+ fileHash,
430
+ filePath: relativeFilePath,
431
+ });
432
+
433
+ const remoteFileUpload = await ctx.remote.uploadFile(
434
+ projectName,
435
+ bucket,
436
+ fileHash,
437
+ fileExt,
438
+ fileBuffer,
439
+ { pat },
440
+ );
441
+
442
+ if (!remoteFileUpload.success) {
443
+ return {
444
+ success: false,
445
+ errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`,
446
+ };
447
+ }
448
+
449
+ ctx.remoteFiles[ctx.sourcePath] = {
450
+ ref,
451
+ metadata: fileSourceMetadata,
452
+ };
453
+
454
+ return {
455
+ success: true,
456
+ shouldApplyPatch: true,
457
+ publicProjectId,
458
+ remoteFileBuckets,
459
+ remoteFilesCounter,
460
+ events: [
461
+ { type: "remote-uploading", ref },
462
+ { type: "remote-uploaded", ref },
463
+ ],
464
+ };
465
+ }
466
+
467
+ export async function handleRemoteFileDownload(
468
+ ctx: FixHandlerContext,
469
+ ): Promise<FixHandlerResult> {
470
+ if (ctx.fix) {
471
+ return {
472
+ success: true,
473
+ shouldApplyPatch: true,
474
+ events: [{ type: "remote-downloading", sourcePath: ctx.sourcePath }],
475
+ };
476
+ } else {
477
+ return {
478
+ success: false,
479
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`,
480
+ };
481
+ }
482
+ }
483
+
484
+ export async function handleRemoteFileCheck(): Promise<FixHandlerResult> {
485
+ // Skip - no action needed
486
+ return { success: true, shouldApplyPatch: true };
487
+ }
488
+
489
+ // Helper function
490
+ export async function checkKeyIsValid(
491
+ key: string,
492
+ sourcePath: string,
493
+ service: Service,
494
+ cache: KeyOfCache,
495
+ ): Promise<{ error: false } | { error: true; message: string }> {
496
+ const [moduleFilePath, modulePath] =
497
+ Internal.splitModuleFilePathAndModulePath(sourcePath as SourcePath);
498
+
499
+ const cacheKey = `${moduleFilePath}::${modulePath}`;
500
+ let keyOfModuleSource: unknown;
501
+ let keyOfModuleSchema: { type: string } | undefined;
502
+
503
+ const cached = cache.get(cacheKey);
504
+ if (cached) {
505
+ keyOfModuleSource = cached.source;
506
+ keyOfModuleSchema = cached.schema;
507
+ } else {
508
+ const keyOfModule = await service.get(moduleFilePath, modulePath, {
509
+ source: true,
510
+ schema: true,
511
+ validate: false,
512
+ });
513
+ keyOfModuleSource = keyOfModule.source;
514
+ keyOfModuleSchema = keyOfModule.schema as { type: string } | undefined;
515
+ cache.set(cacheKey, {
516
+ source: keyOfModuleSource,
517
+ schema: keyOfModuleSchema,
518
+ });
519
+ }
520
+
521
+ if (keyOfModuleSchema && keyOfModuleSchema.type !== "record") {
522
+ return {
523
+ error: true,
524
+ message: `Expected key at ${sourcePath} to be of type 'record'`,
525
+ };
526
+ }
527
+ if (
528
+ keyOfModuleSource &&
529
+ typeof keyOfModuleSource === "object" &&
530
+ key in keyOfModuleSource
531
+ ) {
532
+ return { error: false };
533
+ }
534
+ if (!keyOfModuleSource || typeof keyOfModuleSource !== "object") {
535
+ return {
536
+ error: true,
537
+ message: `Expected ${sourcePath} to be a truthy object`,
538
+ };
539
+ }
540
+ const alternatives = findSimilar(key, Object.keys(keyOfModuleSource));
541
+ return {
542
+ error: true,
543
+ message: `Key '${key}' does not exist in ${sourcePath}. Closest match: '${alternatives[0].target}'. Other similar: ${alternatives
544
+ .slice(1, 4)
545
+ .map((a) => `'${a.target}'`)
546
+ .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`,
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Check if a route is valid by scanning all router modules
552
+ * and validating against include/exclude patterns
553
+ */
554
+ export async function checkRouteIsValid(
555
+ route: string,
556
+ include: SerializedRegExpPattern | undefined,
557
+ exclude: SerializedRegExpPattern | undefined,
558
+ service: Service,
559
+ valFiles: string[],
560
+ cache: RouterModulesCache,
561
+ ): Promise<{ error: false } | { error: true; message: string }> {
562
+ // 1. Scan all val files to find modules with routers (use cache if available)
563
+ if (!cache.loaded) {
564
+ for (const file of valFiles) {
565
+ const moduleFilePath = `/${file}` as ModuleFilePath;
566
+ const valModule = await service.get(moduleFilePath, "" as ModulePath, {
567
+ source: true,
568
+ schema: true,
569
+ validate: false,
570
+ });
571
+
572
+ // Check if this module has a router defined
573
+ if (valModule.schema?.type === "record" && valModule.schema.router) {
574
+ if (valModule.source && typeof valModule.source === "object") {
575
+ cache.modules[moduleFilePath] = valModule.source as Record<
576
+ string,
577
+ unknown
578
+ >;
579
+ }
580
+ }
581
+ }
582
+ cache.loaded = true;
583
+ }
584
+
585
+ const routerModules = cache.modules;
586
+
587
+ // 2. Check if route exists in any router module
588
+ let foundInModule: string | null = null;
589
+ for (const [moduleFilePath, source] of Object.entries(routerModules)) {
590
+ if (route in source) {
591
+ foundInModule = moduleFilePath;
592
+ break;
593
+ }
594
+ }
595
+
596
+ if (!foundInModule) {
597
+ // Route not found in any router module
598
+ let allRoutes = Object.values(routerModules).flatMap((source) =>
599
+ Object.keys(source),
600
+ );
601
+
602
+ if (allRoutes.length === 0) {
603
+ return {
604
+ error: true,
605
+ message: `Route '${route}' could not be validated: No router modules found in the project. Use s.record(...).router(...) to define router modules.`,
606
+ };
607
+ }
608
+
609
+ // Filter routes by include/exclude patterns for suggestions
610
+ allRoutes = filterRoutesByPatterns(allRoutes, include, exclude);
611
+
612
+ const alternatives = findSimilar(route, allRoutes);
613
+
614
+ return {
615
+ error: true,
616
+ message: `Route '${route}' does not exist in any router module. ${
617
+ alternatives.length > 0
618
+ ? `Closest match: '${alternatives[0].target}'. Other similar: ${alternatives
619
+ .slice(1, 4)
620
+ .map((a) => `'${a.target}'`)
621
+ .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`
622
+ : "No similar routes found."
623
+ }`,
624
+ };
625
+ }
626
+
627
+ // 3. Validate against include/exclude patterns
628
+ const patternValidation = validateRoutePatterns(route, include, exclude);
629
+ if (!patternValidation.valid) {
630
+ return {
631
+ error: true,
632
+ message: patternValidation.message,
633
+ };
634
+ }
635
+
636
+ return { error: false };
637
+ }
638
+
639
+ /**
640
+ * Handler for router:check-route validation fix
641
+ */
642
+ export async function handleRouteCheck(
643
+ ctx: FixHandlerContext,
644
+ ): Promise<FixHandlerResult> {
645
+ const { sourcePath, validationError, service, valFiles, routerModulesCache } =
646
+ ctx;
647
+
648
+ // Extract route and patterns from validation error value
649
+ const value = validationError.value as
650
+ | {
651
+ route: unknown;
652
+ include?: { source: string; flags: string };
653
+ exclude?: { source: string; flags: string };
654
+ }
655
+ | undefined;
656
+
657
+ if (!value || typeof value.route !== "string") {
658
+ return {
659
+ success: false,
660
+ errorMessage: `Invalid route value in validation error: ${JSON.stringify(value)}`,
661
+ };
662
+ }
663
+
664
+ const route = value.route;
665
+
666
+ // Check if the route is valid
667
+ const result = await checkRouteIsValid(
668
+ route,
669
+ value.include,
670
+ value.exclude,
671
+ service,
672
+ valFiles,
673
+ routerModulesCache,
674
+ );
675
+
676
+ if (result.error) {
677
+ return {
678
+ success: false,
679
+ errorMessage: `${sourcePath}: ${result.message}`,
680
+ };
681
+ }
682
+
683
+ return { success: true };
684
+ }
685
+
686
+ export async function handleUniqueFolderCheck(
687
+ ctx: FixHandlerContext,
688
+ ): Promise<FixHandlerResult> {
689
+ const value = ctx.validationError.value as
690
+ | { directory: string; type: string }
691
+ | undefined;
692
+ if (!value || typeof value.directory !== "string") {
693
+ return {
694
+ success: false,
695
+ errorMessage: `Unexpected value in unique folder check for ${ctx.sourcePath}`,
696
+ };
697
+ }
698
+ const { directory } = value;
699
+ const conflicts: string[] = [];
700
+ for (const file of ctx.valFiles) {
701
+ const otherModuleFilePath = `/${file}` as ModuleFilePath;
702
+ if (otherModuleFilePath === ctx.moduleFilePath) continue;
703
+ const otherModule = await ctx.service.get(
704
+ otherModuleFilePath,
705
+ "" as ModulePath,
706
+ { source: false, schema: true, validate: false },
707
+ );
708
+ const schema = otherModule.schema as
709
+ | { type?: string; directory?: string; mediaType?: string }
710
+ | undefined;
711
+ if (
712
+ schema?.type === "record" &&
713
+ schema.directory === directory &&
714
+ schema.mediaType
715
+ ) {
716
+ conflicts.push(otherModuleFilePath);
717
+ }
718
+ }
719
+ if (conflicts.length > 0) {
720
+ return {
721
+ success: false,
722
+ errorMessage: `Gallery directory '${directory}' in ${ctx.moduleFilePath} is also used by: ${conflicts.join(", ")}. Each gallery must use a unique directory.`,
723
+ };
724
+ }
725
+ return { success: true };
726
+ }
727
+
728
+ export async function handleCheckAllFiles(
729
+ ctx: FixHandlerContext,
730
+ ): Promise<FixHandlerResult> {
731
+ const value = ctx.validationError.value as
732
+ | { directory: string; type: string }
733
+ | undefined;
734
+ if (!value || typeof value.directory !== "string") {
735
+ return {
736
+ success: false,
737
+ errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`,
738
+ };
739
+ }
740
+ const { directory } = value;
741
+
742
+ const source = ctx.valModule.source;
743
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
744
+ return {
745
+ success: false,
746
+ errorMessage: `Could not get source for ${ctx.sourcePath}`,
747
+ };
748
+ }
749
+ const trackedFiles = new Set(Object.keys(source as Record<string, unknown>));
750
+
751
+ // Check that all tracked files exist on disk
752
+ const missingTrackedFiles = [...trackedFiles].filter((f) => {
753
+ return !ctx.fs.fileExists(path.join(ctx.projectRoot, f));
754
+ });
755
+ if (missingTrackedFiles.length > 0) {
756
+ if (!ctx.fix) {
757
+ return {
758
+ success: false,
759
+ errorMessage: `Gallery in ${ctx.moduleFilePath} has tracked files that do not exist on disk: ${missingTrackedFiles.join(", ")}. Add the files or remove them from the gallery.`,
760
+ };
761
+ }
762
+ // fix: true — let createFixPatch remove the missing entries
763
+ return { success: true, shouldApplyPatch: true };
764
+ }
765
+
766
+ const dirPath = path.join(ctx.projectRoot, directory);
767
+
768
+ const filesInDir: string[] = [];
769
+ try {
770
+ const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, [
771
+ "**/*",
772
+ ]);
773
+ for (const entry of entries) {
774
+ const relPath =
775
+ "/" + path.relative(ctx.projectRoot, entry).split(path.sep).join("/");
776
+ filesInDir.push(relPath);
777
+ }
778
+ } catch {
779
+ // directory doesn't exist — no untracked files possible
780
+ }
781
+
782
+ const untrackedFiles = filesInDir.filter((f) => !trackedFiles.has(f));
783
+ if (untrackedFiles.length > 0) {
784
+ return {
785
+ success: false,
786
+ errorMessage: `Gallery in ${ctx.moduleFilePath} has files not tracked: ${untrackedFiles.join(", ")}. Add these files to the gallery or remove them from the directory.`,
787
+ };
788
+ }
789
+
790
+ // All files accounted for — trigger metadata verification via createFixPatch
791
+ return { success: true, shouldApplyPatch: true };
792
+ }
793
+
794
+ // Fix handler registry
795
+ export const currentFixHandlers: Record<ValidationFix, FixHandler> = {
796
+ "image:check-metadata": handleFileMetadata,
797
+ "image:add-metadata": handleFileMetadata,
798
+ "file:check-metadata": handleFileMetadata,
799
+ "file:add-metadata": handleFileMetadata,
800
+ "keyof:check-keys": handleKeyOfCheck,
801
+ "router:check-route": handleRouteCheck,
802
+ "image:upload-remote": handleRemoteFileUpload,
803
+ "file:upload-remote": handleRemoteFileUpload,
804
+ "image:download-remote": handleRemoteFileDownload,
805
+ "file:download-remote": handleRemoteFileDownload,
806
+ "image:check-remote": handleRemoteFileCheck,
807
+ "images:check-remote": handleRemoteFileCheck,
808
+ "file:check-remote": handleRemoteFileCheck,
809
+ "files:check-remote": handleRemoteFileCheck,
810
+ "images:check-unique-folder": handleUniqueFolderCheck,
811
+ "files:check-unique-folder": handleUniqueFolderCheck,
812
+ "images:check-all-files": handleCheckAllFiles,
813
+ "files:check-all-files": handleCheckAllFiles,
814
+ };
815
+ const deprecatedFixHandlers: Record<string, FixHandler> = {
816
+ "image:replace-metadata": handleFileMetadata,
817
+ };
818
+ export const fixHandlers: Record<string, FixHandler> = {
819
+ ...deprecatedFixHandlers,
820
+ ...currentFixHandlers,
821
+ };
822
+
823
+ export function createDefaultValFSHost(): IValFSHost {
824
+ return {
825
+ ...ts.sys,
826
+ writeFile: (fileName, data, encoding) => {
827
+ nodeFs.mkdirSync(path.dirname(fileName), { recursive: true });
828
+ nodeFs.writeFileSync(
829
+ fileName,
830
+ typeof data === "string" ? data : new Uint8Array(data),
831
+ encoding,
832
+ );
833
+ },
834
+ rmFile: nodeFs.rmSync,
835
+ readBuffer: (fileName) => {
836
+ try {
837
+ return nodeFs.readFileSync(fileName);
838
+ } catch {
839
+ return undefined;
840
+ }
841
+ },
842
+ };
843
+ }
844
+
845
+ export async function* runValidation({
846
+ root,
847
+ fix,
848
+ valFiles,
849
+ project,
850
+ remote,
851
+ fs,
852
+ }: {
853
+ root: string;
854
+ fix: boolean;
855
+ valFiles: string[];
856
+ project: string | undefined;
857
+ remote: IValRemote;
858
+ fs: IValFSHost;
859
+ }): AsyncGenerator<ValidationEvent> {
860
+ const projectRoot = path.resolve(root);
861
+
862
+ const service = await createService(projectRoot, {}, fs);
863
+
864
+ let errors = 0;
865
+
866
+ // Create caches that persist across all file validations
867
+ const keyOfCache: KeyOfCache = new Map();
868
+ const routerModulesCache: RouterModulesCache = {
869
+ loaded: false,
870
+ modules: {},
871
+ };
872
+
873
+ async function* validateFile(file: string): AsyncGenerator<ValidationEvent> {
874
+ const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
875
+ const start = Date.now();
876
+ const valModule = await service.get(moduleFilePath, "" as ModulePath, {
877
+ source: true,
878
+ schema: true,
879
+ validate: true,
880
+ });
881
+ const remoteFiles: Record<
882
+ SourcePath,
883
+ { ref: string; metadata?: Record<string, unknown> }
884
+ > = {};
885
+ let remoteFileBuckets: string[] | undefined = undefined;
886
+ let remoteFilesCounter = 0;
887
+ if (!valModule.errors) {
888
+ yield {
889
+ type: "file-valid",
890
+ file: moduleFilePath,
891
+ durationMs: Date.now() - start,
892
+ };
893
+ return;
894
+ } else {
895
+ let fileErrors = 0;
896
+ let fixedErrors = 0;
897
+ if (valModule.errors) {
898
+ if (valModule.errors.validation) {
899
+ for (const [sourcePath, validationErrors] of Object.entries(
900
+ valModule.errors.validation,
901
+ )) {
902
+ for (const v of validationErrors) {
903
+ if (!v.fixes || v.fixes.length === 0) {
904
+ // No fixes available - just report error
905
+ fileErrors += 1;
906
+ yield {
907
+ type: "validation-error",
908
+ sourcePath,
909
+ message: v.message,
910
+ };
911
+ continue;
912
+ }
913
+
914
+ // Find and execute appropriate handler
915
+ const fixType = v.fixes[0]; // Take first fix
916
+ const handler = fixHandlers[fixType];
917
+
918
+ if (!handler) {
919
+ yield {
920
+ type: "unknown-fix",
921
+ sourcePath,
922
+ fixes: v.fixes,
923
+ };
924
+ fileErrors += 1;
925
+ continue;
926
+ }
927
+
928
+ // Execute handler
929
+ const result = await handler({
930
+ sourcePath: sourcePath as SourcePath,
931
+ validationError: v,
932
+ valModule,
933
+ projectRoot,
934
+ fix: !!fix,
935
+ service,
936
+ valFiles,
937
+ moduleFilePath,
938
+ file,
939
+ fs,
940
+ remoteFiles,
941
+ publicProjectId: undefined,
942
+ remoteFileBuckets,
943
+ remoteFilesCounter,
944
+ remote,
945
+ project,
946
+ keyOfCache,
947
+ routerModulesCache,
948
+ });
949
+
950
+ // Yield any events from handler
951
+ if (result.events) {
952
+ for (const event of result.events) {
953
+ yield event;
954
+ }
955
+ }
956
+
957
+ // Update shared state from handler result
958
+ if (result.remoteFileBuckets !== undefined) {
959
+ remoteFileBuckets = result.remoteFileBuckets;
960
+ }
961
+ if (result.remoteFilesCounter !== undefined) {
962
+ remoteFilesCounter = result.remoteFilesCounter;
963
+ }
964
+
965
+ if (!result.success) {
966
+ yield {
967
+ type: "validation-error",
968
+ sourcePath,
969
+ message: result.errorMessage ?? "Unknown error",
970
+ };
971
+ fileErrors += 1;
972
+ continue;
973
+ }
974
+
975
+ // Apply patch if needed
976
+ if (result.shouldApplyPatch) {
977
+ const fixPatch = await createFixPatch(
978
+ { projectRoot, remoteHost: remote.remoteHost },
979
+ !!fix,
980
+ sourcePath as SourcePath,
981
+ v,
982
+ remoteFiles,
983
+ valModule.source,
984
+ valModule.schema,
985
+ );
986
+
987
+ if (fix && fixPatch?.patch && fixPatch?.patch.length > 0) {
988
+ await service.patch(moduleFilePath, fixPatch.patch);
989
+ fixedErrors += 1;
990
+ yield { type: "fix-applied", file, sourcePath };
991
+ } else if (
992
+ !fix &&
993
+ fixPatch?.patch &&
994
+ fixPatch?.patch.length > 0
995
+ ) {
996
+ fileErrors += 1;
997
+ yield {
998
+ type: "validation-fixable-error",
999
+ sourcePath,
1000
+ message: v.message,
1001
+ fixable: true,
1002
+ };
1003
+ }
1004
+
1005
+ for (const e of fixPatch?.remainingErrors ?? []) {
1006
+ fileErrors += 1;
1007
+ yield {
1008
+ type: "validation-fixable-error",
1009
+ sourcePath,
1010
+ message: e.message,
1011
+ fixable: !!(e.fixes && e.fixes.length),
1012
+ };
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ if (
1019
+ fixedErrors === fileErrors &&
1020
+ (!valModule.errors.fatal || valModule.errors.fatal.length == 0)
1021
+ ) {
1022
+ yield {
1023
+ type: "file-valid",
1024
+ file: moduleFilePath,
1025
+ durationMs: Date.now() - start,
1026
+ };
1027
+ }
1028
+ for (const fatalError of valModule.errors.fatal || []) {
1029
+ fileErrors += 1;
1030
+ yield {
1031
+ type: "fatal-error",
1032
+ file: moduleFilePath,
1033
+ message: fatalError.message,
1034
+ };
1035
+ }
1036
+ } else {
1037
+ yield {
1038
+ type: "file-valid",
1039
+ file: moduleFilePath,
1040
+ durationMs: Date.now() - start,
1041
+ };
1042
+ }
1043
+ if (fileErrors > 0) {
1044
+ yield {
1045
+ type: "file-error-count",
1046
+ file: `/${file}`,
1047
+ errorCount: fileErrors,
1048
+ durationMs: Date.now() - start,
1049
+ };
1050
+ }
1051
+ errors += fileErrors;
1052
+ }
1053
+ }
1054
+
1055
+ for (const file of valFiles.sort()) {
1056
+ yield* validateFile(file);
1057
+ }
1058
+
1059
+ service.dispose();
1060
+
1061
+ if (errors > 0) {
1062
+ yield { type: "summary-errors", count: errors };
1063
+ } else {
1064
+ yield { type: "summary-success" };
1065
+ }
1066
+ }
1067
+
1068
+ // GPT generated levenshtein distance algorithm:
1069
+ export const levenshtein = (a: string, b: string): number => {
1070
+ const [m, n] = [a.length, b.length];
1071
+ if (!m || !n) return Math.max(m, n);
1072
+
1073
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
1074
+
1075
+ for (let j = 1; j <= n; j++) {
1076
+ let prev = dp[0];
1077
+ dp[0] = j;
1078
+
1079
+ for (let i = 1; i <= m; i++) {
1080
+ const temp = dp[i];
1081
+ dp[i] =
1082
+ a[i - 1] === b[j - 1]
1083
+ ? prev
1084
+ : Math.min(prev + 1, dp[i - 1] + 1, dp[i] + 1);
1085
+ prev = temp;
1086
+ }
1087
+ }
1088
+
1089
+ return dp[m];
1090
+ };
1091
+
1092
+ export function findSimilar(key: string, targets: string[]) {
1093
+ return targets
1094
+ .map((target) => ({ target, distance: levenshtein(key, target) }))
1095
+ .sort((a, b) => a.distance - b.distance);
1096
+ }