@spectrabrain/eds 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +3 -3
- package/AI_CONTEXT.json +7 -8
- package/CHANGELOG.md +34 -16
- package/CONSUMER_AGENT.md +65 -55
- package/FRONTEND_CONVENTIONS.md +27 -32
- package/QUICKSTART.md +19 -28
- package/README.md +11 -9
- package/TAILWIND_V4.md +4 -2
- package/TOKEN_CONTEXT.md +7 -7
- package/consumer/align/templates/package.scripts.json +0 -1
- package/consumer/align/templates/src/app/main.tsx +9 -19
- package/consumer/align/templates/vite.config.rolldown.ts +1 -1
- package/consumer/align-analysis.mjs +1712 -299
- package/consumer/align-plan.mjs +647 -139
- package/consumer/align-rules.mjs +59 -23
- package/consumer/align.mjs +639 -103
- package/consumer/cli.mjs +7 -7
- package/dist/components/Button/README.md +1 -1
- package/dist/components/Button/spec.json +2 -2
- package/dist/components.css +1 -1
- package/llms.txt +6 -8
- package/package.json +1 -1
- package/consumer/align/templates/env.mock +0 -1
- package/consumer/align/templates/src/mocks/browser.ts +0 -5
- package/consumer/align/templates/src/mocks/handlers.ts +0 -4
|
@@ -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 {
|
|
56
|
+
import {
|
|
57
|
+
createExitError,
|
|
58
|
+
generatedCssRelativePath,
|
|
59
|
+
readTextIfExists,
|
|
60
|
+
toPosix,
|
|
61
|
+
} from "./paths.mjs";
|
|
43
62
|
|
|
44
|
-
const scriptExtensions = new Set([
|
|
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([
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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 = [
|
|
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
|
|
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(
|
|
165
|
+
const joined = path.posix.join(
|
|
166
|
+
...parts.filter((part) => part !== "" && part !== ".")
|
|
167
|
+
);
|
|
110
168
|
return joined === "" ? "." : joined;
|
|
111
169
|
}
|
|
112
170
|
|
|
113
|
-
//
|
|
171
|
+
// Match test paths so alignment checks leave them unchanged.
|
|
114
172
|
export function isTestSupport(relativePath) {
|
|
115
173
|
const parts = relativePath.split("/");
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
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 === "
|
|
254
|
+
if (character === '"' || character === "'") {
|
|
147
255
|
let end = index + 1;
|
|
148
|
-
while (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
297
|
+
if (
|
|
298
|
+
character === "/" &&
|
|
299
|
+
(lastSignificant === "" ||
|
|
300
|
+
"(,=:[!&|?{};+-*%<>~^".includes(lastSignificant))
|
|
301
|
+
) {
|
|
177
302
|
let end = index + 1;
|
|
178
303
|
let inClass = false;
|
|
179
|
-
while (
|
|
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 (
|
|
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({
|
|
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 (
|
|
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("@")
|
|
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 (
|
|
345
|
-
|
|
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 =
|
|
358
|
-
|
|
359
|
-
|
|
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:
|
|
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 =
|
|
385
|
-
|
|
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:
|
|
390
|
-
|
|
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:
|
|
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 (
|
|
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:
|
|
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 (
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
454
|
-
|
|
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 (
|
|
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({
|
|
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(
|
|
650
|
+
error(
|
|
651
|
+
label(file) +
|
|
652
|
+
" must move to " +
|
|
653
|
+
label(target) +
|
|
654
|
+
(remark.length > 0 ? " " + remark.join(" ") : "")
|
|
655
|
+
);
|
|
476
656
|
} else {
|
|
477
|
-
error(
|
|
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 (
|
|
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(
|
|
488
|
-
(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
505
|
-
|
|
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(
|
|
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 (
|
|
513
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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({
|
|
816
|
+
export function checkPatterns({
|
|
817
|
+
files,
|
|
818
|
+
allFiles,
|
|
819
|
+
srcLabel,
|
|
820
|
+
preservedPaths = [],
|
|
821
|
+
}) {
|
|
560
822
|
const findings = [];
|
|
561
|
-
const warning = (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 (
|
|
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(
|
|
573
|
-
|
|
574
|
-
|
|
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(
|
|
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 (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
warning(
|
|
585
|
-
|
|
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 (
|
|
870
|
+
if (isPreservedSourcePath(file, preservedPaths)) {
|
|
591
871
|
continue;
|
|
592
872
|
}
|
|
593
|
-
if (
|
|
594
|
-
|
|
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(
|
|
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 [
|
|
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]) =>
|
|
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
|
|
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({
|
|
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) =>
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
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))
|
|
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
|
|
701
|
-
|
|
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(
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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 =
|
|
718
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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({
|
|
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
|
|
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 ?? "")
|
|
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) =>
|
|
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
|
|
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 =
|
|
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({
|
|
1575
|
+
export function checkConfigs({
|
|
1576
|
+
packageDirectory,
|
|
1577
|
+
packageJson,
|
|
1578
|
+
app,
|
|
1579
|
+
protectedInside,
|
|
1580
|
+
declared,
|
|
1581
|
+
}) {
|
|
773
1582
|
const findings = [];
|
|
774
|
-
const error = (kind, message) =>
|
|
775
|
-
|
|
776
|
-
const
|
|
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(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
1638
|
+
const uncovered = required.filter(
|
|
1639
|
+
(relativePath) => !ignoreCovers(lines, relativePath)
|
|
1640
|
+
);
|
|
807
1641
|
if (uncovered.length > 0) {
|
|
808
|
-
error(
|
|
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(
|
|
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(
|
|
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) =>
|
|
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 (
|
|
843
|
-
|
|
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(
|
|
1700
|
+
warning(
|
|
1701
|
+
"config",
|
|
1702
|
+
"components.json could not be parsed (" + parseError.message + ")"
|
|
1703
|
+
);
|
|
847
1704
|
}
|
|
848
1705
|
} else if (usesRadix) {
|
|
849
|
-
warning(
|
|
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 =
|
|
854
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1771
|
+
error(
|
|
1772
|
+
"config",
|
|
1773
|
+
name + " could not be parsed (" + parseError.message + ")"
|
|
1774
|
+
);
|
|
889
1775
|
return;
|
|
890
1776
|
}
|
|
891
1777
|
}
|
|
892
|
-
error("config",
|
|
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 (
|
|
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(
|
|
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 (
|
|
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(".")
|
|
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
|
-
?
|
|
945
|
-
: "Several index.html entries (" +
|
|
946
|
-
|
|
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 (
|
|
951
|
-
|
|
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(
|
|
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) =>
|
|
968
|
-
|
|
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(
|
|
976
|
-
|
|
2095
|
+
...[...scriptExtensions, ".json"].map(
|
|
2096
|
+
(extension) => relativePath + extension
|
|
2097
|
+
),
|
|
2098
|
+
...[...scriptExtensions].map(
|
|
2099
|
+
(extension) => relativePath + "/index" + extension
|
|
2100
|
+
),
|
|
977
2101
|
];
|
|
978
|
-
return
|
|
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({
|
|
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) =>
|
|
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 =
|
|
1002
|
-
|
|
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(
|
|
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) =>
|
|
1019
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
2185
|
+
importedFrontendFiles.add(
|
|
2186
|
+
resolveModuleFile(packageDirectory, target)
|
|
2187
|
+
);
|
|
1031
2188
|
}
|
|
1032
2189
|
}
|
|
1033
2190
|
}
|
|
1034
2191
|
}
|
|
1035
2192
|
}
|
|
1036
2193
|
|
|
1037
|
-
const directoryPattern =
|
|
1038
|
-
|
|
1039
|
-
|
|
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 (
|
|
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({
|
|
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) =>
|
|
1065
|
-
|
|
1066
|
-
|
|
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 (
|
|
2264
|
+
if (
|
|
2265
|
+
Object.prototype.hasOwnProperty.call(baseline, relativePath) &&
|
|
2266
|
+
baseline[relativePath] === hashOf(relativePath)
|
|
2267
|
+
) {
|
|
1070
2268
|
continue;
|
|
1071
2269
|
}
|
|
1072
|
-
if (
|
|
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:
|
|
1077
|
-
relativePath +
|
|
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(
|
|
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:
|
|
1095
|
-
|
|
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({
|
|
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({
|
|
1110
|
-
|
|
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 =
|
|
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 [
|
|
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] =
|
|
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(
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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) =>
|
|
1129
|
-
|
|
1130
|
-
|
|
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({
|
|
1133
|
-
|
|
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({
|
|
1136
|
-
|
|
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 {
|
|
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) ?? {
|
|
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
|
|
2559
|
+
const preserved = isPreservedSourcePath(entry.name, preservedPaths);
|
|
1153
2560
|
return {
|
|
1154
2561
|
...entry,
|
|
1155
|
-
fsd:
|
|
1156
|
-
|
|
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
|
}
|