@valbuild/cli 0.147.0 → 0.148.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/src/validate.ts CHANGED
@@ -45,338 +45,360 @@ export async function validate({
45
45
  console.log("Prettier not found, skipping formatting");
46
46
  }
47
47
 
48
- // Runs a single validation pass over the project and returns the number of
49
- // errors found. Re-reads config and val files each call so it always reflects
50
- // the latest state on disk (used both for one-shot and watch mode).
51
- async function runOnce(): Promise<number> {
52
- const valConfigFile = await findAndEvalValConfigFile(projectRoot);
53
-
54
- const resolvedValConfigFile = valConfigFile
55
- ? {
56
- ...valConfigFile,
57
- project: process.env.VAL_PROJECT || valConfigFile.project,
58
- }
59
- : process.env.VAL_PROJECT
60
- ? { project: process.env.VAL_PROJECT }
61
- : undefined;
48
+ const runOnce = () => validateOnce({ projectRoot, fix: !!fix, prettier });
62
49
 
63
- console.log(
64
- picocolors.greenBright(
65
- `Validating project${resolvedValConfigFile?.project ? ` '${picocolors.inverse(resolvedValConfigFile.project)}'` : ""}...`,
66
- ),
67
- );
50
+ if (!watch) {
51
+ const totalErrors = await runOnce();
52
+ if (totalErrors > 0) {
53
+ process.exit(1);
54
+ }
55
+ return;
56
+ }
68
57
 
69
- const valFiles: string[] = await glob("**/*.val.{js,ts}", {
70
- ignore: ["node_modules/**"],
71
- cwd: projectRoot,
72
- });
73
-
74
- console.log(picocolors.greenBright(`Found ${valFiles.length} files...`));
75
-
76
- const fixedFiles = new Set<string>();
77
- let totalErrors = 0;
78
-
79
- // Caches each val file's parsed source so files are read/parsed at most once
80
- // per pass when resolving sourcePaths to file locations and code frames.
81
- const sourceFileCache: SourceFileCache = new Map();
82
-
83
- // Diagnostics are buffered per module (keyed by relative file path) so we can
84
- // render them grouped and prioritised after the run, rather than streaming
85
- // them out interleaved. Transient progress (remote/fix-applied) still streams
86
- // live below.
87
- const reports = new Map<string, ModuleReport>();
88
- const valid: { file: string; durationMs: number }[] = [];
89
- const skipped: string[] = [];
90
-
91
- // Relative file path (no leading slash), matching the code frame's
92
- // relativeFile so headers, diagnostics and frames all agree.
93
- const relFile = (file: string) => file.replace(/^\//, "");
94
- // The module a sourcePath/file belongs to is the part before the `?p=...`.
95
- const moduleOf = (sourcePathOrFile: string) =>
96
- relFile(sourcePathOrFile.split("?")[0]);
97
-
98
- const reportFor = (file: string): ModuleReport => {
99
- const key = relFile(file);
100
- let report = reports.get(key);
101
- if (!report) {
102
- report = { file: key, durationMs: 0, diagnostics: [] };
103
- reports.set(key, report);
104
- }
105
- return report;
106
- };
107
-
108
- for await (const event of runValidation({
109
- root: projectRoot,
110
- fix: !!fix,
111
- valFiles,
112
- project: resolvedValConfigFile?.project,
113
- remote: {
114
- remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
115
- getSettings: (projectName, options) =>
116
- getSettings(projectName, options),
117
- uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) =>
118
- uploadRemoteFile(
119
- process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST,
120
- project,
121
- bucket,
122
- fileHash,
123
- fileExt ?? "",
124
- fileBuffer,
125
- options,
126
- ),
127
- },
128
- fs: createDefaultValFSHost(),
129
- })) {
130
- switch (event.type) {
131
- case "file-valid":
132
- valid.push({
133
- file: relFile(event.file),
134
- durationMs: event.durationMs,
135
- });
136
- break;
137
- case "file-error-count":
138
- reportFor(event.file).durationMs = event.durationMs;
139
- totalErrors += event.errorCount;
140
- break;
141
- case "validation-error":
142
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
143
- severity: "error",
144
- sourcePath: event.sourcePath,
145
- message: event.message,
146
- ...(event.keyError ? { keyError: true } : {}),
147
- });
148
- break;
149
- case "validation-fixable-error":
150
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
151
- severity: event.fixable ? "fixable" : "error",
152
- sourcePath: event.sourcePath,
153
- message: event.message,
154
- ...(event.keyError ? { keyError: true } : {}),
155
- });
156
- break;
157
- case "unknown-fix":
158
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
159
- severity: "error",
160
- sourcePath: event.sourcePath,
161
- message: `Unknown fix: ${event.fixes.join(", ")}`,
162
- ...(event.keyError ? { keyError: true } : {}),
163
- });
164
- break;
165
- case "unregistered-module":
166
- skipped.push(event.file);
167
- break;
168
- case "fatal-error":
169
- // No sourcePath for fatal errors; group by file, render message only.
170
- reportFor(event.file).diagnostics.push({
171
- severity: "error",
172
- sourcePath: event.file,
173
- message: event.message,
174
- });
175
- break;
176
- case "fix-applied":
177
- console.log(
178
- picocolors.yellow("⚠"),
179
- "Applied fix for",
180
- event.sourcePath,
181
- );
182
- fixedFiles.add(event.file);
183
- break;
184
- case "remote-uploading":
185
- console.log(
186
- picocolors.yellow("⚠"),
187
- `Uploading remote file: '${event.ref}'...`,
188
- );
189
- break;
190
- case "remote-uploaded":
191
- console.log(
192
- picocolors.green("✔"),
193
- `Completed upload of remote file: '${event.ref}'`,
194
- );
195
- break;
196
- case "remote-already-uploaded":
197
- console.log(
198
- picocolors.yellow("⚠"),
199
- `Remote file ${event.filePath} already uploaded`,
200
- );
201
- break;
202
- case "remote-downloading":
203
- console.log(
204
- picocolors.yellow("⚠"),
205
- `Downloading remote file in ${event.sourcePath}...`,
206
- );
207
- break;
208
- case "summary-errors":
209
- case "summary-success":
210
- break;
58
+ await watchAndValidate(projectRoot, runOnce);
59
+ }
60
+
61
+ /**
62
+ * One validation pass over the project, printed as `val validate` prints it,
63
+ * returning the number of errors found.
64
+ *
65
+ * Re-reads config and val files on each call so it always reflects the latest
66
+ * state on disk: `val validate` calls it once or on every change (`--watch`),
67
+ * and `val publish` calls it before it builds anything.
68
+ */
69
+ export async function validateOnce({
70
+ projectRoot,
71
+ fix,
72
+ prettier,
73
+ blockingOnly = false,
74
+ }: {
75
+ projectRoot: string;
76
+ fix: boolean;
77
+ /** Formats the files a fix rewrote. Absent: they are left as written. */
78
+ prettier?: typeof import("prettier");
79
+ /**
80
+ * Count only the errors that would hold a publish back in the Studio, not
81
+ * the ones it leaves to the server to repair on save (`isRepairedOnSave`):
82
+ * what `val publish` refuses on. Every error is still printed.
83
+ */
84
+ blockingOnly?: boolean;
85
+ }): Promise<number> {
86
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
87
+
88
+ const resolvedValConfigFile = valConfigFile
89
+ ? {
90
+ ...valConfigFile,
91
+ project: process.env.VAL_PROJECT || valConfigFile.project,
211
92
  }
93
+ : process.env.VAL_PROJECT
94
+ ? { project: process.env.VAL_PROJECT }
95
+ : undefined;
96
+
97
+ console.log(
98
+ picocolors.greenBright(
99
+ `Validating project${resolvedValConfigFile?.project ? ` '${picocolors.inverse(resolvedValConfigFile.project)}'` : ""}...`,
100
+ ),
101
+ );
102
+
103
+ const valFiles: string[] = await glob("**/*.val.{js,ts}", {
104
+ ignore: ["node_modules/**"],
105
+ cwd: projectRoot,
106
+ });
107
+
108
+ console.log(picocolors.greenBright(`Found ${valFiles.length} files...`));
109
+
110
+ const fixedFiles = new Set<string>();
111
+ let totalErrors = 0;
112
+ let repairedOnSave = 0;
113
+
114
+ // Caches each val file's parsed source so files are read/parsed at most once
115
+ // per pass when resolving sourcePaths to file locations and code frames.
116
+ const sourceFileCache: SourceFileCache = new Map();
117
+
118
+ // Diagnostics are buffered per module (keyed by relative file path) so we can
119
+ // render them grouped and prioritised after the run, rather than streaming
120
+ // them out interleaved. Transient progress (remote/fix-applied) still streams
121
+ // live below.
122
+ const reports = new Map<string, ModuleReport>();
123
+ const valid: { file: string; durationMs: number }[] = [];
124
+ const skipped: string[] = [];
125
+
126
+ // Relative file path (no leading slash), matching the code frame's
127
+ // relativeFile so headers, diagnostics and frames all agree.
128
+ const relFile = (file: string) => file.replace(/^\//, "");
129
+ // The module a sourcePath/file belongs to is the part before the `?p=...`.
130
+ const moduleOf = (sourcePathOrFile: string) =>
131
+ relFile(sourcePathOrFile.split("?")[0]);
132
+
133
+ const reportFor = (file: string): ModuleReport => {
134
+ const key = relFile(file);
135
+ let report = reports.get(key);
136
+ if (!report) {
137
+ report = { file: key, durationMs: 0, diagnostics: [] };
138
+ reports.set(key, report);
139
+ }
140
+ return report;
141
+ };
142
+
143
+ for await (const event of runValidation({
144
+ root: projectRoot,
145
+ fix,
146
+ valFiles,
147
+ project: resolvedValConfigFile?.project,
148
+ remote: {
149
+ remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
150
+ getSettings: (projectName, options) => getSettings(projectName, options),
151
+ uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) =>
152
+ uploadRemoteFile(
153
+ process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST,
154
+ project,
155
+ bucket,
156
+ fileHash,
157
+ fileExt ?? "",
158
+ fileBuffer,
159
+ options,
160
+ ),
161
+ },
162
+ fs: createDefaultValFSHost(),
163
+ })) {
164
+ switch (event.type) {
165
+ case "file-valid":
166
+ valid.push({
167
+ file: relFile(event.file),
168
+ durationMs: event.durationMs,
169
+ });
170
+ break;
171
+ case "file-error-count":
172
+ reportFor(event.file).durationMs = event.durationMs;
173
+ totalErrors += event.errorCount;
174
+ break;
175
+ case "validation-error":
176
+ if (event.repairedOnSave) repairedOnSave += 1;
177
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
178
+ severity: "error",
179
+ sourcePath: event.sourcePath,
180
+ message: event.message,
181
+ ...(event.keyError ? { keyError: true } : {}),
182
+ });
183
+ break;
184
+ case "validation-fixable-error":
185
+ if (event.repairedOnSave) repairedOnSave += 1;
186
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
187
+ severity: event.fixable ? "fixable" : "error",
188
+ sourcePath: event.sourcePath,
189
+ message: event.message,
190
+ ...(event.keyError ? { keyError: true } : {}),
191
+ });
192
+ break;
193
+ case "unknown-fix":
194
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
195
+ severity: "error",
196
+ sourcePath: event.sourcePath,
197
+ message: `Unknown fix: ${event.fixes.join(", ")}`,
198
+ ...(event.keyError ? { keyError: true } : {}),
199
+ });
200
+ break;
201
+ case "unregistered-module":
202
+ skipped.push(event.file);
203
+ break;
204
+ case "fatal-error":
205
+ // No sourcePath for fatal errors; group by file, render message only.
206
+ reportFor(event.file).diagnostics.push({
207
+ severity: "error",
208
+ sourcePath: event.file,
209
+ message: event.message,
210
+ });
211
+ break;
212
+ case "fix-applied":
213
+ console.log(
214
+ picocolors.yellow("⚠"),
215
+ "Applied fix for",
216
+ event.sourcePath,
217
+ );
218
+ fixedFiles.add(event.file);
219
+ break;
220
+ case "remote-uploading":
221
+ console.log(
222
+ picocolors.yellow("⚠"),
223
+ `Uploading remote file: '${event.ref}'...`,
224
+ );
225
+ break;
226
+ case "remote-uploaded":
227
+ console.log(
228
+ picocolors.green("✔"),
229
+ `Completed upload of remote file: '${event.ref}'`,
230
+ );
231
+ break;
232
+ case "remote-already-uploaded":
233
+ console.log(
234
+ picocolors.yellow("⚠"),
235
+ `Remote file ${event.filePath} already uploaded`,
236
+ );
237
+ break;
238
+ case "remote-downloading":
239
+ console.log(
240
+ picocolors.yellow("⚠"),
241
+ `Downloading remote file in ${event.sourcePath}...`,
242
+ );
243
+ break;
244
+ case "summary-errors":
245
+ case "summary-success":
246
+ break;
212
247
  }
