@valbuild/cli 0.149.7 → 0.150.1

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/src/validate.ts CHANGED
@@ -12,13 +12,29 @@ import {
12
12
  type SourceFileCache,
13
13
  } from "./utils/sourcePathToFileLocation";
14
14
 
15
- type Diagnostic = {
16
- // "fixable" => ⚠ (run --fix), "error" => ✘
17
- severity: "fixable" | "error";
18
- sourcePath: string;
19
- message: string;
20
- keyError?: boolean;
21
- };
15
+ type Diagnostic =
16
+ | {
17
+ // Both are errors (✘) and count towards the exit code; "fixable" adds
18
+ // "run --fix".
19
+ severity: "fixable" | "error";
20
+ sourcePath: string;
21
+ message: string;
22
+ keyError?: boolean;
23
+ }
24
+ | {
25
+ // ⚠: printed, grouped by message, and counted only under --strict.
26
+ severity: "warning";
27
+ sourcePath: string;
28
+ message: string;
29
+ files: string[];
30
+ hint?: string;
31
+ keyError?: boolean;
32
+ };
33
+
34
+ type WarningDiagnostic = Extract<Diagnostic, { severity: "warning" }>;
35
+
36
+ /** How many of a warning group's files are listed before "… N more". */
37
+ const WARNING_FILES_SHOWN = 5;
22
38
 
