@spectrabrain/eds 0.1.0 → 0.1.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 +13 -63
- package/AI_CONTEXT.json +26 -95
- package/CHANGELOG.md +66 -4
- package/CONSUMER_AGENT.md +86 -164
- package/FRONTEND_CONVENTIONS.md +252 -0
- package/QUICKSTART.md +24 -29
- package/README.md +24 -73
- package/TAILWIND_V4.md +49 -184
- package/TOKEN_CONTEXT.md +63 -36
- package/consumer/align/templates/components.json +21 -0
- package/consumer/align/templates/env.mock +1 -0
- package/consumer/align/templates/eslint.config.js +35 -0
- package/consumer/align/templates/package.scripts.json +10 -0
- package/consumer/align/templates/prettierignore +2 -0
- package/consumer/align/templates/prettierrc.json +7 -0
- package/consumer/align/templates/src/app/App.tsx +16 -0
- package/consumer/align/templates/src/app/main.tsx +27 -0
- package/consumer/align/templates/src/app/providers/ErrorBoundary.tsx +58 -0
- package/consumer/align/templates/src/app/providers/ErrorHandler.tsx +29 -0
- package/consumer/align/templates/src/app/router/index.tsx +23 -0
- package/consumer/align/templates/src/app/styles/index.css +2 -0
- package/consumer/align/templates/src/mocks/browser.ts +5 -0
- package/consumer/align/templates/src/mocks/handlers.ts +4 -0
- package/consumer/align/templates/src/shared/api/index.ts +23 -0
- package/consumer/align/templates/src/shared/api/queryClient.ts +24 -0
- package/consumer/align/templates/src/shared/lib/eventBus.ts +44 -0
- package/consumer/align/templates/src/shared/lib/utils/index.ts +6 -0
- package/consumer/align/templates/src/vite-env.d.ts +2 -0
- package/consumer/align/templates/tsconfig.json +22 -0
- package/consumer/align/templates/vite.config.babel.ts +36 -0
- package/consumer/align/templates/vite.config.rolldown.ts +35 -0
- package/consumer/align-analysis.mjs +1159 -0
- package/consumer/align-plan.mjs +377 -0
- package/consumer/align-rules.mjs +473 -0
- package/consumer/align.mjs +401 -0
- package/consumer/cli.mjs +90 -40
- package/consumer/css-root.mjs +24 -4
- package/consumer/doctor.mjs +47 -21
- package/consumer/init.mjs +39 -25
- package/consumer/managed-block.mjs +11 -5
- package/consumer/paths.mjs +69 -14
- package/consumer/register-scripts.mjs +17 -12
- package/consumer/scan-classes.mjs +4 -3
- package/consumer/sync.mjs +35 -13
- package/consumer/update-agent-context.mjs +63 -7
- package/consumer-postinstall.mjs +8 -7
- package/dist/components/Button/README.md +66 -95
- package/dist/components/Button/spec.json +19 -138
- package/dist/components.css +102 -366
- package/dist/index.d.mts +20 -18
- package/dist/index.d.ts +20 -18
- package/dist/tokens.d.mts +3 -0
- package/dist/tokens.d.ts +3 -0
- package/llms.txt +20 -14
- package/package.json +3 -1
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
allowedSourceRootFiles,
|
|
6
|
+
appEntries,
|
|
7
|
+
auxiliarySourceDirectories,
|
|
8
|
+
backendClientPackages,
|
|
9
|
+
backendDataClientPackages,
|
|
10
|
+
edsLucideVersion,
|
|
11
|
+
templateToolMinimums,
|
|
12
|
+
backendDirectoryNames,
|
|
13
|
+
backendMarkerFiles,
|
|
14
|
+
backendScriptPattern,
|
|
15
|
+
backendServerPackages,
|
|
16
|
+
conventionsFileName,
|
|
17
|
+
entitySegments,
|
|
18
|
+
entryModule,
|
|
19
|
+
featureSegments,
|
|
20
|
+
generatedStylesFileName,
|
|
21
|
+
layerOrder,
|
|
22
|
+
minimumMajors,
|
|
23
|
+
replacementRules,
|
|
24
|
+
requiredDependencies,
|
|
25
|
+
requiredDevDependencies,
|
|
26
|
+
requiredScripts,
|
|
27
|
+
requiredSourceFiles,
|
|
28
|
+
rolldownBabelPackage,
|
|
29
|
+
rootLockfiles,
|
|
30
|
+
sharedSegments,
|
|
31
|
+
sharedWithBackendDirectoryName,
|
|
32
|
+
slicedLayers,
|
|
33
|
+
structureHints,
|
|
34
|
+
templateDevDependencies,
|
|
35
|
+
testDirectoryNames,
|
|
36
|
+
testFilePattern,
|
|
37
|
+
testSetupFilePattern,
|
|
38
|
+
testSupportHint,
|
|
39
|
+
testToolingPattern,
|
|
40
|
+
unsupportedFrameworks,
|
|
41
|
+
} from "./align-rules.mjs";
|
|
42
|
+
import { createExitError, generatedCssRelativePath, readTextIfExists, toPosix } from "./paths.mjs";
|
|
43
|
+
|
|
44
|
+
const scriptExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
45
|
+
const javascriptExtensions = new Set([".js", ".jsx", ".mjs", ".cjs"]);
|
|
46
|
+
const styleExtensions = new Set([".css", ".scss", ".sass", ".less", ".styl"]);
|
|
47
|
+
const walkExcludedDirectories = new Set(["node_modules", "dist", "build", "coverage", "storybook-static", "out"]);
|
|
48
|
+
const frontendSearchDepth = 3;
|
|
49
|
+
const kebabCasePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
50
|
+
const koreanPattern = /[ㄱ-ㆎ가-힣]/;
|
|
51
|
+
const eslintConfigNames = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts"];
|
|
52
|
+
const legacyEslintConfigNames = [".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml"];
|
|
53
|
+
const prettierConfigNames = [
|
|
54
|
+
".prettierrc",
|
|
55
|
+
".prettierrc.json",
|
|
56
|
+
".prettierrc.json5",
|
|
57
|
+
".prettierrc.yaml",
|
|
58
|
+
".prettierrc.yml",
|
|
59
|
+
".prettierrc.toml",
|
|
60
|
+
".prettierrc.js",
|
|
61
|
+
".prettierrc.cjs",
|
|
62
|
+
".prettierrc.mjs",
|
|
63
|
+
".prettierrc.ts",
|
|
64
|
+
"prettier.config.js",
|
|
65
|
+
"prettier.config.cjs",
|
|
66
|
+
"prettier.config.mjs",
|
|
67
|
+
"prettier.config.ts",
|
|
68
|
+
];
|
|
69
|
+
const viteConfigNames = ["vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"];
|
|
70
|
+
// Packages an aligned frontend may declare besides the required ones; anything else is listed for review.
|
|
71
|
+
const conventionalPackages = new Set([
|
|
72
|
+
...requiredDependencies,
|
|
73
|
+
...requiredDevDependencies,
|
|
74
|
+
...templateDevDependencies,
|
|
75
|
+
rolldownBabelPackage,
|
|
76
|
+
"axios",
|
|
77
|
+
"zustand",
|
|
78
|
+
"lucide-react",
|
|
79
|
+
"class-variance-authority",
|
|
80
|
+
"date-fns",
|
|
81
|
+
"react-day-picker",
|
|
82
|
+
"react-virtuoso",
|
|
83
|
+
"react-markdown",
|
|
84
|
+
"remark-gfm",
|
|
85
|
+
"msw",
|
|
86
|
+
"@faker-js/faker",
|
|
87
|
+
"@tailwindcss/postcss",
|
|
88
|
+
"@tailwindcss/vite",
|
|
89
|
+
"tailwindcss-animate",
|
|
90
|
+
"postcss",
|
|
91
|
+
"autoprefixer",
|
|
92
|
+
"shadcn",
|
|
93
|
+
"js-cookie",
|
|
94
|
+
"@types/js-cookie",
|
|
95
|
+
"@types/react",
|
|
96
|
+
"@types/react-dom",
|
|
97
|
+
"@types/node",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
export function hashFile(filePath) {
|
|
101
|
+
try {
|
|
102
|
+
return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function joinPosix(...parts) {
|
|
109
|
+
const joined = path.posix.join(...parts.filter((part) => part !== "" && part !== "."));
|
|
110
|
+
return joined === "" ? "." : joined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test files, files in test directories and test setup files are outside the conventions: every check skips them.
|
|
114
|
+
export function isTestSupport(relativePath) {
|
|
115
|
+
const parts = relativePath.split("/");
|
|
116
|
+
return testFilePattern.test(relativePath) ||
|
|
117
|
+
parts.slice(0, -1).some((part) => testDirectoryNames.includes(part)) ||
|
|
118
|
+
testSetupFilePattern.test(parts[parts.length - 1]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Blanks // and /* */ comments while keeping strings, template literals and every line break, so indices and
|
|
122
|
+
// line numbers still point into the original text. Regular expression literals are recognized heuristically.
|
|
123
|
+
export function stripComments(source) {
|
|
124
|
+
const output = [];
|
|
125
|
+
const templateDepth = [];
|
|
126
|
+
let index = 0;
|
|
127
|
+
let lastSignificant = "";
|
|
128
|
+
const blank = (text) => text.replace(/[^\r\n]/g, " ");
|
|
129
|
+
while (index < source.length) {
|
|
130
|
+
const character = source[index];
|
|
131
|
+
const next = source[index + 1];
|
|
132
|
+
if (character === "/" && next === "/") {
|
|
133
|
+
const end = source.indexOf("\n", index);
|
|
134
|
+
const stop = end < 0 ? source.length : end;
|
|
135
|
+
output.push(blank(source.slice(index, stop)));
|
|
136
|
+
index = stop;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (character === "/" && next === "*") {
|
|
140
|
+
const end = source.indexOf("*/", index + 2);
|
|
141
|
+
const stop = end < 0 ? source.length : end + 2;
|
|
142
|
+
output.push(blank(source.slice(index, stop)));
|
|
143
|
+
index = stop;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (character === "\"" || character === "'") {
|
|
147
|
+
let end = index + 1;
|
|
148
|
+
while (end < source.length && source[end] !== character && source[end] !== "\n") {
|
|
149
|
+
end += source[end] === "\\" ? 2 : 1;
|
|
150
|
+
}
|
|
151
|
+
output.push(source.slice(index, end + 1));
|
|
152
|
+
index = end + 1;
|
|
153
|
+
lastSignificant = character;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (character === "`" || (character === "}" && templateDepth.length > 0 && templateDepth[templateDepth.length - 1] === 0)) {
|
|
157
|
+
if (character === "}") {
|
|
158
|
+
templateDepth.pop();
|
|
159
|
+
}
|
|
160
|
+
let end = index + 1;
|
|
161
|
+
while (end < source.length && source[end] !== "`" && !(source[end] === "$" && source[end + 1] === "{")) {
|
|
162
|
+
end += source[end] === "\\" ? 2 : 1;
|
|
163
|
+
}
|
|
164
|
+
if (source[end] === "$") {
|
|
165
|
+
templateDepth.push(0);
|
|
166
|
+
output.push(source.slice(index, end + 2));
|
|
167
|
+
index = end + 2;
|
|
168
|
+
lastSignificant = "{";
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
output.push(source.slice(index, end + 1));
|
|
172
|
+
index = end + 1;
|
|
173
|
+
lastSignificant = "`";
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (character === "/" && (lastSignificant === "" || "(,=:[!&|?{};+-*%<>~^".includes(lastSignificant))) {
|
|
177
|
+
let end = index + 1;
|
|
178
|
+
let inClass = false;
|
|
179
|
+
while (end < source.length && source[end] !== "\n" && (inClass || source[end] !== "/")) {
|
|
180
|
+
if (source[end] === "\\") {
|
|
181
|
+
end += 2;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (source[end] === "[") inClass = true;
|
|
185
|
+
if (source[end] === "]") inClass = false;
|
|
186
|
+
end += 1;
|
|
187
|
+
}
|
|
188
|
+
output.push(source.slice(index, end + 1));
|
|
189
|
+
index = end + 1;
|
|
190
|
+
lastSignificant = "/";
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (templateDepth.length > 0) {
|
|
194
|
+
if (character === "{") templateDepth[templateDepth.length - 1] += 1;
|
|
195
|
+
if (character === "}") templateDepth[templateDepth.length - 1] -= 1;
|
|
196
|
+
}
|
|
197
|
+
if (!/\s/.test(character)) {
|
|
198
|
+
lastSignificant = character;
|
|
199
|
+
}
|
|
200
|
+
output.push(character);
|
|
201
|
+
index += 1;
|
|
202
|
+
}
|
|
203
|
+
return output.join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
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
|
+
// JSON with comments and trailing commas (tsconfig.json, components.json).
|
|
299
|
+
export function parseJsonc(text) {
|
|
300
|
+
return JSON.parse(stripComments(text).replace(/,(\s*[}\]])/g, "$1"));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function lineNumberAt(text, index) {
|
|
304
|
+
let line = 1;
|
|
305
|
+
for (let position = text.indexOf("\n"); position >= 0 && position < index; position = text.indexOf("\n", position + 1)) {
|
|
306
|
+
line += 1;
|
|
307
|
+
}
|
|
308
|
+
return line;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const importPatterns = [
|
|
312
|
+
/(?:^|[^\w$.])(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s*)?["']([^"'\n]+)["']/g,
|
|
313
|
+
/(?:^|[^\w$.])import\s*\(\s*["']([^"'\n]+)["']\s*\)/g,
|
|
314
|
+
/(?:^|[^\w$.])require\s*\(\s*["']([^"'\n]+)["']\s*\)/g,
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
// Static, dynamic and require() module specifiers with their line numbers.
|
|
318
|
+
export function extractImports(source) {
|
|
319
|
+
const code = stripComments(source);
|
|
320
|
+
const found = [];
|
|
321
|
+
for (const pattern of importPatterns) {
|
|
322
|
+
for (const match of code.matchAll(pattern)) {
|
|
323
|
+
const specifierIndex = match.index + match[0].lastIndexOf(match[1]);
|
|
324
|
+
found.push({ specifier: match[1], line: lineNumberAt(code, specifierIndex) });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return found.sort((left, right) => left.line - right.line);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function packageNameOf(specifier) {
|
|
331
|
+
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("@/") || specifier.startsWith("~/") || specifier.startsWith("node:")) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const parts = specifier.split("/");
|
|
335
|
+
return specifier.startsWith("@") ? (parts.length >= 2 ? parts[0] + "/" + parts[1] : null) : parts[0];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Resolves an import made by a file under the source directory. Returns { path } relative to the source directory,
|
|
339
|
+
// { outside: true } for a relative import that leaves it, or null for packages and other aliases.
|
|
340
|
+
export function resolveSourceImport(fromRelativePath, specifier) {
|
|
341
|
+
if (specifier.startsWith("@/")) {
|
|
342
|
+
return { path: path.posix.normalize(specifier.slice(2)), alias: true };
|
|
343
|
+
}
|
|
344
|
+
if (specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
345
|
+
const joined = path.posix.normalize(path.posix.join(path.posix.dirname(fromRelativePath), specifier));
|
|
346
|
+
if (joined === ".." || joined.startsWith("../")) {
|
|
347
|
+
return { outside: true };
|
|
348
|
+
}
|
|
349
|
+
return { path: joined, alias: false };
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function classifyModule(relativePath) {
|
|
355
|
+
const parts = relativePath.split("/");
|
|
356
|
+
const layer = layerOrder.includes(parts[0]) ? parts[0] : null;
|
|
357
|
+
const slice = layer !== null && slicedLayers.has(layer) && parts.length >= 2
|
|
358
|
+
? parts[1].replace(/\.[cm]?[jt]sx?$/, "")
|
|
359
|
+
: null;
|
|
360
|
+
return { layer, slice };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// One import checked against the FSD direction rules. Entities may import other entities (the EVA convention).
|
|
364
|
+
export function checkImportRule({ fromPath, specifier, line, srcLabel }) {
|
|
365
|
+
const target = resolveSourceImport(fromPath, specifier);
|
|
366
|
+
if (target === null || target.outside) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const source = classifyModule(fromPath);
|
|
370
|
+
const destination = classifyModule(target.path);
|
|
371
|
+
if (source.layer === null || destination.layer === null) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const location = joinPosix(srcLabel, fromPath) + ":" + line;
|
|
375
|
+
const sourceRank = layerOrder.indexOf(source.layer);
|
|
376
|
+
const destinationRank = layerOrder.indexOf(destination.layer);
|
|
377
|
+
if (destinationRank < sourceRank) {
|
|
378
|
+
return {
|
|
379
|
+
level: "error",
|
|
380
|
+
kind: "import",
|
|
381
|
+
message: location + " imports " + specifier + " (" + source.layer + " must not import the higher layer " + destination.layer + ")",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const crossesSlice = sourceRank === destinationRank && source.slice !== null && destination.slice !== null && source.slice !== destination.slice;
|
|
385
|
+
if (crossesSlice && (source.layer === "pages" || source.layer === "features")) {
|
|
386
|
+
return {
|
|
387
|
+
level: "error",
|
|
388
|
+
kind: "import",
|
|
389
|
+
message: location + " imports " + specifier + " (" + source.layer + "/" + source.slice + " must not import another " +
|
|
390
|
+
source.layer + " slice, " + destination.slice + ")",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (crossesSlice && source.layer === "widgets") {
|
|
394
|
+
return {
|
|
395
|
+
level: "warning",
|
|
396
|
+
kind: "import",
|
|
397
|
+
message: location + " imports " + specifier + " (widgets/" + source.slice + " depends on widgets/" + destination.slice + ")",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (!target.alias && (sourceRank !== destinationRank || source.slice !== destination.slice)) {
|
|
401
|
+
return {
|
|
402
|
+
level: "warning",
|
|
403
|
+
kind: "import",
|
|
404
|
+
message: location + " imports " + specifier + " with a relative path across slices; use @/" + target.path,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function checkImports({ files, srcLabel }) {
|
|
411
|
+
const findings = [];
|
|
412
|
+
for (const file of files) {
|
|
413
|
+
if (isTestSupport(file.path)) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
for (const { specifier, line } of extractImports(file.contents)) {
|
|
417
|
+
const finding = checkImportRule({ fromPath: file.path, specifier, line, srcLabel });
|
|
418
|
+
if (finding !== null) {
|
|
419
|
+
findings.push(finding);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return findings;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Walks a directory and returns file paths relative to it (POSIX). Dependency, build and dot directories are skipped.
|
|
427
|
+
export function walkFiles(root) {
|
|
428
|
+
if (!fs.existsSync(root)) {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
const files = [];
|
|
432
|
+
const walk = (directory, prefix) => {
|
|
433
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
434
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const relativePath = prefix === "" ? entry.name : prefix + "/" + entry.name;
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
if (!walkExcludedDirectories.has(entry.name)) {
|
|
440
|
+
walk(path.join(directory, entry.name), relativePath);
|
|
441
|
+
}
|
|
442
|
+
} else if (entry.isFile()) {
|
|
443
|
+
files.push(relativePath);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
walk(root, "");
|
|
448
|
+
return files.sort();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function checkStructure({ files, srcLabel }) {
|
|
452
|
+
const findings = [];
|
|
453
|
+
const error = (message) => findings.push({ level: "error", kind: "structure", message });
|
|
454
|
+
const warning = (message) => findings.push({ level: "warning", kind: "structure", message });
|
|
455
|
+
const label = (relativePath) => joinPosix(srcLabel, relativePath);
|
|
456
|
+
const reportedDirectories = new Set();
|
|
457
|
+
// Required files a root-file move already names, so the same move is not reported twice.
|
|
458
|
+
const movedTargets = new Set();
|
|
459
|
+
|
|
460
|
+
for (const file of files) {
|
|
461
|
+
if (isTestSupport(file)) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const parts = file.split("/");
|
|
465
|
+
const extension = path.posix.extname(file);
|
|
466
|
+
if (javascriptExtensions.has(extension) && !file.endsWith(".d.ts")) {
|
|
467
|
+
findings.push({ level: "error", kind: "language", message: label(file) + " is JavaScript; convert it to TypeScript (.ts or .tsx)" });
|
|
468
|
+
}
|
|
469
|
+
if (parts.length === 1) {
|
|
470
|
+
if (!allowedSourceRootFiles.includes(file)) {
|
|
471
|
+
const hint = structureHints[file.toLowerCase()];
|
|
472
|
+
const [target, ...remark] = (hint ?? "").split(" ");
|
|
473
|
+
if (target.includes("/")) {
|
|
474
|
+
movedTargets.add(target);
|
|
475
|
+
error(label(file) + " must move to " + label(target) + (remark.length > 0 ? " " + remark.join(" ") : ""));
|
|
476
|
+
} else {
|
|
477
|
+
error(label(file) + " must live inside a layer" + (hint ? " (" + hint + ")" : ""));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const [top] = parts;
|
|
483
|
+
if (!layerOrder.includes(top) && !auxiliarySourceDirectories.includes(top)) {
|
|
484
|
+
if (!reportedDirectories.has(top)) {
|
|
485
|
+
reportedDirectories.add(top);
|
|
486
|
+
const hint = structureHints[top.toLowerCase()];
|
|
487
|
+
error(label(top) + "/ is not an FSD layer; move its files" +
|
|
488
|
+
(hint ? " to " + hint : " into app, pages, widgets, features, entities or shared") + " (under " + srcLabel + "/)");
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (top === "styles" && file !== "styles/" + generatedStylesFileName) {
|
|
493
|
+
error(label(file) + " must move: src/styles holds only " + generatedStylesFileName + ", which eds sync generates (global CSS goes in app/styles/index.css)");
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (top === "types" && !file.endsWith(".d.ts")) {
|
|
497
|
+
warning(label(file) + " is not a declaration file; API types go in entities/<domain>/types and src/types keeps global .d.ts files");
|
|
498
|
+
} else if (top === "app" && !appEntries.includes(parts[1])) {
|
|
499
|
+
warning(label(file) + " is outside the app layer entries (" + appEntries.join(", ") + ")");
|
|
500
|
+
} else if (top === "shared" && !sharedSegments.includes(parts[1])) {
|
|
501
|
+
warning(label(file) + " is outside the shared segments (" + sharedSegments.join(", ") + ")");
|
|
502
|
+
} else if (top === "entities") {
|
|
503
|
+
checkSliceNaming(parts, label, warning);
|
|
504
|
+
if (parts.length === 2 || (parts.length === 3 && parts[2] !== "index.ts")) {
|
|
505
|
+
warning(label(file) + " belongs in an entity segment (" + entitySegments.join(", ") + ")");
|
|
506
|
+
} else if (parts.length > 3 && !entitySegments.includes(parts[2])) {
|
|
507
|
+
warning(label(file) + " is outside the entity segments (" + entitySegments.join(", ") + ")");
|
|
508
|
+
}
|
|
509
|
+
} else if (top === "features") {
|
|
510
|
+
checkSliceNaming(parts, label, warning);
|
|
511
|
+
checkFeaturePath(parts, file, label, warning);
|
|
512
|
+
} else if (top === "pages" && extension === ".tsx" && path.posix.basename(file) !== "page.tsx") {
|
|
513
|
+
warning(label(file) + " is a page component not named page.tsx (pages/<domain>/<action>/page.tsx, or pages/<name>/page.tsx outside a backend resource); move shared parts to widgets or features");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const fileSet = new Set(files);
|
|
518
|
+
for (const requiredFile of requiredSourceFiles) {
|
|
519
|
+
if (!fileSet.has(requiredFile) && !movedTargets.has(requiredFile)) {
|
|
520
|
+
error("missing " + label(requiredFile));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// A misnamed slice is reported once, not once per file inside it.
|
|
524
|
+
return findings.filter((finding, index) => findings.findIndex((other) => other.message === finding.message) === index);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function checkSliceNaming(parts, label, warning) {
|
|
528
|
+
if (parts.length >= 3 && !kebabCasePattern.test(parts[1])) {
|
|
529
|
+
warning(label(parts.slice(0, 2).join("/")) + " must be a lower-case domain name (for example accounts, device-groups)");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// features/<domain>/{ui,lib,types}/... is shared inside the domain; features/<domain>/<feature>/{ui,lib,types}/... is one feature.
|
|
534
|
+
function checkFeaturePath(parts, file, label, warning) {
|
|
535
|
+
if (parts.length === 2) {
|
|
536
|
+
warning(label(file) + " belongs in features/<domain>/<feature>/");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (parts.length === 3) {
|
|
540
|
+
if (parts[2] !== "index.ts") {
|
|
541
|
+
warning(label(file) + " belongs in a feature segment (" + featureSegments.join(", ") + ")");
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (featureSegments.includes(parts[2])) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!kebabCasePattern.test(parts[2])) {
|
|
549
|
+
warning(label(parts.slice(0, 3).join("/")) + " must be a kebab-case feature name (for example edit-device)");
|
|
550
|
+
}
|
|
551
|
+
if (parts.length === 4 && parts[3] !== "index.ts") {
|
|
552
|
+
warning(label(file) + " belongs in a feature segment (" + featureSegments.join(", ") + ")");
|
|
553
|
+
} else if (parts.length > 4 && !featureSegments.includes(parts[3])) {
|
|
554
|
+
warning(label(file) + " is outside the feature segments (" + featureSegments.join(", ") + ")");
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Heuristics for the code patterns. They never fail the check: each is either fixed or justified in PLAN.md.
|
|
559
|
+
export function checkPatterns({ files, allFiles, srcLabel }) {
|
|
560
|
+
const findings = [];
|
|
561
|
+
const warning = (message) => findings.push({ level: "warning", kind: "pattern", message });
|
|
562
|
+
const label = (relativePath) => joinPosix(srcLabel, relativePath);
|
|
563
|
+
|
|
564
|
+
for (const file of files) {
|
|
565
|
+
if (isTestSupport(file.path) || file.path.startsWith("mocks/") || file.path.endsWith(".d.ts")) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const code = stripComments(file.contents);
|
|
569
|
+
const inSharedApi = file.path.startsWith("shared/api/");
|
|
570
|
+
const fetchIndex = code.search(/(?<![\w$.])fetch\s*\(/);
|
|
571
|
+
if (fetchIndex >= 0 && !inSharedApi) {
|
|
572
|
+
warning(label(file.path) + ":" + lineNumberAt(code, fetchIndex) + " calls fetch(); call the backend through shared/api and an entity service");
|
|
573
|
+
}
|
|
574
|
+
const axiosIndex = code.search(/\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/);
|
|
575
|
+
if (axiosIndex >= 0 && !inSharedApi) {
|
|
576
|
+
warning(label(file.path) + ":" + lineNumberAt(code, axiosIndex) + " uses axios directly; use the instance from @/shared/api");
|
|
577
|
+
}
|
|
578
|
+
const { layer } = classifyModule(file.path);
|
|
579
|
+
if (file.path.endsWith(".tsx") && ["widgets", "features", "entities", "shared"].includes(layer ?? "") && /\bexport\s+default\b/.test(code)) {
|
|
580
|
+
warning(label(file.path) + " has a default export; components outside pages use named exports");
|
|
581
|
+
}
|
|
582
|
+
const joinedIndex = koreanConcatenationIndex(code);
|
|
583
|
+
if (joinedIndex >= 0) {
|
|
584
|
+
warning(label(file.path) + ":" + lineNumberAt(code, joinedIndex) + " joins Korean text with +; write the sentence as one string " +
|
|
585
|
+
"(a template literal when it holds values) so it can move to a translation resource later");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const file of allFiles) {
|
|
590
|
+
if (isTestSupport(file)) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (styleExtensions.has(path.posix.extname(file)) && file !== "app/styles/index.css" && file !== "styles/" + generatedStylesFileName) {
|
|
594
|
+
warning(label(file) + " is a separate stylesheet; move global rules to app/styles/index.css and component styles to Tailwind classNames");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return findings;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function majorOf(version) {
|
|
601
|
+
const match = String(version ?? "").match(/^[^\d]*(\d+)/);
|
|
602
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Reads the installed version by walking node_modules upward, like Node's resolution, without honoring exports.
|
|
606
|
+
export function installedVersion(packageDirectory, name) {
|
|
607
|
+
let directory = packageDirectory;
|
|
608
|
+
for (;;) {
|
|
609
|
+
const manifest = readTextIfExists(path.join(directory, "node_modules", name, "package.json"));
|
|
610
|
+
if (manifest !== null) {
|
|
611
|
+
try {
|
|
612
|
+
return String(JSON.parse(manifest).version ?? "") || null;
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const parent = path.dirname(directory);
|
|
618
|
+
if (parent === directory) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
directory = parent;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function declaredDependencies(packageJson) {
|
|
626
|
+
const declared = new Map();
|
|
627
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
628
|
+
for (const [name, range] of Object.entries(packageJson?.[section] ?? {})) {
|
|
629
|
+
if (!declared.has(name)) {
|
|
630
|
+
declared.set(name, { range: String(range), section });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return declared;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function detectFramework(declared) {
|
|
638
|
+
const unsupported = unsupportedFrameworks.find(([name]) => declared.has(name));
|
|
639
|
+
return {
|
|
640
|
+
unsupported: unsupported ? unsupported[1] : null,
|
|
641
|
+
react: declared.has("react"),
|
|
642
|
+
vite: declared.has("vite"),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Dependency classification for the plan and the check. backendUsed lists packages the protected code imports.
|
|
647
|
+
function versionParts(version) {
|
|
648
|
+
const match = String(version ?? "").match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
649
|
+
return match ? [match[1], match[2] ?? "0", match[3] ?? "0"].map((part) => Number.parseInt(part, 10)) : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function isOlderVersion(version, minimum) {
|
|
653
|
+
const actual = versionParts(version);
|
|
654
|
+
const required = versionParts(minimum);
|
|
655
|
+
if (actual === null || required === null) {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
for (let index = 0; index < 3; index += 1) {
|
|
659
|
+
if (actual[index] !== required[index]) {
|
|
660
|
+
return actual[index] < required[index];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// toolVersions: the installed (or else declared) version of each templateToolMinimums package. restCalls: whether the
|
|
667
|
+
// frontend code calls fetch() or axios, which keeps axios required next to a data SDK.
|
|
668
|
+
export function classifyDependencies({ declared, majors = {}, backendUsed = new Set(), toolVersions = {}, restCalls = true }) {
|
|
669
|
+
const replacements = [];
|
|
670
|
+
const replaced = new Set();
|
|
671
|
+
for (const rule of replacementRules) {
|
|
672
|
+
for (const name of rule.packages) {
|
|
673
|
+
if (declared.has(name)) {
|
|
674
|
+
replaced.add(name);
|
|
675
|
+
replacements.push({
|
|
676
|
+
name,
|
|
677
|
+
category: rule.category,
|
|
678
|
+
target: rule.target,
|
|
679
|
+
label: rule.label ?? rule.target,
|
|
680
|
+
keptForBackend: backendUsed.has(name),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const backendClients = backendClientPackages.filter((name) => declared.has(name));
|
|
686
|
+
const dataClients = backendDataClientPackages.filter((name) => declared.has(name));
|
|
687
|
+
const sdkOnly = dataClients.length > 0 && !restCalls && !declared.has("axios");
|
|
688
|
+
const backendServers = backendServerPackages.filter((name) => declared.has(name));
|
|
689
|
+
const viteMajor = majors.vite ?? majorOf(declared.get("vite")?.range);
|
|
690
|
+
const missing = [];
|
|
691
|
+
for (const name of requiredDependencies) {
|
|
692
|
+
if (!declared.has(name)) missing.push({ name, dev: name === "tailwindcss" });
|
|
693
|
+
}
|
|
694
|
+
if (!sdkOnly && !declared.has("axios")) {
|
|
695
|
+
missing.push({ name: "axios", dev: false });
|
|
696
|
+
}
|
|
697
|
+
for (const name of requiredDevDependencies) {
|
|
698
|
+
if (!declared.has(name)) missing.push({ name, dev: true });
|
|
699
|
+
}
|
|
700
|
+
if (viteMajor !== null && viteMajor >= 8 && !declared.has(rolldownBabelPackage)) {
|
|
701
|
+
missing.push({ name: rolldownBabelPackage, dev: true });
|
|
702
|
+
}
|
|
703
|
+
const outdated = [];
|
|
704
|
+
for (const [name, minimum] of Object.entries(minimumMajors)) {
|
|
705
|
+
const major = majors[name] ?? majorOf(declared.get(name)?.range);
|
|
706
|
+
if (declared.has(name) && major !== null && major < minimum) {
|
|
707
|
+
outdated.push({ name, major, minimum });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const outdatedTools = Object.entries(templateToolMinimums)
|
|
711
|
+
.filter(([name, minimum]) => declared.has(name) && isOlderVersion(toolVersions[name] ?? declared.get(name).range, minimum))
|
|
712
|
+
.map(([name, minimum]) => ({ name, version: versionParts(toolVersions[name] ?? declared.get(name).range).join("."), minimum }));
|
|
713
|
+
const others = [...declared.keys()].filter((name) =>
|
|
714
|
+
!replaced.has(name) && !conventionalPackages.has(name) && !backendClients.includes(name) && !backendServers.includes(name) &&
|
|
715
|
+
!backendUsed.has(name) && !testToolingPattern.test(name) && !name.startsWith("@radix-ui/") && !name.startsWith("@types/")).sort();
|
|
716
|
+
const lucideRange = declared.get("lucide-react")?.range ?? null;
|
|
717
|
+
const lucideMismatch = lucideRange !== null && edsLucideVersion !== null && lucideRange.replace(/^[\^~=v]+/, "") !== edsLucideVersion ? lucideRange : null;
|
|
718
|
+
return { replacements, missing, outdated, outdatedTools, backendClients, dataClients, sdkOnly, backendServers, others, viteMajor, lucideMismatch };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function checkDependencies(classification) {
|
|
722
|
+
const findings = [];
|
|
723
|
+
for (const entry of classification.replacements) {
|
|
724
|
+
if (!entry.keptForBackend) {
|
|
725
|
+
findings.push({ level: "error", kind: "dependency", message: entry.name + " is still declared; replace it with " + entry.target + " (" + entry.category + ")" });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
for (const entry of classification.missing) {
|
|
729
|
+
findings.push({ level: "error", kind: "dependency", message: entry.name + " is not declared" + (entry.dev ? " (devDependencies)" : "") });
|
|
730
|
+
}
|
|
731
|
+
for (const entry of classification.outdated) {
|
|
732
|
+
findings.push({ level: "error", kind: "dependency", message: entry.name + " is major " + entry.major + "; the conventions use " + entry.minimum + " or later" });
|
|
733
|
+
}
|
|
734
|
+
if (classification.lucideMismatch) {
|
|
735
|
+
findings.push({
|
|
736
|
+
level: "warning",
|
|
737
|
+
kind: "dependency",
|
|
738
|
+
message: "lucide-react " + classification.lucideMismatch + " differs from the version EDS uses (" + edsLucideVersion + "); declare " + edsLucideVersion + " so the icon code is bundled once",
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
for (const entry of classification.outdatedTools ?? []) {
|
|
742
|
+
findings.push({ level: "error", kind: "config", message: entry.name + " " + entry.version + " is older than the eslint.config template requires (" + entry.minimum + ")" });
|
|
743
|
+
}
|
|
744
|
+
return findings;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function findExisting(directory, names) {
|
|
748
|
+
return names.find((name) => fs.existsSync(path.join(directory, name))) ?? null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function readIgnoreLines(text) {
|
|
752
|
+
return (text ?? "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function ignoreCovers(lines, relativePath) {
|
|
756
|
+
const normalized = lines.map((line) => line.replace(/^\//, "").replace(/\/\*\*$/, "").replace(/\/$/, ""));
|
|
757
|
+
const parts = relativePath.split("/");
|
|
758
|
+
for (let length = parts.length; length >= 1; length -= 1) {
|
|
759
|
+
if (normalized.includes(parts.slice(0, length).join("/"))) {
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return normalized.includes(path.posix.basename(relativePath)) || normalized.includes("**/" + path.posix.basename(relativePath));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function readScriptEntry(indexHtml) {
|
|
767
|
+
const tag = indexHtml.match(/<script\b[^>]*\btype=["']module["'][^>]*>/i)?.[0] ?? null;
|
|
768
|
+
return tag?.match(/\bsrc=["']([^"']+)["']/i)?.[1] ?? null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Config and script checks. app.srcDir and app.appDir are relative to the package directory.
|
|
772
|
+
export function checkConfigs({ packageDirectory, packageJson, app, protectedInside, declared }) {
|
|
773
|
+
const findings = [];
|
|
774
|
+
const error = (kind, message) => findings.push({ level: "error", kind, message });
|
|
775
|
+
const warning = (kind, message) => findings.push({ level: "warning", kind, message });
|
|
776
|
+
const read = (relativePath) => readTextIfExists(path.join(packageDirectory, relativePath));
|
|
777
|
+
|
|
778
|
+
const eslintConfig = findExisting(packageDirectory, eslintConfigNames);
|
|
779
|
+
if (eslintConfig === null) {
|
|
780
|
+
error("config", "no ESLint flat config (eslint.config.js)");
|
|
781
|
+
} else {
|
|
782
|
+
const contents = read(eslintConfig);
|
|
783
|
+
if (!/typescript-eslint/.test(contents) || !/react-hooks/.test(contents)) {
|
|
784
|
+
warning("config", eslintConfig + " does not use typescript-eslint and eslint-plugin-react-hooks like the template");
|
|
785
|
+
}
|
|
786
|
+
const unignored = protectedInside.filter((relativePath) =>
|
|
787
|
+
isDirectory(path.join(packageDirectory, relativePath)) && !contents.includes("\"" + relativePath) && !contents.includes("'" + relativePath));
|
|
788
|
+
if (unignored.length > 0) {
|
|
789
|
+
warning("config", eslintConfig + " should list the protected paths in globalIgnores: " + unignored.join(", "));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const legacyEslint = findExisting(packageDirectory, legacyEslintConfigNames);
|
|
793
|
+
if (legacyEslint !== null || packageJson.eslintConfig !== undefined) {
|
|
794
|
+
error("config", (legacyEslint ?? "package.json eslintConfig") + " is a legacy ESLint config; remove it after moving its rules to eslint.config.js");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (findExisting(packageDirectory, prettierConfigNames) === null && packageJson.prettier === undefined) {
|
|
798
|
+
error("config", "no Prettier config (.prettierrc.json)");
|
|
799
|
+
}
|
|
800
|
+
const prettierIgnore = read(".prettierignore");
|
|
801
|
+
if (prettierIgnore === null) {
|
|
802
|
+
error("config", "no .prettierignore");
|
|
803
|
+
} else {
|
|
804
|
+
const lines = readIgnoreLines(prettierIgnore);
|
|
805
|
+
const required = [toPosix(generatedCssRelativePath), ...protectedInside];
|
|
806
|
+
const uncovered = required.filter((relativePath) => !ignoreCovers(lines, relativePath));
|
|
807
|
+
if (uncovered.length > 0) {
|
|
808
|
+
error("config", ".prettierignore must list " + uncovered.join(", ") + " (generated or protected files must not be reformatted)");
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
checkTsconfig({ packageDirectory, app, read, error });
|
|
813
|
+
|
|
814
|
+
const viteConfig = findExisting(packageDirectory, viteConfigNames);
|
|
815
|
+
if (viteConfig === null) {
|
|
816
|
+
error("config", "no vite.config.ts");
|
|
817
|
+
} else {
|
|
818
|
+
const contents = read(viteConfig);
|
|
819
|
+
if (!/babel-plugin-react-compiler|reactCompilerPreset/.test(contents)) {
|
|
820
|
+
error("config", viteConfig + " does not enable the React Compiler");
|
|
821
|
+
}
|
|
822
|
+
if (!/["']@["']\s*:|find\s*:\s*["']@["']/.test(contents)) {
|
|
823
|
+
error("config", viteConfig + " does not define the @ alias");
|
|
824
|
+
}
|
|
825
|
+
if (!/svgr/.test(contents)) {
|
|
826
|
+
warning("config", viteConfig + " does not register vite-plugin-svgr");
|
|
827
|
+
}
|
|
828
|
+
// Through @tailwindcss/postcss, url() in imported package CSS is not rebased: the EDS font is not emitted.
|
|
829
|
+
if (declared.has("tailwindcss") && !/@tailwindcss\/vite/.test(contents)) {
|
|
830
|
+
error("config", viteConfig + " must run Tailwind through @tailwindcss/vite (with @tailwindcss/postcss the EDS font file is not emitted)");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (declared.has("@tailwindcss/postcss")) {
|
|
834
|
+
warning("config", "@tailwindcss/postcss is declared; remove it and its postcss.config entry once @tailwindcss/vite runs Tailwind");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const componentsJsonText = read("components.json");
|
|
838
|
+
const usesRadix = [...declared.keys()].some((name) => name.startsWith("@radix-ui/react-"));
|
|
839
|
+
if (componentsJsonText !== null) {
|
|
840
|
+
try {
|
|
841
|
+
const aliases = parseJsonc(componentsJsonText).aliases ?? {};
|
|
842
|
+
if (aliases.ui !== "@/shared/ui" || aliases.utils !== "@/shared/lib/utils") {
|
|
843
|
+
warning("config", "components.json aliases must point ui to @/shared/ui and utils to @/shared/lib/utils");
|
|
844
|
+
}
|
|
845
|
+
} catch (parseError) {
|
|
846
|
+
warning("config", "components.json could not be parsed (" + parseError.message + ")");
|
|
847
|
+
}
|
|
848
|
+
} else if (usesRadix) {
|
|
849
|
+
warning("config", "no components.json although shadcn/Radix components are used");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const indexHtml = read(joinPosix(app.appDir, "index.html"));
|
|
853
|
+
const entry = indexHtml === null ? null : readScriptEntry(indexHtml)?.replace(/^\.\//, "/") ?? null;
|
|
854
|
+
const expectedEntry = "/" + joinPosix(path.posix.relative(app.appDir, app.srcDir) || ".", entryModule);
|
|
855
|
+
if (entry !== expectedEntry) {
|
|
856
|
+
// Moving the entry is part of the FSD structure step, so this is a structure finding, not a config one.
|
|
857
|
+
error("structure", joinPosix(app.appDir, "index.html") + " must load " + expectedEntry + (entry === null ? "" : " (loads " + entry + ")"));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const scripts = packageJson.scripts ?? {};
|
|
861
|
+
for (const [name, command] of Object.entries(requiredScripts)) {
|
|
862
|
+
if (typeof scripts[name] !== "string") {
|
|
863
|
+
error("script", "package.json has no \"" + name + "\" script (template: \"" + command + "\")");
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const agents = read("AGENTS.md");
|
|
868
|
+
if (agents === null || !agents.includes(conventionsFileName)) {
|
|
869
|
+
warning("agents", "AGENTS.md does not point at " + conventionsFileName + " yet (PLAN.md step 8)");
|
|
870
|
+
}
|
|
871
|
+
return findings;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function checkTsconfig({ packageDirectory, app, read, error }) {
|
|
875
|
+
const candidates = ["tsconfig.json", "tsconfig.app.json"];
|
|
876
|
+
const expected = new Set(["./" + app.srcDir + "/*", app.srcDir + "/*"]);
|
|
877
|
+
for (const name of candidates) {
|
|
878
|
+
const text = read(name);
|
|
879
|
+
if (text === null) {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
try {
|
|
883
|
+
const paths = parseJsonc(text).compilerOptions?.paths?.["@/*"];
|
|
884
|
+
if (Array.isArray(paths) && expected.has(String(paths[0]))) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
} catch (parseError) {
|
|
888
|
+
error("config", name + " could not be parsed (" + parseError.message + ")");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
error("config", "tsconfig.json must map \"@/*\" to [\"./" + app.srcDir + "/*\"]");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Finds package directories (relative to root) whose package.json declares react.
|
|
896
|
+
export function findFrontendPackages(root) {
|
|
897
|
+
const found = [];
|
|
898
|
+
const visit = (directory, depth) => {
|
|
899
|
+
const manifest = readTextIfExists(path.join(directory, "package.json"));
|
|
900
|
+
if (manifest !== null) {
|
|
901
|
+
try {
|
|
902
|
+
if (declaredDependencies(JSON.parse(manifest)).has("react")) {
|
|
903
|
+
found.push(toPosix(path.relative(root, directory)) || ".");
|
|
904
|
+
}
|
|
905
|
+
} catch {
|
|
906
|
+
// A broken package.json is not a frontend candidate.
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (depth >= frontendSearchDepth) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
913
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && !walkExcludedDirectories.has(entry.name)) {
|
|
914
|
+
visit(path.join(directory, entry.name), depth + 1);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
visit(root, 0);
|
|
919
|
+
return found;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// The Vite root is the directory of index.html; the source directory is src/ next to it.
|
|
923
|
+
export function locateApp(packageDirectory) {
|
|
924
|
+
const candidates = [];
|
|
925
|
+
const visit = (directory, depth) => {
|
|
926
|
+
const indexHtml = readTextIfExists(path.join(directory, "index.html"));
|
|
927
|
+
if (indexHtml !== null && readScriptEntry(indexHtml) !== null) {
|
|
928
|
+
candidates.push(toPosix(path.relative(packageDirectory, directory)) || ".");
|
|
929
|
+
}
|
|
930
|
+
if (depth >= 2) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
934
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && !walkExcludedDirectories.has(entry.name) && entry.name !== "public" && entry.name !== "src") {
|
|
935
|
+
visit(path.join(directory, entry.name), depth + 1);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
visit(packageDirectory, 0);
|
|
940
|
+
const appDir = candidates.includes(".") ? "." : candidates.length === 1 ? candidates[0] : null;
|
|
941
|
+
if (appDir === null) {
|
|
942
|
+
throw createExitError(
|
|
943
|
+
candidates.length === 0
|
|
944
|
+
? "No index.html with a <script type=\"module\"> entry in the frontend package; eds align supports Vite apps only."
|
|
945
|
+
: "Several index.html entries (" + candidates.join(", ") + "); run eds align inside the package of the app to align.",
|
|
946
|
+
2,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
const srcDir = joinPosix(appDir, "src");
|
|
950
|
+
if (!fs.existsSync(path.join(packageDirectory, srcDir)) || !fs.statSync(path.join(packageDirectory, srcDir)).isDirectory()) {
|
|
951
|
+
throw createExitError("No " + srcDir + " directory next to " + joinPosix(appDir, "index.html") + ".", 2);
|
|
952
|
+
}
|
|
953
|
+
const indexHtml = readTextIfExists(path.join(packageDirectory, appDir, "index.html"));
|
|
954
|
+
return { appDir, srcDir, entry: readScriptEntry(indexHtml) };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function isDirectory(filePath) {
|
|
958
|
+
try {
|
|
959
|
+
return fs.statSync(filePath).isDirectory();
|
|
960
|
+
} catch {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function readSourceFiles(root, relativePaths) {
|
|
966
|
+
return relativePaths
|
|
967
|
+
.filter((relativePath) => scriptExtensions.has(path.posix.extname(relativePath)))
|
|
968
|
+
.map((relativePath) => ({ path: relativePath, contents: fs.readFileSync(path.join(root, relativePath), "utf8") }));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// A relative import target without extension resolved the way bundlers do (file, then index file).
|
|
972
|
+
function resolveModuleFile(packageDirectory, relativePath) {
|
|
973
|
+
const candidates = [
|
|
974
|
+
relativePath,
|
|
975
|
+
...[...scriptExtensions, ".json"].map((extension) => relativePath + extension),
|
|
976
|
+
...[...scriptExtensions].map((extension) => relativePath + "/index" + extension),
|
|
977
|
+
];
|
|
978
|
+
return candidates.find((candidate) => fs.existsSync(path.join(packageDirectory, candidate)) && !isDirectory(path.join(packageDirectory, candidate))) ?? relativePath;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Binaries whose package name differs from the command.
|
|
982
|
+
const binaryPackages = { tsc: "typescript" };
|
|
983
|
+
|
|
984
|
+
// Declared packages a backend script runs (for example tsx, esbuild, drizzle-kit).
|
|
985
|
+
function scriptPackages(command, declared) {
|
|
986
|
+
return command
|
|
987
|
+
.split(/[\s;&|()]+/)
|
|
988
|
+
.filter((token) => token !== "" && !token.includes("="))
|
|
989
|
+
.map((token) => binaryPackages[token] ?? token)
|
|
990
|
+
.filter((token) => declared.has(token));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Backend and deployment paths. inside: relative to the package; repository: relative to the git root.
|
|
994
|
+
export function detectProtectedPaths({ packageDirectory, gitRoot, declared, scripts }) {
|
|
995
|
+
const packageRelative = toPosix(path.relative(gitRoot, packageDirectory));
|
|
996
|
+
const toRepository = (relativePath) => (packageRelative === "" ? relativePath : packageRelative + "/" + relativePath);
|
|
997
|
+
const inside = [];
|
|
998
|
+
for (const name of backendDirectoryNames) {
|
|
999
|
+
if (isDirectory(path.join(packageDirectory, name))) inside.push(name);
|
|
1000
|
+
}
|
|
1001
|
+
const hasBackend = inside.length > 0 || backendServerPackages.some((name) => declared.has(name));
|
|
1002
|
+
if (hasBackend && isDirectory(path.join(packageDirectory, sharedWithBackendDirectoryName))) {
|
|
1003
|
+
inside.push(sharedWithBackendDirectoryName);
|
|
1004
|
+
}
|
|
1005
|
+
for (const name of backendMarkerFiles) {
|
|
1006
|
+
if (fs.existsSync(path.join(packageDirectory, name))) inside.push(name);
|
|
1007
|
+
}
|
|
1008
|
+
const repositoryOnly = [];
|
|
1009
|
+
if (packageRelative !== "") {
|
|
1010
|
+
for (const name of [...backendDirectoryNames, ...backendMarkerFiles]) {
|
|
1011
|
+
if (fs.existsSync(path.join(gitRoot, name))) repositoryOnly.push(name);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Packages the backend code in this package imports, and frontend files it imports (their exports must stay).
|
|
1016
|
+
const backendUsed = new Set(backendServerPackages.filter((name) => declared.has(name)));
|
|
1017
|
+
const importedFrontendFiles = new Set();
|
|
1018
|
+
const insideDirectories = inside.filter((relativePath) => isDirectory(path.join(packageDirectory, relativePath)));
|
|
1019
|
+
const isInsideProtected = (relativePath) => inside.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/"));
|
|
1020
|
+
for (const directory of insideDirectories) {
|
|
1021
|
+
for (const file of readSourceFiles(path.join(packageDirectory, directory), walkFiles(path.join(packageDirectory, directory)))) {
|
|
1022
|
+
const filePath = directory + "/" + file.path;
|
|
1023
|
+
for (const { specifier } of extractImports(file.contents)) {
|
|
1024
|
+
const name = packageNameOf(specifier);
|
|
1025
|
+
if (name !== null && declared.has(name)) {
|
|
1026
|
+
backendUsed.add(name);
|
|
1027
|
+
} else if (specifier.startsWith(".")) {
|
|
1028
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(filePath), specifier));
|
|
1029
|
+
if (!target.startsWith("..") && !isInsideProtected(target)) {
|
|
1030
|
+
importedFrontendFiles.add(resolveModuleFile(packageDirectory, target));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const directoryPattern = insideDirectories.length === 0
|
|
1038
|
+
? null
|
|
1039
|
+
: new RegExp("(?:^|[\\s\"'=(])(?:\\./)?(?:" + insideDirectories.map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")/");
|
|
1040
|
+
const backendScripts = {};
|
|
1041
|
+
for (const [name, command] of Object.entries(scripts ?? {})) {
|
|
1042
|
+
if (typeof command === "string" && (backendScriptPattern.test(command) || directoryPattern?.test(command))) {
|
|
1043
|
+
backendScripts[name] = command;
|
|
1044
|
+
for (const packageUsed of scriptPackages(command, declared)) {
|
|
1045
|
+
backendUsed.add(packageUsed);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
inside,
|
|
1052
|
+
repository: [...inside.map(toRepository), ...repositoryOnly],
|
|
1053
|
+
repositoryOnly,
|
|
1054
|
+
backendUsed: [...backendUsed].sort(),
|
|
1055
|
+
importedFrontendFiles: [...importedFrontendFiles].sort(),
|
|
1056
|
+
backendScripts,
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Paths changed since the base commit (from git), classified against the protected list recorded in the state.
|
|
1061
|
+
export function classifyChangedPaths({ changed, baseline, hashOf, packageRelative, protectedPaths, alignRelative, base }) {
|
|
1062
|
+
const shortBase = String(base ?? "").slice(0, 12);
|
|
1063
|
+
const findings = [];
|
|
1064
|
+
const inPackage = (relativePath) => packageRelative === "" || relativePath === packageRelative || relativePath.startsWith(packageRelative + "/");
|
|
1065
|
+
for (const relativePath of [...new Set(changed)].sort()) {
|
|
1066
|
+
if (relativePath === alignRelative || relativePath.startsWith(alignRelative + "/")) {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
if (Object.prototype.hasOwnProperty.call(baseline, relativePath) && baseline[relativePath] === hashOf(relativePath)) {
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
if (protectedPaths.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/"))) {
|
|
1073
|
+
findings.push({
|
|
1074
|
+
level: "error",
|
|
1075
|
+
kind: "protected",
|
|
1076
|
+
message: relativePath + " changed since " + shortBase + "; it is backend or deployment code (git checkout " + shortBase + " -- " +
|
|
1077
|
+
relativePath + " restores it; delete it if it is new)",
|
|
1078
|
+
});
|
|
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
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return findings;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export function checkBackendPackage({ packageJson, state }) {
|
|
1087
|
+
const findings = [];
|
|
1088
|
+
const scripts = packageJson.scripts ?? {};
|
|
1089
|
+
for (const [name, command] of Object.entries(state.protected.backendScripts ?? {})) {
|
|
1090
|
+
if (scripts[name] !== command) {
|
|
1091
|
+
findings.push({
|
|
1092
|
+
level: "error",
|
|
1093
|
+
kind: "backend-script",
|
|
1094
|
+
message: "package.json script \"" + name + "\" runs backend code and must stay " + JSON.stringify(command) +
|
|
1095
|
+
(scripts[name] === undefined ? " (it was removed)" : " (now " + JSON.stringify(scripts[name]) + ")"),
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const declared = declaredDependencies(packageJson);
|
|
1100
|
+
for (const name of state.protected.backendUsed ?? []) {
|
|
1101
|
+
if (!declared.has(name)) {
|
|
1102
|
+
findings.push({ level: "error", kind: "backend-dependency", message: name + " was removed from package.json but the backend code uses it" });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return findings;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Everything the plan and the check need, read from the working tree.
|
|
1109
|
+
export function analyzeFrontend({ packageDirectory, gitRoot, protectedInfo = null }) {
|
|
1110
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8"));
|
|
1111
|
+
const declared = declaredDependencies(packageJson);
|
|
1112
|
+
const app = locateApp(packageDirectory);
|
|
1113
|
+
const detectedProtected = protectedInfo ?? detectProtectedPaths({ packageDirectory, gitRoot, declared, scripts: packageJson.scripts });
|
|
1114
|
+
const majors = {};
|
|
1115
|
+
for (const name of ["vite", "react", "tailwindcss", "@tanstack/react-query", "typescript", "@vitejs/plugin-react"]) {
|
|
1116
|
+
const installed = installedVersion(packageDirectory, name);
|
|
1117
|
+
majors[name] = majorOf(installed ?? declared.get(name)?.range);
|
|
1118
|
+
}
|
|
1119
|
+
const srcRoot = path.join(packageDirectory, app.srcDir);
|
|
1120
|
+
const allFiles = walkFiles(srcRoot);
|
|
1121
|
+
const sourceFiles = readSourceFiles(srcRoot, allFiles.filter((file) => !file.endsWith(".d.ts")));
|
|
1122
|
+
const toolVersions = Object.fromEntries(Object.keys(templateToolMinimums).map((name) => [name, installedVersion(packageDirectory, name)]).filter(([, version]) => version !== null));
|
|
1123
|
+
const restCalls = sourceFiles.some((file) => /\bfetch\s*\(|\baxios\b/.test(file.contents));
|
|
1124
|
+
const dependencies = classifyDependencies({ declared, majors, backendUsed: new Set(detectedProtected.backendUsed), toolVersions, restCalls });
|
|
1125
|
+
// shadcn ui files that import a package the plan replaces (for example form.tsx and react-hook-form).
|
|
1126
|
+
dependencies.uiDependents = dependencies.replacements
|
|
1127
|
+
.filter((entry) => !entry.keptForBackend)
|
|
1128
|
+
.flatMap((entry) => sourceFiles
|
|
1129
|
+
.filter((file) => /(^|\/)ui\//.test(file.path) && new RegExp("from\\s+[\"']" + entry.name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&") + "[\"'/]").test(file.contents))
|
|
1130
|
+
.map((file) => ({ file: joinPosix(app.srcDir, file.path), name: entry.name })));
|
|
1131
|
+
const findings = [
|
|
1132
|
+
...checkStructure({ files: allFiles, srcLabel: app.srcDir }),
|
|
1133
|
+
...checkImports({ files: sourceFiles, srcLabel: app.srcDir }),
|
|
1134
|
+
...checkDependencies(dependencies),
|
|
1135
|
+
...checkConfigs({ packageDirectory, packageJson, app, protectedInside: detectedProtected.inside, declared }),
|
|
1136
|
+
...checkPatterns({ files: sourceFiles, allFiles, srcLabel: app.srcDir }),
|
|
1137
|
+
];
|
|
1138
|
+
return { packageJson, declared, app, protectedInfo: detectedProtected, majors, dependencies, allFiles, findings };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/ top-level entries with their file counts and where each goes.
|
|
1142
|
+
export function summarizeStructure(allFiles) {
|
|
1143
|
+
const entries = new Map();
|
|
1144
|
+
for (const file of allFiles) {
|
|
1145
|
+
const [top] = file.split("/");
|
|
1146
|
+
const isFile = !file.includes("/");
|
|
1147
|
+
const entry = entries.get(top) ?? { name: top, type: isFile ? "file" : "directory", files: 0 };
|
|
1148
|
+
entry.files += 1;
|
|
1149
|
+
entries.set(top, entry);
|
|
1150
|
+
}
|
|
1151
|
+
return [...entries.values()].map((entry) => {
|
|
1152
|
+
const test = entry.type === "directory" ? testDirectoryNames.includes(entry.name) : isTestSupport(entry.name);
|
|
1153
|
+
return {
|
|
1154
|
+
...entry,
|
|
1155
|
+
fsd: test || layerOrder.includes(entry.name) || auxiliarySourceDirectories.includes(entry.name) || allowedSourceRootFiles.includes(entry.name),
|
|
1156
|
+
hint: test ? testSupportHint : structureHints[entry.name.toLowerCase()] ?? null,
|
|
1157
|
+
};
|
|
1158
|
+
});
|
|
1159
|
+
}
|