248
+ }
213
249
 
214
- // Renders a module's diagnostics under a single left "│" gutter bar, with the
215
- // file name on top and blank gutter lines for air. Output flows least- to
216
- // most-actionable top-to-bottom, so fixable diagnostics are shown last (at the
217
- // bottom, nearest the prompt).
218
- const renderModule = (report: ModuleReport) => {
219
- const bar = picocolors.dim("│");
220
- const diagnostics = [...report.diagnostics].sort((a, b) =>
221
- a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1,
250
+ // Renders a module's diagnostics under a single left "│" gutter bar, with the
251
+ // 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).
254
+ const renderModule = (report: ModuleReport) => {
255
+ const bar = picocolors.dim("│");
256
+ const diagnostics = [...report.diagnostics].sort((a, b) =>
257
+ a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1,
258
+ );
259
+ const fixableCount = diagnostics.filter(
260
+ (d) => d.severity === "fixable",
261
+ ).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" : ""}`;
272
+ }
273
+ console.log(
274
+ `${picocolors.bold(report.file)} ${symbol} ${label} ${picocolors.dim(
275
+ `(${report.durationMs}ms)`,
276
+ )}`,
277
+ );
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(
283
+ d.sourcePath,
284
+ projectRoot,
285
+ sourceFileCache,
286
+ target,
222
287
  );
223
- const fixableCount = diagnostics.filter(
224
- (d) => d.severity === "fixable",
225
- ).length;
226
- const total = diagnostics.length;
227
- const hasError = fixableCount < total;
228
- const symbol = hasError ? picocolors.red("✘") : picocolors.yellow("⚠");
229
- let label: string;
230
- if (fixableCount === total) {
231
- label = `${fixableCount} fixable`;
232
- } else if (fixableCount > 0) {
233
- label = `${total} error${total > 1 ? "s" : ""} (${fixableCount} fixable)`;
288
+ console.log(bar);
289
+ if (parts) {
290
+ // `file:line:col` (no key/value label) so VS Code's terminal links it.
291
+ console.log(
292
+ `${bar} ${dsym} ${parts.relativeFile}:${parts.line}:${parts.character}`,
293
+ );
294
+ console.log(`${bar} ${d.message}`);
234
295
  } else {
235
- label = `${total} error${total > 1 ? "s" : ""}`;
296
+ console.log(`${bar} ${dsym} ${d.message}`);
236
297
  }
237
- console.log(
238
- `${picocolors.bold(report.file)} ${symbol} ${label} ${picocolors.dim(
239
- `(${report.durationMs}ms)`,
240
- )}`,
298
+ const frame = sourcePathToCodeFrame(
299
+ d.sourcePath,
300
+ projectRoot,
301
+ sourceFileCache,
302
+ target,
241
303
  );
242
- for (const d of diagnostics) {
243
- const target = d.keyError ? "key" : "value";
244
- const dsym =
245
- d.severity === "fixable"
246
- ? picocolors.yellow("⚠")
247
- : picocolors.red("✘");
248
- const parts = sourcePathToLocationParts(
249
- d.sourcePath,
250
- projectRoot,
251
- sourceFileCache,
252
- target,
253
- );
304
+ if (frame !== undefined) {
254
305
  console.log(bar);
255
- if (parts) {
256
- // `file:line:col` (no key/value label) so VS Code's terminal links it.
257
- console.log(
258
- `${bar} ${dsym} ${parts.relativeFile}:${parts.line}:${parts.character}`,
259
- );
260
- console.log(`${bar} ${d.message}`);
261
- } else {
262
- console.log(`${bar} ${dsym} ${d.message}`);
263
- }
264
- const frame = sourcePathToCodeFrame(
265
- d.sourcePath,
266
- projectRoot,
267
- sourceFileCache,
268
- target,
269
- );
270
- if (frame !== undefined) {
271
- console.log(bar);
272
- for (const frameLine of frame.split("\n")) {
273
- console.log(`${bar} ${frameLine}`);
274
- }
275
- }
276
- if (d.severity === "fixable") {
277
- console.log(
278
- `${bar} ${picocolors.dim("→ run with --fix to apply")}`,
279
- );
306
+ for (const frameLine of frame.split("\n")) {
307
+ console.log(`${bar} ${frameLine}`);
280
308
  }
281
309
  }
282
- console.log(bar);
283
- console.log("");
284
- };
285
-
286
- // Format the files that had fixes applied, the way the project formats
287
- // everything else. See `formatFixedFiles` for why this cannot simply be
288
- // `prettier.format(code, { filepath })`.
289
- //
290
- // A file that could not be formatted is reported and skipped rather than
291
- // thrown: the fix itself landed, and aborting here would leave the fix
292
- // applied and the report below unwritten.
293
- if (prettier) {
294
- const { failures } = await formatFixedFiles(
295
- prettier,
296
- projectRoot,
297
- fixedFiles,
298
- );
299
- for (const failure of failures) {
310
+ if (d.severity === "fixable") {
300
311
  console.log(
301
- picocolors.yellow("⚠"),
302
- `Could not format ${relFile(failure.file)}: ${failure.message}`,
312
+ `${bar} ${picocolors.dim("→ run with --fix to apply")}`,
303
313
  );
304
314
  }
305
315
  }
316
+ console.log(bar);
317
+ console.log("");
318
+ };
306
319
 
307
- // Render the grouped report least- to most-actionable, top-to-bottom, so the
308
- // most important things end up at the bottom nearest the prompt: valid files
309
- // and skipped modules first, then error-only modules, then fixable modules.
310
- const allReports = [...reports.values()];
311
- const fixableModules = allReports.filter((r) =>
312
- r.diagnostics.some((d) => d.severity === "fixable"),
313
- );
314
- const errorModules = allReports.filter(
315
- (r) => !r.diagnostics.some((d) => d.severity === "fixable"),
320
+ // Format the files that had fixes applied, the way the project formats
321
+ // everything else. See `formatFixedFiles` for why this cannot simply be
322
+ // `prettier.format(code, { filepath })`.
323
+ //
324
+ // A file that could not be formatted is reported and skipped rather than
325
+ // thrown: the fix itself landed, and aborting here would leave the fix
326
+ // applied and the report below unwritten.
327
+ if (prettier) {
328
+ const { failures } = await formatFixedFiles(
329
+ prettier,
330
+ projectRoot,
331
+ fixedFiles,
316
332
  );
317
-
318
- for (const v of valid) {
319
- console.log(
320
- picocolors.green("✔"),
321
- picocolors.dim(`${v.file} valid (${v.durationMs}ms)`),
322
- );
323
- }
324
- for (const file of skipped) {
333
+ for (const failure of failures) {
325
334
  console.log(
326
335
  picocolors.yellow("⚠"),
327
- picocolors.dim(`/${file} is not registered in val.modules - skipping`),
336
+ `Could not format ${relFile(failure.file)}: ${failure.message}`,
328
337
  );
329
338
  }
339
+ }
330
340
 
331
- if (allReports.length > 0) {
332
- console.log("");
333
- }
334
- for (const report of [...errorModules, ...fixableModules]) {
335
- renderModule(report);
336
- }
341
+ // Render the grouped report least- to most-actionable, top-to-bottom, so the
342
+ // 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.
344
+ const allReports = [...reports.values()];
345
+ const fixableModules = allReports.filter((r) =>
346
+ r.diagnostics.some((d) => d.severity === "fixable"),
347
+ );
348
+ const errorModules = allReports.filter(
349
+ (r) => !r.diagnostics.some((d) => d.severity === "fixable"),
350
+ );
337
351
 
338
- const fixableTotal = allReports.reduce(
339
- (n, r) =>
340
- n + r.diagnostics.filter((d) => d.severity === "fixable").length,
341
- 0,
352
+ for (const v of valid) {
353
+ console.log(
354
+ picocolors.green("✔"),
355
+ picocolors.dim(`${v.file} valid (${v.durationMs}ms)`),
356
+ );
357
+ }
358
+ for (const file of skipped) {
359
+ console.log(
360
+ picocolors.yellow("⚠"),
361
+ picocolors.dim(`/${file} is not registered in val.modules - skipping`),
342
362
  );
343
- if (totalErrors > 0) {
344
- let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
345
- if (fixableTotal > 0) {
346
- summary += ` (${fixableTotal} fixable)`;
347
- }
348
- summary += ` across ${allReports.length} file${
349
- allReports.length > 1 ? "s" : ""
350
- }`;
351
- if (valid.length > 0) {
352
- summary += ` · ${valid.length} valid`;
353
- }
354
- if (skipped.length > 0) {
355
- summary += ` · ${skipped.length} skipped`;
356
- }
357
- console.log(picocolors.red("✘"), summary);
358
- } else {
359
- let summary = "No validation errors found";
360
- if (valid.length > 0) {
361
- summary += ` · ${valid.length} valid`;
362
- }
363
- if (skipped.length > 0) {
364
- summary += ` · ${skipped.length} skipped`;
365
- }
366
- console.log(picocolors.green("✔"), summary);
367
- }
368
- return totalErrors;
369
363
  }
370
364
 
371
- if (!watch) {
372
- const totalErrors = await runOnce();
373
- if (totalErrors > 0) {
374
- process.exit(1);
375
- }
376
- return;
365
+ if (allReports.length > 0) {
366
+ console.log("");
367
+ }
368
+ for (const report of [...errorModules, ...fixableModules]) {
369
+ renderModule(report);
377
370
  }
378
371
 
379
- await watchAndValidate(projectRoot, runOnce);
372
+ const fixableTotal = allReports.reduce(
373
+ (n, r) => n + r.diagnostics.filter((d) => d.severity === "fixable").length,
374
+ 0,
375
+ );
376
+ if (totalErrors > 0) {
377
+ let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
378
+ if (fixableTotal > 0) {
379
+ summary += ` (${fixableTotal} fixable)`;
380
+ }
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);
391
+ } 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);
400
+ }
401
+ return blockingOnly ? totalErrors - repairedOnSave : totalErrors;
380
402
  }
381
403
 
382
404
  // Directory names anywhere in the path that should never be watched.