23
39
  type ModuleReport = {
24
40
  // Relative file path (no leading slash).
@@ -31,10 +47,13 @@ export async function validate({
31
47
  root,
32
48
  fix,
33
49
  watch,
50
+ strict,
34
51
  }: {
35
52
  root?: string;
36
53
  fix?: boolean;
37
54
  watch?: boolean;
55
+ /** Fail on warnings too. */
56
+ strict?: boolean;
38
57
  }) {
39
58
  const projectRoot = root ? path.resolve(root) : process.cwd();
40
59
 
@@ -45,7 +64,13 @@ export async function validate({
45
64
  console.log("Prettier not found, skipping formatting");
46
65
  }
47
66
 
48
- const runOnce = () => validateOnce({ projectRoot, fix: !!fix, prettier });
67
+ const runOnce = () =>
68
+ validateOnce({
69
+ projectRoot,
70
+ fix: !!fix,
71
+ prettier,
72
+ ...(strict ? { strict: true } : {}),
73
+ });
49
74
 
50
75
  if (!watch) {
51
76
  const totalErrors = await runOnce();
@@ -60,7 +85,7 @@ export async function validate({
60
85
 
61
86
  /**
62
87
  * One validation pass over the project, printed as `val validate` prints it,
63
- * returning the number of errors found.
88
+ * returning the number of errors found -- and of warnings, under `strict`.
64
89
  *
65
90
  * Re-reads config and val files on each call so it always reflects the latest
66
91
  * state on disk: `val validate` calls it once or on every change (`--watch`),
@@ -71,6 +96,7 @@ export async function validateOnce({
71
96
  fix,
72
97
  prettier,
73
98
  blockingOnly = false,
99
+ strict = false,
74
100
  }: {
75
101
  projectRoot: string;
76
102
  fix: boolean;
@@ -82,6 +108,12 @@ export async function validateOnce({
82
108
  * what `val publish` refuses on. Every error is still printed.
83
109
  */
84
110
  blockingOnly?: boolean;
111
+ /**
112
+ * Count warnings as errors (`--strict`). A warning is a local file in a
113
+ * project that stores files remotely: see `WARNING_FIXES` in
114
+ * `@valbuild/shared`.
115
+ */
116
+ strict?: boolean;
85
117
  }): Promise<number> {
86
118
  const valConfigFile = await findAndEvalValConfigFile(projectRoot);
87
119
 
@@ -109,6 +141,7 @@ export async function validateOnce({
109
141
 
110
142
  const fixedFiles = new Set<string>();
111
143
  let totalErrors = 0;
144
+ let totalWarnings = 0;
112
145
  let repairedOnSave = 0;
113
146
 
114
147
  // Caches each val file's parsed source so files are read/parsed at most once
@@ -163,10 +196,15 @@ export async function validateOnce({
163
196
  })) {
164
197
  switch (event.type) {
165
198
  case "file-valid":
166
- valid.push({
167
- file: relFile(event.file),
168
- durationMs: event.durationMs,
169
- });
199
+ // No errors, but it may have warnings: then it is shown with them.
200
+ if (reports.has(relFile(event.file))) {
201
+ reportFor(event.file).durationMs = event.durationMs;
202
+ } else {
203
+ valid.push({
204
+ file: relFile(event.file),
205
+ durationMs: event.durationMs,
206
+ });
207
+ }
170
208
  break;
171
209
  case "file-error-count":
172
210
  reportFor(event.file).durationMs = event.durationMs;
@@ -190,6 +228,17 @@ export async function validateOnce({
190
228
  ...(event.keyError ? { keyError: true } : {}),
191
229
  });
192
230
  break;
231
+ case "validation-warning":
232
+ totalWarnings += 1;
233
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
234
+ severity: "warning",
235
+ sourcePath: event.sourcePath,
236
+ message: event.message,
237
+ files: event.files,
238
+ ...(event.hint !== undefined ? { hint: event.hint } : {}),
239
+ ...(event.keyError ? { keyError: true } : {}),
240
+ });
241
+ break;
193
242
  case "unknown-fix":
194
243
  reportFor(moduleOf(event.sourcePath)).diagnostics.push({
195
244
  severity: "error",
@@ -249,42 +298,94 @@ export async function validateOnce({
249
298
 
250
299
  // Renders a module's diagnostics under a single left "│" gutter bar, with the
251
300
  // file name on top and blank gutter lines for air. Output flows least- to
252
- // most-actionable top-to-bottom, so fixable diagnostics are shown last (at the
253
- // bottom, nearest the prompt).
301
+ // most-actionable top-to-bottom: warnings first, then errors, and fixable
302
+ // errors last (at the bottom, nearest the prompt).
254
303
  const renderModule = (report: ModuleReport) => {
255
304
  const bar = picocolors.dim("│");
256
- const diagnostics = [...report.diagnostics].sort((a, b) =>
257
- a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1,
305
+ const rank: Record<Diagnostic["severity"], number> = {
306
+ warning: 0,
307
+ error: 1,
308
+ fixable: 2,
309
+ };
310
+ const diagnostics = [...report.diagnostics].sort(
311
+ (a, b) => rank[a.severity] - rank[b.severity],
258
312
  );
313
+ const warningCount = diagnostics.filter(
314
+ (d) => d.severity === "warning",
315
+ ).length;
259
316
  const fixableCount = diagnostics.filter(
260
317
  (d) => d.severity === "fixable",
261
318
  ).length;
262
- const total = diagnostics.length;
263
- const hasError = fixableCount < total;
264
- const symbol = hasError ? picocolors.red("✘") : picocolors.yellow("⚠");
265
- let label: string;
266
- if (fixableCount === total) {
267
- label = `${fixableCount} fixable`;
268
- } else if (fixableCount > 0) {
269
- label = `${total} error${total > 1 ? "s" : ""} (${fixableCount} fixable)`;
270
- } else {
271
- label = `${total} error${total > 1 ? "s" : ""}`;
319
+ const errorCount = diagnostics.length - warningCount;
320
+ const symbol =
321
+ errorCount > 0 ? picocolors.red("✘") : picocolors.yellow("⚠");
322
+ const labels: string[] = [];
323
+ if (errorCount > 0) {
324
+ labels.push(
325
+ `${errorCount} error${errorCount > 1 ? "s" : ""}${
326
+ fixableCount > 0 ? ` (${fixableCount} fixable)` : ""
327
+ }`,
328
+ );
329
+ }
330
+ if (warningCount > 0) {
331
+ labels.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
272
332
  }
273
333
  console.log(
274
- `${picocolors.bold(report.file)} ${symbol} ${label} ${picocolors.dim(
334
+ `${picocolors.bold(report.file)} ${symbol} ${labels.join(" · ")} ${picocolors.dim(
275
335
  `(${report.durationMs}ms)`,
276
336
  )}`,
277
337
  );
278
- for (const d of diagnostics) {
279
- const target = d.keyError ? "key" : "value";
280
- const dsym =
281
- d.severity === "fixable" ? picocolors.yellow("⚠") : picocolors.red("✘");
282
- const parts = sourcePathToLocationParts(
338
+ const locationOf = (d: Diagnostic) =>
339
+ sourcePathToLocationParts(
283
340
  d.sourcePath,
284
341
  projectRoot,
285
342
  sourceFileCache,
286
- target,
343
+ d.keyError ? "key" : "value",
287
344
  );
345
+
346
+ // Warnings say the same thing about many values (every local image in a
347
+ // gallery), so each message is printed once, over the places it is about.
348
+ const warningGroups = new Map<string, WarningDiagnostic[]>();
349
+ for (const d of diagnostics) {
350
+ if (d.severity !== "warning") continue;
351
+ const group = warningGroups.get(d.message);
352
+ if (group) {
353
+ group.push(d);
354
+ } else {
355
+ warningGroups.set(d.message, [d]);
356
+ }
357
+ }
358
+ for (const [message, group] of warningGroups) {
359
+ console.log(bar);
360
+ console.log(`${bar} ${picocolors.yellow("⚠")} ${message}`);
361
+ console.log(bar);
362
+ for (const d of group.slice(0, WARNING_FILES_SHOWN)) {
363
+ const parts = locationOf(d);
364
+ const files = d.files.join(", ");
365
+ // `file:line:col` first, so VS Code's terminal links it.
366
+ const where = parts
367
+ ? `${parts.relativeFile}:${parts.line}:${parts.character}`
368
+ : d.sourcePath;
369
+ console.log(`${bar} ${where} ${picocolors.dim(files)}`);
370
+ }
371
+ if (group.length > WARNING_FILES_SHOWN) {
372
+ console.log(
373
+ `${bar} ${picocolors.dim(
374
+ `… ${group.length - WARNING_FILES_SHOWN} more`,
375
+ )}`,
376
+ );
377
+ }
378
+ const hint = group.find((d) => d.hint !== undefined)?.hint;
379
+ if (hint !== undefined) {
380
+ console.log(bar);
381
+ console.log(`${bar} ${picocolors.dim(`→ ${hint}`)}`);
382
+ }
383
+ }
384
+
385
+ for (const d of diagnostics) {
386
+ if (d.severity === "warning") continue;
387
+ const parts = locationOf(d);
388
+ const dsym = picocolors.red("✘");
288
389
  console.log(bar);
289
390
  if (parts) {
290
391
  // `file:line:col` (no key/value label) so VS Code's terminal links it.
@@ -299,7 +400,7 @@ export async function validateOnce({
299
400
  d.sourcePath,
300
401
  projectRoot,
301
402
  sourceFileCache,
302
- target,
403
+ d.keyError ? "key" : "value",
303
404
  );
304
405
  if (frame !== undefined) {
305
406
  console.log(bar);
@@ -340,13 +441,17 @@ export async function validateOnce({
340
441
 
341
442
  // Render the grouped report least- to most-actionable, top-to-bottom, so the
342
443
  // most important things end up at the bottom nearest the prompt: valid files
343
- // and skipped modules first, then error-only modules, then fixable modules.
444
+ // and skipped modules first, then warning-only modules, then error-only
445
+ // modules, then fixable modules.
344
446
  const allReports = [...reports.values()];
447
+ const hasErrors = (r: ModuleReport) =>
448
+ r.diagnostics.some((d) => d.severity !== "warning");
449
+ const warningModules = allReports.filter((r) => !hasErrors(r));
345
450
  const fixableModules = allReports.filter((r) =>
346
451
  r.diagnostics.some((d) => d.severity === "fixable"),
347
452
  );
348
453
  const errorModules = allReports.filter(
349
- (r) => !r.diagnostics.some((d) => d.severity === "fixable"),
454
+ (r) => hasErrors(r) && !r.diagnostics.some((d) => d.severity === "fixable"),
350
455
  );
351
456
 
352
457
  for (const v of valid) {
@@ -365,7 +470,11 @@ export async function validateOnce({
365
470
  if (allReports.length > 0) {
366
471
  console.log("");
367
472
  }
368
- for (const report of [...errorModules, ...fixableModules]) {
473
+ for (const report of [
474
+ ...warningModules,
475
+ ...errorModules,
476
+ ...fixableModules,
477
+ ]) {
369
478
  renderModule(report);
370
479
  }
371
480
 
@@ -373,32 +482,51 @@ export async function validateOnce({
373
482
  (n, r) => n + r.diagnostics.filter((d) => d.severity === "fixable").length,
374
483
  0,
375
484
  );
485
+ const errorFiles = allReports.filter(hasErrors).length;
486
+ const warningFiles = allReports.filter((r) =>
487
+ r.diagnostics.some((d) => d.severity === "warning"),
488
+ ).length;
489
+ const plural = (n: number, word: string) =>
490
+ `${n} ${word}${n === 1 ? "" : "s"}`;
491
+ const warnings = `${plural(totalWarnings, "warning")} in ${plural(warningFiles, "file")}`;
492
+ const warningPart =
493
+ totalWarnings > 0
494
+ ? `${warnings}${strict ? ", which --strict counts as errors" : ""}`
495
+ : undefined;
496
+ const tail = [
497
+ ...(valid.length > 0 ? [`${valid.length} valid`] : []),
498
+ ...(skipped.length > 0 ? [`${skipped.length} skipped`] : []),
499
+ ];
376
500
  if (totalErrors > 0) {
377
- let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
501
+ let summary = plural(totalErrors, "error");
378
502
  if (fixableTotal > 0) {
379
503
  summary += ` (${fixableTotal} fixable)`;
380
504
  }
381
- summary += ` across ${allReports.length} file${
382
- allReports.length > 1 ? "s" : ""
383
- }`;
384
- if (valid.length > 0) {
385
- summary += ` · ${valid.length} valid`;
386
- }
387
- if (skipped.length > 0) {
388
- summary += ` · ${skipped.length} skipped`;
389
- }
390
- console.log(picocolors.red("✘"), summary);
505
+ summary += ` across ${plural(errorFiles, "file")}`;
506
+ console.log(
507
+ picocolors.red("✘"),
508
+ [summary, ...(warningPart ? [warningPart] : []), ...tail].join(" · "),
509
+ );
510
+ } else if (warningPart && strict) {
511
+ // Not "No validation errors" beside a ✘: under --strict this run failed,
512
+ // and the warnings are why.
513
+ console.log(
514
+ picocolors.red("✘"),
515
+ [`${warnings}, and --strict fails on warnings`, ...tail].join(" · "),
516
+ );
517
+ } else if (warningPart) {
518
+ console.log(
519
+ picocolors.green("✔"),
520
+ ["No validation errors", warningPart, ...tail].join(" · "),
521
+ );
391
522
  } else {
392
- let summary = "No validation errors found";
393
- if (valid.length > 0) {
394
- summary += ` · ${valid.length} valid`;
395
- }
396
- if (skipped.length > 0) {
397
- summary += ` · ${skipped.length} skipped`;
398
- }
399
- console.log(picocolors.green("✔"), summary);
523
+ console.log(
524
+ picocolors.green("✔"),
525
+ ["No validation errors found", ...tail].join(" · "),
526
+ );
400
527
  }
401
- return blockingOnly ? totalErrors - repairedOnSave : totalErrors;
528
+ const errors = blockingOnly ? totalErrors - repairedOnSave : totalErrors;
529
+ return strict ? errors + totalWarnings : errors;
402
530
  }
403
531
 
404
532
  // Directory names anywhere in the path that should never be watched.
@@ -0,0 +1,170 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { validateOnce } from "./validate";
5
+
6
+ /**
7
+ * A local file in a project with remote files is a WARNING: `val validate`
8
+ * prints it and passes, `--strict` makes it fail. Every project made from a
9
+ * template starts this way, hosted ones included, and the site serves those
10
+ * files from public/ until they are uploaded.
11
+ */
12
+
13
+ const BASIC_FIXTURE = path.resolve(__dirname, "__fixtures__/basic");
14
+
15
+ const PNG = Buffer.from(
16
+ "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX/AAD//" +
17
+ "/+l2Z/dAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oIBRELLnJqTAAAAAxJREFUCNdjYGBgAAAABAABJzQnCgAAAABJRU5ErkJggg==",
18
+ "base64",
19
+ );
20
+
21
+ const IMAGES = Array.from(
22
+ { length: 7 },
23
+ (_, i) => `/public/val/images/image-${i}_0000${i}.png`,
24
+ );
25
+
26
+ const PROJECT: Record<string, string | Buffer> = {
27
+ "val.modules.ts": `import { modules } from "@valbuild/core";
28
+ import { config } from "./val.config";
29
+
30
+ export default modules(config, [
31
+ { def: () => import("./content/images.val") },
32
+ ]);
33
+ `,
34
+ "content/images.val.ts": `import { c, s } from "../val.config";
35
+
36
+ export default c.define(
37
+ "/content/images.val.ts",
38
+ s.imagedir("/public/val/images").remote(),
39
+ {
40
+ ${IMAGES.map(
41
+ (image) =>
42
+ ` "${image}": { width: 8, height: 8, mimeType: "image/png", alt: null },`,
43
+ ).join("\n")}
44
+ },
45
+ );
46
+ `,
47
+ ...Object.fromEntries(IMAGES.map((image) => [image.slice(1), PNG])),
48
+ };
49
+
50
+ describe("val validate: a local file in a project with remote files", () => {
51
+ let root: string;
52
+ let output: string[];
53
+ let log: jest.SpyInstance;
54
+
55
+ beforeEach(() => {
56
+ const tmpBase = path.join(__dirname, "..", ".tmp");
57
+ fs.mkdirSync(tmpBase, { recursive: true });
58
+ root = fs.mkdtempSync(path.join(tmpBase, "warnings-"));
59
+ for (const file of ["val.config.ts", "tsconfig.json"]) {
60
+ fs.copyFileSync(path.join(BASIC_FIXTURE, file), path.join(root, file));
61
+ }
62
+ for (const [file, content] of Object.entries(PROJECT)) {
63
+ fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
64
+ fs.writeFileSync(path.join(root, file), content);
65
+ }
66
+ output = [];
67
+ log = jest.spyOn(console, "log").mockImplementation((...args) => {
68
+ output.push(args.join(" "));
69
+ });
70
+ });
71
+
72
+ afterEach(() => {
73
+ log.mockRestore();
74
+ fs.rmSync(root, { recursive: true, force: true });
75
+ });
76
+
77
+ // eslint-disable-next-line no-control-regex
78
+ const plain = () => output.join("\n").replace(/\u001b\[[0-9;]*m/g, "");
79
+
80
+ test("is printed once, over every file it is about, and passes", async () => {
81
+ expect(await validateOnce({ projectRoot: root, fix: false })).toBe(0);
82
+ const text = plain();
83
+ expect(text).toContain("content/images.val.ts ⚠ 7 warnings");
84
+ expect(
85
+ text.match(/Local files in a project with remote files/g),
86
+ ).toHaveLength(1);
87
+ expect(text).toContain(IMAGES[0]);
88
+ expect(text).toContain("… 2 more");
89
+ expect(text).toContain("→ val validate --fix uploads them");
90
+ expect(text).toContain("✔ No validation errors · 7 warnings in 1 file");
91
+ });
92
+
93
+ test("fails under --strict", async () => {
94
+ expect(
95
+ await validateOnce({ projectRoot: root, fix: false, strict: true }),
96
+ ).toBe(7);
97
+ const text = plain();
98
+ expect(text).toContain(
99
+ "✘ 7 warnings in 1 file, and --strict fails on warnings",
100
+ );
101
+ // Not "passed" and "failed" in one line.
102
+ expect(text).not.toContain("No validation errors");
103
+ });
104
+
105
+ test("does not hold back val publish, unless --strict", async () => {
106
+ expect(
107
+ await validateOnce({ projectRoot: root, fix: false, blockingOnly: true }),
108
+ ).toBe(0);
109
+ expect(
110
+ await validateOnce({
111
+ projectRoot: root,
112
+ fix: false,
113
+ blockingOnly: true,
114
+ strict: true,
115
+ }),
116
+ ).toBe(7);
117
+ });
118
+
119
+ test("fails under --fix when the files cannot be uploaded", async () => {
120
+ // Not logged in: there is no .val/pat.json in the project.
121
+ expect(
122
+ await validateOnce({ projectRoot: root, fix: true }),
123
+ ).toBeGreaterThan(0);
124
+ const text = plain();
125
+ expect(text).toContain("not logged in");
126
+ expect(text).not.toContain("⚠");
127
+ });
128
+
129
+ test("an image inside rich text is a warning too", async () => {
130
+ // A rich text image's `src` resolves to the rich text schema, not the
131
+ // image one, once the schema is serialized -- which the handler read as
132
+ // "neither image nor file" and reported as an error.
133
+ fs.writeFileSync(
134
+ path.join(root, "val.modules.ts"),
135
+ `import { modules } from "@valbuild/core";
136
+ import { config } from "./val.config";
137
+
138
+ export default modules(config, [
139
+ { def: () => import("./content/page.val") },
140
+ ]);
141
+ `,
142
+ );
143
+ fs.writeFileSync(
144
+ path.join(root, "content/page.val.ts"),
145
+ `import { c, s } from "../val.config";
146
+
147
+ export default c.define(
148
+ "/content/page.val.ts",
149
+ s.richtext({ img: s.image().remote() }),
150
+ [
151
+ {
152
+ tag: "p",
153
+ children: [
154
+ "Look: ",
155
+ {
156
+ tag: "img",
157
+ src: { path: "${IMAGES[0]}", width: 8, height: 8, mimeType: "image/png" },
158
+ },
159
+ ],
160
+ },
161
+ ],
162
+ );
163
+ `,
164
+ );
165
+ expect(await validateOnce({ projectRoot: root, fix: false })).toBe(0);
166
+ const text = plain();
167
+ expect(text).toContain("content/page.val.ts ⚠ 1 warning");
168
+ expect(text).not.toContain("neither image nor file");
169
+ });
170
+ });