@spectrabrain/eds 0.3.0 → 0.4.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.
@@ -20,6 +20,18 @@ import {
20
20
  generatedStylesFileName,
21
21
  layerOrder,
22
22
  minimumMajors,
23
+ minimumPluginReactVersion,
24
+ minimumTailwindViteVersion,
25
+ minimumVersions,
26
+ maximumSupportedViteMajor,
27
+ preservedMockCamelCaseFilePattern,
28
+ preservedResourceDirectoryNames,
29
+ preservedResourceDirectoryPattern,
30
+ preservedMockPathPattern,
31
+ preservedResourceFilePattern,
32
+ preservedResourceHookPattern,
33
+ preservedResourceDependencyPattern,
34
+ preservedSourceHint,
23
35
  replacementRules,
24
36
  requiredDependencies,
25
37
  requiredDevDependencies,
@@ -34,22 +46,58 @@ import {
34
46
  templateDevDependencies,
35
47
  testDirectoryNames,
36
48
  testFilePattern,
49
+ testHelperFilePattern,
50
+ testConfigFilePattern,
51
+ testSupportFilePattern,
37
52
  testSetupFilePattern,
38
- testSupportHint,
39
53
  testToolingPattern,
40
54
  unsupportedFrameworks,
41
55
  } from "./align-rules.mjs";
42
- import { createExitError, generatedCssRelativePath, readTextIfExists, toPosix } from "./paths.mjs";
56
+ import {
57
+ createExitError,
58
+ generatedCssRelativePath,
59
+ readTextIfExists,
60
+ toPosix,
61
+ } from "./paths.mjs";
43
62
 
44
- const scriptExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
63
+ const scriptExtensions = new Set([
64
+ ".ts",
65
+ ".tsx",
66
+ ".js",
67
+ ".jsx",
68
+ ".mts",
69
+ ".cts",
70
+ ".mjs",
71
+ ".cjs",
72
+ ]);
45
73
  const javascriptExtensions = new Set([".js", ".jsx", ".mjs", ".cjs"]);
46
74
  const styleExtensions = new Set([".css", ".scss", ".sass", ".less", ".styl"]);
47
- const walkExcludedDirectories = new Set(["node_modules", "dist", "build", "coverage", "storybook-static", "out"]);
75
+ const walkExcludedDirectories = new Set([
76
+ "node_modules",
77
+ "dist",
78
+ "build",
79
+ "coverage",
80
+ "storybook-static",
81
+ "out",
82
+ ]);
48
83
  const frontendSearchDepth = 3;
49
84
  const kebabCasePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
50
- const koreanPattern = /[ㄱ-ㆎ가-힣]/;
51
- const eslintConfigNames = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts"];
52
- const legacyEslintConfigNames = [".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml"];
85
+ const eslintConfigNames = [
86
+ "eslint.config.js",
87
+ "eslint.config.mjs",
88
+ "eslint.config.cjs",
89
+ "eslint.config.ts",
90
+ "eslint.config.mts",
91
+ "eslint.config.cts",
92
+ ];
93
+ const legacyEslintConfigNames = [
94
+ ".eslintrc",
95
+ ".eslintrc.js",
96
+ ".eslintrc.cjs",
97
+ ".eslintrc.json",
98
+ ".eslintrc.yml",
99
+ ".eslintrc.yaml",
100
+ ];
53
101
  const prettierConfigNames = [
54
102
  ".prettierrc",
55
103
  ".prettierrc.json",
@@ -66,7 +114,14 @@ const prettierConfigNames = [
66
114
  "prettier.config.mjs",
67
115
  "prettier.config.ts",
68
116
  ];
69
- const viteConfigNames = ["vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"];
117
+ const viteConfigNames = [
118
+ "vite.config.ts",
119
+ "vite.config.mts",
120
+ "vite.config.cts",
121
+ "vite.config.js",
122
+ "vite.config.mjs",
123
+ "vite.config.cjs",
124
+ ];
70
125
  // Packages an aligned frontend may declare besides the required ones; anything else is listed for review.
71
126
  const conventionalPackages = new Set([
72
127
  ...requiredDependencies,
@@ -82,8 +137,6 @@ const conventionalPackages = new Set([
82
137
  "react-virtuoso",
83
138
  "react-markdown",
84
139
  "remark-gfm",
85
- "msw",
86
- "@faker-js/faker",
87
140
  "@tailwindcss/postcss",
88
141
  "@tailwindcss/vite",
89
142
  "tailwindcss-animate",
@@ -99,23 +152,78 @@ const conventionalPackages = new Set([
99
152
 
100
153
  export function hashFile(filePath) {
101
154
  try {
102
- return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
155
+ return crypto
156
+ .createHash("sha1")
157
+ .update(fs.readFileSync(filePath))
158
+ .digest("hex");
103
159
  } catch {
104
160
  return null;
105
161
  }
106
162
  }
107
163
 
108
164
  function joinPosix(...parts) {
109
- const joined = path.posix.join(...parts.filter((part) => part !== "" && part !== "."));
165
+ const joined = path.posix.join(
166
+ ...parts.filter((part) => part !== "" && part !== ".")
167
+ );
110
168
  return joined === "" ? "." : joined;
111
169
  }
112
170
 
113
- // Test files, files in test directories and test setup files are outside the conventions: every check skips them.
171
+ // Match test paths so alignment checks leave them unchanged.
114
172
  export function isTestSupport(relativePath) {
115
173
  const parts = relativePath.split("/");
116
- return testFilePattern.test(relativePath) ||
117
- parts.slice(0, -1).some((part) => testDirectoryNames.includes(part)) ||
118
- testSetupFilePattern.test(parts[parts.length - 1]);
174
+ return (
175
+ testFilePattern.test(relativePath) ||
176
+ testSupportFilePattern.test(parts[parts.length - 1]) ||
177
+ testHelperFilePattern.test(parts[parts.length - 1]) ||
178
+ parts.some((part) => testDirectoryNames.includes(part.toLowerCase())) ||
179
+ testSetupFilePattern.test(parts[parts.length - 1]) ||
180
+ testConfigFilePattern.test(parts[parts.length - 1])
181
+ );
182
+ }
183
+
184
+ function isPreservedDirectoryName(name, parts, index) {
185
+ const lowerName = name.toLowerCase();
186
+ const sourceIndex = parts.lastIndexOf("src");
187
+ const isMessageResourceDirectory =
188
+ lowerName === "messages" && (index === 0 || index === sourceIndex + 1);
189
+ return (
190
+ testDirectoryNames.includes(lowerName) ||
191
+ preservedResourceDirectoryNames.includes(lowerName) ||
192
+ isMessageResourceDirectory ||
193
+ preservedResourceDirectoryPattern.test(name) ||
194
+ preservedMockPathPattern.test(name)
195
+ );
196
+ }
197
+
198
+ function isInsidePreservedPath(relativePath, preservedPaths) {
199
+ const filePath = toPosix(relativePath).replace(/^\.\//, "");
200
+ return preservedPaths.some((entry) => {
201
+ const preservedPath = toPosix(entry)
202
+ .replace(/^\.\//, "")
203
+ .replace(/\/+$/, "");
204
+ return (
205
+ preservedPath === "" ||
206
+ preservedPath === "." ||
207
+ filePath === preservedPath ||
208
+ filePath.startsWith(preservedPath + "/")
209
+ );
210
+ });
211
+ }
212
+
213
+ export function isPreservedSourcePath(relativePath, preservedPaths = []) {
214
+ const parts = relativePath.split("/");
215
+ return (
216
+ isTestSupport(relativePath) ||
217
+ parts.some(
218
+ (part, index) =>
219
+ isPreservedDirectoryName(part, parts, index) ||
220
+ preservedResourceFilePattern.test(part) ||
221
+ preservedResourceHookPattern.test(part) ||
222
+ preservedMockPathPattern.test(part) ||
223
+ preservedMockCamelCaseFilePattern.test(part)
224
+ ) ||
225
+ isInsidePreservedPath(relativePath, preservedPaths)
226
+ );
119
227
  }
120
228
 
121
229
  // Blanks // and /* */ comments while keeping strings, template literals and every line break, so indices and
@@ -143,9 +251,13 @@ export function stripComments(source) {
143
251
  index = stop;
144
252
  continue;
145
253
  }
146
- if (character === "\"" || character === "'") {
254
+ if (character === '"' || character === "'") {
147
255
  let end = index + 1;
148
- while (end < source.length && source[end] !== character && source[end] !== "\n") {
256
+ while (
257
+ end < source.length &&
258
+ source[end] !== character &&
259
+ source[end] !== "\n"
260
+ ) {
149
261
  end += source[end] === "\\" ? 2 : 1;
150
262
  }
151
263
  output.push(source.slice(index, end + 1));
@@ -153,12 +265,21 @@ export function stripComments(source) {
153
265
  lastSignificant = character;
154
266
  continue;
155
267
  }
156
- if (character === "`" || (character === "}" && templateDepth.length > 0 && templateDepth[templateDepth.length - 1] === 0)) {
268
+ if (
269
+ character === "`" ||
270
+ (character === "}" &&
271
+ templateDepth.length > 0 &&
272
+ templateDepth[templateDepth.length - 1] === 0)
273
+ ) {
157
274
  if (character === "}") {
158
275
  templateDepth.pop();
159
276
  }
160
277
  let end = index + 1;
161
- while (end < source.length && source[end] !== "`" && !(source[end] === "$" && source[end + 1] === "{")) {
278
+ while (
279
+ end < source.length &&
280
+ source[end] !== "`" &&
281
+ !(source[end] === "$" && source[end + 1] === "{")
282
+ ) {
162
283
  end += source[end] === "\\" ? 2 : 1;
163
284
  }
164
285
  if (source[end] === "$") {
@@ -173,10 +294,18 @@ export function stripComments(source) {
173
294
  lastSignificant = "`";
174
295
  continue;
175
296
  }
176
- if (character === "/" && (lastSignificant === "" || "(,=:[!&|?{};+-*%<>~^".includes(lastSignificant))) {
297
+ if (
298
+ character === "/" &&
299
+ (lastSignificant === "" ||
300
+ "(,=:[!&|?{};+-*%<>~^".includes(lastSignificant))
301
+ ) {
177
302
  let end = index + 1;
178
303
  let inClass = false;
179
- while (end < source.length && source[end] !== "\n" && (inClass || source[end] !== "/")) {
304
+ while (
305
+ end < source.length &&
306
+ source[end] !== "\n" &&
307
+ (inClass || source[end] !== "/")
308
+ ) {
180
309
  if (source[end] === "\\") {
181
310
  end += 2;
182
311
  continue;
@@ -203,98 +332,6 @@ export function stripComments(source) {
203
332
  return output.join("");
204
333
  }
205
334
 
206
- // Spans of the string and template literals in comment-free code, with the regex-literal heuristic of stripComments.
207
- // A template literal is one span from its opening to its closing backtick, and `text` holds its static parts.
208
- function literalSpans(code) {
209
- const spans = [];
210
- const templates = [];
211
- let index = 0;
212
- let lastSignificant = "";
213
- while (index < code.length) {
214
- const character = code[index];
215
- const open = templates[templates.length - 1];
216
- if (character === "\"" || character === "'") {
217
- let end = index + 1;
218
- while (end < code.length && code[end] !== character && code[end] !== "\n") {
219
- end += code[end] === "\\" ? 2 : 1;
220
- }
221
- spans.push({ start: index, end: end + 1, text: code.slice(index + 1, end) });
222
- index = end + 1;
223
- lastSignificant = character;
224
- continue;
225
- }
226
- if (character === "`" || (character === "}" && open?.inExpression && open.depth === 0)) {
227
- const template = character === "`" ? { start: index, text: "", depth: 0, inExpression: false } : open;
228
- if (character === "`") {
229
- templates.push(template);
230
- }
231
- template.inExpression = false;
232
- let end = index + 1;
233
- while (end < code.length && code[end] !== "`" && !(code[end] === "$" && code[end + 1] === "{")) {
234
- end += code[end] === "\\" ? 2 : 1;
235
- }
236
- template.text += code.slice(index + 1, end);
237
- if (code[end] === "$") {
238
- template.inExpression = true;
239
- template.depth = 0;
240
- index = end + 2;
241
- lastSignificant = "{";
242
- continue;
243
- }
244
- templates.pop();
245
- spans.push({ start: template.start, end: end + 1, text: template.text });
246
- index = end + 1;
247
- lastSignificant = "`";
248
- continue;
249
- }
250
- if (character === "/" && (lastSignificant === "" || "(,=:[!&|?{};+-*%<>~^".includes(lastSignificant))) {
251
- let end = index + 1;
252
- let inClass = false;
253
- while (end < code.length && code[end] !== "\n" && (inClass || code[end] !== "/")) {
254
- if (code[end] === "\\") {
255
- end += 2;
256
- continue;
257
- }
258
- if (code[end] === "[") inClass = true;
259
- if (code[end] === "]") inClass = false;
260
- end += 1;
261
- }
262
- index = end + 1;
263
- lastSignificant = "/";
264
- continue;
265
- }
266
- if (open?.inExpression) {
267
- if (character === "{") open.depth += 1;
268
- if (character === "}") open.depth -= 1;
269
- }
270
- if (!/\s/.test(character)) {
271
- lastSignificant = character;
272
- }
273
- index += 1;
274
- }
275
- return spans;
276
- }
277
-
278
- // Index of the first literal with Korean text that a + or += operator joins to other text, or -1. UI text is written
279
- // inline for now and moves to translation resources later, which needs whole sentences.
280
- export function koreanConcatenationIndex(code) {
281
- for (const span of literalSpans(code)) {
282
- if (!koreanPattern.test(span.text)) {
283
- continue;
284
- }
285
- let before = span.start - 1;
286
- while (before >= 0 && /\s/.test(code[before])) before -= 1;
287
- let after = span.end;
288
- while (after < code.length && /\s/.test(code[after])) after += 1;
289
- const plusBefore = (code[before] === "+" && code[before - 1] !== "+") || (code[before] === "=" && code[before - 1] === "+");
290
- const plusAfter = code[after] === "+" && code[after + 1] !== "+" && code[after + 1] !== "=";
291
- if (plusBefore || plusAfter) {
292
- return span.start;
293
- }
294
- }
295
- return -1;
296
- }
297
-
298
335
  // JSON with comments and trailing commas (tsconfig.json, components.json).
299
336
  export function parseJsonc(text) {
300
337
  return JSON.parse(stripComments(text).replace(/,(\s*[}\]])/g, "$1"));
@@ -302,7 +339,11 @@ export function parseJsonc(text) {
302
339
 
303
340
  function lineNumberAt(text, index) {
304
341
  let line = 1;
305
- for (let position = text.indexOf("\n"); position >= 0 && position < index; position = text.indexOf("\n", position + 1)) {
342
+ for (
343
+ let position = text.indexOf("\n");
344
+ position >= 0 && position < index;
345
+ position = text.indexOf("\n", position + 1)
346
+ ) {
306
347
  line += 1;
307
348
  }
308
349
  return line;
@@ -321,18 +362,31 @@ export function extractImports(source) {
321
362
  for (const pattern of importPatterns) {
322
363
  for (const match of code.matchAll(pattern)) {
323
364
  const specifierIndex = match.index + match[0].lastIndexOf(match[1]);
324
- found.push({ specifier: match[1], line: lineNumberAt(code, specifierIndex) });
365
+ found.push({
366
+ specifier: match[1],
367
+ line: lineNumberAt(code, specifierIndex),
368
+ });
325
369
  }
326
370
  }
327
371
  return found.sort((left, right) => left.line - right.line);
328
372
  }
329
373
 
330
374
  export function packageNameOf(specifier) {
331
- if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("@/") || specifier.startsWith("~/") || specifier.startsWith("node:")) {
375
+ if (
376
+ specifier.startsWith(".") ||
377
+ specifier.startsWith("/") ||
378
+ specifier.startsWith("@/") ||
379
+ specifier.startsWith("~/") ||
380
+ specifier.startsWith("node:")
381
+ ) {
332
382
  return null;
333
383
  }
334
384
  const parts = specifier.split("/");
335
- return specifier.startsWith("@") ? (parts.length >= 2 ? parts[0] + "/" + parts[1] : null) : parts[0];
385
+ return specifier.startsWith("@")
386
+ ? parts.length >= 2
387
+ ? parts[0] + "/" + parts[1]
388
+ : null
389
+ : parts[0];
336
390
  }
337
391
 
338
392
  // Resolves an import made by a file under the source directory. Returns { path } relative to the source directory,
@@ -341,8 +395,15 @@ export function resolveSourceImport(fromRelativePath, specifier) {
341
395
  if (specifier.startsWith("@/")) {
342
396
  return { path: path.posix.normalize(specifier.slice(2)), alias: true };
343
397
  }
344
- if (specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")) {
345
- const joined = path.posix.normalize(path.posix.join(path.posix.dirname(fromRelativePath), specifier));
398
+ if (
399
+ specifier === "." ||
400
+ specifier === ".." ||
401
+ specifier.startsWith("./") ||
402
+ specifier.startsWith("../")
403
+ ) {
404
+ const joined = path.posix.normalize(
405
+ path.posix.join(path.posix.dirname(fromRelativePath), specifier)
406
+ );
346
407
  if (joined === ".." || joined.startsWith("../")) {
347
408
  return { outside: true };
348
409
  }
@@ -354,9 +415,10 @@ export function resolveSourceImport(fromRelativePath, specifier) {
354
415
  export function classifyModule(relativePath) {
355
416
  const parts = relativePath.split("/");
356
417
  const layer = layerOrder.includes(parts[0]) ? parts[0] : null;
357
- const slice = layer !== null && slicedLayers.has(layer) && parts.length >= 2
358
- ? parts[1].replace(/\.[cm]?[jt]sx?$/, "")
359
- : null;
418
+ const slice =
419
+ layer !== null && slicedLayers.has(layer) && parts.length >= 2
420
+ ? parts[1].replace(/\.[cm]?[jt]sx?$/, "")
421
+ : null;
360
422
  return { layer, slice };
361
423
  }
362
424
 
@@ -378,43 +440,90 @@ export function checkImportRule({ fromPath, specifier, line, srcLabel }) {
378
440
  return {
379
441
  level: "error",
380
442
  kind: "import",
381
- message: location + " imports " + specifier + " (" + source.layer + " must not import the higher layer " + destination.layer + ")",
443
+ message:
444
+ location +
445
+ " imports " +
446
+ specifier +
447
+ " (" +
448
+ source.layer +
449
+ " must not import the higher layer " +
450
+ destination.layer +
451
+ ")",
382
452
  };
383
453
  }
384
- const crossesSlice = sourceRank === destinationRank && source.slice !== null && destination.slice !== null && source.slice !== destination.slice;
385
- if (crossesSlice && (source.layer === "pages" || source.layer === "features")) {
454
+ const crossesSlice =
455
+ sourceRank === destinationRank &&
456
+ source.slice !== null &&
457
+ destination.slice !== null &&
458
+ source.slice !== destination.slice;
459
+ if (
460
+ crossesSlice &&
461
+ (source.layer === "pages" || source.layer === "features")
462
+ ) {
386
463
  return {
387
464
  level: "error",
388
465
  kind: "import",
389
- message: location + " imports " + specifier + " (" + source.layer + "/" + source.slice + " must not import another " +
390
- source.layer + " slice, " + destination.slice + ")",
466
+ message:
467
+ location +
468
+ " imports " +
469
+ specifier +
470
+ " (" +
471
+ source.layer +
472
+ "/" +
473
+ source.slice +
474
+ " must not import another " +
475
+ source.layer +
476
+ " slice, " +
477
+ destination.slice +
478
+ ")",
391
479
  };
392
480
  }
393
481
  if (crossesSlice && source.layer === "widgets") {
394
482
  return {
395
483
  level: "warning",
396
484
  kind: "import",
397
- message: location + " imports " + specifier + " (widgets/" + source.slice + " depends on widgets/" + destination.slice + ")",
485
+ message:
486
+ location +
487
+ " imports " +
488
+ specifier +
489
+ " (widgets/" +
490
+ source.slice +
491
+ " depends on widgets/" +
492
+ destination.slice +
493
+ ")",
398
494
  };
399
495
  }
400
- if (!target.alias && (sourceRank !== destinationRank || source.slice !== destination.slice)) {
496
+ if (
497
+ !target.alias &&
498
+ (sourceRank !== destinationRank || source.slice !== destination.slice)
499
+ ) {
401
500
  return {
402
501
  level: "warning",
403
502
  kind: "import",
404
- message: location + " imports " + specifier + " with a relative path across slices; use @/" + target.path,
503
+ message:
504
+ location +
505
+ " imports " +
506
+ specifier +
507
+ " with a relative path across slices; use @/" +
508
+ target.path,
405
509
  };
406
510
  }
407
511
  return null;
408
512
  }
409
513
 
410
- export function checkImports({ files, srcLabel }) {
514
+ export function checkImports({ files, srcLabel, preservedPaths = [] }) {
411
515
  const findings = [];
412
516
  for (const file of files) {
413
- if (isTestSupport(file.path)) {
517
+ if (isPreservedSourcePath(file.path, preservedPaths)) {
414
518
  continue;
415
519
  }
416
520
  for (const { specifier, line } of extractImports(file.contents)) {
417
- const finding = checkImportRule({ fromPath: file.path, specifier, line, srcLabel });
521
+ const finding = checkImportRule({
522
+ fromPath: file.path,
523
+ specifier,
524
+ line,
525
+ srcLabel,
526
+ });
418
527
  if (finding !== null) {
419
528
  findings.push(finding);
420
529
  }
@@ -434,7 +543,8 @@ export function walkFiles(root) {
434
543
  if (entry.name.startsWith(".") && entry.isDirectory()) {
435
544
  continue;
436
545
  }
437
- const relativePath = prefix === "" ? entry.name : prefix + "/" + entry.name;
546
+ const relativePath =
547
+ prefix === "" ? entry.name : prefix + "/" + entry.name;
438
548
  if (entry.isDirectory()) {
439
549
  if (!walkExcludedDirectories.has(entry.name)) {
440
550
  walk(path.join(directory, entry.name), relativePath);
@@ -448,23 +558,88 @@ export function walkFiles(root) {
448
558
  return files.sort();
449
559
  }
450
560
 
451
- export function checkStructure({ files, srcLabel }) {
561
+ // Enumerate files under paths recorded relative to a repository root. Hidden resource directories are included.
562
+ export function filesUnderPaths(root, relativePaths = []) {
563
+ const rootPath = path.resolve(root);
564
+ const files = new Set();
565
+ const excludedDirectories = new Set([...walkExcludedDirectories, ".git"]);
566
+ const isInsideRoot = (candidate) =>
567
+ candidate === rootPath || candidate.startsWith(rootPath + path.sep);
568
+ const addSymlink = (absolutePath, relativePath) => {
569
+ try {
570
+ if (isInsideRoot(fs.realpathSync(absolutePath)))
571
+ files.add(toPosix(relativePath));
572
+ } catch {
573
+ // A dangling symlink has no readable target to snapshot.
574
+ }
575
+ };
576
+ const walk = (absoluteDirectory, relativeDirectory) => {
577
+ for (const entry of fs.readdirSync(absoluteDirectory, {
578
+ withFileTypes: true,
579
+ })) {
580
+ const relativePath =
581
+ relativeDirectory === ""
582
+ ? entry.name
583
+ : relativeDirectory + "/" + entry.name;
584
+ const absolutePath = path.join(absoluteDirectory, entry.name);
585
+ if (entry.isDirectory()) {
586
+ if (!excludedDirectories.has(entry.name))
587
+ walk(absolutePath, relativePath);
588
+ } else if (entry.isFile()) {
589
+ files.add(toPosix(relativePath));
590
+ } else if (entry.isSymbolicLink()) {
591
+ addSymlink(absolutePath, relativePath);
592
+ }
593
+ }
594
+ };
595
+
596
+ for (const rawPath of relativePaths) {
597
+ if (typeof rawPath !== "string" || rawPath === "") continue;
598
+ const relativePath = toPosix(rawPath).replace(/^\.\//, "");
599
+ const absolutePath = path.resolve(rootPath, ...relativePath.split("/"));
600
+ if (!isInsideRoot(absolutePath)) continue;
601
+ let stat;
602
+ try {
603
+ stat = fs.lstatSync(absolutePath);
604
+ } catch {
605
+ continue;
606
+ }
607
+ if (stat.isDirectory()) {
608
+ walk(absolutePath, relativePath);
609
+ } else if (stat.isFile()) {
610
+ files.add(relativePath);
611
+ } else if (stat.isSymbolicLink()) {
612
+ addSymlink(absolutePath, relativePath);
613
+ }
614
+ }
615
+ return [...files].sort();
616
+ }
617
+
618
+ export function checkStructure({ files, srcLabel, preservedPaths = [] }) {
452
619
  const findings = [];
453
- const error = (message) => findings.push({ level: "error", kind: "structure", message });
454
- const warning = (message) => findings.push({ level: "warning", kind: "structure", message });
620
+ const error = (message) =>
621
+ findings.push({ level: "error", kind: "structure", message });
622
+ const warning = (message) =>
623
+ findings.push({ level: "warning", kind: "structure", message });
455
624
  const label = (relativePath) => joinPosix(srcLabel, relativePath);
456
625
  const reportedDirectories = new Set();
457
626
  // Required files a root-file move already names, so the same move is not reported twice.
458
627
  const movedTargets = new Set();
459
628
 
460
629
  for (const file of files) {
461
- if (isTestSupport(file)) {
630
+ if (isPreservedSourcePath(file, preservedPaths)) {
462
631
  continue;
463
632
  }
464
633
  const parts = file.split("/");
465
634
  const extension = path.posix.extname(file);
466
635
  if (javascriptExtensions.has(extension) && !file.endsWith(".d.ts")) {
467
- findings.push({ level: "error", kind: "language", message: label(file) + " is JavaScript; convert it to TypeScript (.ts or .tsx)" });
636
+ findings.push({
637
+ level: "error",
638
+ kind: "language",
639
+ message:
640
+ label(file) +
641
+ " is JavaScript; convert it to TypeScript (.ts or .tsx)",
642
+ });
468
643
  }
469
644
  if (parts.length === 1) {
470
645
  if (!allowedSourceRootFiles.includes(file)) {
@@ -472,45 +647,103 @@ export function checkStructure({ files, srcLabel }) {
472
647
  const [target, ...remark] = (hint ?? "").split(" ");
473
648
  if (target.includes("/")) {
474
649
  movedTargets.add(target);
475
- error(label(file) + " must move to " + label(target) + (remark.length > 0 ? " " + remark.join(" ") : ""));
650
+ error(
651
+ label(file) +
652
+ " must move to " +
653
+ label(target) +
654
+ (remark.length > 0 ? " " + remark.join(" ") : "")
655
+ );
476
656
  } else {
477
- error(label(file) + " must live inside a layer" + (hint ? " (" + hint + ")" : ""));
657
+ error(
658
+ label(file) +
659
+ " must live inside a layer" +
660
+ (hint ? " (" + hint + ")" : "")
661
+ );
478
662
  }
479
663
  }
480
664
  continue;
481
665
  }
482
666
  const [top] = parts;
483
- if (!layerOrder.includes(top) && !auxiliarySourceDirectories.includes(top)) {
667
+ if (
668
+ !layerOrder.includes(top) &&
669
+ !auxiliarySourceDirectories.includes(top)
670
+ ) {
484
671
  if (!reportedDirectories.has(top)) {
485
672
  reportedDirectories.add(top);
486
673
  const hint = structureHints[top.toLowerCase()];
487
- error(label(top) + "/ is not an FSD layer; move its files" +
488
- (hint ? " to " + hint : " into app, pages, widgets, features, entities or shared") + " (under " + srcLabel + "/)");
674
+ error(
675
+ label(top) +
676
+ "/ is not an FSD layer; move its files" +
677
+ (hint
678
+ ? " to " + hint
679
+ : " into app, pages, widgets, features, entities or shared") +
680
+ " (under " +
681
+ srcLabel +
682
+ "/)"
683
+ );
489
684
  }
490
685
  continue;
491
686
  }
492
687
  if (top === "styles" && file !== "styles/" + generatedStylesFileName) {
493
- error(label(file) + " must move: src/styles holds only " + generatedStylesFileName + ", which eds sync generates (global CSS goes in app/styles/index.css)");
688
+ error(
689
+ label(file) +
690
+ " must move: src/styles holds only " +
691
+ generatedStylesFileName +
692
+ ", which eds sync generates (global CSS goes in app/styles/index.css)"
693
+ );
494
694
  continue;
495
695
  }
496
696
  if (top === "types" && !file.endsWith(".d.ts")) {
497
- warning(label(file) + " is not a declaration file; API types go in entities/<domain>/types and src/types keeps global .d.ts files");
697
+ warning(
698
+ label(file) +
699
+ " is not a declaration file; API types go in entities/<domain>/types and src/types keeps global .d.ts files"
700
+ );
498
701
  } else if (top === "app" && !appEntries.includes(parts[1])) {
499
- warning(label(file) + " is outside the app layer entries (" + appEntries.join(", ") + ")");
702
+ warning(
703
+ label(file) +
704
+ " is outside the app layer entries (" +
705
+ appEntries.join(", ") +
706
+ ")"
707
+ );
500
708
  } else if (top === "shared" && !sharedSegments.includes(parts[1])) {
501
- warning(label(file) + " is outside the shared segments (" + sharedSegments.join(", ") + ")");
709
+ warning(
710
+ label(file) +
711
+ " is outside the shared segments (" +
712
+ sharedSegments.join(", ") +
713
+ ")"
714
+ );
502
715
  } else if (top === "entities") {
503
716
  checkSliceNaming(parts, label, warning);
504
- if (parts.length === 2 || (parts.length === 3 && parts[2] !== "index.ts")) {
505
- warning(label(file) + " belongs in an entity segment (" + entitySegments.join(", ") + ")");
717
+ if (
718
+ parts.length === 2 ||
719
+ (parts.length === 3 && parts[2] !== "index.ts")
720
+ ) {
721
+ warning(
722
+ label(file) +
723
+ " belongs in an entity segment (" +
724
+ entitySegments.join(", ") +
725
+ ")"
726
+ );
506
727
  } else if (parts.length > 3 && !entitySegments.includes(parts[2])) {
507
- warning(label(file) + " is outside the entity segments (" + entitySegments.join(", ") + ")");
728
+ warning(
729
+ label(file) +
730
+ " is outside the entity segments (" +
731
+ entitySegments.join(", ") +
732
+ ")"
733
+ );
508
734
  }
509
735
  } else if (top === "features") {
510
736
  checkSliceNaming(parts, label, warning);
511
737
  checkFeaturePath(parts, file, label, warning);
512
- } else if (top === "pages" && extension === ".tsx" && path.posix.basename(file) !== "page.tsx") {
513
- warning(label(file) + " is a page component not named page.tsx (pages/<domain>/<action>/page.tsx, or pages/<name>/page.tsx outside a backend resource); move shared parts to widgets or features");
738
+ } else if (
739
+ top === "pages" &&
740
+ extension === ".tsx" &&
741
+ path.posix.basename(file) !== "page.tsx"
742
+ ) {
743
+ warning(
744
+ label(file) +
745
+ " is a page component not named page.tsx (pages/<domain>/<action>/page.tsx, or pages/<name>/page.tsx outside a backend resource); move shared parts to widgets or features"
746
+ );
514
747
  }
515
748
  }
516
749
 
@@ -521,12 +754,18 @@ export function checkStructure({ files, srcLabel }) {
521
754
  }
522
755
  }
523
756
  // A misnamed slice is reported once, not once per file inside it.
524
- return findings.filter((finding, index) => findings.findIndex((other) => other.message === finding.message) === index);
757
+ return findings.filter(
758
+ (finding, index) =>
759
+ findings.findIndex((other) => other.message === finding.message) === index
760
+ );
525
761
  }
526
762
 
527
763
  function checkSliceNaming(parts, label, warning) {
528
764
  if (parts.length >= 3 && !kebabCasePattern.test(parts[1])) {
529
- warning(label(parts.slice(0, 2).join("/")) + " must be a lower-case domain name (for example accounts, device-groups)");
765
+ warning(
766
+ label(parts.slice(0, 2).join("/")) +
767
+ " must be a lower-case domain name (for example accounts, device-groups)"
768
+ );
530
769
  }
531
770
  }
532
771
 
@@ -538,7 +777,12 @@ function checkFeaturePath(parts, file, label, warning) {
538
777
  }
539
778
  if (parts.length === 3) {
540
779
  if (parts[2] !== "index.ts") {
541
- warning(label(file) + " belongs in a feature segment (" + featureSegments.join(", ") + ")");
780
+ warning(
781
+ label(file) +
782
+ " belongs in a feature segment (" +
783
+ featureSegments.join(", ") +
784
+ ")"
785
+ );
542
786
  }
543
787
  return;
544
788
  }
@@ -546,52 +790,95 @@ function checkFeaturePath(parts, file, label, warning) {
546
790
  return;
547
791
  }
548
792
  if (!kebabCasePattern.test(parts[2])) {
549
- warning(label(parts.slice(0, 3).join("/")) + " must be a kebab-case feature name (for example edit-device)");
793
+ warning(
794
+ label(parts.slice(0, 3).join("/")) +
795
+ " must be a kebab-case feature name (for example edit-device)"
796
+ );
550
797
  }
551
798
  if (parts.length === 4 && parts[3] !== "index.ts") {
552
- warning(label(file) + " belongs in a feature segment (" + featureSegments.join(", ") + ")");
799
+ warning(
800
+ label(file) +
801
+ " belongs in a feature segment (" +
802
+ featureSegments.join(", ") +
803
+ ")"
804
+ );
553
805
  } else if (parts.length > 4 && !featureSegments.includes(parts[3])) {
554
- warning(label(file) + " is outside the feature segments (" + featureSegments.join(", ") + ")");
806
+ warning(
807
+ label(file) +
808
+ " is outside the feature segments (" +
809
+ featureSegments.join(", ") +
810
+ ")"
811
+ );
555
812
  }
556
813
  }
557
814
 
558
815
  // Heuristics for the code patterns. They never fail the check: each is either fixed or justified in PLAN.md.
559
- export function checkPatterns({ files, allFiles, srcLabel }) {
816
+ export function checkPatterns({
817
+ files,
818
+ allFiles,
819
+ srcLabel,
820
+ preservedPaths = [],
821
+ }) {
560
822
  const findings = [];
561
- const warning = (message) => findings.push({ level: "warning", kind: "pattern", message });
823
+ const warning = (message) =>
824
+ findings.push({ level: "warning", kind: "pattern", message });
562
825
  const label = (relativePath) => joinPosix(srcLabel, relativePath);
563
826
 
564
827
  for (const file of files) {
565
- if (isTestSupport(file.path) || file.path.startsWith("mocks/") || file.path.endsWith(".d.ts")) {
828
+ if (
829
+ isPreservedSourcePath(file.path, preservedPaths) ||
830
+ file.path.endsWith(".d.ts")
831
+ ) {
566
832
  continue;
567
833
  }
568
834
  const code = stripComments(file.contents);
569
835
  const inSharedApi = file.path.startsWith("shared/api/");
570
836
  const fetchIndex = code.search(/(?<![\w$.])fetch\s*\(/);
571
837
  if (fetchIndex >= 0 && !inSharedApi) {
572
- warning(label(file.path) + ":" + lineNumberAt(code, fetchIndex) + " calls fetch(); call the backend through shared/api and an entity service");
573
- }
574
- const axiosIndex = code.search(/\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/);
838
+ warning(
839
+ label(file.path) +
840
+ ":" +
841
+ lineNumberAt(code, fetchIndex) +
842
+ " calls fetch(); call the backend through shared/api and an entity service"
843
+ );
844
+ }
845
+ const axiosIndex = code.search(
846
+ /\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/
847
+ );
575
848
  if (axiosIndex >= 0 && !inSharedApi) {
576
- warning(label(file.path) + ":" + lineNumberAt(code, axiosIndex) + " uses axios directly; use the instance from @/shared/api");
849
+ warning(
850
+ label(file.path) +
851
+ ":" +
852
+ lineNumberAt(code, axiosIndex) +
853
+ " uses axios directly; use the instance from @/shared/api"
854
+ );
577
855
  }
578
856
  const { layer } = classifyModule(file.path);
579
- if (file.path.endsWith(".tsx") && ["widgets", "features", "entities", "shared"].includes(layer ?? "") && /\bexport\s+default\b/.test(code)) {
580
- warning(label(file.path) + " has a default export; components outside pages use named exports");
581
- }
582
- const joinedIndex = koreanConcatenationIndex(code);
583
- if (joinedIndex >= 0) {
584
- warning(label(file.path) + ":" + lineNumberAt(code, joinedIndex) + " joins Korean text with +; write the sentence as one string " +
585
- "(a template literal when it holds values) so it can move to a translation resource later");
857
+ if (
858
+ file.path.endsWith(".tsx") &&
859
+ ["widgets", "features", "entities", "shared"].includes(layer ?? "") &&
860
+ /\bexport\s+default\b/.test(code)
861
+ ) {
862
+ warning(
863
+ label(file.path) +
864
+ " has a default export; components outside pages use named exports"
865
+ );
586
866
  }
587
867
  }
588
868
 
589
869
  for (const file of allFiles) {
590
- if (isTestSupport(file)) {
870
+ if (isPreservedSourcePath(file, preservedPaths)) {
591
871
  continue;
592
872
  }
593
- if (styleExtensions.has(path.posix.extname(file)) && file !== "app/styles/index.css" && file !== "styles/" + generatedStylesFileName) {
594
- warning(label(file) + " is a separate stylesheet; move global rules to app/styles/index.css and component styles to Tailwind classNames");
873
+ if (
874
+ styleExtensions.has(path.posix.extname(file)) &&
875
+ file !== "app/styles/index.css" &&
876
+ file !== "styles/" + generatedStylesFileName
877
+ ) {
878
+ warning(
879
+ label(file) +
880
+ " is a separate stylesheet; move global rules to app/styles/index.css and component styles to Tailwind classNames"
881
+ );
595
882
  }
596
883
  }
597
884
  return findings;
@@ -602,11 +889,168 @@ export function majorOf(version) {
602
889
  return match ? Number.parseInt(match[1], 10) : null;
603
890
  }
604
891
 
892
+ function compareViteRangeVersions(left, right) {
893
+ const leftParts = versionParts(left);
894
+ const rightParts = versionParts(right);
895
+ for (let index = 0; index < 3; index += 1) {
896
+ if (leftParts[index] !== rightParts[index]) {
897
+ return leftParts[index] < rightParts[index] ? -1 : 1;
898
+ }
899
+ }
900
+ return 0;
901
+ }
902
+
903
+ function viteRangeUpperMajor(range) {
904
+ const clause = String(range ?? "").trim();
905
+ if (clause === "") return null;
906
+
907
+ const hyphenRange = clause.match(/^([^\s]+)\s+-\s+([^\s]+)$/);
908
+ if (hyphenRange) {
909
+ const upper = hyphenRange[2].match(
910
+ /^v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i
911
+ );
912
+ return upper ? Number.parseInt(upper[1], 10) : null;
913
+ }
914
+
915
+ const tokens = clause.split(/[\s,]+/).filter(Boolean);
916
+ let upperMajor = Number.POSITIVE_INFINITY;
917
+ let hasUpperBound = false;
918
+ for (const token of tokens) {
919
+ const match = token.match(
920
+ /^(\^|~|>=|<=|>|<|=)?v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:-[0-9A-Za-z.-]+)?$/i
921
+ );
922
+ if (!match) return null;
923
+ const operator = match[1] ?? "";
924
+ const major = Number.parseInt(match[2], 10);
925
+ const minor = /^\d+$/.test(match[3] ?? "")
926
+ ? Number.parseInt(match[3], 10)
927
+ : null;
928
+ const patch = /^\d+$/.test(match[4] ?? "")
929
+ ? Number.parseInt(match[4], 10)
930
+ : null;
931
+
932
+ if (["", "^", "~", "="].includes(operator)) {
933
+ upperMajor = Math.min(upperMajor, major);
934
+ hasUpperBound = true;
935
+ } else if (operator === "<" || operator === "<=") {
936
+ const boundMajor =
937
+ operator === "<" &&
938
+ (minor === null || minor === 0) &&
939
+ (patch === null || patch === 0)
940
+ ? major - 1
941
+ : major;
942
+ upperMajor = Math.min(upperMajor, boundMajor);
943
+ hasUpperBound = true;
944
+ }
945
+ }
946
+
947
+ return hasUpperBound ? upperMajor : null;
948
+ }
949
+
950
+ function viteRangeLowerVersion(range) {
951
+ const clause = String(range ?? "").trim();
952
+ if (clause === "") return null;
953
+
954
+ const hyphenRange = clause.match(/^([^\s]+)\s+-\s+([^\s]+)$/);
955
+ const tokens = hyphenRange
956
+ ? [hyphenRange[1]]
957
+ : clause.split(/[\s,]+/).filter(Boolean);
958
+ let lowerBound = null;
959
+ for (const token of tokens) {
960
+ const match = token.match(
961
+ /^(\^|~|>=|<=|>|<|=)?v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i
962
+ );
963
+ if (!match) return null;
964
+ const operator = match[1] ?? "";
965
+ if (operator === "<" || operator === "<=") continue;
966
+
967
+ const major = Number.parseInt(match[2], 10);
968
+ const minor = /^\d+$/.test(match[3] ?? "")
969
+ ? Number.parseInt(match[3], 10)
970
+ : 0;
971
+ const patch = /^\d+$/.test(match[4] ?? "")
972
+ ? Number.parseInt(match[4], 10)
973
+ : 0;
974
+ const version = major + "." + minor + "." + patch;
975
+ const exclusive = operator === ">";
976
+ const comparison =
977
+ lowerBound === null
978
+ ? 1
979
+ : compareViteRangeVersions(version, lowerBound.version);
980
+ if (
981
+ lowerBound === null ||
982
+ comparison > 0 ||
983
+ (comparison === 0 && exclusive && !lowerBound.exclusive)
984
+ ) {
985
+ lowerBound = { version, exclusive };
986
+ }
987
+ }
988
+ return lowerBound?.version ?? null;
989
+ }
990
+
991
+ function analyzeViteRange(range) {
992
+ const alternatives = String(range ?? "")
993
+ .split("||")
994
+ .map((alternative) => alternative.trim());
995
+ if (alternatives.length === 0 || alternatives.some((value) => value === "")) {
996
+ return { status: "unknown", major: null };
997
+ }
998
+
999
+ const majors = [];
1000
+ let unverified = false;
1001
+ let outsideSingleMajor = false;
1002
+ let belowMinimum = false;
1003
+ for (const alternative of alternatives) {
1004
+ const upperMajor = viteRangeUpperMajor(alternative);
1005
+ if (upperMajor === null) {
1006
+ unverified = true;
1007
+ continue;
1008
+ }
1009
+ const lowerVersion = viteRangeLowerVersion(alternative);
1010
+ if (lowerVersion === null) {
1011
+ outsideSingleMajor = true;
1012
+ continue;
1013
+ }
1014
+ const lowerMajor = majorOf(lowerVersion);
1015
+ if (lowerMajor !== upperMajor) {
1016
+ outsideSingleMajor = true;
1017
+ continue;
1018
+ }
1019
+ majors.push(lowerMajor);
1020
+ if (isOlderVersion(lowerVersion, minimumVersions.vite)) {
1021
+ belowMinimum = true;
1022
+ }
1023
+ }
1024
+
1025
+ if (unverified) return { status: "unknown", major: null };
1026
+ const uniqueMajors = [...new Set(majors)];
1027
+ if (
1028
+ outsideSingleMajor ||
1029
+ uniqueMajors.length !== alternatives.length ||
1030
+ uniqueMajors.length !== 1
1031
+ ) {
1032
+ return { status: "range", major: null };
1033
+ }
1034
+
1035
+ const major = uniqueMajors[0];
1036
+ if (major > maximumSupportedViteMajor) {
1037
+ return { status: "unsupported", major };
1038
+ }
1039
+ if (belowMinimum) return { status: "range", major };
1040
+ return { status: "verified", major };
1041
+ }
1042
+
1043
+ export function hasVerifiedViteRange(range) {
1044
+ return analyzeViteRange(range).status === "verified";
1045
+ }
1046
+
605
1047
  // Reads the installed version by walking node_modules upward, like Node's resolution, without honoring exports.
606
1048
  export function installedVersion(packageDirectory, name) {
607
1049
  let directory = packageDirectory;
608
1050
  for (;;) {
609
- const manifest = readTextIfExists(path.join(directory, "node_modules", name, "package.json"));
1051
+ const manifest = readTextIfExists(
1052
+ path.join(directory, "node_modules", name, "package.json")
1053
+ );
610
1054
  if (manifest !== null) {
611
1055
  try {
612
1056
  return String(JSON.parse(manifest).version ?? "") || null;
@@ -624,7 +1068,11 @@ export function installedVersion(packageDirectory, name) {
624
1068
 
625
1069
  export function declaredDependencies(packageJson) {
626
1070
  const declared = new Map();
627
- for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
1071
+ for (const section of [
1072
+ "dependencies",
1073
+ "devDependencies",
1074
+ "optionalDependencies",
1075
+ ]) {
628
1076
  for (const [name, range] of Object.entries(packageJson?.[section] ?? {})) {
629
1077
  if (!declared.has(name)) {
630
1078
  declared.set(name, { range: String(range), section });
@@ -635,7 +1083,9 @@ export function declaredDependencies(packageJson) {
635
1083
  }
636
1084
 
637
1085
  export function detectFramework(declared) {
638
- const unsupported = unsupportedFrameworks.find(([name]) => declared.has(name));
1086
+ const unsupported = unsupportedFrameworks.find(([name]) =>
1087
+ declared.has(name)
1088
+ );
639
1089
  return {
640
1090
  unsupported: unsupported ? unsupported[1] : null,
641
1091
  react: declared.has("react"),
@@ -646,7 +1096,11 @@ export function detectFramework(declared) {
646
1096
  // Dependency classification for the plan and the check. backendUsed lists packages the protected code imports.
647
1097
  function versionParts(version) {
648
1098
  const match = String(version ?? "").match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
649
- return match ? [match[1], match[2] ?? "0", match[3] ?? "0"].map((part) => Number.parseInt(part, 10)) : null;
1099
+ return match
1100
+ ? [match[1], match[2] ?? "0", match[3] ?? "0"].map((part) =>
1101
+ Number.parseInt(part, 10)
1102
+ )
1103
+ : null;
650
1104
  }
651
1105
 
652
1106
  export function isOlderVersion(version, minimum) {
@@ -663,9 +1117,45 @@ export function isOlderVersion(version, minimum) {
663
1117
  return false;
664
1118
  }
665
1119
 
1120
+ function isCompatibleVite8CompilerPeer(name, version) {
1121
+ const parts = versionParts(version);
1122
+ if (parts === null || /\d+\.\d+\.\d+-/.test(String(version))) {
1123
+ return false;
1124
+ }
1125
+ if (name === rolldownBabelPackage) {
1126
+ return (
1127
+ (parts[0] === 0 &&
1128
+ parts[1] === 1 &&
1129
+ !isOlderVersion(version, minimumVersions.rolldownPluginForVite8)) ||
1130
+ (parts[0] === 0 && parts[1] === 2)
1131
+ );
1132
+ }
1133
+ if (name === "babel-plugin-react-compiler") {
1134
+ return (
1135
+ parts[0] === 1 &&
1136
+ !isOlderVersion(version, minimumVersions.reactCompilerForVite8)
1137
+ );
1138
+ }
1139
+ if (name === "@babel/core") {
1140
+ return (
1141
+ (parts[0] === 7 &&
1142
+ !isOlderVersion(version, minimumVersions.babelCoreForVite8)) ||
1143
+ parts[0] === 8
1144
+ );
1145
+ }
1146
+ return false;
1147
+ }
1148
+
666
1149
  // toolVersions: the installed (or else declared) version of each templateToolMinimums package. restCalls: whether the
667
1150
  // frontend code calls fetch() or axios, which keeps axios required next to a data SDK.
668
- export function classifyDependencies({ declared, majors = {}, backendUsed = new Set(), toolVersions = {}, restCalls = true }) {
1151
+ export function classifyDependencies({
1152
+ declared,
1153
+ majors = {},
1154
+ backendUsed = new Set(),
1155
+ toolVersions = {},
1156
+ restCalls = true,
1157
+ nodeVersion = process.versions.node,
1158
+ }) {
669
1159
  const replacements = [];
670
1160
  const replaced = new Set();
671
1161
  for (const rule of replacementRules) {
@@ -682,14 +1172,45 @@ export function classifyDependencies({ declared, majors = {}, backendUsed = new
682
1172
  }
683
1173
  }
684
1174
  }
685
- const backendClients = backendClientPackages.filter((name) => declared.has(name));
686
- const dataClients = backendDataClientPackages.filter((name) => declared.has(name));
687
- const sdkOnly = dataClients.length > 0 && !restCalls && !declared.has("axios");
688
- const backendServers = backendServerPackages.filter((name) => declared.has(name));
689
- const viteMajor = majors.vite ?? majorOf(declared.get("vite")?.range);
1175
+ const backendClients = backendClientPackages.filter((name) =>
1176
+ declared.has(name)
1177
+ );
1178
+ const dataClients = backendDataClientPackages.filter((name) =>
1179
+ declared.has(name)
1180
+ );
1181
+ const sdkOnly =
1182
+ dataClients.length > 0 && !restCalls && !declared.has("axios");
1183
+ const backendServers = backendServerPackages.filter((name) =>
1184
+ declared.has(name)
1185
+ );
1186
+ const declaredViteRange = declared.get("vite")?.range ?? null;
1187
+ const viteRange = declared.has("vite")
1188
+ ? analyzeViteRange(declaredViteRange)
1189
+ : null;
1190
+ const installedViteMajor =
1191
+ majors.vite ?? majorOf(toolVersions.vite);
1192
+ const viteMajor = installedViteMajor ?? viteRange?.major ?? null;
1193
+ const viteRangeMismatch =
1194
+ viteRange?.status === "verified" &&
1195
+ installedViteMajor !== null &&
1196
+ installedViteMajor !== viteRange.major;
1197
+ const unverifiedViteRange = viteRange?.status === "unknown";
1198
+ const unsupportedViteRange =
1199
+ declared.has("vite") &&
1200
+ (viteRange?.status === "range" ||
1201
+ viteRange?.status === "unknown" ||
1202
+ viteRangeMismatch);
1203
+ const unsupportedVite =
1204
+ declared.has("vite") &&
1205
+ (viteRange?.status !== "verified" || viteRangeMismatch);
1206
+ const unsupportedNode =
1207
+ viteMajor === 8 &&
1208
+ (versionParts(nodeVersion) === null ||
1209
+ isOlderVersion(nodeVersion, minimumVersions.nodeForVite8));
690
1210
  const missing = [];
691
1211
  for (const name of requiredDependencies) {
692
- if (!declared.has(name)) missing.push({ name, dev: name === "tailwindcss" });
1212
+ if (!declared.has(name))
1213
+ missing.push({ name, dev: name === "tailwindcss" });
693
1214
  }
694
1215
  if (!sdkOnly && !declared.has("axios")) {
695
1216
  missing.push({ name: "axios", dev: false });
@@ -697,8 +1218,12 @@ export function classifyDependencies({ declared, majors = {}, backendUsed = new
697
1218
  for (const name of requiredDevDependencies) {
698
1219
  if (!declared.has(name)) missing.push({ name, dev: true });
699
1220
  }
700
- if (viteMajor !== null && viteMajor >= 8 && !declared.has(rolldownBabelPackage)) {
701
- missing.push({ name: rolldownBabelPackage, dev: true });
1221
+ if (viteMajor === 8) {
1222
+ for (const name of [rolldownBabelPackage, "@babel/core"]) {
1223
+ if (!declared.has(name)) {
1224
+ missing.push({ name, dev: true });
1225
+ }
1226
+ }
702
1227
  }
703
1228
  const outdated = [];
704
1229
  for (const [name, minimum] of Object.entries(minimumMajors)) {
@@ -707,73 +1232,360 @@ export function classifyDependencies({ declared, majors = {}, backendUsed = new
707
1232
  outdated.push({ name, major, minimum });
708
1233
  }
709
1234
  }
1235
+ const pluginReactMinimum = minimumPluginReactVersion(viteMajor);
1236
+ const pluginReactVersion =
1237
+ toolVersions["@vitejs/plugin-react"] ??
1238
+ declared.get("@vitejs/plugin-react")?.range;
1239
+ const pluginReactParts = versionParts(pluginReactVersion);
1240
+ const pluginReactMajor =
1241
+ majors["@vitejs/plugin-react"] ??
1242
+ pluginReactParts?.[0] ??
1243
+ majorOf(declared.get("@vitejs/plugin-react")?.range);
1244
+ const usesVite8Plugin = viteMajor === 8;
1245
+ const pluginReactCompatible =
1246
+ unsupportedVite ||
1247
+ (pluginReactParts !== null &&
1248
+ (usesVite8Plugin
1249
+ ? pluginReactParts[0] === 6 &&
1250
+ !isOlderVersion(pluginReactVersion, pluginReactMinimum)
1251
+ : pluginReactParts[0] === 5 ||
1252
+ (pluginReactParts[0] === 4 &&
1253
+ !isOlderVersion(pluginReactVersion, pluginReactMinimum))));
1254
+ if (
1255
+ !unsupportedVite &&
1256
+ declared.has("@vitejs/plugin-react") &&
1257
+ !pluginReactCompatible
1258
+ ) {
1259
+ outdated.push({
1260
+ name: "@vitejs/plugin-react",
1261
+ major: pluginReactMajor ?? "unknown",
1262
+ version: pluginReactParts?.join(".") ?? "unknown",
1263
+ minimum: pluginReactMinimum,
1264
+ allowedRange: usesVite8Plugin
1265
+ ? "^" + pluginReactMinimum
1266
+ : "^" + pluginReactMinimum + " or ^5",
1267
+ requiredBy: "Vite " + (viteMajor ?? "5.2+"),
1268
+ });
1269
+ }
1270
+ const vite8CompilerPeerRequirements = [
1271
+ {
1272
+ name: rolldownBabelPackage,
1273
+ allowedRange: "^0.1.7 or ^0.2.0",
1274
+ },
1275
+ {
1276
+ name: "babel-plugin-react-compiler",
1277
+ allowedRange: "^1.0.0",
1278
+ },
1279
+ {
1280
+ name: "@babel/core",
1281
+ allowedRange: "^7.29.0 or ^8.0.0-rc.1",
1282
+ },
1283
+ ];
1284
+ if (viteMajor === 8) {
1285
+ for (const requirement of vite8CompilerPeerRequirements) {
1286
+ if (!declared.has(requirement.name)) {
1287
+ continue;
1288
+ }
1289
+ const version =
1290
+ toolVersions[requirement.name] ??
1291
+ declared.get(requirement.name).range;
1292
+ if (!isCompatibleVite8CompilerPeer(requirement.name, version)) {
1293
+ outdated.push({
1294
+ name: requirement.name,
1295
+ version: versionParts(version)?.join(".") ?? "unknown",
1296
+ allowedRange: requirement.allowedRange,
1297
+ requiredBy: "Vite 8 React Compiler",
1298
+ compilerPeer: true,
1299
+ });
1300
+ }
1301
+ }
1302
+ }
1303
+ const viteVersion = toolVersions.vite ?? declared.get("vite")?.range;
1304
+ const viteParts = versionParts(viteVersion);
1305
+ if (declared.has("vite")) {
1306
+ if (unsupportedVite) {
1307
+ outdated.push({
1308
+ name: "vite",
1309
+ major: viteMajor ?? "unknown",
1310
+ version: viteParts?.join(".") ?? "unknown",
1311
+ unsupported: true,
1312
+ unsupportedRange:
1313
+ unsupportedViteRange &&
1314
+ !(viteMajor !== null && viteMajor > maximumSupportedViteMajor),
1315
+ unverifiedRange: unverifiedViteRange,
1316
+ range: declaredViteRange,
1317
+ });
1318
+ } else if (
1319
+ viteParts === null ||
1320
+ isOlderVersion(viteVersion, minimumVersions.vite)
1321
+ ) {
1322
+ outdated.push({
1323
+ name: "vite",
1324
+ major: viteParts?.[0] ?? majorOf(viteVersion),
1325
+ version: viteParts?.join(".") ?? "unknown",
1326
+ minimum: minimumVersions.vite,
1327
+ });
1328
+ }
1329
+ }
1330
+ const tailwindViteVersion =
1331
+ toolVersions["@tailwindcss/vite"] ?? declared.get("@tailwindcss/vite")?.range;
1332
+ const tailwindViteParts = versionParts(tailwindViteVersion);
1333
+ const tailwindViteMinimum = minimumTailwindViteVersion(viteMajor);
1334
+ if (
1335
+ tailwindViteMinimum !== null &&
1336
+ declared.has("@tailwindcss/vite") &&
1337
+ (tailwindViteParts === null ||
1338
+ isOlderVersion(tailwindViteVersion, tailwindViteMinimum))
1339
+ ) {
1340
+ outdated.push({
1341
+ name: "@tailwindcss/vite",
1342
+ major: tailwindViteParts?.[0] ?? majorOf(tailwindViteVersion),
1343
+ version: tailwindViteParts?.join(".") ?? "unknown",
1344
+ minimum: tailwindViteMinimum,
1345
+ requiredBy: "Vite " + viteMajor,
1346
+ });
1347
+ }
710
1348
  const outdatedTools = Object.entries(templateToolMinimums)
711
- .filter(([name, minimum]) => declared.has(name) && isOlderVersion(toolVersions[name] ?? declared.get(name).range, minimum))
712
- .map(([name, minimum]) => ({ name, version: versionParts(toolVersions[name] ?? declared.get(name).range).join("."), minimum }));
713
- const others = [...declared.keys()].filter((name) =>
714
- !replaced.has(name) && !conventionalPackages.has(name) && !backendClients.includes(name) && !backendServers.includes(name) &&
715
- !backendUsed.has(name) && !testToolingPattern.test(name) && !name.startsWith("@radix-ui/") && !name.startsWith("@types/")).sort();
1349
+ .filter(
1350
+ ([name, minimum]) =>
1351
+ declared.has(name) &&
1352
+ isOlderVersion(toolVersions[name] ?? declared.get(name).range, minimum)
1353
+ )
1354
+ .map(([name, minimum]) => ({
1355
+ name,
1356
+ version: versionParts(
1357
+ toolVersions[name] ?? declared.get(name).range
1358
+ ).join("."),
1359
+ minimum,
1360
+ }));
1361
+ const others = [...declared.keys()]
1362
+ .filter(
1363
+ (name) =>
1364
+ !replaced.has(name) &&
1365
+ !conventionalPackages.has(name) &&
1366
+ !backendClients.includes(name) &&
1367
+ !backendServers.includes(name) &&
1368
+ !backendUsed.has(name) &&
1369
+ !testToolingPattern.test(name) &&
1370
+ !preservedResourceDependencyPattern.test(name) &&
1371
+ !name.startsWith("@radix-ui/") &&
1372
+ !name.startsWith("@types/")
1373
+ )
1374
+ .sort();
716
1375
  const lucideRange = declared.get("lucide-react")?.range ?? null;
717
- const lucideMismatch = lucideRange !== null && edsLucideVersion !== null && lucideRange.replace(/^[\^~=v]+/, "") !== edsLucideVersion ? lucideRange : null;
718
- return { replacements, missing, outdated, outdatedTools, backendClients, dataClients, sdkOnly, backendServers, others, viteMajor, lucideMismatch };
1376
+ const lucideMismatch =
1377
+ lucideRange !== null &&
1378
+ edsLucideVersion !== null &&
1379
+ lucideRange.replace(/^[\^~=v]+/, "") !== edsLucideVersion
1380
+ ? lucideRange
1381
+ : null;
1382
+ return {
1383
+ replacements,
1384
+ missing,
1385
+ outdated,
1386
+ outdatedTools,
1387
+ unsupportedVite: unsupportedVite
1388
+ ? viteRange?.status === "unsupported"
1389
+ ? viteRange.major
1390
+ : viteMajor !== null && viteMajor > maximumSupportedViteMajor
1391
+ ? viteMajor
1392
+ : unverifiedViteRange
1393
+ ? "unknown"
1394
+ : "range"
1395
+ : null,
1396
+ unsupportedViteRange,
1397
+ unsupportedNode: unsupportedNode ? nodeVersion ?? "unknown" : null,
1398
+ backendClients,
1399
+ dataClients,
1400
+ sdkOnly,
1401
+ backendServers,
1402
+ others,
1403
+ viteMajor,
1404
+ lucideMismatch,
1405
+ };
719
1406
  }
720
1407
 
721
1408
  export function checkDependencies(classification) {
722
1409
  const findings = [];
1410
+ if (classification.unsupportedNode) {
1411
+ findings.push({
1412
+ level: "error",
1413
+ kind: "dependency",
1414
+ message:
1415
+ "Vite 8 React Compiler setup requires Node " +
1416
+ minimumVersions.nodeForVite8 +
1417
+ " or later; current Node is " +
1418
+ classification.unsupportedNode,
1419
+ });
1420
+ }
723
1421
  for (const entry of classification.replacements) {
724
1422
  if (!entry.keptForBackend) {
725
- findings.push({ level: "error", kind: "dependency", message: entry.name + " is still declared; replace it with " + entry.target + " (" + entry.category + ")" });
1423
+ findings.push({
1424
+ level: "error",
1425
+ kind: "dependency",
1426
+ message:
1427
+ entry.name +
1428
+ " is still declared; replace it with " +
1429
+ entry.target +
1430
+ " (" +
1431
+ entry.category +
1432
+ ")",
1433
+ });
726
1434
  }
727
1435
  }
728
1436
  for (const entry of classification.missing) {
729
- findings.push({ level: "error", kind: "dependency", message: entry.name + " is not declared" + (entry.dev ? " (devDependencies)" : "") });
1437
+ findings.push({
1438
+ level: "error",
1439
+ kind: "dependency",
1440
+ message:
1441
+ entry.name +
1442
+ " is not declared" +
1443
+ (entry.dev ? " (devDependencies)" : ""),
1444
+ });
730
1445
  }
731
1446
  for (const entry of classification.outdated) {
732
- findings.push({ level: "error", kind: "dependency", message: entry.name + " is major " + entry.major + "; the conventions use " + entry.minimum + " or later" });
1447
+ findings.push({
1448
+ level: "error",
1449
+ kind: "dependency",
1450
+ message:
1451
+ entry.unsupported
1452
+ ? entry.unsupportedRange
1453
+ ? "Vite range " +
1454
+ entry.range +
1455
+ " must guarantee Vite 5.2.0 or later within one supported major; eds align supports Vite 5.2.0–" +
1456
+ maximumSupportedViteMajor +
1457
+ ".x"
1458
+ : entry.version === "unknown"
1459
+ ? "Vite version cannot be verified; eds align supports Vite 5.2.0–" +
1460
+ maximumSupportedViteMajor +
1461
+ ".x"
1462
+ : "Vite " +
1463
+ entry.version +
1464
+ " is outside the supported range; eds align supports Vite 5.2.0–" +
1465
+ maximumSupportedViteMajor +
1466
+ ".x"
1467
+ : entry.allowedRange !== undefined
1468
+ ? entry.version === "unknown"
1469
+ ? entry.name +
1470
+ " version cannot be verified for " +
1471
+ entry.requiredBy +
1472
+ "; use " +
1473
+ entry.name +
1474
+ "@" +
1475
+ entry.allowedRange
1476
+ : entry.name +
1477
+ " " +
1478
+ entry.version +
1479
+ " is incompatible with " +
1480
+ entry.requiredBy +
1481
+ "; use " +
1482
+ entry.name +
1483
+ "@" +
1484
+ entry.allowedRange
1485
+ : entry.version !== undefined
1486
+ ? entry.version === "unknown"
1487
+ ? entry.name +
1488
+ " version cannot be verified; the minimum is " +
1489
+ entry.minimum
1490
+ : entry.name +
1491
+ " " +
1492
+ entry.version +
1493
+ " is below the minimum " +
1494
+ entry.minimum +
1495
+ (entry.name === "vite"
1496
+ ? " required by @tailwindcss/vite 4"
1497
+ : " required for " + entry.requiredBy)
1498
+ : entry.name +
1499
+ " is major " +
1500
+ entry.major +
1501
+ "; the conventions use " +
1502
+ entry.minimum +
1503
+ " or later",
1504
+ });
733
1505
  }
734
1506
  if (classification.lucideMismatch) {
735
1507
  findings.push({
736
1508
  level: "warning",
737
1509
  kind: "dependency",
738
- message: "lucide-react " + classification.lucideMismatch + " differs from the version EDS uses (" + edsLucideVersion + "); declare " + edsLucideVersion + " so the icon code is bundled once",
1510
+ message:
1511
+ "lucide-react " +
1512
+ classification.lucideMismatch +
1513
+ " differs from the version EDS uses (" +
1514
+ edsLucideVersion +
1515
+ "); declare " +
1516
+ edsLucideVersion +
1517
+ " so the icon code is bundled once",
739
1518
  });
740
1519
  }
741
1520
  for (const entry of classification.outdatedTools ?? []) {
742
- findings.push({ level: "error", kind: "config", message: entry.name + " " + entry.version + " is older than the eslint.config template requires (" + entry.minimum + ")" });
1521
+ findings.push({
1522
+ level: "error",
1523
+ kind: "config",
1524
+ message:
1525
+ entry.name +
1526
+ " " +
1527
+ entry.version +
1528
+ " is older than the eslint.config template requires (" +
1529
+ entry.minimum +
1530
+ ")",
1531
+ });
743
1532
  }
744
1533
  return findings;
745
1534
  }
746
1535
 
747
1536
  function findExisting(directory, names) {
748
- return names.find((name) => fs.existsSync(path.join(directory, name))) ?? null;
1537
+ return (
1538
+ names.find((name) => fs.existsSync(path.join(directory, name))) ?? null
1539
+ );
749
1540
  }
750
1541
 
751
1542
  function readIgnoreLines(text) {
752
- return (text ?? "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
1543
+ return (text ?? "")
1544
+ .split(/\r?\n/)
1545
+ .map((line) => line.trim())
1546
+ .filter((line) => line !== "" && !line.startsWith("#"));
753
1547
  }
754
1548
 
755
1549
  export function ignoreCovers(lines, relativePath) {
756
- const normalized = lines.map((line) => line.replace(/^\//, "").replace(/\/\*\*$/, "").replace(/\/$/, ""));
1550
+ const normalized = lines.map((line) =>
1551
+ line
1552
+ .replace(/^\//, "")
1553
+ .replace(/\/\*\*$/, "")
1554
+ .replace(/\/$/, "")
1555
+ );
757
1556
  const parts = relativePath.split("/");
758
1557
  for (let length = parts.length; length >= 1; length -= 1) {
759
1558
  if (normalized.includes(parts.slice(0, length).join("/"))) {
760
1559
  return true;
761
1560
  }
762
1561
  }
763
- return normalized.includes(path.posix.basename(relativePath)) || normalized.includes("**/" + path.posix.basename(relativePath));
1562
+ return (
1563
+ normalized.includes(path.posix.basename(relativePath)) ||
1564
+ normalized.includes("**/" + path.posix.basename(relativePath))
1565
+ );
764
1566
  }
765
1567
 
766
1568
  export function readScriptEntry(indexHtml) {
767
- const tag = indexHtml.match(/<script\b[^>]*\btype=["']module["'][^>]*>/i)?.[0] ?? null;
1569
+ const tag =
1570
+ indexHtml.match(/<script\b[^>]*\btype=["']module["'][^>]*>/i)?.[0] ?? null;
768
1571
  return tag?.match(/\bsrc=["']([^"']+)["']/i)?.[1] ?? null;
769
1572
  }
770
1573
 
771
1574
  // Config and script checks. app.srcDir and app.appDir are relative to the package directory.
772
- export function checkConfigs({ packageDirectory, packageJson, app, protectedInside, declared }) {
1575
+ export function checkConfigs({
1576
+ packageDirectory,
1577
+ packageJson,
1578
+ app,
1579
+ protectedInside,
1580
+ declared,
1581
+ }) {
773
1582
  const findings = [];
774
- const error = (kind, message) => findings.push({ level: "error", kind, message });
775
- const warning = (kind, message) => findings.push({ level: "warning", kind, message });
776
- const read = (relativePath) => readTextIfExists(path.join(packageDirectory, relativePath));
1583
+ const error = (kind, message) =>
1584
+ findings.push({ level: "error", kind, message });
1585
+ const warning = (kind, message) =>
1586
+ findings.push({ level: "warning", kind, message });
1587
+ const read = (relativePath) =>
1588
+ readTextIfExists(path.join(packageDirectory, relativePath));
777
1589
 
778
1590
  const eslintConfig = findExisting(packageDirectory, eslintConfigNames);
779
1591
  if (eslintConfig === null) {
@@ -781,20 +1593,40 @@ export function checkConfigs({ packageDirectory, packageJson, app, protectedInsi
781
1593
  } else {
782
1594
  const contents = read(eslintConfig);
783
1595
  if (!/typescript-eslint/.test(contents) || !/react-hooks/.test(contents)) {
784
- warning("config", eslintConfig + " does not use typescript-eslint and eslint-plugin-react-hooks like the template");
785
- }
786
- const unignored = protectedInside.filter((relativePath) =>
787
- isDirectory(path.join(packageDirectory, relativePath)) && !contents.includes("\"" + relativePath) && !contents.includes("'" + relativePath));
1596
+ warning(
1597
+ "config",
1598
+ eslintConfig +
1599
+ " does not use typescript-eslint and eslint-plugin-react-hooks like the template"
1600
+ );
1601
+ }
1602
+ const unignored = protectedInside.filter(
1603
+ (relativePath) =>
1604
+ isDirectory(path.join(packageDirectory, relativePath)) &&
1605
+ !contents.includes('"' + relativePath) &&
1606
+ !contents.includes("'" + relativePath)
1607
+ );
788
1608
  if (unignored.length > 0) {
789
- warning("config", eslintConfig + " should list the protected paths in globalIgnores: " + unignored.join(", "));
1609
+ warning(
1610
+ "config",
1611
+ eslintConfig +
1612
+ " should list the protected paths in globalIgnores: " +
1613
+ unignored.join(", ")
1614
+ );
790
1615
  }
791
1616
  }
792
1617
  const legacyEslint = findExisting(packageDirectory, legacyEslintConfigNames);
793
1618
  if (legacyEslint !== null || packageJson.eslintConfig !== undefined) {
794
- error("config", (legacyEslint ?? "package.json eslintConfig") + " is a legacy ESLint config; remove it after moving its rules to eslint.config.js");
1619
+ error(
1620
+ "config",
1621
+ (legacyEslint ?? "package.json eslintConfig") +
1622
+ " is a legacy ESLint config; remove it after moving its rules to eslint.config.js"
1623
+ );
795
1624
  }
796
1625
 
797
- if (findExisting(packageDirectory, prettierConfigNames) === null && packageJson.prettier === undefined) {
1626
+ if (
1627
+ findExisting(packageDirectory, prettierConfigNames) === null &&
1628
+ packageJson.prettier === undefined
1629
+ ) {
798
1630
  error("config", "no Prettier config (.prettierrc.json)");
799
1631
  }
800
1632
  const prettierIgnore = read(".prettierignore");
@@ -803,9 +1635,16 @@ export function checkConfigs({ packageDirectory, packageJson, app, protectedInsi
803
1635
  } else {
804
1636
  const lines = readIgnoreLines(prettierIgnore);
805
1637
  const required = [toPosix(generatedCssRelativePath), ...protectedInside];
806
- const uncovered = required.filter((relativePath) => !ignoreCovers(lines, relativePath));
1638
+ const uncovered = required.filter(
1639
+ (relativePath) => !ignoreCovers(lines, relativePath)
1640
+ );
807
1641
  if (uncovered.length > 0) {
808
- error("config", ".prettierignore must list " + uncovered.join(", ") + " (generated or protected files must not be reformatted)");
1642
+ error(
1643
+ "config",
1644
+ ".prettierignore must list " +
1645
+ uncovered.join(", ") +
1646
+ " (generated or protected files must not be reformatted)"
1647
+ );
809
1648
  }
810
1649
  }
811
1650
 
@@ -827,46 +1666,90 @@ export function checkConfigs({ packageDirectory, packageJson, app, protectedInsi
827
1666
  }
828
1667
  // Through @tailwindcss/postcss, url() in imported package CSS is not rebased: the EDS font is not emitted.
829
1668
  if (declared.has("tailwindcss") && !/@tailwindcss\/vite/.test(contents)) {
830
- error("config", viteConfig + " must run Tailwind through @tailwindcss/vite (with @tailwindcss/postcss the EDS font file is not emitted)");
1669
+ error(
1670
+ "config",
1671
+ viteConfig +
1672
+ " must run Tailwind through @tailwindcss/vite (with @tailwindcss/postcss the EDS font file is not emitted)"
1673
+ );
831
1674
  }
832
1675
  }
833
1676
  if (declared.has("@tailwindcss/postcss")) {
834
- warning("config", "@tailwindcss/postcss is declared; remove it and its postcss.config entry once @tailwindcss/vite runs Tailwind");
1677
+ warning(
1678
+ "config",
1679
+ "@tailwindcss/postcss is declared; remove it and its postcss.config entry once @tailwindcss/vite runs Tailwind"
1680
+ );
835
1681
  }
836
1682
 
837
1683
  const componentsJsonText = read("components.json");
838
- const usesRadix = [...declared.keys()].some((name) => name.startsWith("@radix-ui/react-"));
1684
+ const usesRadix = [...declared.keys()].some((name) =>
1685
+ name.startsWith("@radix-ui/react-")
1686
+ );
839
1687
  if (componentsJsonText !== null) {
840
1688
  try {
841
1689
  const aliases = parseJsonc(componentsJsonText).aliases ?? {};
842
- if (aliases.ui !== "@/shared/ui" || aliases.utils !== "@/shared/lib/utils") {
843
- warning("config", "components.json aliases must point ui to @/shared/ui and utils to @/shared/lib/utils");
1690
+ if (
1691
+ aliases.ui !== "@/shared/ui" ||
1692
+ aliases.utils !== "@/shared/lib/utils"
1693
+ ) {
1694
+ warning(
1695
+ "config",
1696
+ "components.json aliases must point ui to @/shared/ui and utils to @/shared/lib/utils"
1697
+ );
844
1698
  }
845
1699
  } catch (parseError) {
846
- warning("config", "components.json could not be parsed (" + parseError.message + ")");
1700
+ warning(
1701
+ "config",
1702
+ "components.json could not be parsed (" + parseError.message + ")"
1703
+ );
847
1704
  }
848
1705
  } else if (usesRadix) {
849
- warning("config", "no components.json although shadcn/Radix components are used");
1706
+ warning(
1707
+ "config",
1708
+ "no components.json although shadcn/Radix components are used"
1709
+ );
850
1710
  }
851
1711
 
852
1712
  const indexHtml = read(joinPosix(app.appDir, "index.html"));
853
- const entry = indexHtml === null ? null : readScriptEntry(indexHtml)?.replace(/^\.\//, "/") ?? null;
854
- const expectedEntry = "/" + joinPosix(path.posix.relative(app.appDir, app.srcDir) || ".", entryModule);
1713
+ const entry =
1714
+ indexHtml === null
1715
+ ? null
1716
+ : readScriptEntry(indexHtml)?.replace(/^\.\//, "/") ?? null;
1717
+ const expectedEntry =
1718
+ "/" +
1719
+ joinPosix(path.posix.relative(app.appDir, app.srcDir) || ".", entryModule);
855
1720
  if (entry !== expectedEntry) {
856
1721
  // Moving the entry is part of the FSD structure step, so this is a structure finding, not a config one.
857
- error("structure", joinPosix(app.appDir, "index.html") + " must load " + expectedEntry + (entry === null ? "" : " (loads " + entry + ")"));
1722
+ error(
1723
+ "structure",
1724
+ joinPosix(app.appDir, "index.html") +
1725
+ " must load " +
1726
+ expectedEntry +
1727
+ (entry === null ? "" : " (loads " + entry + ")")
1728
+ );
858
1729
  }
859
1730
 
860
1731
  const scripts = packageJson.scripts ?? {};
861
1732
  for (const [name, command] of Object.entries(requiredScripts)) {
862
1733
  if (typeof scripts[name] !== "string") {
863
- error("script", "package.json has no \"" + name + "\" script (template: \"" + command + "\")");
1734
+ error(
1735
+ "script",
1736
+ 'package.json has no "' +
1737
+ name +
1738
+ '" script (template: "' +
1739
+ command +
1740
+ '")'
1741
+ );
864
1742
  }
865
1743
  }
866
1744
 
867
1745
  const agents = read("AGENTS.md");
868
1746
  if (agents === null || !agents.includes(conventionsFileName)) {
869
- warning("agents", "AGENTS.md does not point at " + conventionsFileName + " yet (PLAN.md step 8)");
1747
+ warning(
1748
+ "agents",
1749
+ "AGENTS.md does not point at " +
1750
+ conventionsFileName +
1751
+ " yet (PLAN.md step 8)"
1752
+ );
870
1753
  }
871
1754
  return findings;
872
1755
  }
@@ -885,11 +1768,213 @@ function checkTsconfig({ packageDirectory, app, read, error }) {
885
1768
  return;
886
1769
  }
887
1770
  } catch (parseError) {
888
- error("config", name + " could not be parsed (" + parseError.message + ")");
1771
+ error(
1772
+ "config",
1773
+ name + " could not be parsed (" + parseError.message + ")"
1774
+ );
889
1775
  return;
890
1776
  }
891
1777
  }
892
- error("config", "tsconfig.json must map \"@/*\" to [\"./" + app.srcDir + "/*\"]");
1778
+ error("config", 'tsconfig.json must map "@/*" to ["./' + app.srcDir + '/*"]');
1779
+ }
1780
+
1781
+ const preservedTsconfigFields = ["include", "exclude", "types", "references"];
1782
+
1783
+ function stableJson(value) {
1784
+ if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]";
1785
+ if (value !== null && typeof value === "object") {
1786
+ return (
1787
+ "{" +
1788
+ Object.keys(value)
1789
+ .sort()
1790
+ .map((key) => JSON.stringify(key) + ":" + stableJson(value[key]))
1791
+ .join(",") +
1792
+ "}"
1793
+ );
1794
+ }
1795
+ return JSON.stringify(value);
1796
+ }
1797
+
1798
+ // Keep existing TypeScript include/exclude/type/reference settings by config file for --check.
1799
+ export function collectPreservedTsconfigSettings(
1800
+ packageDirectory,
1801
+ filePaths = walkFiles(packageDirectory)
1802
+ ) {
1803
+ const settings = {};
1804
+ const configPaths = filePaths.filter((relativePath) =>
1805
+ /^tsconfig(?:\..+)?\.json$/i.test(path.posix.basename(relativePath))
1806
+ );
1807
+ for (const relativePath of configPaths) {
1808
+ const fileSettings = Object.fromEntries(
1809
+ preservedTsconfigFields.map((field) => [field, new Set()])
1810
+ );
1811
+ const contents = fs.readFileSync(
1812
+ path.join(packageDirectory, relativePath),
1813
+ "utf8"
1814
+ );
1815
+ let config;
1816
+ try {
1817
+ config = parseJsonc(contents);
1818
+ } catch {
1819
+ settings[relativePath] = {
1820
+ parseErrorHash: crypto
1821
+ .createHash("sha256")
1822
+ .update(contents)
1823
+ .digest("hex"),
1824
+ };
1825
+ continue;
1826
+ }
1827
+ for (const field of preservedTsconfigFields) {
1828
+ const values = Array.isArray(
1829
+ field === "types" ? config.compilerOptions?.types : config[field]
1830
+ )
1831
+ ? field === "types"
1832
+ ? config.compilerOptions.types
1833
+ : config[field]
1834
+ : [];
1835
+ for (const value of values) {
1836
+ const pathValue =
1837
+ field === "references" && typeof value === "object" && value !== null
1838
+ ? value.path
1839
+ : value;
1840
+ const serialized =
1841
+ typeof pathValue === "string"
1842
+ ? pathValue.replaceAll("\\", "/")
1843
+ : JSON.stringify(value);
1844
+ const normalizedPath =
1845
+ typeof pathValue === "string"
1846
+ ? path.posix.normalize(
1847
+ path.posix.join(path.posix.dirname(relativePath), serialized)
1848
+ )
1849
+ : null;
1850
+ const normalized =
1851
+ field === "references" && typeof value === "object" && value !== null
1852
+ ? stableJson({
1853
+ ...value,
1854
+ ...(normalizedPath === null ? {} : { path: normalizedPath }),
1855
+ })
1856
+ : ["include", "exclude", "references"].includes(field) &&
1857
+ normalizedPath !== null
1858
+ ? normalizedPath
1859
+ : serialized;
1860
+ fileSettings[field].add(normalized);
1861
+ }
1862
+ }
1863
+ settings[relativePath] = Object.fromEntries(
1864
+ Object.entries(fileSettings).map(([field, values]) => [
1865
+ field,
1866
+ [...values].sort(),
1867
+ ])
1868
+ );
1869
+ }
1870
+ return settings;
1871
+ }
1872
+
1873
+ export function isLegacyPreservedTsconfigSettings(settings) {
1874
+ return (
1875
+ settings !== null &&
1876
+ typeof settings === "object" &&
1877
+ Object.keys(settings).length > 0 &&
1878
+ Object.values(settings).every(Array.isArray)
1879
+ );
1880
+ }
1881
+
1882
+ export function checkPreservedTsconfigSettings({
1883
+ baseline = {},
1884
+ current = {},
1885
+ }) {
1886
+ const findings = [];
1887
+ if (isLegacyPreservedTsconfigSettings(baseline)) {
1888
+ for (const field of preservedTsconfigFields) {
1889
+ const currentValues = new Set(
1890
+ Object.values(current).flatMap((settings) => settings[field] ?? [])
1891
+ );
1892
+ for (const value of baseline[field] ?? []) {
1893
+ if (!currentValues.has(value)) {
1894
+ findings.push({
1895
+ level: "error",
1896
+ kind: "preserved",
1897
+ message:
1898
+ "tsconfig " +
1899
+ field +
1900
+ " no longer includes the existing setting " +
1901
+ JSON.stringify(value) +
1902
+ "; restore it or rerun eds align to refresh the plan",
1903
+ });
1904
+ }
1905
+ }
1906
+ }
1907
+ return findings;
1908
+ }
1909
+ for (const [configPath, baselineSettings] of Object.entries(baseline)) {
1910
+ if (!Object.hasOwn(current, configPath)) {
1911
+ findings.push({
1912
+ level: "error",
1913
+ kind: "preserved",
1914
+ message: "tsconfig " + configPath + " was removed; restore the file",
1915
+ });
1916
+ continue;
1917
+ }
1918
+ const primaryConfig =
1919
+ configPath === "tsconfig.json" || configPath === "tsconfig.app.json";
1920
+ const baselineParseErrorHash = baselineSettings?.parseErrorHash;
1921
+ const currentParseErrorHash = current[configPath]?.parseErrorHash;
1922
+ if (typeof baselineParseErrorHash === "string") {
1923
+ if (currentParseErrorHash !== baselineParseErrorHash) {
1924
+ findings.push({
1925
+ level: "error",
1926
+ kind: "preserved",
1927
+ message:
1928
+ "tsconfig " +
1929
+ configPath +
1930
+ " could not be parsed when the plan was created and has changed; fix it and rerun eds align",
1931
+ });
1932
+ } else if (!primaryConfig) {
1933
+ findings.push({
1934
+ level: "error",
1935
+ kind: "config",
1936
+ message:
1937
+ "tsconfig " +
1938
+ configPath +
1939
+ " could not be parsed; existing settings cannot be checked",
1940
+ });
1941
+ }
1942
+ continue;
1943
+ }
1944
+ if (typeof currentParseErrorHash === "string") {
1945
+ if (!primaryConfig) {
1946
+ findings.push({
1947
+ level: "error",
1948
+ kind: "config",
1949
+ message:
1950
+ "tsconfig " +
1951
+ configPath +
1952
+ " could not be parsed; existing settings cannot be checked",
1953
+ });
1954
+ }
1955
+ continue;
1956
+ }
1957
+ for (const field of preservedTsconfigFields) {
1958
+ const currentValues = new Set(current[configPath]?.[field] ?? []);
1959
+ for (const value of baselineSettings[field] ?? []) {
1960
+ if (!currentValues.has(value)) {
1961
+ findings.push({
1962
+ level: "error",
1963
+ kind: "preserved",
1964
+ message:
1965
+ "tsconfig " +
1966
+ configPath +
1967
+ " " +
1968
+ field +
1969
+ " no longer includes the existing setting " +
1970
+ JSON.stringify(value) +
1971
+ "; restore it",
1972
+ });
1973
+ }
1974
+ }
1975
+ }
1976
+ }
1977
+ return findings;
893
1978
  }
894
1979
 
895
1980
  // Finds package directories (relative to root) whose package.json declares react.
@@ -910,7 +1995,11 @@ export function findFrontendPackages(root) {
910
1995
  return;
911
1996
  }
912
1997
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
913
- if (entry.isDirectory() && !entry.name.startsWith(".") && !walkExcludedDirectories.has(entry.name)) {
1998
+ if (
1999
+ entry.isDirectory() &&
2000
+ !entry.name.startsWith(".") &&
2001
+ !walkExcludedDirectories.has(entry.name)
2002
+ ) {
914
2003
  visit(path.join(directory, entry.name), depth + 1);
915
2004
  }
916
2005
  }
@@ -925,32 +2014,58 @@ export function locateApp(packageDirectory) {
925
2014
  const visit = (directory, depth) => {
926
2015
  const indexHtml = readTextIfExists(path.join(directory, "index.html"));
927
2016
  if (indexHtml !== null && readScriptEntry(indexHtml) !== null) {
928
- candidates.push(toPosix(path.relative(packageDirectory, directory)) || ".");
2017
+ candidates.push(
2018
+ toPosix(path.relative(packageDirectory, directory)) || "."
2019
+ );
929
2020
  }
930
2021
  if (depth >= 2) {
931
2022
  return;
932
2023
  }
933
2024
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
934
- if (entry.isDirectory() && !entry.name.startsWith(".") && !walkExcludedDirectories.has(entry.name) && entry.name !== "public" && entry.name !== "src") {
2025
+ if (
2026
+ entry.isDirectory() &&
2027
+ !entry.name.startsWith(".") &&
2028
+ !walkExcludedDirectories.has(entry.name) &&
2029
+ entry.name !== "public" &&
2030
+ entry.name !== "src"
2031
+ ) {
935
2032
  visit(path.join(directory, entry.name), depth + 1);
936
2033
  }
937
2034
  }
938
2035
  };
939
2036
  visit(packageDirectory, 0);
940
- const appDir = candidates.includes(".") ? "." : candidates.length === 1 ? candidates[0] : null;
2037
+ const appDir = candidates.includes(".")
2038
+ ? "."
2039
+ : candidates.length === 1
2040
+ ? candidates[0]
2041
+ : null;
941
2042
  if (appDir === null) {
942
2043
  throw createExitError(
943
2044
  candidates.length === 0
944
- ? "No index.html with a <script type=\"module\"> entry in the frontend package; eds align supports Vite apps only."
945
- : "Several index.html entries (" + candidates.join(", ") + "); run eds align inside the package of the app to align.",
946
- 2,
2045
+ ? 'No index.html with a <script type="module"> entry in the frontend package; eds align supports Vite apps only.'
2046
+ : "Several index.html entries (" +
2047
+ candidates.join(", ") +
2048
+ "); run eds align inside the package of the app to align.",
2049
+ 2
947
2050
  );
948
2051
  }
949
2052
  const srcDir = joinPosix(appDir, "src");
950
- if (!fs.existsSync(path.join(packageDirectory, srcDir)) || !fs.statSync(path.join(packageDirectory, srcDir)).isDirectory()) {
951
- throw createExitError("No " + srcDir + " directory next to " + joinPosix(appDir, "index.html") + ".", 2);
2053
+ if (
2054
+ !fs.existsSync(path.join(packageDirectory, srcDir)) ||
2055
+ !fs.statSync(path.join(packageDirectory, srcDir)).isDirectory()
2056
+ ) {
2057
+ throw createExitError(
2058
+ "No " +
2059
+ srcDir +
2060
+ " directory next to " +
2061
+ joinPosix(appDir, "index.html") +
2062
+ ".",
2063
+ 2
2064
+ );
952
2065
  }
953
- const indexHtml = readTextIfExists(path.join(packageDirectory, appDir, "index.html"));
2066
+ const indexHtml = readTextIfExists(
2067
+ path.join(packageDirectory, appDir, "index.html")
2068
+ );
954
2069
  return { appDir, srcDir, entry: readScriptEntry(indexHtml) };
955
2070
  }
956
2071
 
@@ -964,18 +2079,33 @@ function isDirectory(filePath) {
964
2079
 
965
2080
  function readSourceFiles(root, relativePaths) {
966
2081
  return relativePaths
967
- .filter((relativePath) => scriptExtensions.has(path.posix.extname(relativePath)))
968
- .map((relativePath) => ({ path: relativePath, contents: fs.readFileSync(path.join(root, relativePath), "utf8") }));
2082
+ .filter((relativePath) =>
2083
+ scriptExtensions.has(path.posix.extname(relativePath))
2084
+ )
2085
+ .map((relativePath) => ({
2086
+ path: relativePath,
2087
+ contents: fs.readFileSync(path.join(root, relativePath), "utf8"),
2088
+ }));
969
2089
  }
970
2090
 
971
2091
  // A relative import target without extension resolved the way bundlers do (file, then index file).
972
2092
  function resolveModuleFile(packageDirectory, relativePath) {
973
2093
  const candidates = [
974
2094
  relativePath,
975
- ...[...scriptExtensions, ".json"].map((extension) => relativePath + extension),
976
- ...[...scriptExtensions].map((extension) => relativePath + "/index" + extension),
2095
+ ...[...scriptExtensions, ".json"].map(
2096
+ (extension) => relativePath + extension
2097
+ ),
2098
+ ...[...scriptExtensions].map(
2099
+ (extension) => relativePath + "/index" + extension
2100
+ ),
977
2101
  ];
978
- return candidates.find((candidate) => fs.existsSync(path.join(packageDirectory, candidate)) && !isDirectory(path.join(packageDirectory, candidate))) ?? relativePath;
2102
+ return (
2103
+ candidates.find(
2104
+ (candidate) =>
2105
+ fs.existsSync(path.join(packageDirectory, candidate)) &&
2106
+ !isDirectory(path.join(packageDirectory, candidate))
2107
+ ) ?? relativePath
2108
+ );
979
2109
  }
980
2110
 
981
2111
  // Binaries whose package name differs from the command.
@@ -991,15 +2121,28 @@ function scriptPackages(command, declared) {
991
2121
  }
992
2122
 
993
2123
  // Backend and deployment paths. inside: relative to the package; repository: relative to the git root.
994
- export function detectProtectedPaths({ packageDirectory, gitRoot, declared, scripts }) {
2124
+ export function detectProtectedPaths({
2125
+ packageDirectory,
2126
+ gitRoot,
2127
+ declared,
2128
+ scripts,
2129
+ }) {
995
2130
  const packageRelative = toPosix(path.relative(gitRoot, packageDirectory));
996
- const toRepository = (relativePath) => (packageRelative === "" ? relativePath : packageRelative + "/" + relativePath);
2131
+ const toRepository = (relativePath) =>
2132
+ packageRelative === ""
2133
+ ? relativePath
2134
+ : packageRelative + "/" + relativePath;
997
2135
  const inside = [];
998
2136
  for (const name of backendDirectoryNames) {
999
2137
  if (isDirectory(path.join(packageDirectory, name))) inside.push(name);
1000
2138
  }
1001
- const hasBackend = inside.length > 0 || backendServerPackages.some((name) => declared.has(name));
1002
- if (hasBackend && isDirectory(path.join(packageDirectory, sharedWithBackendDirectoryName))) {
2139
+ const hasBackend =
2140
+ inside.length > 0 ||
2141
+ backendServerPackages.some((name) => declared.has(name));
2142
+ if (
2143
+ hasBackend &&
2144
+ isDirectory(path.join(packageDirectory, sharedWithBackendDirectoryName))
2145
+ ) {
1003
2146
  inside.push(sharedWithBackendDirectoryName);
1004
2147
  }
1005
2148
  for (const name of backendMarkerFiles) {
@@ -1013,33 +2156,57 @@ export function detectProtectedPaths({ packageDirectory, gitRoot, declared, scri
1013
2156
  }
1014
2157
 
1015
2158
  // Packages the backend code in this package imports, and frontend files it imports (their exports must stay).
1016
- const backendUsed = new Set(backendServerPackages.filter((name) => declared.has(name)));
2159
+ const backendUsed = new Set(
2160
+ backendServerPackages.filter((name) => declared.has(name))
2161
+ );
1017
2162
  const importedFrontendFiles = new Set();
1018
- const insideDirectories = inside.filter((relativePath) => isDirectory(path.join(packageDirectory, relativePath)));
1019
- const isInsideProtected = (relativePath) => inside.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/"));
2163
+ const insideDirectories = inside.filter((relativePath) =>
2164
+ isDirectory(path.join(packageDirectory, relativePath))
2165
+ );
2166
+ const isInsideProtected = (relativePath) =>
2167
+ inside.some(
2168
+ (entry) => relativePath === entry || relativePath.startsWith(entry + "/")
2169
+ );
1020
2170
  for (const directory of insideDirectories) {
1021
- for (const file of readSourceFiles(path.join(packageDirectory, directory), walkFiles(path.join(packageDirectory, directory)))) {
2171
+ for (const file of readSourceFiles(
2172
+ path.join(packageDirectory, directory),
2173
+ walkFiles(path.join(packageDirectory, directory))
2174
+ )) {
1022
2175
  const filePath = directory + "/" + file.path;
1023
2176
  for (const { specifier } of extractImports(file.contents)) {
1024
2177
  const name = packageNameOf(specifier);
1025
2178
  if (name !== null && declared.has(name)) {
1026
2179
  backendUsed.add(name);
1027
2180
  } else if (specifier.startsWith(".")) {
1028
- const target = path.posix.normalize(path.posix.join(path.posix.dirname(filePath), specifier));
2181
+ const target = path.posix.normalize(
2182
+ path.posix.join(path.posix.dirname(filePath), specifier)
2183
+ );
1029
2184
  if (!target.startsWith("..") && !isInsideProtected(target)) {
1030
- importedFrontendFiles.add(resolveModuleFile(packageDirectory, target));
2185
+ importedFrontendFiles.add(
2186
+ resolveModuleFile(packageDirectory, target)
2187
+ );
1031
2188
  }
1032
2189
  }
1033
2190
  }
1034
2191
  }
1035
2192
  }
1036
2193
 
1037
- const directoryPattern = insideDirectories.length === 0
1038
- ? null
1039
- : new RegExp("(?:^|[\\s\"'=(])(?:\\./)?(?:" + insideDirectories.map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")/");
2194
+ const directoryPattern =
2195
+ insideDirectories.length === 0
2196
+ ? null
2197
+ : new RegExp(
2198
+ "(?:^|[\\s\"'=(])(?:\\./)?(?:" +
2199
+ insideDirectories
2200
+ .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
2201
+ .join("|") +
2202
+ ")/"
2203
+ );
1040
2204
  const backendScripts = {};
1041
2205
  for (const [name, command] of Object.entries(scripts ?? {})) {
1042
- if (typeof command === "string" && (backendScriptPattern.test(command) || directoryPattern?.test(command))) {
2206
+ if (
2207
+ typeof command === "string" &&
2208
+ (backendScriptPattern.test(command) || directoryPattern?.test(command))
2209
+ ) {
1043
2210
  backendScripts[name] = command;
1044
2211
  for (const packageUsed of scriptPackages(command, declared)) {
1045
2212
  backendUsed.add(packageUsed);
@@ -1058,26 +2225,96 @@ export function detectProtectedPaths({ packageDirectory, gitRoot, declared, scri
1058
2225
  }
1059
2226
 
1060
2227
  // Paths changed since the base commit (from git), classified against the protected list recorded in the state.
1061
- export function classifyChangedPaths({ changed, baseline, hashOf, packageRelative, protectedPaths, alignRelative, base }) {
2228
+ export function classifyChangedPaths({
2229
+ changed,
2230
+ baseline,
2231
+ hashOf,
2232
+ packageRelative,
2233
+ protectedPaths,
2234
+ preservedPaths = [],
2235
+ baseTrackedPaths = [],
2236
+ alignRelative,
2237
+ base,
2238
+ }) {
1062
2239
  const shortBase = String(base ?? "").slice(0, 12);
1063
2240
  const findings = [];
1064
- const inPackage = (relativePath) => packageRelative === "" || relativePath === packageRelative || relativePath.startsWith(packageRelative + "/");
1065
- for (const relativePath of [...new Set(changed)].sort()) {
1066
- if (relativePath === alignRelative || relativePath.startsWith(alignRelative + "/")) {
2241
+ const inPackage = (relativePath) =>
2242
+ packageRelative === "" ||
2243
+ relativePath === packageRelative ||
2244
+ relativePath.startsWith(packageRelative + "/");
2245
+ const isPreserved = (relativePath) =>
2246
+ isPreservedSourcePath(relativePath) ||
2247
+ preservedPaths.some(
2248
+ (entry) => relativePath === entry || relativePath.startsWith(entry + "/")
2249
+ );
2250
+ const changedPaths = new Set(changed);
2251
+ const baseTracked = new Set(baseTrackedPaths);
2252
+ for (const [relativePath, baselineHash] of Object.entries(baseline)) {
2253
+ if (baselineHash !== hashOf(relativePath)) {
2254
+ changedPaths.add(relativePath);
2255
+ }
2256
+ }
2257
+ for (const relativePath of [...changedPaths].sort()) {
2258
+ if (
2259
+ relativePath === alignRelative ||
2260
+ relativePath.startsWith(alignRelative + "/")
2261
+ ) {
1067
2262
  continue;
1068
2263
  }
1069
- if (Object.prototype.hasOwnProperty.call(baseline, relativePath) && baseline[relativePath] === hashOf(relativePath)) {
2264
+ if (
2265
+ Object.prototype.hasOwnProperty.call(baseline, relativePath) &&
2266
+ baseline[relativePath] === hashOf(relativePath)
2267
+ ) {
1070
2268
  continue;
1071
2269
  }
1072
- if (protectedPaths.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/"))) {
2270
+ if (
2271
+ protectedPaths.some(
2272
+ (entry) =>
2273
+ relativePath === entry || relativePath.startsWith(entry + "/")
2274
+ )
2275
+ ) {
1073
2276
  findings.push({
1074
2277
  level: "error",
1075
2278
  kind: "protected",
1076
- message: relativePath + " changed since " + shortBase + "; it is backend or deployment code (git checkout " + shortBase + " -- " +
1077
- relativePath + " restores it; delete it if it is new)",
2279
+ message:
2280
+ relativePath +
2281
+ " changed since " +
2282
+ shortBase +
2283
+ "; it is backend or deployment code (git checkout " +
2284
+ shortBase +
2285
+ " -- " +
2286
+ relativePath +
2287
+ " restores it; delete it if it is new)",
2288
+ });
2289
+ } else if (
2290
+ !inPackage(relativePath) &&
2291
+ !(packageRelative !== "" && rootLockfiles.includes(relativePath))
2292
+ ) {
2293
+ findings.push({
2294
+ level: "error",
2295
+ kind: "outside",
2296
+ message:
2297
+ relativePath +
2298
+ " changed since " +
2299
+ shortBase +
2300
+ " outside the frontend package",
2301
+ });
2302
+ } else if (isPreserved(relativePath)) {
2303
+ if (
2304
+ !Object.hasOwn(baseline, relativePath) &&
2305
+ !baseTracked.has(relativePath)
2306
+ ) {
2307
+ continue;
2308
+ }
2309
+ findings.push({
2310
+ level: "error",
2311
+ kind: "preserved",
2312
+ message:
2313
+ relativePath +
2314
+ " changed since " +
2315
+ shortBase +
2316
+ "; restore the file to its state and location at the alignment baseline",
1078
2317
  });
1079
- } else if (!inPackage(relativePath) && !(packageRelative !== "" && rootLockfiles.includes(relativePath))) {
1080
- findings.push({ level: "error", kind: "outside", message: relativePath + " changed since " + shortBase + " outside the frontend package" });
1081
2318
  }
1082
2319
  }
1083
2320
  return findings;
@@ -1086,74 +2323,250 @@ export function classifyChangedPaths({ changed, baseline, hashOf, packageRelativ
1086
2323
  export function checkBackendPackage({ packageJson, state }) {
1087
2324
  const findings = [];
1088
2325
  const scripts = packageJson.scripts ?? {};
1089
- for (const [name, command] of Object.entries(state.protected.backendScripts ?? {})) {
2326
+ for (const [name, command] of Object.entries(
2327
+ state.protected.backendScripts ?? {}
2328
+ )) {
1090
2329
  if (scripts[name] !== command) {
1091
2330
  findings.push({
1092
2331
  level: "error",
1093
2332
  kind: "backend-script",
1094
- message: "package.json script \"" + name + "\" runs backend code and must stay " + JSON.stringify(command) +
1095
- (scripts[name] === undefined ? " (it was removed)" : " (now " + JSON.stringify(scripts[name]) + ")"),
2333
+ message:
2334
+ 'package.json script "' +
2335
+ name +
2336
+ '" runs backend code and must stay ' +
2337
+ JSON.stringify(command) +
2338
+ (scripts[name] === undefined
2339
+ ? " (it was removed)"
2340
+ : " (now " + JSON.stringify(scripts[name]) + ")"),
1096
2341
  });
1097
2342
  }
1098
2343
  }
1099
2344
  const declared = declaredDependencies(packageJson);
1100
2345
  for (const name of state.protected.backendUsed ?? []) {
1101
2346
  if (!declared.has(name)) {
1102
- findings.push({ level: "error", kind: "backend-dependency", message: name + " was removed from package.json but the backend code uses it" });
2347
+ findings.push({
2348
+ level: "error",
2349
+ kind: "backend-dependency",
2350
+ message:
2351
+ name + " was removed from package.json but the backend code uses it",
2352
+ });
1103
2353
  }
1104
2354
  }
1105
2355
  return findings;
1106
2356
  }
1107
2357
 
1108
2358
  // Everything the plan and the check need, read from the working tree.
1109
- export function analyzeFrontend({ packageDirectory, gitRoot, protectedInfo = null }) {
1110
- const packageJson = JSON.parse(fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8"));
2359
+ export function analyzeFrontend({
2360
+ packageDirectory,
2361
+ gitRoot,
2362
+ protectedInfo = null,
2363
+ preservedPaths = [],
2364
+ nodeVersion = process.versions.node,
2365
+ }) {
2366
+ const packageJson = JSON.parse(
2367
+ fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8")
2368
+ );
1111
2369
  const declared = declaredDependencies(packageJson);
1112
2370
  const app = locateApp(packageDirectory);
1113
- const detectedProtected = protectedInfo ?? detectProtectedPaths({ packageDirectory, gitRoot, declared, scripts: packageJson.scripts });
2371
+ const detectedProtected =
2372
+ protectedInfo ??
2373
+ detectProtectedPaths({
2374
+ packageDirectory,
2375
+ gitRoot,
2376
+ declared,
2377
+ scripts: packageJson.scripts,
2378
+ });
1114
2379
  const majors = {};
1115
- for (const name of ["vite", "react", "tailwindcss", "@tanstack/react-query", "typescript", "@vitejs/plugin-react"]) {
2380
+ for (const name of [
2381
+ "vite",
2382
+ "react",
2383
+ "tailwindcss",
2384
+ "@tanstack/react-query",
2385
+ "typescript",
2386
+ "@vitejs/plugin-react",
2387
+ ]) {
1116
2388
  const installed = installedVersion(packageDirectory, name);
1117
- majors[name] = majorOf(installed ?? declared.get(name)?.range);
2389
+ majors[name] =
2390
+ name === "vite"
2391
+ ? majorOf(installed)
2392
+ : majorOf(installed ?? declared.get(name)?.range);
1118
2393
  }
1119
2394
  const srcRoot = path.join(packageDirectory, app.srcDir);
1120
2395
  const allFiles = walkFiles(srcRoot);
1121
- const sourceFiles = readSourceFiles(srcRoot, allFiles.filter((file) => !file.endsWith(".d.ts")));
1122
- const toolVersions = Object.fromEntries(Object.keys(templateToolMinimums).map((name) => [name, installedVersion(packageDirectory, name)]).filter(([, version]) => version !== null));
1123
- const restCalls = sourceFiles.some((file) => /\bfetch\s*\(|\baxios\b/.test(file.contents));
1124
- const dependencies = classifyDependencies({ declared, majors, backendUsed: new Set(detectedProtected.backendUsed), toolVersions, restCalls });
2396
+ const sourceFiles = readSourceFiles(
2397
+ srcRoot,
2398
+ allFiles.filter((file) => !file.endsWith(".d.ts"))
2399
+ );
2400
+ const packageFiles = walkFiles(packageDirectory);
2401
+ const preservedTsconfigSettings = collectPreservedTsconfigSettings(
2402
+ packageDirectory,
2403
+ packageFiles
2404
+ );
2405
+ const preservedFilePaths = packageFiles.filter((relativePath) =>
2406
+ isPreservedSourcePath(relativePath)
2407
+ );
2408
+ const preservedDirectoryPaths = new Set();
2409
+ for (const file of preservedFilePaths) {
2410
+ const parts = file.split("/");
2411
+ for (let index = 0; index < parts.length - 1; index += 1) {
2412
+ if (isPreservedDirectoryName(parts[index], parts, index)) {
2413
+ preservedDirectoryPaths.add(parts.slice(0, index + 1).join("/"));
2414
+ }
2415
+ }
2416
+ }
2417
+ const autoPreservedPaths = [
2418
+ ...new Set([...preservedFilePaths, ...preservedDirectoryPaths]),
2419
+ ].sort();
2420
+ const normalizedPreservedPaths = [...new Set(preservedPaths)].sort();
2421
+ const allPreservedPaths = [
2422
+ ...new Set([...autoPreservedPaths, ...normalizedPreservedPaths]),
2423
+ ].sort();
2424
+ const sourcePrefix = app.srcDir + "/";
2425
+ const preservedSourcePaths = allPreservedPaths.flatMap((relativePath) => {
2426
+ if (relativePath === ".") return ["."];
2427
+ if (relativePath === app.srcDir) return ["."];
2428
+ return relativePath.startsWith(sourcePrefix)
2429
+ ? [relativePath.slice(sourcePrefix.length)]
2430
+ : [];
2431
+ });
2432
+ const isPreservedPackageFile = (relativePath) =>
2433
+ isPreservedSourcePath(relativePath, allPreservedPaths);
2434
+ const alignedSourceFiles = sourceFiles.filter(
2435
+ (file) => !isPreservedSourcePath(file.path, preservedSourcePaths)
2436
+ );
2437
+ const reactMigrationTargets = alignedSourceFiles
2438
+ .filter(({ path: file }) => /\.(?:js|jsx|ts|tsx)$/i.test(file))
2439
+ .map(({ path: file }) => joinPosix(app.srcDir, file));
2440
+ const existingPreservedFiles = packageFiles.filter(isPreservedPackageFile);
2441
+ const preservedFiles = readSourceFiles(
2442
+ packageDirectory,
2443
+ existingPreservedFiles
2444
+ );
2445
+ const toolVersions = Object.fromEntries(
2446
+ [
2447
+ ...new Set([
2448
+ "vite",
2449
+ "@vitejs/plugin-react",
2450
+ "@tailwindcss/vite",
2451
+ rolldownBabelPackage,
2452
+ "babel-plugin-react-compiler",
2453
+ ...Object.keys(templateToolMinimums),
2454
+ ]),
2455
+ ]
2456
+ .map((name) => [name, installedVersion(packageDirectory, name)])
2457
+ .filter(([, version]) => version !== null)
2458
+ );
2459
+ const restCalls = alignedSourceFiles.some((file) =>
2460
+ /\bfetch\s*\(|\baxios\b/.test(file.contents)
2461
+ );
2462
+ const dependencies = classifyDependencies({
2463
+ declared,
2464
+ majors,
2465
+ backendUsed: new Set(detectedProtected.backendUsed),
2466
+ toolVersions,
2467
+ restCalls,
2468
+ nodeVersion,
2469
+ });
2470
+ majors.vite = dependencies.viteMajor;
1125
2471
  // shadcn ui files that import a package the plan replaces (for example form.tsx and react-hook-form).
1126
2472
  dependencies.uiDependents = dependencies.replacements
1127
2473
  .filter((entry) => !entry.keptForBackend)
1128
- .flatMap((entry) => sourceFiles
1129
- .filter((file) => /(^|\/)ui\//.test(file.path) && new RegExp("from\\s+[\"']" + entry.name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&") + "[\"'/]").test(file.contents))
1130
- .map((file) => ({ file: joinPosix(app.srcDir, file.path), name: entry.name })));
2474
+ .flatMap((entry) =>
2475
+ alignedSourceFiles
2476
+ .filter(
2477
+ (file) =>
2478
+ /(^|\/)ui\//.test(file.path) &&
2479
+ new RegExp(
2480
+ "from\\s+[\"']" +
2481
+ entry.name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&") +
2482
+ "[\"'/]"
2483
+ ).test(file.contents)
2484
+ )
2485
+ .map((file) => ({
2486
+ file: joinPosix(app.srcDir, file.path),
2487
+ name: entry.name,
2488
+ }))
2489
+ );
2490
+ dependencies.preservedDependents = dependencies.replacements
2491
+ .filter((entry) => !entry.keptForBackend)
2492
+ .flatMap((entry) =>
2493
+ preservedFiles
2494
+ .filter((file) =>
2495
+ extractImports(file.contents).some(
2496
+ ({ specifier }) => packageNameOf(specifier) === entry.name
2497
+ )
2498
+ )
2499
+ .map((file) => ({ file: file.path, name: entry.name }))
2500
+ );
1131
2501
  const findings = [
1132
- ...checkStructure({ files: allFiles, srcLabel: app.srcDir }),
1133
- ...checkImports({ files: sourceFiles, srcLabel: app.srcDir }),
2502
+ ...checkStructure({
2503
+ files: allFiles,
2504
+ srcLabel: app.srcDir,
2505
+ preservedPaths: preservedSourcePaths,
2506
+ }),
2507
+ ...checkImports({
2508
+ files: sourceFiles,
2509
+ srcLabel: app.srcDir,
2510
+ preservedPaths: preservedSourcePaths,
2511
+ }),
1134
2512
  ...checkDependencies(dependencies),
1135
- ...checkConfigs({ packageDirectory, packageJson, app, protectedInside: detectedProtected.inside, declared }),
1136
- ...checkPatterns({ files: sourceFiles, allFiles, srcLabel: app.srcDir }),
2513
+ ...checkConfigs({
2514
+ packageDirectory,
2515
+ packageJson,
2516
+ app,
2517
+ protectedInside: detectedProtected.inside,
2518
+ declared,
2519
+ }),
2520
+ ...checkPatterns({
2521
+ files: sourceFiles,
2522
+ allFiles,
2523
+ srcLabel: app.srcDir,
2524
+ preservedPaths: preservedSourcePaths,
2525
+ }),
1137
2526
  ];
1138
- return { packageJson, declared, app, protectedInfo: detectedProtected, majors, dependencies, allFiles, findings };
2527
+ return {
2528
+ packageJson,
2529
+ declared,
2530
+ app,
2531
+ protectedInfo: detectedProtected,
2532
+ majors,
2533
+ dependencies,
2534
+ allFiles,
2535
+ reactMigrationTargets,
2536
+ autoPreservedPaths,
2537
+ preservedPaths: allPreservedPaths,
2538
+ preservedSourcePaths,
2539
+ preservedTsconfigSettings,
2540
+ findings,
2541
+ };
1139
2542
  }
1140
2543
 
1141
2544
  // src/ top-level entries with their file counts and where each goes.
1142
- export function summarizeStructure(allFiles) {
2545
+ export function summarizeStructure(allFiles, preservedPaths = []) {
1143
2546
  const entries = new Map();
1144
2547
  for (const file of allFiles) {
1145
2548
  const [top] = file.split("/");
1146
2549
  const isFile = !file.includes("/");
1147
- const entry = entries.get(top) ?? { name: top, type: isFile ? "file" : "directory", files: 0 };
2550
+ const entry = entries.get(top) ?? {
2551
+ name: top,
2552
+ type: isFile ? "file" : "directory",
2553
+ files: 0,
2554
+ };
1148
2555
  entry.files += 1;
1149
2556
  entries.set(top, entry);
1150
2557
  }
1151
2558
  return [...entries.values()].map((entry) => {
1152
- const test = entry.type === "directory" ? testDirectoryNames.includes(entry.name) : isTestSupport(entry.name);
2559
+ const preserved = isPreservedSourcePath(entry.name, preservedPaths);
1153
2560
  return {
1154
2561
  ...entry,
1155
- fsd: test || layerOrder.includes(entry.name) || auxiliarySourceDirectories.includes(entry.name) || allowedSourceRootFiles.includes(entry.name),
1156
- hint: test ? testSupportHint : structureHints[entry.name.toLowerCase()] ?? null,
2562
+ fsd:
2563
+ preserved ||
2564
+ layerOrder.includes(entry.name) ||
2565
+ auxiliarySourceDirectories.includes(entry.name) ||
2566
+ allowedSourceRootFiles.includes(entry.name),
2567
+ hint: preserved
2568
+ ? preservedSourceHint
2569
+ : structureHints[entry.name.toLowerCase()] ?? null,
1157
2570
  };
1158
2571
  });
1159
2572
  }