@valbuild/cli 0.134.0 → 0.135.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/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # @valbuild/cli
2
2
 
3
+ ## 0.135.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [#707](https://github.com/valbuild/val/pull/707) [`c9affec`](https://github.com/valbuild/val/commit/c9affece6ae6ba62bd6c1113ed1fdab6317fa112) Thanks [@freekh](https://github.com/freekh)! - `val validate --fix` now formats with your prettier config instead of prettier's defaults
8
+
9
+ `--fix` formatted the files it repaired by calling `prettier.format(code, { filepath })`, which never reads `.prettierrc`: `filepath` picks the parser and nothing else — only `resolveConfig`, `getFileInfo` and prettier's own CLI consult the config. On a project whose style is not prettier's default, a two-line content fix therefore arrived as a whole-file rewrite, and in a repo with a format check in CI it turned a content fix into a red build.
10
+
11
+ There is now one implementation of "format a written file the way this project does", `createPrettierFormatter`, exported from `@valbuild/server` and re-exported by `@valbuild/next/server` and `@valbuild/tanstack/server`. It resolves `.prettierrc` for each file (including any `overrides` that match it), leaves anything in `.prettierignore` untouched, and falls back to prettier's defaults when the project has no config. `val validate --fix` uses it, so the CLI and the Studio can no longer disagree about formatting.
12
+
13
+ Use it for your app's `formatter` too — this is the recommended setup, and it replaces reading `.prettierrc.json` by hand:
14
+
15
+ ```ts
16
+ import prettier from "prettier";
17
+ import {
18
+ initValServer,
19
+ createPrettierFormatter,
20
+ } from "@valbuild/next/server";
21
+
22
+ const { valNextAppRouter } = initValServer(
23
+ valModules,
24
+ { ...config },
25
+ {
26
+ draftMode,
27
+ formatter: createPrettierFormatter(prettier, {
28
+ projectRoot: process.cwd(),
29
+ }),
30
+ },
31
+ );
32
+ ```
33
+
34
+ Existing `formatter` callbacks keep working unchanged.
35
+
36
+ - Updated dependencies [[`c9affec`](https://github.com/valbuild/val/commit/c9affece6ae6ba62bd6c1113ed1fdab6317fa112), [`72a7ca7`](https://github.com/valbuild/val/commit/72a7ca78354fff00e37a51359b17fba9334dc5e6)]:
37
+ - @valbuild/server@0.135.0
38
+ - @valbuild/language-server@0.135.0
39
+
40
+ ## 0.134.1
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [[`821e789`](https://github.com/valbuild/val/commit/821e789c1af47510af5d23eb96384ff78ddd7646)]:
45
+ - @valbuild/shared@0.134.1
46
+ - @valbuild/language-server@0.134.1
47
+ - @valbuild/server@0.134.1
48
+
3
49
  ## 0.134.0
4
50
 
5
51
  ### Minor Changes
@@ -4,12 +4,12 @@ var meow = require('meow');
4
4
  var chalk = require('chalk');
5
5
  var path = require('path');
6
6
  var pc = require('picocolors');
7
- var fs$1 = require('fs/promises');
8
7
  var fastGlob = require('fast-glob');
9
8
  var core = require('@valbuild/core');
10
9
  var server = require('@valbuild/server');
10
+ var fs = require('fs/promises');
11
11
  var internal = require('@valbuild/shared/internal');
12
- var fs = require('fs');
12
+ var fs$1 = require('fs');
13
13
  var ts = require('typescript');
14
14
  var JSZip = require('jszip');
15
15
  var readline = require('readline');
@@ -42,8 +42,8 @@ var meow__default = /*#__PURE__*/_interopDefault(meow);
42
42
  var chalk__default = /*#__PURE__*/_interopDefault(chalk);
43
43
  var path__default = /*#__PURE__*/_interopDefault(path);
44
44
  var pc__default = /*#__PURE__*/_interopDefault(pc);
45
- var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
46
45
  var fs__default = /*#__PURE__*/_interopDefault(fs);
46
+ var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
47
47
  var ts__default = /*#__PURE__*/_interopDefault(ts);
48
48
  var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
49
49
  var readline__default = /*#__PURE__*/_interopDefault(readline);
@@ -65,6 +65,62 @@ function info(message, opts = {}) {
65
65
  console.log(chalk__default["default"].blue("️ℹ️ : ") + message);
66
66
  }
67
67
 
68
+ /** A file whose fix landed but which could not be formatted afterwards. */
69
+
70
+ /**
71
+ * Format the files `--fix` just wrote, the way the project formats everything.
72
+ *
73
+ * Through `createPrettierFormatter`, which is the same function an app hands to
74
+ * `initValServer` / `initValMcp`, so a fix applied here and an edit saved from
75
+ * the Studio come out identically formatted. Calling
76
+ * `prettier.format(code, { filepath })` directly — which this did — reads no
77
+ * config at all: `filepath` picks the parser, and `.prettierrc` is only ever
78
+ * consulted by `resolveConfig`, `getFileInfo` and prettier's own CLI. A project
79
+ * whose style is not prettier's default got its whole file rewritten by a
80
+ * two-line content fix, and a red `format` job with it.
81
+ *
82
+ * Separate from `validate.ts` so it can be tested against a real project on
83
+ * disk: `runValidation` stops before this step, and `validate()` itself ends in
84
+ * `process.exit`, so neither can reach it from a test.
85
+ *
86
+ * Rendering is the caller's, which is why failures are returned rather than
87
+ * logged. A file that cannot be formatted is not a failed run: the fix itself
88
+ * landed, and stopping here would leave the fix applied and the report unwritten.
89
+ */
90
+ async function formatFixedFiles(prettier, projectRoot, files) {
91
+ const format = server.createPrettierFormatter(prettier, {
92
+ projectRoot
93
+ });
94
+ const written = [];
95
+ const failures = [];
96
+ for (const file of files) {
97
+ const filePath = path__default["default"].join(projectRoot, file);
98
+ const fileContent = await fs__default["default"].readFile(filePath, "utf-8");
99
+ let formattedContent;
100
+ try {
101
+ formattedContent = await format(fileContent, file);
102
+ } catch (err) {
103
+ failures.push({
104
+ file,
105
+ message: err instanceof Error ? err.message : String(err)
106
+ });
107
+ continue;
108
+ }
109
+ // A file prettier left alone — one the project put in `.prettierignore`,
110
+ // or one already in its final form — is not rewritten. Writing identical
111
+ // bytes still bumps the mtime, and a `--fix` run alongside a dev server
112
+ // makes it rebuild for nothing.
113
+ if (formattedContent !== fileContent) {
114
+ await fs__default["default"].writeFile(filePath, formattedContent);
115
+ written.push(file);
116
+ }
117
+ }
118
+ return {
119
+ written,
120
+ failures
121
+ };
122
+ }
123
+
68
124
  async function* runValidation({
69
125
  root,
70
126
  fix,
@@ -560,7 +616,7 @@ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
560
616
  const filePath = path__default["default"].join(projectRoot, jsonFilePath);
561
617
  let fileContent;
562
618
  try {
563
- fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
619
+ fileContent = fs__default$1["default"].readFileSync(filePath, "utf-8");
564
620
  } catch {
565
621
  cache.set(jsonFilePath, null);
566
622
  return null;
@@ -580,7 +636,7 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
580
636
  const filePath = path__default["default"].join(projectRoot, moduleFilePath);
581
637
  let fileContent;
582
638
  try {
583
- fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
639
+ fileContent = fs__default$1["default"].readFileSync(filePath, "utf-8");
584
640
  } catch {
585
641
  cache.set(moduleFilePath, null);
586
642
  return null;
@@ -788,15 +844,19 @@ async function validate({
788
844
  console.log("");
789
845
  };
790
846
 
791
- // Run prettier on files that had fixes applied
847
+ // Format the files that had fixes applied, the way the project formats
848
+ // everything else. See `formatFixedFiles` for why this cannot simply be
849
+ // `prettier.format(code, { filepath })`.
850
+ //
851
+ // A file that could not be formatted is reported and skipped rather than
852
+ // thrown: the fix itself landed, and aborting here would leave the fix
853
+ // applied and the report below unwritten.
792
854
  if (prettier) {
793
- for (const file of fixedFiles) {
794
- const filePath = path__default["default"].join(projectRoot, file);
795
- const fileContent = await fs__default$1["default"].readFile(filePath, "utf-8");
796
- const formattedContent = await prettier.format(fileContent, {
797
- filepath: filePath
798
- });
799
- await fs__default$1["default"].writeFile(filePath, formattedContent);
855
+ const {
856
+ failures
857
+ } = await formatFixedFiles(prettier, projectRoot, fixedFiles);
858
+ for (const failure of failures) {
859
+ console.log(pc__default["default"].yellow("⚠"), `Could not format ${relFile(failure.file)}: ${failure.message}`);
800
860
  }
801
861
  }
802
862
 
@@ -1084,8 +1144,8 @@ function tryGetGitConfig(root) {
1084
1144
  let lastDir = null;
1085
1145
  while (currentDir !== lastDir) {
1086
1146
  const gitConfigPath = path__default["default"].join(currentDir, ".git", "config");
1087
- if (fs__default["default"].existsSync(gitConfigPath)) {
1088
- return fs__default["default"].readFileSync(gitConfigPath, "utf-8");
1147
+ if (fs__default$1["default"].existsSync(gitConfigPath)) {
1148
+ return fs__default$1["default"].readFileSync(gitConfigPath, "utf-8");
1089
1149
  }
1090
1150
  lastDir = currentDir;
1091
1151
  currentDir = path__default["default"].dirname(currentDir);
@@ -1162,7 +1222,7 @@ class DebugContextError extends Error {}
1162
1222
  async function createDebugContext(options) {
1163
1223
  var _config$files;
1164
1224
  const projectRoot = options.root ? path__default["default"].resolve(options.root) : process.cwd();
1165
- if (!fs__default["default"].existsSync(projectRoot)) {
1225
+ if (!fs__default$1["default"].existsSync(projectRoot)) {
1166
1226
  throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
1167
1227
  }
1168
1228
  const config = await server.findAndEvalValConfigFile(projectRoot);
@@ -1235,8 +1295,8 @@ async function createDebugContext(options) {
1235
1295
  */
1236
1296
  function readAuth(projectRoot) {
1237
1297
  const patFile = server.getPersonalAccessTokenPath(projectRoot);
1238
- if (fs__default["default"].existsSync(patFile)) {
1239
- const contents = fs__default["default"].readFileSync(patFile, "utf-8");
1298
+ if (fs__default$1["default"].existsSync(patFile)) {
1299
+ const contents = fs__default$1["default"].readFileSync(patFile, "utf-8");
1240
1300
  const parsed = server.parsePersonalAccessTokenFile(contents);
1241
1301
  if (parsed.success) {
1242
1302
  return {
@@ -1787,7 +1847,7 @@ function readProjectValVersions(projectRoot) {
1787
1847
  function readLocalFile(projectRoot, projectRelativePath) {
1788
1848
  const absPath = path__default["default"].join(projectRoot, projectRelativePath);
1789
1849
  try {
1790
- return fs__default["default"].readFileSync(absPath, "utf-8");
1850
+ return fs__default$1["default"].readFileSync(absPath, "utf-8");
1791
1851
  } catch {
1792
1852
  return null;
1793
1853
  }
@@ -1929,10 +1989,10 @@ async function writeZip(outPath, entries) {
1929
1989
  type: "nodebuffer",
1930
1990
  compression: "DEFLATE"
1931
1991
  });
1932
- fs__default["default"].mkdirSync(path__default["default"].dirname(outPath), {
1992
+ fs__default$1["default"].mkdirSync(path__default["default"].dirname(outPath), {
1933
1993
  recursive: true
1934
1994
  });
1935
- fs__default["default"].writeFileSync(outPath, buffer);
1995
+ fs__default$1["default"].writeFileSync(outPath, buffer);
1936
1996
  }
1937
1997
  function timestamp() {
1938
1998
  return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
@@ -2081,7 +2141,7 @@ async function collectArtifacts(dir) {
2081
2141
  const skipped = [];
2082
2142
  let totalBytes = 0;
2083
2143
  const walk = async current => {
2084
- const entries = await fs__default["default"].promises.readdir(current, {
2144
+ const entries = await fs__default$1["default"].promises.readdir(current, {
2085
2145
  withFileTypes: true
2086
2146
  });
2087
2147
  for (const entry of entries) {
@@ -2095,7 +2155,7 @@ async function collectArtifacts(dir) {
2095
2155
  // A symlinked file is published as its bytes; a symlinked directory is
2096
2156
  // not walked, because one usually comes with a cycle, and a publisher
2097
2157
  // that hangs is worse than one that says what it left out.
2098
- const stat = await fs__default["default"].promises.stat(absolute).catch(() => null);
2158
+ const stat = await fs__default$1["default"].promises.stat(absolute).catch(() => null);
2099
2159
  if (stat === null) {
2100
2160
  skipped.push(`${key} (broken symlink)`);
2101
2161
  continue;
@@ -2136,7 +2196,7 @@ async function hashFile(absolute) {
2136
2196
  let bytes = 0;
2137
2197
  // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
2138
2198
  // it is a needless way to run a CI runner out of memory.
2139
- const stream = fs__default["default"].createReadStream(absolute);
2199
+ const stream = fs__default$1["default"].createReadStream(absolute);
2140
2200
  await new Promise((resolve, reject) => {
2141
2201
  stream.on("data", chunk => {
2142
2202
  hash.update(chunk);
@@ -2186,7 +2246,7 @@ async function layerRevOf(artifacts) {
2186
2246
  layerRev: null
2187
2247
  };
2188
2248
  }
2189
- const bytes = await fs__default["default"].promises.readFile(layer.file);
2249
+ const bytes = await fs__default$1["default"].promises.readFile(layer.file);
2190
2250
  let parsed;
2191
2251
  try {
2192
2252
  parsed = JSON.parse(zlib__default["default"].gunzipSync(bytes).toString("utf-8"));
@@ -2214,7 +2274,7 @@ const DEFAULT_ARTIFACTS_DIR = ".val/publish";
2214
2274
  function resolveArtifactsDir(options) {
2215
2275
  const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
2216
2276
  const dir = path__default["default"].resolve(options.root, named);
2217
- if (!fs__default["default"].existsSync(dir) || !fs__default["default"].statSync(dir).isDirectory()) {
2277
+ if (!fs__default$1["default"].existsSync(dir) || !fs__default$1["default"].statSync(dir).isDirectory()) {
2218
2278
  return {
2219
2279
  status: "error",
2220
2280
  message: `No artifacts to publish: ${dir} is not a directory.\n\n` + "Build the project first, then point at what it produced:\n\n" + " npx val publish --artifacts <directory>\n\n" + "The path of each file under it is its artifact key - `server`, `client`,\n" + "`public/...`, `chunk/client/...`, `layer`."
@@ -2554,7 +2614,7 @@ async function putArtifact(slot, file, fetchImpl) {
2554
2614
  * different size as far as the signature is concerned. An artifact is capped
2555
2615
  * at 128 MB by the API, which is the bound this trades against.
2556
2616
  */
2557
- const body = await fs__default["default"].promises.readFile(file);
2617
+ const body = await fs__default$1["default"].promises.readFile(file);
2558
2618
  let res;
2559
2619
  try {
2560
2620
  res = await fetchImpl(slot.url, {
@@ -2787,12 +2847,12 @@ function looksLikePersonalAccessToken(token) {
2787
2847
  }
2788
2848
  function readPersonalAccessToken(root) {
2789
2849
  const patFile = server.getPersonalAccessTokenPath(root);
2790
- if (!fs__default["default"].existsSync(patFile)) {
2850
+ if (!fs__default$1["default"].existsSync(patFile)) {
2791
2851
  return {
2792
2852
  status: "none"
2793
2853
  };
2794
2854
  }
2795
- const parsed = server.parsePersonalAccessTokenFile(fs__default["default"].readFileSync(patFile, "utf-8"));
2855
+ const parsed = server.parsePersonalAccessTokenFile(fs__default$1["default"].readFileSync(patFile, "utf-8"));
2796
2856
  if (!parsed.success) {
2797
2857
  return {
2798
2858
  status: "error",
@@ -4,12 +4,12 @@ var meow = require('meow');
4
4
  var chalk = require('chalk');
5
5
  var path = require('path');
6
6
  var pc = require('picocolors');
7
- var fs$1 = require('fs/promises');
8
7
  var fastGlob = require('fast-glob');
9
8
  var core = require('@valbuild/core');
10
9
  var server = require('@valbuild/server');
10
+ var fs = require('fs/promises');
11
11
  var internal = require('@valbuild/shared/internal');
12
- var fs = require('fs');
12
+ var fs$1 = require('fs');
13
13
  var ts = require('typescript');
14
14
  var JSZip = require('jszip');
15
15
  var readline = require('readline');
@@ -42,8 +42,8 @@ var meow__default = /*#__PURE__*/_interopDefault(meow);
42
42
  var chalk__default = /*#__PURE__*/_interopDefault(chalk);
43
43
  var path__default = /*#__PURE__*/_interopDefault(path);
44
44
  var pc__default = /*#__PURE__*/_interopDefault(pc);
45
- var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
46
45
  var fs__default = /*#__PURE__*/_interopDefault(fs);
46
+ var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
47
47
  var ts__default = /*#__PURE__*/_interopDefault(ts);
48
48
  var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
49
49
  var readline__default = /*#__PURE__*/_interopDefault(readline);
@@ -65,6 +65,62 @@ function info(message, opts = {}) {
65
65
  console.log(chalk__default["default"].blue("️ℹ️ : ") + message);
66
66
  }
67
67
 
68
+ /** A file whose fix landed but which could not be formatted afterwards. */
69
+
70
+ /**
71
+ * Format the files `--fix` just wrote, the way the project formats everything.
72
+ *
73
+ * Through `createPrettierFormatter`, which is the same function an app hands to
74
+ * `initValServer` / `initValMcp`, so a fix applied here and an edit saved from
75
+ * the Studio come out identically formatted. Calling
76
+ * `prettier.format(code, { filepath })` directly — which this did — reads no
77
+ * config at all: `filepath` picks the parser, and `.prettierrc` is only ever
78
+ * consulted by `resolveConfig`, `getFileInfo` and prettier's own CLI. A project
79
+ * whose style is not prettier's default got its whole file rewritten by a
80
+ * two-line content fix, and a red `format` job with it.
81
+ *
82
+ * Separate from `validate.ts` so it can be tested against a real project on
83
+ * disk: `runValidation` stops before this step, and `validate()` itself ends in
84
+ * `process.exit`, so neither can reach it from a test.
85
+ *
86
+ * Rendering is the caller's, which is why failures are returned rather than
87
+ * logged. A file that cannot be formatted is not a failed run: the fix itself
88
+ * landed, and stopping here would leave the fix applied and the report unwritten.
89
+ */
90
+ async function formatFixedFiles(prettier, projectRoot, files) {
91
+ const format = server.createPrettierFormatter(prettier, {
92
+ projectRoot
93
+ });
94
+ const written = [];
95
+ const failures = [];
96
+ for (const file of files) {
97
+ const filePath = path__default["default"].join(projectRoot, file);
98
+ const fileContent = await fs__default["default"].readFile(filePath, "utf-8");
99
+ let formattedContent;
100
+ try {
101
+ formattedContent = await format(fileContent, file);
102
+ } catch (err) {
103
+ failures.push({
104
+ file,
105
+ message: err instanceof Error ? err.message : String(err)
106
+ });
107
+ continue;
108
+ }
109
+ // A file prettier left alone — one the project put in `.prettierignore`,
110
+ // or one already in its final form — is not rewritten. Writing identical
111
+ // bytes still bumps the mtime, and a `--fix` run alongside a dev server
112
+ // makes it rebuild for nothing.
113
+ if (formattedContent !== fileContent) {
114
+ await fs__default["default"].writeFile(filePath, formattedContent);
115
+ written.push(file);
116
+ }
117
+ }
118
+ return {
119
+ written,
120
+ failures
121
+ };
122
+ }
123
+
68
124
  async function* runValidation({
69
125
  root,
70
126
  fix,
@@ -560,7 +616,7 @@ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
560
616
  const filePath = path__default["default"].join(projectRoot, jsonFilePath);
561
617
  let fileContent;
562
618
  try {
563
- fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
619
+ fileContent = fs__default$1["default"].readFileSync(filePath, "utf-8");
564
620
  } catch {
565
621
  cache.set(jsonFilePath, null);
566
622
  return null;
@@ -580,7 +636,7 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
580
636
  const filePath = path__default["default"].join(projectRoot, moduleFilePath);
581
637
  let fileContent;
582
638
  try {
583
- fileContent = fs__default["default"].readFileSync(filePath, "utf-8");
639
+ fileContent = fs__default$1["default"].readFileSync(filePath, "utf-8");
584
640
  } catch {
585
641
  cache.set(moduleFilePath, null);
586
642
  return null;
@@ -788,15 +844,19 @@ async function validate({
788
844
  console.log("");
789
845
  };
790
846
 
791
- // Run prettier on files that had fixes applied
847
+ // Format the files that had fixes applied, the way the project formats
848
+ // everything else. See `formatFixedFiles` for why this cannot simply be
849
+ // `prettier.format(code, { filepath })`.
850
+ //
851
+ // A file that could not be formatted is reported and skipped rather than
852
+ // thrown: the fix itself landed, and aborting here would leave the fix
853
+ // applied and the report below unwritten.
792
854
  if (prettier) {
793
- for (const file of fixedFiles) {
794
- const filePath = path__default["default"].join(projectRoot, file);
795
- const fileContent = await fs__default$1["default"].readFile(filePath, "utf-8");
796
- const formattedContent = await prettier.format(fileContent, {
797
- filepath: filePath
798
- });
799
- await fs__default$1["default"].writeFile(filePath, formattedContent);
855
+ const {
856
+ failures
857
+ } = await formatFixedFiles(prettier, projectRoot, fixedFiles);
858
+ for (const failure of failures) {
859
+ console.log(pc__default["default"].yellow("⚠"), `Could not format ${relFile(failure.file)}: ${failure.message}`);
800
860
  }
801
861
  }
802
862
 
@@ -1084,8 +1144,8 @@ function tryGetGitConfig(root) {
1084
1144
  let lastDir = null;
1085
1145
  while (currentDir !== lastDir) {
1086
1146
  const gitConfigPath = path__default["default"].join(currentDir, ".git", "config");
1087
- if (fs__default["default"].existsSync(gitConfigPath)) {
1088
- return fs__default["default"].readFileSync(gitConfigPath, "utf-8");
1147
+ if (fs__default$1["default"].existsSync(gitConfigPath)) {
1148
+ return fs__default$1["default"].readFileSync(gitConfigPath, "utf-8");
1089
1149
  }
1090
1150
  lastDir = currentDir;
1091
1151
  currentDir = path__default["default"].dirname(currentDir);
@@ -1162,7 +1222,7 @@ class DebugContextError extends Error {}
1162
1222
  async function createDebugContext(options) {
1163
1223
  var _config$files;
1164
1224
  const projectRoot = options.root ? path__default["default"].resolve(options.root) : process.cwd();
1165
- if (!fs__default["default"].existsSync(projectRoot)) {
1225
+ if (!fs__default$1["default"].existsSync(projectRoot)) {
1166
1226
  throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
1167
1227
  }
1168
1228
  const config = await server.findAndEvalValConfigFile(projectRoot);
@@ -1235,8 +1295,8 @@ async function createDebugContext(options) {
1235
1295
  */
1236
1296
  function readAuth(projectRoot) {
1237
1297
  const patFile = server.getPersonalAccessTokenPath(projectRoot);
1238
- if (fs__default["default"].existsSync(patFile)) {
1239
- const contents = fs__default["default"].readFileSync(patFile, "utf-8");
1298
+ if (fs__default$1["default"].existsSync(patFile)) {
1299
+ const contents = fs__default$1["default"].readFileSync(patFile, "utf-8");
1240
1300
  const parsed = server.parsePersonalAccessTokenFile(contents);
1241
1301
  if (parsed.success) {
1242
1302
  return {
@@ -1787,7 +1847,7 @@ function readProjectValVersions(projectRoot) {
1787
1847
  function readLocalFile(projectRoot, projectRelativePath) {
1788
1848
  const absPath = path__default["default"].join(projectRoot, projectRelativePath);
1789
1849
  try {
1790
- return fs__default["default"].readFileSync(absPath, "utf-8");
1850
+ return fs__default$1["default"].readFileSync(absPath, "utf-8");
1791
1851
  } catch {
1792
1852
  return null;
1793
1853
  }
@@ -1929,10 +1989,10 @@ async function writeZip(outPath, entries) {
1929
1989
  type: "nodebuffer",
1930
1990
  compression: "DEFLATE"
1931
1991
  });
1932
- fs__default["default"].mkdirSync(path__default["default"].dirname(outPath), {
1992
+ fs__default$1["default"].mkdirSync(path__default["default"].dirname(outPath), {
1933
1993
  recursive: true
1934
1994
  });
1935
- fs__default["default"].writeFileSync(outPath, buffer);
1995
+ fs__default$1["default"].writeFileSync(outPath, buffer);
1936
1996
  }
1937
1997
  function timestamp() {
1938
1998
  return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
@@ -2081,7 +2141,7 @@ async function collectArtifacts(dir) {
2081
2141
  const skipped = [];
2082
2142
  let totalBytes = 0;
2083
2143
  const walk = async current => {
2084
- const entries = await fs__default["default"].promises.readdir(current, {
2144
+ const entries = await fs__default$1["default"].promises.readdir(current, {
2085
2145
  withFileTypes: true
2086
2146
  });
2087
2147
  for (const entry of entries) {
@@ -2095,7 +2155,7 @@ async function collectArtifacts(dir) {
2095
2155
  // A symlinked file is published as its bytes; a symlinked directory is
2096
2156
  // not walked, because one usually comes with a cycle, and a publisher
2097
2157
  // that hangs is worse than one that says what it left out.
2098
- const stat = await fs__default["default"].promises.stat(absolute).catch(() => null);
2158
+ const stat = await fs__default$1["default"].promises.stat(absolute).catch(() => null);
2099
2159
  if (stat === null) {
2100
2160
  skipped.push(`${key} (broken symlink)`);
2101
2161
  continue;
@@ -2136,7 +2196,7 @@ async function hashFile(absolute) {
2136
2196
  let bytes = 0;
2137
2197
  // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
2138
2198
  // it is a needless way to run a CI runner out of memory.
2139
- const stream = fs__default["default"].createReadStream(absolute);
2199
+ const stream = fs__default$1["default"].createReadStream(absolute);
2140
2200
  await new Promise((resolve, reject) => {
2141
2201
  stream.on("data", chunk => {
2142
2202
  hash.update(chunk);
@@ -2186,7 +2246,7 @@ async function layerRevOf(artifacts) {
2186
2246
  layerRev: null
2187
2247
  };
2188
2248
  }
2189
- const bytes = await fs__default["default"].promises.readFile(layer.file);
2249
+ const bytes = await fs__default$1["default"].promises.readFile(layer.file);
2190
2250
  let parsed;
2191
2251
  try {
2192
2252
  parsed = JSON.parse(zlib__default["default"].gunzipSync(bytes).toString("utf-8"));
@@ -2214,7 +2274,7 @@ const DEFAULT_ARTIFACTS_DIR = ".val/publish";
2214
2274
  function resolveArtifactsDir(options) {
2215
2275
  const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
2216
2276
  const dir = path__default["default"].resolve(options.root, named);
2217
- if (!fs__default["default"].existsSync(dir) || !fs__default["default"].statSync(dir).isDirectory()) {
2277
+ if (!fs__default$1["default"].existsSync(dir) || !fs__default$1["default"].statSync(dir).isDirectory()) {
2218
2278
  return {
2219
2279
  status: "error",
2220
2280
  message: `No artifacts to publish: ${dir} is not a directory.\n\n` + "Build the project first, then point at what it produced:\n\n" + " npx val publish --artifacts <directory>\n\n" + "The path of each file under it is its artifact key - `server`, `client`,\n" + "`public/...`, `chunk/client/...`, `layer`."
@@ -2554,7 +2614,7 @@ async function putArtifact(slot, file, fetchImpl) {
2554
2614
  * different size as far as the signature is concerned. An artifact is capped
2555
2615
  * at 128 MB by the API, which is the bound this trades against.
2556
2616
  */
2557
- const body = await fs__default["default"].promises.readFile(file);
2617
+ const body = await fs__default$1["default"].promises.readFile(file);
2558
2618
  let res;
2559
2619
  try {
2560
2620
  res = await fetchImpl(slot.url, {
@@ -2787,12 +2847,12 @@ function looksLikePersonalAccessToken(token) {
2787
2847
  }
2788
2848
  function readPersonalAccessToken(root) {
2789
2849
  const patFile = server.getPersonalAccessTokenPath(root);
2790
- if (!fs__default["default"].existsSync(patFile)) {
2850
+ if (!fs__default$1["default"].existsSync(patFile)) {
2791
2851
  return {
2792
2852
  status: "none"
2793
2853
  };
2794
2854
  }
2795
- const parsed = server.parsePersonalAccessTokenFile(fs__default["default"].readFileSync(patFile, "utf-8"));
2855
+ const parsed = server.parsePersonalAccessTokenFile(fs__default$1["default"].readFileSync(patFile, "utf-8"));
2796
2856
  if (!parsed.success) {
2797
2857
  return {
2798
2858
  status: "error",
@@ -2,12 +2,12 @@ import meow from 'meow';
2
2
  import chalk from 'chalk';
3
3
  import path from 'path';
4
4
  import pc from 'picocolors';
5
- import fs$1 from 'fs/promises';
6
5
  import { glob } from 'fast-glob';
7
6
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
8
- import { createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
7
+ import { createPrettierFormatter, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
8
+ import fs from 'fs/promises';
9
9
  import { resolveSchemaSourceFixes } from '@valbuild/shared/internal';
10
- import fs from 'fs';
10
+ import fs$1 from 'fs';
11
11
  import ts from 'typescript';
12
12
  import JSZip from 'jszip';
13
13
  import readline from 'readline';
@@ -31,6 +31,62 @@ function info(message, opts = {}) {
31
31
  console.log(chalk.blue("️ℹ️ : ") + message);
32
32
  }
33
33
 
34
+ /** A file whose fix landed but which could not be formatted afterwards. */
35
+
36
+ /**
37
+ * Format the files `--fix` just wrote, the way the project formats everything.
38
+ *
39
+ * Through `createPrettierFormatter`, which is the same function an app hands to
40
+ * `initValServer` / `initValMcp`, so a fix applied here and an edit saved from
41
+ * the Studio come out identically formatted. Calling
42
+ * `prettier.format(code, { filepath })` directly — which this did — reads no
43
+ * config at all: `filepath` picks the parser, and `.prettierrc` is only ever
44
+ * consulted by `resolveConfig`, `getFileInfo` and prettier's own CLI. A project
45
+ * whose style is not prettier's default got its whole file rewritten by a
46
+ * two-line content fix, and a red `format` job with it.
47
+ *
48
+ * Separate from `validate.ts` so it can be tested against a real project on
49
+ * disk: `runValidation` stops before this step, and `validate()` itself ends in
50
+ * `process.exit`, so neither can reach it from a test.
51
+ *
52
+ * Rendering is the caller's, which is why failures are returned rather than
53
+ * logged. A file that cannot be formatted is not a failed run: the fix itself
54
+ * landed, and stopping here would leave the fix applied and the report unwritten.
55
+ */
56
+ async function formatFixedFiles(prettier, projectRoot, files) {
57
+ const format = createPrettierFormatter(prettier, {
58
+ projectRoot
59
+ });
60
+ const written = [];
61
+ const failures = [];
62
+ for (const file of files) {
63
+ const filePath = path.join(projectRoot, file);
64
+ const fileContent = await fs.readFile(filePath, "utf-8");
65
+ let formattedContent;
66
+ try {
67
+ formattedContent = await format(fileContent, file);
68
+ } catch (err) {
69
+ failures.push({
70
+ file,
71
+ message: err instanceof Error ? err.message : String(err)
72
+ });
73
+ continue;
74
+ }
75
+ // A file prettier left alone — one the project put in `.prettierignore`,
76
+ // or one already in its final form — is not rewritten. Writing identical
77
+ // bytes still bumps the mtime, and a `--fix` run alongside a dev server
78
+ // makes it rebuild for nothing.
79
+ if (formattedContent !== fileContent) {
80
+ await fs.writeFile(filePath, formattedContent);
81
+ written.push(file);
82
+ }
83
+ }
84
+ return {
85
+ written,
86
+ failures
87
+ };
88
+ }
89
+
34
90
  async function* runValidation({
35
91
  root,
36
92
  fix,
@@ -526,7 +582,7 @@ function getCachedJsonFile(jsonFilePath, projectRoot, cache) {
526
582
  const filePath = path.join(projectRoot, jsonFilePath);
527
583
  let fileContent;
528
584
  try {
529
- fileContent = fs.readFileSync(filePath, "utf-8");
585
+ fileContent = fs$1.readFileSync(filePath, "utf-8");
530
586
  } catch {
531
587
  cache.set(jsonFilePath, null);
532
588
  return null;
@@ -546,7 +602,7 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
546
602
  const filePath = path.join(projectRoot, moduleFilePath);
547
603
  let fileContent;
548
604
  try {
549
- fileContent = fs.readFileSync(filePath, "utf-8");
605
+ fileContent = fs$1.readFileSync(filePath, "utf-8");
550
606
  } catch {
551
607
  cache.set(moduleFilePath, null);
552
608
  return null;
@@ -754,15 +810,19 @@ async function validate({
754
810
  console.log("");
755
811
  };
756
812
 
757
- // Run prettier on files that had fixes applied
813
+ // Format the files that had fixes applied, the way the project formats
814
+ // everything else. See `formatFixedFiles` for why this cannot simply be
815
+ // `prettier.format(code, { filepath })`.
816
+ //
817
+ // A file that could not be formatted is reported and skipped rather than
818
+ // thrown: the fix itself landed, and aborting here would leave the fix
819
+ // applied and the report below unwritten.
758
820
  if (prettier) {
759
- for (const file of fixedFiles) {
760
- const filePath = path.join(projectRoot, file);
761
- const fileContent = await fs$1.readFile(filePath, "utf-8");
762
- const formattedContent = await prettier.format(fileContent, {
763
- filepath: filePath
764
- });
765
- await fs$1.writeFile(filePath, formattedContent);
821
+ const {
822
+ failures
823
+ } = await formatFixedFiles(prettier, projectRoot, fixedFiles);
824
+ for (const failure of failures) {
825
+ console.log(pc.yellow("⚠"), `Could not format ${relFile(failure.file)}: ${failure.message}`);
766
826
  }
767
827
  }
768
828
 
@@ -1050,8 +1110,8 @@ function tryGetGitConfig(root) {
1050
1110
  let lastDir = null;
1051
1111
  while (currentDir !== lastDir) {
1052
1112
  const gitConfigPath = path.join(currentDir, ".git", "config");
1053
- if (fs.existsSync(gitConfigPath)) {
1054
- return fs.readFileSync(gitConfigPath, "utf-8");
1113
+ if (fs$1.existsSync(gitConfigPath)) {
1114
+ return fs$1.readFileSync(gitConfigPath, "utf-8");
1055
1115
  }
1056
1116
  lastDir = currentDir;
1057
1117
  currentDir = path.dirname(currentDir);
@@ -1128,7 +1188,7 @@ class DebugContextError extends Error {}
1128
1188
  async function createDebugContext(options) {
1129
1189
  var _config$files;
1130
1190
  const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
1131
- if (!fs.existsSync(projectRoot)) {
1191
+ if (!fs$1.existsSync(projectRoot)) {
1132
1192
  throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
1133
1193
  }
1134
1194
  const config = await findAndEvalValConfigFile(projectRoot);
@@ -1201,8 +1261,8 @@ async function createDebugContext(options) {
1201
1261
  */
1202
1262
  function readAuth(projectRoot) {
1203
1263
  const patFile = getPersonalAccessTokenPath(projectRoot);
1204
- if (fs.existsSync(patFile)) {
1205
- const contents = fs.readFileSync(patFile, "utf-8");
1264
+ if (fs$1.existsSync(patFile)) {
1265
+ const contents = fs$1.readFileSync(patFile, "utf-8");
1206
1266
  const parsed = parsePersonalAccessTokenFile(contents);
1207
1267
  if (parsed.success) {
1208
1268
  return {
@@ -1753,7 +1813,7 @@ function readProjectValVersions(projectRoot) {
1753
1813
  function readLocalFile(projectRoot, projectRelativePath) {
1754
1814
  const absPath = path.join(projectRoot, projectRelativePath);
1755
1815
  try {
1756
- return fs.readFileSync(absPath, "utf-8");
1816
+ return fs$1.readFileSync(absPath, "utf-8");
1757
1817
  } catch {
1758
1818
  return null;
1759
1819
  }
@@ -1895,10 +1955,10 @@ async function writeZip(outPath, entries) {
1895
1955
  type: "nodebuffer",
1896
1956
  compression: "DEFLATE"
1897
1957
  });
1898
- fs.mkdirSync(path.dirname(outPath), {
1958
+ fs$1.mkdirSync(path.dirname(outPath), {
1899
1959
  recursive: true
1900
1960
  });
1901
- fs.writeFileSync(outPath, buffer);
1961
+ fs$1.writeFileSync(outPath, buffer);
1902
1962
  }
1903
1963
  function timestamp() {
1904
1964
  return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
@@ -2047,7 +2107,7 @@ async function collectArtifacts(dir) {
2047
2107
  const skipped = [];
2048
2108
  let totalBytes = 0;
2049
2109
  const walk = async current => {
2050
- const entries = await fs.promises.readdir(current, {
2110
+ const entries = await fs$1.promises.readdir(current, {
2051
2111
  withFileTypes: true
2052
2112
  });
2053
2113
  for (const entry of entries) {
@@ -2061,7 +2121,7 @@ async function collectArtifacts(dir) {
2061
2121
  // A symlinked file is published as its bytes; a symlinked directory is
2062
2122
  // not walked, because one usually comes with a cycle, and a publisher
2063
2123
  // that hangs is worse than one that says what it left out.
2064
- const stat = await fs.promises.stat(absolute).catch(() => null);
2124
+ const stat = await fs$1.promises.stat(absolute).catch(() => null);
2065
2125
  if (stat === null) {
2066
2126
  skipped.push(`${key} (broken symlink)`);
2067
2127
  continue;
@@ -2102,7 +2162,7 @@ async function hashFile(absolute) {
2102
2162
  let bytes = 0;
2103
2163
  // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
2104
2164
  // it is a needless way to run a CI runner out of memory.
2105
- const stream = fs.createReadStream(absolute);
2165
+ const stream = fs$1.createReadStream(absolute);
2106
2166
  await new Promise((resolve, reject) => {
2107
2167
  stream.on("data", chunk => {
2108
2168
  hash.update(chunk);
@@ -2152,7 +2212,7 @@ async function layerRevOf(artifacts) {
2152
2212
  layerRev: null
2153
2213
  };
2154
2214
  }
2155
- const bytes = await fs.promises.readFile(layer.file);
2215
+ const bytes = await fs$1.promises.readFile(layer.file);
2156
2216
  let parsed;
2157
2217
  try {
2158
2218
  parsed = JSON.parse(zlib.gunzipSync(bytes).toString("utf-8"));
@@ -2180,7 +2240,7 @@ const DEFAULT_ARTIFACTS_DIR = ".val/publish";
2180
2240
  function resolveArtifactsDir(options) {
2181
2241
  const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
2182
2242
  const dir = path.resolve(options.root, named);
2183
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
2243
+ if (!fs$1.existsSync(dir) || !fs$1.statSync(dir).isDirectory()) {
2184
2244
  return {
2185
2245
  status: "error",
2186
2246
  message: `No artifacts to publish: ${dir} is not a directory.\n\n` + "Build the project first, then point at what it produced:\n\n" + " npx val publish --artifacts <directory>\n\n" + "The path of each file under it is its artifact key - `server`, `client`,\n" + "`public/...`, `chunk/client/...`, `layer`."
@@ -2520,7 +2580,7 @@ async function putArtifact(slot, file, fetchImpl) {
2520
2580
  * different size as far as the signature is concerned. An artifact is capped
2521
2581
  * at 128 MB by the API, which is the bound this trades against.
2522
2582
  */
2523
- const body = await fs.promises.readFile(file);
2583
+ const body = await fs$1.promises.readFile(file);
2524
2584
  let res;
2525
2585
  try {
2526
2586
  res = await fetchImpl(slot.url, {
@@ -2753,12 +2813,12 @@ function looksLikePersonalAccessToken(token) {
2753
2813
  }
2754
2814
  function readPersonalAccessToken(root) {
2755
2815
  const patFile = getPersonalAccessTokenPath(root);
2756
- if (!fs.existsSync(patFile)) {
2816
+ if (!fs$1.existsSync(patFile)) {
2757
2817
  return {
2758
2818
  status: "none"
2759
2819
  };
2760
2820
  }
2761
- const parsed = parsePersonalAccessTokenFile(fs.readFileSync(patFile, "utf-8"));
2821
+ const parsed = parsePersonalAccessTokenFile(fs$1.readFileSync(patFile, "utf-8"));
2762
2822
  if (!parsed.success) {
2763
2823
  return {
2764
2824
  status: "error",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.134.0",
4
+ "version": "0.135.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -28,9 +28,9 @@
28
28
  "zod-validation-error": "^5.0.0",
29
29
  "@valbuild/core": "0.134.0",
30
30
  "@valbuild/eslint-plugin": "0.125.0",
31
- "@valbuild/language-server": "0.134.0",
32
- "@valbuild/shared": "0.134.0",
33
- "@valbuild/server": "0.134.0"
31
+ "@valbuild/language-server": "0.135.0",
32
+ "@valbuild/server": "0.135.0",
33
+ "@valbuild/shared": "0.134.1"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "prettier": "*",
@@ -46,6 +46,8 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@jest/globals": "^30.5.0",
49
+ "@prettier/sync": "^0.6.1",
50
+ "prettier": "~3.8.5",
49
51
  "tsx": "^4.23.12"
50
52
  },
51
53
  "scripts": {
@@ -0,0 +1,229 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import synchronizedPrettier from "@prettier/sync";
5
+ import { DEFAULT_VAL_REMOTE_HOST } from "@valbuild/core";
6
+ import {
7
+ createDefaultValFSHost,
8
+ runValidation,
9
+ IValRemote,
10
+ } from "./runValidation";
11
+ import { formatFixedFiles } from "./formatFixedFiles";
12
+
13
+ /**
14
+ * The step `val validate --fix` ends on, against a real project on disk.
15
+ *
16
+ * The fixes here are produced by `runValidation` itself rather than written by
17
+ * the test, because the thing being checked is what happens to the TypeScript
18
+ * the fix pipeline emits — which is unformatted, and which used to be handed to
19
+ * `prettier.format(code, { filepath })` and therefore rewritten in prettier's
20
+ * defaults no matter what the project had configured.
21
+ *
22
+ * It stops short of calling `validate()` because that ends in `process.exit`
23
+ * once the fixture's remaining errors are counted, which would take the jest
24
+ * worker with it. `validate()` does nothing between the two but render.
25
+ *
26
+ * `@prettier/sync` rather than `prettier`: prettier 3's CommonJS entry loads its
27
+ * ESM build with a dynamic `import()`, which jest refuses without
28
+ * `--experimental-vm-modules`.
29
+ */
30
+
31
+ const BASIC_FIXTURE = path.resolve(__dirname, "__fixtures__/basic");
32
+
33
+ // The fixture is copied INSIDE the package rather than into the OS temp dir:
34
+ // the project's `val.config.ts` is evaluated with a `require` resolving from
35
+ // its own path, so it has to be somewhere `@valbuild/core` can be walked up to.
36
+ const TMP_BASE = path.join(__dirname, ".tmp");
37
+
38
+ /** A module in neither style, so either pass has something to change. */
39
+ const UNFORMATTED_MODULE = `import {c,s} from "../val.config"
40
+ export default c.define("/content/basic-image.val.ts",s.image(),{path:"/public/val/image.png"})
41
+ `;
42
+
43
+ const mockRemote: IValRemote = {
44
+ remoteHost: DEFAULT_VAL_REMOTE_HOST,
45
+ getSettings: async () => {
46
+ throw new Error("Not expected to be called");
47
+ },
48
+ uploadFile: async () => {
49
+ throw new Error("Not expected to be called");
50
+ },
51
+ };
52
+
53
+ /** Apply every fix in `valFiles`, and report which files were written. */
54
+ async function applyFixes(
55
+ tmpDir: string,
56
+ valFiles: string[],
57
+ ): Promise<string[]> {
58
+ const fixed = new Set<string>();
59
+ for await (const event of runValidation({
60
+ root: tmpDir,
61
+ fix: true,
62
+ valFiles,
63
+ project: undefined,
64
+ remote: mockRemote,
65
+ fs: createDefaultValFSHost(),
66
+ })) {
67
+ if (event.type === "fix-applied") {
68
+ fixed.add(event.file);
69
+ }
70
+ }
71
+ return [...fixed];
72
+ }
73
+
74
+ describe("formatFixedFiles", () => {
75
+ let tmpDir: string;
76
+
77
+ beforeEach(() => {
78
+ fs.mkdirSync(TMP_BASE, { recursive: true });
79
+ tmpDir = fs.mkdtempSync(path.join(TMP_BASE, "formatFixedFiles-"));
80
+ fs.cpSync(BASIC_FIXTURE, tmpDir, { recursive: true });
81
+ // Every case writes its own config into its own directory, and prettier
82
+ // caches per directory for the life of the process — including the fact
83
+ // that a directory had none.
84
+ synchronizedPrettier.clearConfigCache();
85
+ });
86
+
87
+ afterEach(() => {
88
+ fs.rmSync(tmpDir, { recursive: true, force: true });
89
+ });
90
+
91
+ function writeConfig(config: Record<string, unknown>): void {
92
+ fs.writeFileSync(
93
+ path.join(tmpDir, ".prettierrc.json"),
94
+ JSON.stringify(config),
95
+ );
96
+ }
97
+
98
+ test("formats a fixed file with the project's config, not prettier's defaults", async () => {
99
+ writeConfig({ singleQuote: true, semi: false });
100
+ const fixed = await applyFixes(tmpDir, ["content/basic-image.val.ts"]);
101
+ expect(fixed).toEqual(["content/basic-image.val.ts"]);
102
+
103
+ const { written, failures } = await formatFixedFiles(
104
+ synchronizedPrettier,
105
+ tmpDir,
106
+ fixed,
107
+ );
108
+
109
+ expect(failures).toEqual([]);
110
+ expect(written).toEqual(["content/basic-image.val.ts"]);
111
+ const after = fs.readFileSync(
112
+ path.join(tmpDir, "content/basic-image.val.ts"),
113
+ "utf-8",
114
+ );
115
+ // The fix landed...
116
+ expect(after).toContain("width: 1");
117
+ // ...in the project's style. Prettier's default is double quotes and semis,
118
+ // which is what the old `prettier.format(code, { filepath })` produced.
119
+ expect(after).toContain("'/public/val/image.png'");
120
+ expect(after).not.toContain('"/public/val/image.png"');
121
+ expect(after.trimEnd().endsWith(")")).toBe(true);
122
+ });
123
+
124
+ test("leaves a fixed file the project put in .prettierignore unformatted", async () => {
125
+ writeConfig({ singleQuote: true });
126
+ fs.writeFileSync(
127
+ path.join(tmpDir, ".prettierignore"),
128
+ "content/basic-image.val.ts\n",
129
+ );
130
+ const fixed = await applyFixes(tmpDir, ["content/basic-image.val.ts"]);
131
+
132
+ const { written, failures } = await formatFixedFiles(
133
+ synchronizedPrettier,
134
+ tmpDir,
135
+ fixed,
136
+ );
137
+
138
+ expect(failures).toEqual([]);
139
+ // Nothing rewritten, so the mtime is not bumped either.
140
+ expect(written).toEqual([]);
141
+ const after = fs.readFileSync(
142
+ path.join(tmpDir, "content/basic-image.val.ts"),
143
+ "utf-8",
144
+ );
145
+ // The fix still landed — only the formatting was skipped.
146
+ expect(after).toContain("width: 1");
147
+ expect(after).toContain('"/public/val/image.png"');
148
+ });
149
+
150
+ test("does not rewrite a file that is already formatted", async () => {
151
+ writeConfig({});
152
+ const fixed = await applyFixes(tmpDir, ["content/basic-image.val.ts"]);
153
+ const target = path.join(tmpDir, "content/basic-image.val.ts");
154
+
155
+ // First pass formats it; a second pass has nothing left to do.
156
+ await formatFixedFiles(synchronizedPrettier, tmpDir, fixed);
157
+ const { written, failures } = await formatFixedFiles(
158
+ synchronizedPrettier,
159
+ tmpDir,
160
+ fixed,
161
+ );
162
+
163
+ expect(failures).toEqual([]);
164
+ expect(written).toEqual([]);
165
+ expect(fs.readFileSync(target, "utf-8")).toContain("width: 1");
166
+ });
167
+
168
+ test("reports a file it cannot format instead of throwing, and keeps going", async () => {
169
+ writeConfig({});
170
+ const fixed = await applyFixes(tmpDir, ["content/basic-image.val.ts"]);
171
+ // A parser prettier does not have: the same shape as a malformed config or
172
+ // a plugin that fails to load, and the only failure mode reachable here
173
+ // without breaking the file the fix just wrote.
174
+ const brokenPrettier = {
175
+ ...synchronizedPrettier,
176
+ format: () => {
177
+ throw new Error("No parser could be inferred");
178
+ },
179
+ };
180
+
181
+ const { written, failures } = await formatFixedFiles(
182
+ brokenPrettier,
183
+ tmpDir,
184
+ [...fixed, ...fixed],
185
+ );
186
+
187
+ expect(written).toEqual([]);
188
+ // Both entries reported: the loop continued past the first failure.
189
+ expect(failures).toHaveLength(2);
190
+ expect(failures[0]).toEqual({
191
+ file: "content/basic-image.val.ts",
192
+ message: "No parser could be inferred",
193
+ });
194
+ // The fix is still on disk, unformatted rather than lost.
195
+ expect(
196
+ fs.readFileSync(path.join(tmpDir, "content/basic-image.val.ts"), "utf-8"),
197
+ ).toContain("width: 1");
198
+ });
199
+
200
+ test("resolves a project-relative path against the project, with or without a leading slash", async () => {
201
+ writeConfig({ singleQuote: true });
202
+ const fixed = await applyFixes(tmpDir, ["content/basic-image.val.ts"]);
203
+ // `runValidation` reports this one without a leading slash, but a
204
+ // `ModuleFilePath` carries one and `validate.ts` strips it for display, so
205
+ // both shapes reach here. Resolving either against the filesystem root
206
+ // rather than the project would find a different `.prettierrc` — or none,
207
+ // which is the old bug wearing a different hat.
208
+ expect(fixed).toEqual(["content/basic-image.val.ts"]);
209
+ const target = path.join(tmpDir, "content/basic-image.val.ts");
210
+
211
+ for (const reported of [
212
+ "content/basic-image.val.ts",
213
+ "/content/basic-image.val.ts",
214
+ ]) {
215
+ fs.writeFileSync(target, UNFORMATTED_MODULE);
216
+ const { written, failures } = await formatFixedFiles(
217
+ synchronizedPrettier,
218
+ tmpDir,
219
+ [reported],
220
+ );
221
+
222
+ expect(failures).toEqual([]);
223
+ expect(written).toEqual([reported]);
224
+ expect(fs.readFileSync(target, "utf-8")).toContain(
225
+ "'/public/val/image.png'",
226
+ );
227
+ }
228
+ });
229
+ });
@@ -0,0 +1,69 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { createPrettierFormatter, type PrettierLike } from "@valbuild/server";
4
+
5
+ /** A file whose fix landed but which could not be formatted afterwards. */
6
+ export type FormatFailure = {
7
+ /** Project-relative, as `--fix` reported it. */
8
+ file: string;
9
+ message: string;
10
+ };
11
+
12
+ export type FormatFixedFilesResult = {
13
+ /** Files actually rewritten — a file already in its final form is not. */
14
+ written: string[];
15
+ failures: FormatFailure[];
16
+ };
17
+
18
+ /**
19
+ * Format the files `--fix` just wrote, the way the project formats everything.
20
+ *
21
+ * Through `createPrettierFormatter`, which is the same function an app hands to
22
+ * `initValServer` / `initValMcp`, so a fix applied here and an edit saved from
23
+ * the Studio come out identically formatted. Calling
24
+ * `prettier.format(code, { filepath })` directly — which this did — reads no
25
+ * config at all: `filepath` picks the parser, and `.prettierrc` is only ever
26
+ * consulted by `resolveConfig`, `getFileInfo` and prettier's own CLI. A project
27
+ * whose style is not prettier's default got its whole file rewritten by a
28
+ * two-line content fix, and a red `format` job with it.
29
+ *
30
+ * Separate from `validate.ts` so it can be tested against a real project on
31
+ * disk: `runValidation` stops before this step, and `validate()` itself ends in
32
+ * `process.exit`, so neither can reach it from a test.
33
+ *
34
+ * Rendering is the caller's, which is why failures are returned rather than
35
+ * logged. A file that cannot be formatted is not a failed run: the fix itself
36
+ * landed, and stopping here would leave the fix applied and the report unwritten.
37
+ */
38
+ export async function formatFixedFiles(
39
+ prettier: PrettierLike,
40
+ projectRoot: string,
41
+ files: Iterable<string>,
42
+ ): Promise<FormatFixedFilesResult> {
43
+ const format = createPrettierFormatter(prettier, { projectRoot });
44
+ const written: string[] = [];
45
+ const failures: FormatFailure[] = [];
46
+ for (const file of files) {
47
+ const filePath = path.join(projectRoot, file);
48
+ const fileContent = await fs.readFile(filePath, "utf-8");
49
+ let formattedContent: string;
50
+ try {
51
+ formattedContent = await format(fileContent, file);
52
+ } catch (err) {
53
+ failures.push({
54
+ file,
55
+ message: err instanceof Error ? err.message : String(err),
56
+ });
57
+ continue;
58
+ }
59
+ // A file prettier left alone — one the project put in `.prettierignore`,
60
+ // or one already in its final form — is not rewritten. Writing identical
61
+ // bytes still bumps the mtime, and a `--fix` run alongside a dev server
62
+ // makes it rebuild for nothing.
63
+ if (formattedContent !== fileContent) {
64
+ await fs.writeFile(filePath, formattedContent);
65
+ written.push(file);
66
+ }
67
+ }
68
+ return { written, failures };
69
+ }
package/src/validate.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import path from "path";
2
2
  import picocolors from "picocolors";
3
- import fs from "fs/promises";
4
3
  import { glob } from "fast-glob";
5
4
  import { DEFAULT_CONTENT_HOST, DEFAULT_VAL_REMOTE_HOST } from "@valbuild/core";
6
5
  import { getSettings, uploadRemoteFile } from "@valbuild/server";
6
+ import { formatFixedFiles } from "./formatFixedFiles";
7
7
  import { findAndEvalValConfigFile } from "./utils/evalValConfigFile";
8
8
  import { createDefaultValFSHost, runValidation } from "./runValidation";
9
9
  import {
@@ -283,15 +283,24 @@ export async function validate({
283
283
  console.log("");
284
284
  };
285
285
 
286
- // Run prettier on files that had fixes applied
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.
287
293
  if (prettier) {
288
- for (const file of fixedFiles) {
289
- const filePath = path.join(projectRoot, file);
290
- const fileContent = await fs.readFile(filePath, "utf-8");
291
- const formattedContent = await prettier.format(fileContent, {
292
- filepath: filePath,
293
- });
294
- await fs.writeFile(filePath, formattedContent);
294
+ const { failures } = await formatFixedFiles(
295
+ prettier,
296
+ projectRoot,
297
+ fixedFiles,
298
+ );
299
+ for (const failure of failures) {
300
+ console.log(
301
+ picocolors.yellow("⚠"),
302
+ `Could not format ${relFile(failure.file)}: ${failure.message}`,
303
+ );
295
304
  }
296
305
  }
297
306