@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.
@@ -4,9 +4,9 @@ import path from 'path';
4
4
  import pc from 'picocolors';
5
5
  import { glob } from 'fast-glob';
6
6
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
7
- import { createPrettierFormatter, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, filesOfVideo, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError, APP_MODE_REQUIRES_REMOTE_FILES } from '@valbuild/server';
7
+ import { createPrettierFormatter, askOncePerHost, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, filesOfVideo, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError, APP_MODE_REQUIRES_REMOTE_FILES } from '@valbuild/server';
8
8
  import fs from 'fs/promises';
9
- import { resolveSchemaSourceFixes, traverseSchemaSource, parseBuildTarget, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
9
+ import { resolveSchemaSourceFixes, isRepairedOnSave, traverseSchemaSource, parseBuildTarget, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
10
10
  import fs$1 from 'fs';
11
11
  import ts from 'typescript';
12
12
  import { spawn } from 'child_process';
@@ -96,6 +96,10 @@ async function* runValidation({
96
96
  fs
97
97
  }) {
98
98
  const projectRoot = path.resolve(root);
99
+ // One question per platform host for the whole run: each error is fixed by
100
+ // its own createFixPatch call, and a host that does not answer would
101
+ // otherwise cost a timeout per ref.
102
+ const askHostProject = askOncePerHost();
99
103
  const service = await createService(projectRoot, fs);
100
104
 
101
105
  // Modules registered in the project's val.modules. Files found on disk that
@@ -292,6 +296,9 @@ async function* runValidation({
292
296
  message: result.errorMessage ?? "Unknown error",
293
297
  ...(v.keyError ? {
294
298
  keyError: true
299
+ } : {}),
300
+ ...(isRepairedOnSave(v) ? {
301
+ repairedOnSave: true
295
302
  } : {})
296
303
  };
297
304
  fileErrors += 1;
@@ -313,6 +320,9 @@ async function* runValidation({
313
320
  fixable: true,
314
321
  ...(v.keyError ? {
315
322
  keyError: true
323
+ } : {}),
324
+ ...(isRepairedOnSave(v) ? {
325
+ repairedOnSave: true
316
326
  } : {})
317
327
  };
318
328
  }
@@ -321,7 +331,8 @@ async function* runValidation({
321
331
  if (result.shouldApplyPatch) {
322
332
  const fixPatch = await createFixPatch({
323
333
  projectRoot,
324
- remoteHost: remote.remoteHost
334
+ remoteHost: remote.remoteHost,
335
+ askHostProject
325
336
  }, !!fix, sourcePath, v, remoteFiles, valModule.source, valModule.schema);
326
337
  if (fix && fixPatch !== null && fixPatch !== void 0 && fixPatch.patch && (fixPatch === null || fixPatch === void 0 ? void 0 : fixPatch.patch.length) > 0) {
327
338
  await service.patch(moduleFilePath, fixPatch.patch);
@@ -351,6 +362,9 @@ async function* runValidation({
351
362
  fixable: true,
352
363
  ...(v.keyError ? {
353
364
  keyError: true
365
+ } : {}),
366
+ ...(isRepairedOnSave(v) ? {
367
+ repairedOnSave: true
354
368
  } : {})
355
369
  };
356
370
  }
@@ -365,6 +379,9 @@ async function* runValidation({
365
379
  fixable: !!(e.fixes && e.fixes.length),
366
380
  ...(e.keyError ? {
367
381
  keyError: true
382
+ } : {}),
383
+ ...(isRepairedOnSave(e) ? {
384
+ repairedOnSave: true
368
385
  } : {})
369
386
  };
370
387
  }
@@ -643,255 +660,273 @@ async function validate({
643
660
  } catch {
644
661
  console.log("Prettier not found, skipping formatting");
645
662
  }
663
+ const runOnce = () => validateOnce({
664
+ projectRoot,
665
+ fix: !!fix,
666
+ prettier
667
+ });
668
+ if (!watch) {
669
+ const totalErrors = await runOnce();
670
+ if (totalErrors > 0) {
671
+ process.exit(1);
672
+ }
673
+ return;
674
+ }
675
+ await watchAndValidate(projectRoot, runOnce);
676
+ }
646
677
 
647
- // Runs a single validation pass over the project and returns the number of
648
- // errors found. Re-reads config and val files each call so it always reflects
649
- // the latest state on disk (used both for one-shot and watch mode).
650
- async function runOnce() {
651
- const valConfigFile = await findAndEvalValConfigFile(projectRoot);
652
- const resolvedValConfigFile = valConfigFile ? {
653
- ...valConfigFile,
654
- project: process.env.VAL_PROJECT || valConfigFile.project
655
- } : process.env.VAL_PROJECT ? {
656
- project: process.env.VAL_PROJECT
657
- } : undefined;
658
- console.log(pc.greenBright(`Validating project${resolvedValConfigFile !== null && resolvedValConfigFile !== void 0 && resolvedValConfigFile.project ? ` '${pc.inverse(resolvedValConfigFile.project)}'` : ""}...`));
659
- const valFiles = await glob("**/*.val.{js,ts}", {
660
- ignore: ["node_modules/**"],
661
- cwd: projectRoot
662
- });
663
- console.log(pc.greenBright(`Found ${valFiles.length} files...`));
664
- const fixedFiles = new Set();
665
- let totalErrors = 0;
678
+ /**
679
+ * One validation pass over the project, printed as `val validate` prints it,
680
+ * returning the number of errors found.
681
+ *
682
+ * Re-reads config and val files on each call so it always reflects the latest
683
+ * state on disk: `val validate` calls it once or on every change (`--watch`),
684
+ * and `val publish` calls it before it builds anything.
685
+ */
686
+ async function validateOnce({
687
+ projectRoot,
688
+ fix,
689
+ prettier,
690
+ blockingOnly = false
691
+ }) {
692
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
693
+ const resolvedValConfigFile = valConfigFile ? {
694
+ ...valConfigFile,
695
+ project: process.env.VAL_PROJECT || valConfigFile.project
696
+ } : process.env.VAL_PROJECT ? {
697
+ project: process.env.VAL_PROJECT
698
+ } : undefined;
699
+ console.log(pc.greenBright(`Validating project${resolvedValConfigFile !== null && resolvedValConfigFile !== void 0 && resolvedValConfigFile.project ? ` '${pc.inverse(resolvedValConfigFile.project)}'` : ""}...`));
700
+ const valFiles = await glob("**/*.val.{js,ts}", {
701
+ ignore: ["node_modules/**"],
702
+ cwd: projectRoot
703
+ });
704
+ console.log(pc.greenBright(`Found ${valFiles.length} files...`));
705
+ const fixedFiles = new Set();
706
+ let totalErrors = 0;
707
+ let repairedOnSave = 0;
666
708
 
667
- // Caches each val file's parsed source so files are read/parsed at most once
668
- // per pass when resolving sourcePaths to file locations and code frames.
669
- const sourceFileCache = new Map();
709
+ // Caches each val file's parsed source so files are read/parsed at most once
710
+ // per pass when resolving sourcePaths to file locations and code frames.
711
+ const sourceFileCache = new Map();
670
712
 
671
- // Diagnostics are buffered per module (keyed by relative file path) so we can
672
- // render them grouped and prioritised after the run, rather than streaming
673
- // them out interleaved. Transient progress (remote/fix-applied) still streams
674
- // live below.
675
- const reports = new Map();
676
- const valid = [];
677
- const skipped = [];
713
+ // Diagnostics are buffered per module (keyed by relative file path) so we can
714
+ // render them grouped and prioritised after the run, rather than streaming
715
+ // them out interleaved. Transient progress (remote/fix-applied) still streams
716
+ // live below.
717
+ const reports = new Map();
718
+ const valid = [];
719
+ const skipped = [];
678
720
 
679
- // Relative file path (no leading slash), matching the code frame's
680
- // relativeFile so headers, diagnostics and frames all agree.
681
- const relFile = file => file.replace(/^\//, "");
682
- // The module a sourcePath/file belongs to is the part before the `?p=...`.
683
- const moduleOf = sourcePathOrFile => relFile(sourcePathOrFile.split("?")[0]);
684
- const reportFor = file => {
685
- const key = relFile(file);
686
- let report = reports.get(key);
687
- if (!report) {
688
- report = {
689
- file: key,
690
- durationMs: 0,
691
- diagnostics: []
692
- };
693
- reports.set(key, report);
694
- }
695
- return report;
696
- };
697
- for await (const event of runValidation({
698
- root: projectRoot,
699
- fix: !!fix,
700
- valFiles,
701
- project: resolvedValConfigFile === null || resolvedValConfigFile === void 0 ? void 0 : resolvedValConfigFile.project,
702
- remote: {
703
- remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
704
- getSettings: (projectName, options) => getSettings(projectName, options),
705
- uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => uploadRemoteFile(process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST, project, bucket, fileHash, fileExt ?? "", fileBuffer, options)
706
- },
707
- fs: createDefaultValFSHost()
708
- })) {
709
- switch (event.type) {
710
- case "file-valid":
711
- valid.push({
712
- file: relFile(event.file),
713
- durationMs: event.durationMs
714
- });
715
- break;
716
- case "file-error-count":
717
- reportFor(event.file).durationMs = event.durationMs;
718
- totalErrors += event.errorCount;
719
- break;
720
- case "validation-error":
721
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
722
- severity: "error",
723
- sourcePath: event.sourcePath,
724
- message: event.message,
725
- ...(event.keyError ? {
726
- keyError: true
727
- } : {})
728
- });
729
- break;
730
- case "validation-fixable-error":
731
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
732
- severity: event.fixable ? "fixable" : "error",
733
- sourcePath: event.sourcePath,
734
- message: event.message,
735
- ...(event.keyError ? {
736
- keyError: true
737
- } : {})
738
- });
739
- break;
740
- case "unknown-fix":
741
- reportFor(moduleOf(event.sourcePath)).diagnostics.push({
742
- severity: "error",
743
- sourcePath: event.sourcePath,
744
- message: `Unknown fix: ${event.fixes.join(", ")}`,
745
- ...(event.keyError ? {
746
- keyError: true
747
- } : {})
748
- });
749
- break;
750
- case "unregistered-module":
751
- skipped.push(event.file);
752
- break;
753
- case "fatal-error":
754
- // No sourcePath for fatal errors; group by file, render message only.
755
- reportFor(event.file).diagnostics.push({
756
- severity: "error",
757
- sourcePath: event.file,
758
- message: event.message
759
- });
760
- break;
761
- case "fix-applied":
762
- console.log(pc.yellow("⚠"), "Applied fix for", event.sourcePath);
763
- fixedFiles.add(event.file);
764
- break;
765
- case "remote-uploading":
766
- console.log(pc.yellow("⚠"), `Uploading remote file: '${event.ref}'...`);
767
- break;
768
- case "remote-uploaded":
769
- console.log(pc.green("✔"), `Completed upload of remote file: '${event.ref}'`);
770
- break;
771
- case "remote-already-uploaded":
772
- console.log(pc.yellow("⚠"), `Remote file ${event.filePath} already uploaded`);
773
- break;
774
- case "remote-downloading":
775
- console.log(pc.yellow("⚠"), `Downloading remote file in ${event.sourcePath}...`);
776
- break;
777
- }
721
+ // Relative file path (no leading slash), matching the code frame's
722
+ // relativeFile so headers, diagnostics and frames all agree.
723
+ const relFile = file => file.replace(/^\//, "");
724
+ // The module a sourcePath/file belongs to is the part before the `?p=...`.
725
+ const moduleOf = sourcePathOrFile => relFile(sourcePathOrFile.split("?")[0]);
726
+ const reportFor = file => {
727
+ const key = relFile(file);
728
+ let report = reports.get(key);
729
+ if (!report) {
730
+ report = {
731
+ file: key,
732
+ durationMs: 0,
733
+ diagnostics: []
734
+ };
735
+ reports.set(key, report);
778
736
  }
737
+ return report;
738
+ };
739
+ for await (const event of runValidation({
740
+ root: projectRoot,
741
+ fix,
742
+ valFiles,
743
+ project: resolvedValConfigFile === null || resolvedValConfigFile === void 0 ? void 0 : resolvedValConfigFile.project,
744
+ remote: {
745
+ remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
746
+ getSettings: (projectName, options) => getSettings(projectName, options),
747
+ uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => uploadRemoteFile(process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST, project, bucket, fileHash, fileExt ?? "", fileBuffer, options)
748
+ },
749
+ fs: createDefaultValFSHost()
750
+ })) {
751
+ switch (event.type) {
752
+ case "file-valid":
753
+ valid.push({
754
+ file: relFile(event.file),
755
+ durationMs: event.durationMs
756
+ });
757
+ break;
758
+ case "file-error-count":
759
+ reportFor(event.file).durationMs = event.durationMs;
760
+ totalErrors += event.errorCount;
761
+ break;
762
+ case "validation-error":
763
+ if (event.repairedOnSave) repairedOnSave += 1;
764
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
765
+ severity: "error",
766
+ sourcePath: event.sourcePath,
767
+ message: event.message,
768
+ ...(event.keyError ? {
769
+ keyError: true
770
+ } : {})
771
+ });
772
+ break;
773
+ case "validation-fixable-error":
774
+ if (event.repairedOnSave) repairedOnSave += 1;
775
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
776
+ severity: event.fixable ? "fixable" : "error",
777
+ sourcePath: event.sourcePath,
778
+ message: event.message,
779
+ ...(event.keyError ? {
780
+ keyError: true
781
+ } : {})
782
+ });
783
+ break;
784
+ case "unknown-fix":
785
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
786
+ severity: "error",
787
+ sourcePath: event.sourcePath,
788
+ message: `Unknown fix: ${event.fixes.join(", ")}`,
789
+ ...(event.keyError ? {
790
+ keyError: true
791
+ } : {})
792
+ });
793
+ break;
794
+ case "unregistered-module":
795
+ skipped.push(event.file);
796
+ break;
797
+ case "fatal-error":
798
+ // No sourcePath for fatal errors; group by file, render message only.
799
+ reportFor(event.file).diagnostics.push({
800
+ severity: "error",
801
+ sourcePath: event.file,
802
+ message: event.message
803
+ });
804
+ break;
805
+ case "fix-applied":
806
+ console.log(pc.yellow("⚠"), "Applied fix for", event.sourcePath);
807
+ fixedFiles.add(event.file);
808
+ break;
809
+ case "remote-uploading":
810
+ console.log(pc.yellow("⚠"), `Uploading remote file: '${event.ref}'...`);
811
+ break;
812
+ case "remote-uploaded":
813
+ console.log(pc.green("✔"), `Completed upload of remote file: '${event.ref}'`);
814
+ break;
815
+ case "remote-already-uploaded":
816
+ console.log(pc.yellow("⚠"), `Remote file ${event.filePath} already uploaded`);
817
+ break;
818
+ case "remote-downloading":
819
+ console.log(pc.yellow("⚠"), `Downloading remote file in ${event.sourcePath}...`);
820
+ break;
821
+ }
822
+ }
779
823
 
780
- // Renders a module's diagnostics under a single left "│" gutter bar, with the
781
- // file name on top and blank gutter lines for air. Output flows least- to
782
- // most-actionable top-to-bottom, so fixable diagnostics are shown last (at the
783
- // bottom, nearest the prompt).
784
- const renderModule = report => {
785
- const bar = pc.dim("│");
786
- const diagnostics = [...report.diagnostics].sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1);
787
- const fixableCount = diagnostics.filter(d => d.severity === "fixable").length;
788
- const total = diagnostics.length;
789
- const hasError = fixableCount < total;
790
- const symbol = hasError ? pc.red("✘") : pc.yellow("⚠");
791
- let label;
792
- if (fixableCount === total) {
793
- label = `${fixableCount} fixable`;
794
- } else if (fixableCount > 0) {
795
- label = `${total} error${total > 1 ? "s" : ""} (${fixableCount} fixable)`;
824
+ // Renders a module's diagnostics under a single left "│" gutter bar, with the
825
+ // file name on top and blank gutter lines for air. Output flows least- to
826
+ // most-actionable top-to-bottom, so fixable diagnostics are shown last (at the
827
+ // bottom, nearest the prompt).
828
+ const renderModule = report => {
829
+ const bar = pc.dim("│");
830
+ const diagnostics = [...report.diagnostics].sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1);
831
+ const fixableCount = diagnostics.filter(d => d.severity === "fixable").length;
832
+ const total = diagnostics.length;
833
+ const hasError = fixableCount < total;
834
+ const symbol = hasError ? pc.red("✘") : pc.yellow("⚠");
835
+ let label;
836
+ if (fixableCount === total) {
837
+ label = `${fixableCount} fixable`;
838
+ } else if (fixableCount > 0) {
839
+ label = `${total} error${total > 1 ? "s" : ""} (${fixableCount} fixable)`;
840
+ } else {
841
+ label = `${total} error${total > 1 ? "s" : ""}`;
842
+ }
843
+ console.log(`${pc.bold(report.file)} ${symbol} ${label} ${pc.dim(`(${report.durationMs}ms)`)}`);
844
+ for (const d of diagnostics) {
845
+ const target = d.keyError ? "key" : "value";
846
+ const dsym = d.severity === "fixable" ? pc.yellow("⚠") : pc.red("✘");
847
+ const parts = sourcePathToLocationParts(d.sourcePath, projectRoot, sourceFileCache, target);
848
+ console.log(bar);
849
+ if (parts) {
850
+ // `file:line:col` (no key/value label) so VS Code's terminal links it.
851
+ console.log(`${bar} ${dsym} ${parts.relativeFile}:${parts.line}:${parts.character}`);
852
+ console.log(`${bar} ${d.message}`);
796
853
  } else {
797
- label = `${total} error${total > 1 ? "s" : ""}`;
854
+ console.log(`${bar} ${dsym} ${d.message}`);
798
855
  }
799
- console.log(`${pc.bold(report.file)} ${symbol} ${label} ${pc.dim(`(${report.durationMs}ms)`)}`);
800
- for (const d of diagnostics) {
801
- const target = d.keyError ? "key" : "value";
802
- const dsym = d.severity === "fixable" ? pc.yellow("⚠") : pc.red("✘");
803
- const parts = sourcePathToLocationParts(d.sourcePath, projectRoot, sourceFileCache, target);
856
+ const frame = sourcePathToCodeFrame(d.sourcePath, projectRoot, sourceFileCache, target);
857
+ if (frame !== undefined) {
804
858
  console.log(bar);
805
- if (parts) {
806
- // `file:line:col` (no key/value label) so VS Code's terminal links it.
807
- console.log(`${bar} ${dsym} ${parts.relativeFile}:${parts.line}:${parts.character}`);
808
- console.log(`${bar} ${d.message}`);
809
- } else {
810
- console.log(`${bar} ${dsym} ${d.message}`);
811
- }
812
- const frame = sourcePathToCodeFrame(d.sourcePath, projectRoot, sourceFileCache, target);
813
- if (frame !== undefined) {
814
- console.log(bar);
815
- for (const frameLine of frame.split("\n")) {
816
- console.log(`${bar} ${frameLine}`);
817
- }
818
- }
819
- if (d.severity === "fixable") {
820
- console.log(`${bar} ${pc.dim("→ run with --fix to apply")}`);
859
+ for (const frameLine of frame.split("\n")) {
860
+ console.log(`${bar} ${frameLine}`);
821
861
  }
822
862
  }
823
- console.log(bar);
824
- console.log("");
825
- };
826
-
827
- // Format the files that had fixes applied, the way the project formats
828
- // everything else. See `formatFixedFiles` for why this cannot simply be
829
- // `prettier.format(code, { filepath })`.
830
- //
831
- // A file that could not be formatted is reported and skipped rather than
832
- // thrown: the fix itself landed, and aborting here would leave the fix
833
- // applied and the report below unwritten.
834
- if (prettier) {
835
- const {
836
- failures
837
- } = await formatFixedFiles(prettier, projectRoot, fixedFiles);
838
- for (const failure of failures) {
839
- console.log(pc.yellow("⚠"), `Could not format ${relFile(failure.file)}: ${failure.message}`);
863
+ if (d.severity === "fixable") {
864
+ console.log(`${bar} ${pc.dim("→ run with --fix to apply")}`);
840
865
  }
841
866
  }
867
+ console.log(bar);
868
+ console.log("");
869
+ };
842
870
 
843
- // Render the grouped report least- to most-actionable, top-to-bottom, so the
844
- // most important things end up at the bottom nearest the prompt: valid files
845
- // and skipped modules first, then error-only modules, then fixable modules.
846
- const allReports = [...reports.values()];
847
- const fixableModules = allReports.filter(r => r.diagnostics.some(d => d.severity === "fixable"));
848
- const errorModules = allReports.filter(r => !r.diagnostics.some(d => d.severity === "fixable"));
849
- for (const v of valid) {
850
- console.log(pc.green("✔"), pc.dim(`${v.file} valid (${v.durationMs}ms)`));
871
+ // Format the files that had fixes applied, the way the project formats
872
+ // everything else. See `formatFixedFiles` for why this cannot simply be
873
+ // `prettier.format(code, { filepath })`.
874
+ //
875
+ // A file that could not be formatted is reported and skipped rather than
876
+ // thrown: the fix itself landed, and aborting here would leave the fix
877
+ // applied and the report below unwritten.
878
+ if (prettier) {
879
+ const {
880
+ failures
881
+ } = await formatFixedFiles(prettier, projectRoot, fixedFiles);
882
+ for (const failure of failures) {
883
+ console.log(pc.yellow("⚠"), `Could not format ${relFile(failure.file)}: ${failure.message}`);
851
884
  }
852
- for (const file of skipped) {
853
- console.log(pc.yellow("⚠"), pc.dim(`/${file} is not registered in val.modules - skipping`));
885
+ }
886
+
887
+ // Render the grouped report least- to most-actionable, top-to-bottom, so the
888
+ // most important things end up at the bottom nearest the prompt: valid files
889
+ // and skipped modules first, then error-only modules, then fixable modules.
890
+ const allReports = [...reports.values()];
891
+ const fixableModules = allReports.filter(r => r.diagnostics.some(d => d.severity === "fixable"));
892
+ const errorModules = allReports.filter(r => !r.diagnostics.some(d => d.severity === "fixable"));
893
+ for (const v of valid) {
894
+ console.log(pc.green("✔"), pc.dim(`${v.file} valid (${v.durationMs}ms)`));
895
+ }
896
+ for (const file of skipped) {
897
+ console.log(pc.yellow("⚠"), pc.dim(`/${file} is not registered in val.modules - skipping`));
898
+ }
899
+ if (allReports.length > 0) {
900
+ console.log("");
901
+ }
902
+ for (const report of [...errorModules, ...fixableModules]) {
903
+ renderModule(report);
904
+ }
905
+ const fixableTotal = allReports.reduce((n, r) => n + r.diagnostics.filter(d => d.severity === "fixable").length, 0);
906
+ if (totalErrors > 0) {
907
+ let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
908
+ if (fixableTotal > 0) {
909
+ summary += ` (${fixableTotal} fixable)`;
854
910
  }
855
- if (allReports.length > 0) {
856
- console.log("");
911
+ summary += ` across ${allReports.length} file${allReports.length > 1 ? "s" : ""}`;
912
+ if (valid.length > 0) {
913
+ summary += ` · ${valid.length} valid`;
857
914
  }
858
- for (const report of [...errorModules, ...fixableModules]) {
859
- renderModule(report);
915
+ if (skipped.length > 0) {
916
+ summary += ` · ${skipped.length} skipped`;
860
917
  }
861
- const fixableTotal = allReports.reduce((n, r) => n + r.diagnostics.filter(d => d.severity === "fixable").length, 0);
862
- if (totalErrors > 0) {
863
- let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
864
- if (fixableTotal > 0) {
865
- summary += ` (${fixableTotal} fixable)`;
866
- }
867
- summary += ` across ${allReports.length} file${allReports.length > 1 ? "s" : ""}`;
868
- if (valid.length > 0) {
869
- summary += ` · ${valid.length} valid`;
870
- }
871
- if (skipped.length > 0) {
872
- summary += ` · ${skipped.length} skipped`;
873
- }
874
- console.log(pc.red("✘"), summary);
875
- } else {
876
- let summary = "No validation errors found";
877
- if (valid.length > 0) {
878
- summary += ` · ${valid.length} valid`;
879
- }
880
- if (skipped.length > 0) {
881
- summary += ` · ${skipped.length} skipped`;
882
- }
883
- console.log(pc.green("✔"), summary);
918
+ console.log(pc.red("✘"), summary);
919
+ } else {
920
+ let summary = "No validation errors found";
921
+ if (valid.length > 0) {
922
+ summary += ` · ${valid.length} valid`;
884
923
  }
885
- return totalErrors;
886
- }
887
- if (!watch) {
888
- const totalErrors = await runOnce();
889
- if (totalErrors > 0) {
890
- process.exit(1);
924
+ if (skipped.length > 0) {
925
+ summary += ` · ${skipped.length} skipped`;
891
926
  }
892
- return;
927
+ console.log(pc.green("✔"), summary);
893
928
  }
894
- await watchAndValidate(projectRoot, runOnce);
929
+ return blockingOnly ? totalErrors - repairedOnSave : totalErrors;
895
930
  }
896
931
 
897
932
  // Directory names anywhere in the path that should never be watched.
@@ -3887,6 +3922,30 @@ function remoteFilesRefusal(read, {
3887
3922
  * person reads the lines.
3888
3923
  */
3889
3924
  async function publish(options) {
3925
+ // Content that does not validate is not published. This is the check that
3926
+ // lets the Studio trust a connected project's published content without
3927
+ // re-checking every file itself: whatever reached the site went through
3928
+ // here. Without `--fix`, so CI never rewrites what it was asked to publish.
3929
+ //
3930
+ // Refused on what the Studio's Publish refuses on, and no more: an error
3931
+ // the Studio leaves to the server to repair (a local image in a remote
3932
+ // project, metadata to fill in) is printed and published, as it would be
3933
+ // from the Studio. Every site made on /new starts with its template's
3934
+ // images local, so counting those would refuse its first publish.
3935
+ if (!options.skipValidation) {
3936
+ const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
3937
+ const errorCount = await validateOnce({
3938
+ projectRoot,
3939
+ fix: false,
3940
+ blockingOnly: true
3941
+ });
3942
+ if (errorCount > 0) {
3943
+ error(`Not published: ${errorCount} validation error${errorCount === 1 ? "" : "s"}.`);
3944
+ console.error(pc.dim(` Run "val validate --fix" in the project, commit what it changes and push, ` + `and fix by hand what it cannot. --skip-validation publishes anyway.`));
3945
+ process.exitCode = 1;
3946
+ return;
3947
+ }
3948
+ }
3890
3949
  const result = await runPublish({
3891
3950
  ...(options.root ? {
3892
3951
  root: options.root
@@ -4181,6 +4240,8 @@ async function main() {
4181
4240
  --links-own-css The app links its own CSS (--no-links-own-css for the
4182
4241
  opposite; omit it when the build did not say)
4183
4242
  --dry-run Verify, and stop before the site changes
4243
+ --skip-validation Publish without running "val validate" first. It runs by
4244
+ default, and an error stops the publish
4184
4245
 
4185
4246
  Command: list-unused-files
4186
4247
  Description: EXPERIMENTAL.
@@ -4272,6 +4333,9 @@ async function main() {
4272
4333
  dryRun: {
4273
4334
  type: "boolean"
4274
4335
  },
4336
+ skipValidation: {
4337
+ type: "boolean"
4338
+ },
4275
4339
  yes: {
4276
4340
  type: "boolean"
4277
4341
  },
@@ -4343,7 +4407,8 @@ async function main() {
4343
4407
  layerRev: flags.layerRev,
4344
4408
  buildHash: flags.buildHash,
4345
4409
  linksOwnCss: flags.linksOwnCss,
4346
- dryRun: flags.dryRun
4410
+ dryRun: flags.dryRun,
4411
+ skipValidation: flags.skipValidation
4347
4412
  });
4348
4413
  case "login":
4349
4414
  return login({