@valbuild/cli 0.97.2 → 0.97.4
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 +1471 -118
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1471 -118
- package/cli/dist/valbuild-cli-cli.esm.js +1469 -118
- package/package.json +6 -4
- package/src/__fixtures__/basic/val.config.ts +2 -2
- package/src/__fixtures__/basic/val.modules.ts +16 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/11111111-1111-4111-8111-111111111111/patch.json +19 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/22222222-2222-4222-8222-222222222222/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/head/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/content/projects.val.ts +23 -0
- package/src/__fixtures__/debug-snapshot/content/summary.ts +6 -0
- package/src/__fixtures__/debug-snapshot/content/tags.val.ts +10 -0
- package/src/__fixtures__/debug-snapshot/content/unrelated.val.ts +5 -0
- package/src/__fixtures__/debug-snapshot/tsconfig.json +12 -0
- package/src/__fixtures__/debug-snapshot/val.config.ts +5 -0
- package/src/__fixtures__/debug-snapshot/val.modules.ts +8 -0
- package/src/cli.ts +89 -2
- package/src/debug/context.ts +173 -0
- package/src/debug/importGraph.ts +126 -0
- package/src/debug/moduleClosure.ts +167 -0
- package/src/debug/report.ts +80 -0
- package/src/debug/snapshot.ts +497 -0
- package/src/debug/snapshotRoundTrip.test.ts +95 -0
- package/src/debug.test.ts +107 -0
- package/src/debug.ts +120 -0
- package/src/deleteUnappliablePatches.ts +139 -0
- package/src/listUnusedFiles.ts +16 -4
- package/src/runValidation.test.ts +6 -6
- package/src/runValidation.ts +40 -15
- package/src/utils/evalValConfigFile.ts +13 -5
- package/src/utils/sourcePathToFileLocation.ts +184 -0
- package/src/validate.ts +415 -154
package/src/debug.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import JSZip from "jszip";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { createDebugContext, DebugContextError } from "./debug/context";
|
|
6
|
+
import { buildSnapshot } from "./debug/snapshot";
|
|
7
|
+
import { printPatchReport } from "./debug/report";
|
|
8
|
+
import { error } from "./logger";
|
|
9
|
+
|
|
10
|
+
export async function debug(options: {
|
|
11
|
+
root?: string;
|
|
12
|
+
out?: string;
|
|
13
|
+
commit?: string;
|
|
14
|
+
branch?: string;
|
|
15
|
+
remote?: boolean;
|
|
16
|
+
includeFiles?: boolean;
|
|
17
|
+
verbose?: boolean;
|
|
18
|
+
}): Promise<void> {
|
|
19
|
+
let ctx;
|
|
20
|
+
try {
|
|
21
|
+
ctx = await createDebugContext(options);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err instanceof DebugContextError) {
|
|
24
|
+
return error(err.message);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(
|
|
30
|
+
pc.dim(
|
|
31
|
+
`Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`,
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
console.log(
|
|
35
|
+
pc.yellow(
|
|
36
|
+
"The snapshot includes unpublished content. Only share it with Val developers.",
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
console.log("");
|
|
40
|
+
|
|
41
|
+
const snapshot = await buildSnapshot(ctx, {
|
|
42
|
+
includeFiles: options.includeFiles,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
printPatchReport(
|
|
46
|
+
snapshot.manifest.modules.length > 0 ? await readPatchMetadata(ctx) : [],
|
|
47
|
+
snapshot.report,
|
|
48
|
+
{ verbose: options.verbose },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const outPath = path.resolve(
|
|
52
|
+
options.out ??
|
|
53
|
+
`./val-debug-${sanitize(ctx.branch ?? "nobranch")}-${(ctx.commit ?? "nocommit").slice(0, 8)}-${timestamp()}.zip`,
|
|
54
|
+
);
|
|
55
|
+
await writeZip(outPath, snapshot.entries);
|
|
56
|
+
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log(`Snapshot written to ${pc.cyan(outPath)}`);
|
|
59
|
+
for (const line of [
|
|
60
|
+
`unzip ${path.basename(outPath)} -d debug/<name>`,
|
|
61
|
+
`pnpm debug:replay debug/<name>`,
|
|
62
|
+
]) {
|
|
63
|
+
console.log(pc.dim(` ${line}`));
|
|
64
|
+
}
|
|
65
|
+
if (snapshot.manifest.unresolvedImports.length > 0) {
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log(
|
|
68
|
+
pc.yellow(
|
|
69
|
+
`${snapshot.manifest.unresolvedImports.length} import(s) could not be resolved to a project file. ` +
|
|
70
|
+
`Bare package imports are expected; a tsconfig path alias means the snapshot may not evaluate. See manifest.json.`,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Patch metadata for the printed report. Fetched without ops so the (large)
|
|
78
|
+
* patch bodies are not pulled a second time.
|
|
79
|
+
*/
|
|
80
|
+
async function readPatchMetadata(
|
|
81
|
+
ctx: Awaited<ReturnType<typeof createDebugContext>>,
|
|
82
|
+
) {
|
|
83
|
+
const res = await ctx.serverOps.fetchPatches({
|
|
84
|
+
patchIds: undefined,
|
|
85
|
+
excludePatchOps: true,
|
|
86
|
+
});
|
|
87
|
+
if (res.error) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return res.patches.map((patch) => ({
|
|
91
|
+
patchId: patch.patchId,
|
|
92
|
+
path: patch.path,
|
|
93
|
+
createdAt: patch.createdAt,
|
|
94
|
+
authorId: patch.authorId,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function writeZip(
|
|
99
|
+
outPath: string,
|
|
100
|
+
entries: Record<string, string>,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const zip = new JSZip();
|
|
103
|
+
for (const [entryPath, contents] of Object.entries(entries)) {
|
|
104
|
+
zip.file(entryPath, contents);
|
|
105
|
+
}
|
|
106
|
+
const buffer = await zip.generateAsync({
|
|
107
|
+
type: "nodebuffer",
|
|
108
|
+
compression: "DEFLATE",
|
|
109
|
+
});
|
|
110
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(outPath, buffer);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function timestamp(): string {
|
|
115
|
+
return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sanitize(value: string): string {
|
|
119
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
120
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import readline from "readline";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { PatchId } from "@valbuild/core";
|
|
4
|
+
import { OrderedPatches, PatchAnalysis } from "@valbuild/server";
|
|
5
|
+
import { createDebugContext, DebugContextError } from "./debug/context";
|
|
6
|
+
import { printPatchReport } from "./debug/report";
|
|
7
|
+
import { error, info } from "./logger";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Removes the pending patches that cannot be applied, which is what unblocks a
|
|
11
|
+
* publish that fails with "Failed to create commit".
|
|
12
|
+
*
|
|
13
|
+
* Deliberately a separate command from `val debug`: capturing a snapshot must
|
|
14
|
+
* always be read-only, and deleting destroys the evidence - so take the snapshot
|
|
15
|
+
* first.
|
|
16
|
+
*/
|
|
17
|
+
export async function deleteUnappliablePatches(options: {
|
|
18
|
+
root?: string;
|
|
19
|
+
commit?: string;
|
|
20
|
+
branch?: string;
|
|
21
|
+
remote?: boolean;
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
yes?: boolean;
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
}): Promise<void> {
|
|
26
|
+
let ctx;
|
|
27
|
+
try {
|
|
28
|
+
ctx = await createDebugContext(options);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof DebugContextError) {
|
|
31
|
+
return error(err.message);
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
console.log(
|
|
36
|
+
pc.dim(
|
|
37
|
+
`Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const first = await analyse(ctx);
|
|
42
|
+
if (first === null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
printPatchReport(first.metadata, first.prepared, {
|
|
46
|
+
verbose: options.verbose,
|
|
47
|
+
});
|
|
48
|
+
const unappliablePatchIds = Object.keys(
|
|
49
|
+
first.prepared.unappliablePatches,
|
|
50
|
+
).map((patchId) => patchId as PatchId);
|
|
51
|
+
if (unappliablePatchIds.length === 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (options.dryRun) {
|
|
55
|
+
console.log("");
|
|
56
|
+
info("Dry run: nothing was deleted.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!options.yes) {
|
|
60
|
+
console.log("");
|
|
61
|
+
const confirmed = await confirm(
|
|
62
|
+
`Delete ${unappliablePatchIds.length} patch(es)? The changes they contain are lost. [y/N] `,
|
|
63
|
+
);
|
|
64
|
+
if (!confirmed) {
|
|
65
|
+
info("Aborted, nothing was deleted.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const deleteRes = await ctx.serverOps.deletePatches(unappliablePatchIds);
|
|
71
|
+
if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
|
|
72
|
+
for (const [patchId, err] of Object.entries(deleteRes.errors)) {
|
|
73
|
+
error(`Could not delete ${patchId}: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
info(`Deleted ${unappliablePatchIds.length} patch(es).`, { isGood: true });
|
|
78
|
+
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log(pc.dim("Re-checking the remaining chain..."));
|
|
81
|
+
const second = await analyse(ctx);
|
|
82
|
+
if (second === null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const stillUnappliable = Object.keys(second.prepared.unappliablePatches);
|
|
86
|
+
if (stillUnappliable.length === 0) {
|
|
87
|
+
info(
|
|
88
|
+
`The remaining ${second.metadata.length} patch(es) all apply. Publishing should work now.`,
|
|
89
|
+
{ isGood: true },
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
printPatchReport(second.metadata, second.prepared, {
|
|
94
|
+
verbose: options.verbose,
|
|
95
|
+
});
|
|
96
|
+
error(
|
|
97
|
+
`${stillUnappliable.length} patch(es) still cannot be applied. Run the command again to remove them too.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function analyse(ctx: Awaited<ReturnType<typeof createDebugContext>>) {
|
|
102
|
+
const patchesRes = await ctx.serverOps.fetchPatches({
|
|
103
|
+
patchIds: undefined,
|
|
104
|
+
excludePatchOps: false,
|
|
105
|
+
});
|
|
106
|
+
if (patchesRes.error) {
|
|
107
|
+
error(`Could not fetch patches: ${patchesRes.error.message}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const analysis: PatchAnalysis & OrderedPatches = {
|
|
111
|
+
...ctx.serverOps.analyzePatches(patchesRes.patches),
|
|
112
|
+
...patchesRes,
|
|
113
|
+
};
|
|
114
|
+
const prepared = await ctx.serverOps.prepare(analysis, {
|
|
115
|
+
continueOnError: true,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
prepared,
|
|
119
|
+
metadata: patchesRes.patches.map((patch) => ({
|
|
120
|
+
patchId: patch.patchId,
|
|
121
|
+
path: patch.path,
|
|
122
|
+
createdAt: patch.createdAt,
|
|
123
|
+
authorId: patch.authorId,
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function confirm(question: string): Promise<boolean> {
|
|
129
|
+
const rl = readline.createInterface({
|
|
130
|
+
input: process.stdin,
|
|
131
|
+
output: process.stdout,
|
|
132
|
+
});
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
rl.question(question, (answer) => {
|
|
135
|
+
rl.close();
|
|
136
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
package/src/listUnusedFiles.ts
CHANGED
|
@@ -8,12 +8,22 @@ import {
|
|
|
8
8
|
import { createService } from "@valbuild/server";
|
|
9
9
|
import { glob } from "fast-glob";
|
|
10
10
|
import path from "path";
|
|
11
|
+
import { evalValConfigFile } from "./utils/evalValConfigFile";
|
|
11
12
|
|
|
12
13
|
export async function listUnusedFiles({ root }: { root?: string }) {
|
|
13
|
-
const managedDir = "public/val";
|
|
14
14
|
const projectRoot = root ? path.resolve(root) : process.cwd();
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const valConfigFile =
|
|
17
|
+
(await evalValConfigFile(projectRoot, "val.config.ts")) ||
|
|
18
|
+
(await evalValConfigFile(projectRoot, "val.config.js"));
|
|
19
|
+
// Strip the leading "/" so it is relative to the project root (e.g. "public/val").
|
|
20
|
+
const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace(
|
|
21
|
+
/^\//,
|
|
22
|
+
"",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const service = await createService(projectRoot);
|
|
26
|
+
const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
|
|
17
27
|
|
|
18
28
|
const valFiles: string[] = await glob("**/*.val.{js,ts}", {
|
|
19
29
|
ignore: ["node_modules/**"],
|
|
@@ -23,10 +33,12 @@ export async function listUnusedFiles({ root }: { root?: string }) {
|
|
|
23
33
|
const filesUsedByVal: string[] = [];
|
|
24
34
|
async function pushFilesUsedByVal(file: string) {
|
|
25
35
|
const moduleId = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
|
|
36
|
+
if (!registered.has(moduleId)) {
|
|
37
|
+
// Not registered in val.modules - skip (e.g. reusable schema fragments).
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
26
40
|
const valModule = await service.get(moduleId, "" as ModulePath, {
|
|
27
41
|
validate: true,
|
|
28
|
-
source: true,
|
|
29
|
-
schema: true,
|
|
30
42
|
});
|
|
31
43
|
// TODO: not sure using validation is the best way to do this, but it works currently.
|
|
32
44
|
if (valModule.errors) {
|
|
@@ -265,12 +265,12 @@ describe("runValidation", () => {
|
|
|
265
265
|
next = await gen.next();
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
const service = await createService(tmpDir,
|
|
268
|
+
const service = await createService(tmpDir, createDefaultValFSHost());
|
|
269
269
|
try {
|
|
270
270
|
const result = await service.get(
|
|
271
271
|
"/content/basic-gallery-missing-tracked.val.ts" as ModuleFilePath,
|
|
272
272
|
"" as ModulePath,
|
|
273
|
-
{
|
|
273
|
+
{ validate: true },
|
|
274
274
|
);
|
|
275
275
|
expect(result.source).not.toHaveProperty(
|
|
276
276
|
"/public/val/images4/missing.png",
|
|
@@ -322,12 +322,12 @@ describe("runValidation", () => {
|
|
|
322
322
|
next = await gen.next();
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
const service = await createService(tmpDir,
|
|
325
|
+
const service = await createService(tmpDir, createDefaultValFSHost());
|
|
326
326
|
try {
|
|
327
327
|
const result = await service.get(
|
|
328
328
|
"/content/basic-gallery-wrong-metadata.val.ts" as ModuleFilePath,
|
|
329
329
|
"" as ModulePath,
|
|
330
|
-
{
|
|
330
|
+
{ validate: true },
|
|
331
331
|
);
|
|
332
332
|
expect(result.source).toMatchObject({
|
|
333
333
|
"/public/val/images3/image.png": {
|
|
@@ -356,12 +356,12 @@ describe("runValidation", () => {
|
|
|
356
356
|
next = await gen.next();
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
const service = await createService(tmpDir,
|
|
359
|
+
const service = await createService(tmpDir, createDefaultValFSHost());
|
|
360
360
|
try {
|
|
361
361
|
const result = await service.get(
|
|
362
362
|
"/content/basic-image.val.ts" as ModuleFilePath,
|
|
363
363
|
"" as ModulePath,
|
|
364
|
-
{
|
|
364
|
+
{ validate: true },
|
|
365
365
|
);
|
|
366
366
|
// The schema always emits image:check-metadata when metadata exists
|
|
367
367
|
// (actual metadata verification happens in the fix handler).
|
package/src/runValidation.ts
CHANGED
|
@@ -62,6 +62,8 @@ export type ValidationError = {
|
|
|
62
62
|
message: string;
|
|
63
63
|
value?: unknown;
|
|
64
64
|
fixes?: ValidationFix[];
|
|
65
|
+
// True when the error is about an object/record key rather than its value.
|
|
66
|
+
keyError?: boolean;
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export type FixHandlerContext = {
|
|
@@ -109,14 +111,26 @@ export type ValidationEvent =
|
|
|
109
111
|
errorCount: number;
|
|
110
112
|
durationMs: number;
|
|
111
113
|
}
|
|
112
|
-
| {
|
|
114
|
+
| {
|
|
115
|
+
type: "validation-error";
|
|
116
|
+
sourcePath: string;
|
|
117
|
+
message: string;
|
|
118
|
+
keyError?: boolean;
|
|
119
|
+
}
|
|
113
120
|
| {
|
|
114
121
|
type: "validation-fixable-error";
|
|
115
122
|
sourcePath: string;
|
|
116
123
|
message: string;
|
|
117
124
|
fixable: boolean;
|
|
125
|
+
keyError?: boolean;
|
|
118
126
|
}
|
|
119
|
-
| {
|
|
127
|
+
| {
|
|
128
|
+
type: "unknown-fix";
|
|
129
|
+
sourcePath: string;
|
|
130
|
+
fixes: string[];
|
|
131
|
+
keyError?: boolean;
|
|
132
|
+
}
|
|
133
|
+
| { type: "unregistered-module"; file: string }
|
|
120
134
|
| { type: "fix-applied"; file: string; sourcePath: string }
|
|
121
135
|
| { type: "fatal-error"; file: string; message: string }
|
|
122
136
|
| { type: "remote-uploading"; ref: string }
|
|
@@ -335,12 +349,12 @@ export async function handleRemoteFileUpload(
|
|
|
335
349
|
const relativeFilePath = path
|
|
336
350
|
.relative(ctx.projectRoot, filePath)
|
|
337
351
|
.split(path.sep)
|
|
338
|
-
.join("/") as `public
|
|
352
|
+
.join("/") as `public/${string}`;
|
|
339
353
|
|
|
340
|
-
if (!relativeFilePath.startsWith("public/
|
|
354
|
+
if (!relativeFilePath.startsWith("public/")) {
|
|
341
355
|
return {
|
|
342
356
|
success: false,
|
|
343
|
-
errorMessage: `File path must be within the public/
|
|
357
|
+
errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`,
|
|
344
358
|
};
|
|
345
359
|
}
|
|
346
360
|
|
|
@@ -443,7 +457,7 @@ export async function handleUniqueFolderCheck(
|
|
|
443
457
|
const otherModule = await ctx.service.get(
|
|
444
458
|
otherModuleFilePath,
|
|
445
459
|
"" as ModulePath,
|
|
446
|
-
{
|
|
460
|
+
{ validate: false },
|
|
447
461
|
);
|
|
448
462
|
const schema = otherModule.schema as
|
|
449
463
|
| { type?: string; directory?: string; mediaType?: string }
|
|
@@ -602,19 +616,21 @@ export async function* runValidation({
|
|
|
602
616
|
}): AsyncGenerator<ValidationEvent> {
|
|
603
617
|
const projectRoot = path.resolve(root);
|
|
604
618
|
|
|
605
|
-
const service = await createService(projectRoot,
|
|
619
|
+
const service = await createService(projectRoot, fs);
|
|
620
|
+
|
|
621
|
+
// Modules registered in the project's val.modules. Files found on disk that
|
|
622
|
+
// are not registered here are not validated (a warning is emitted instead).
|
|
623
|
+
const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
|
|
606
624
|
|
|
607
625
|
let errors = 0;
|
|
608
626
|
|
|
609
627
|
// Build a single schema/source snapshot up front so the shared resolver
|
|
610
628
|
// can resolve keyof:check-keys / router:check-route references that span
|
|
611
|
-
// multiple val files.
|
|
629
|
+
// multiple val files. Use the full registry so cross-module references
|
|
630
|
+
// resolve even against modules not in the validated subset.
|
|
612
631
|
const snapshot: SchemaSourceSnapshot = { schemas: {}, sources: {} };
|
|
613
|
-
for (const
|
|
614
|
-
const moduleFilePath = `/${file}` as ModuleFilePath;
|
|
632
|
+
for (const moduleFilePath of registered) {
|
|
615
633
|
const valModule = await service.get(moduleFilePath, "" as ModulePath, {
|
|
616
|
-
source: true,
|
|
617
|
-
schema: true,
|
|
618
634
|
validate: false,
|
|
619
635
|
});
|
|
620
636
|
if (valModule.schema) {
|
|
@@ -627,10 +643,12 @@ export async function* runValidation({
|
|
|
627
643
|
|
|
628
644
|
async function* validateFile(file: string): AsyncGenerator<ValidationEvent> {
|
|
629
645
|
const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
|
|
646
|
+
if (!registered.has(moduleFilePath)) {
|
|
647
|
+
yield { type: "unregistered-module", file };
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
630
650
|
const start = Date.now();
|
|
631
651
|
const valModule = await service.get(moduleFilePath, "" as ModulePath, {
|
|
632
|
-
source: true,
|
|
633
|
-
schema: true,
|
|
634
652
|
validate: true,
|
|
635
653
|
});
|
|
636
654
|
const remoteFiles: Record<
|
|
@@ -670,6 +688,7 @@ export async function* runValidation({
|
|
|
670
688
|
type: "validation-error",
|
|
671
689
|
sourcePath,
|
|
672
690
|
message: v.message,
|
|
691
|
+
...(v.keyError ? { keyError: true } : {}),
|
|
673
692
|
};
|
|
674
693
|
continue;
|
|
675
694
|
}
|
|
@@ -683,6 +702,7 @@ export async function* runValidation({
|
|
|
683
702
|
type: "unknown-fix",
|
|
684
703
|
sourcePath,
|
|
685
704
|
fixes: v.fixes,
|
|
705
|
+
...(v.keyError ? { keyError: true } : {}),
|
|
686
706
|
};
|
|
687
707
|
fileErrors += 1;
|
|
688
708
|
continue;
|
|
@@ -728,6 +748,7 @@ export async function* runValidation({
|
|
|
728
748
|
type: "validation-error",
|
|
729
749
|
sourcePath,
|
|
730
750
|
message: result.errorMessage ?? "Unknown error",
|
|
751
|
+
...(v.keyError ? { keyError: true } : {}),
|
|
731
752
|
};
|
|
732
753
|
fileErrors += 1;
|
|
733
754
|
continue;
|
|
@@ -760,6 +781,7 @@ export async function* runValidation({
|
|
|
760
781
|
sourcePath,
|
|
761
782
|
message: v.message,
|
|
762
783
|
fixable: true,
|
|
784
|
+
...(v.keyError ? { keyError: true } : {}),
|
|
763
785
|
};
|
|
764
786
|
}
|
|
765
787
|
|
|
@@ -767,9 +789,12 @@ export async function* runValidation({
|
|
|
767
789
|
fileErrors += 1;
|
|
768
790
|
yield {
|
|
769
791
|
type: "validation-fixable-error",
|
|
770
|
-
|
|
792
|
+
// Gallery checks expand into per-entry errors that point at
|
|
793
|
+
// the individual entry; fall back to the record sourcePath.
|
|
794
|
+
sourcePath: e.sourcePath ?? sourcePath,
|
|
771
795
|
message: e.message,
|
|
772
796
|
fixable: !!(e.fixes && e.fixes.length),
|
|
797
|
+
...(e.keyError ? { keyError: true } : {}),
|
|
773
798
|
};
|
|
774
799
|
}
|
|
775
800
|
}
|
|
@@ -11,11 +11,19 @@ const ValConfigSchema = z.object({
|
|
|
11
11
|
root: z.string().optional(),
|
|
12
12
|
files: z
|
|
13
13
|
.object({
|
|
14
|
-
directory: z
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
directory: z.string().refine(
|
|
15
|
+
(val): val is `/public` | `/public/${string}` =>
|
|
16
|
+
(val === "/public" ||
|
|
17
|
+
(val.startsWith("/public/") && !val.endsWith("/"))) &&
|
|
18
|
+
// Reject path traversal so the directory cannot escape /public
|
|
19
|
+
!val
|
|
20
|
+
.split("/")
|
|
21
|
+
.some((segment) => segment === "." || segment === ".."),
|
|
22
|
+
{
|
|
23
|
+
message:
|
|
24
|
+
"files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments",
|
|
25
|
+
},
|
|
26
|
+
),
|
|
19
27
|
})
|
|
20
28
|
.optional(),
|
|
21
29
|
gitCommit: z.string().optional(),
|