@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.
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +415 -160
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +415 -160
- package/cli/dist/valbuild-cli-cli.esm.js +413 -158
- package/package.json +5 -5
- package/src/__fixtures__/basic/content/basic-errors.val.ts +7 -0
- package/src/__fixtures__/basic/content/basic-files.val.ts +14 -0
- package/src/__fixtures__/basic/content/basic-gallery-2.val.ts +17 -0
- package/src/__fixtures__/basic/content/basic-gallery-fail-on-non-unique-dir.val.ts +17 -0
- package/src/__fixtures__/basic/content/basic-gallery-missing-tracked.val.ts +17 -0
- package/src/__fixtures__/basic/content/basic-gallery-wrong-metadata.val.ts +17 -0
- package/src/__fixtures__/basic/content/basic-gallery.val.ts +18 -0
- package/src/__fixtures__/basic/content/basic-image-from-galleries.val.ts +15 -0
- package/src/__fixtures__/basic/content/basic-image-from-gallery.val.ts +12 -0
- package/src/__fixtures__/basic/content/basic-image.val.ts +7 -0
- package/src/__fixtures__/basic/content/basic-valid.val.ts +7 -0
- package/src/__fixtures__/basic/public/val/files/tracked.txt +1 -0
- package/src/__fixtures__/basic/public/val/files/untracked.txt +1 -0
- package/src/__fixtures__/basic/public/val/image.png +0 -0
- package/src/__fixtures__/basic/public/val/images/image.png +0 -0
- package/src/__fixtures__/basic/public/val/images2/image.png +0 -0
- package/src/__fixtures__/basic/public/val/images3/image.png +0 -0
- package/src/__fixtures__/basic/tsconfig.json +12 -0
- package/src/__fixtures__/basic/val.config.ts +5 -0
- package/src/runValidation.test.ts +386 -0
- package/src/runValidation.ts +1096 -0
- 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
|
+
}
|