@spectrabrain/eds 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,20 +5,52 @@ import { fileURLToPath } from "node:url";
5
5
  import {
6
6
  analyzeFrontend,
7
7
  checkBackendPackage,
8
+ checkPreservedTsconfigSettings,
8
9
  classifyChangedPaths,
9
10
  declaredDependencies,
10
11
  detectFramework,
12
+ filesUnderPaths,
11
13
  findFrontendPackages,
12
14
  hashFile,
15
+ isLegacyPreservedTsconfigSettings,
16
+ isPreservedSourcePath,
13
17
  parseJsonc,
14
18
  summarizeStructure,
15
19
  walkFiles,
16
20
  } from "./align-analysis.mjs";
17
- import { countFindings, packageManagerCommands, renderNotes, renderPlan } from "./align-plan.mjs";
18
- import { alignDirectory, conventionsFileName, planFileName, rootLockfiles, stateFileName, stateSchemaVersion, templatesDirectoryName } from "./align-rules.mjs";
19
- import { cliCommand, createExitError, detectPackageManager, generatedCssRelativePath, installCommand, packageName, readPackageJson, resolvePackage, toPosix } from "./paths.mjs";
21
+ import {
22
+ countFindings,
23
+ packageManagerCommands,
24
+ renderNotes,
25
+ renderPlan,
26
+ } from "./align-plan.mjs";
27
+ import {
28
+ alignDirectory,
29
+ conventionsFileName,
30
+ maximumSupportedViteMajor,
31
+ minimumVersions,
32
+ planFileName,
33
+ rootLockfiles,
34
+ stateFileName,
35
+ stateSchemaVersion,
36
+ templatesDirectoryName,
37
+ } from "./align-rules.mjs";
38
+ import {
39
+ cliCommand,
40
+ createExitError,
41
+ detectPackageManager,
42
+ generatedCssRelativePath,
43
+ installCommand,
44
+ packageName,
45
+ readPackageJson,
46
+ resolvePackage,
47
+ toPosix,
48
+ } from "./paths.mjs";
20
49
 
21
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
50
+ const packageRoot = path.resolve(
51
+ path.dirname(fileURLToPath(import.meta.url)),
52
+ ".."
53
+ );
22
54
  const templatesRoot = path.join(packageRoot, "consumer", "align", "templates");
23
55
  const notesFileName = "NOTES.md";
24
56
  const findingLimitPerKind = 40;
@@ -26,7 +58,12 @@ const placeholderPattern = /\{\{eds\.(\w+)\}\}/g;
26
58
 
27
59
  function git(cwd, args) {
28
60
  try {
29
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
61
+ return execFileSync("git", args, {
62
+ cwd,
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ });
30
67
  } catch {
31
68
  return null;
32
69
  }
@@ -44,10 +81,24 @@ function readState(statePath, shown = (relativePath) => relativePath) {
44
81
  try {
45
82
  state = JSON.parse(fs.readFileSync(statePath, "utf8"));
46
83
  } catch (error) {
47
- throw createExitError(toPosix(statePath) + " could not be read (" + error.message + "); delete " + shown(alignDirectory) + " and run eds align again.", 2);
84
+ throw createExitError(
85
+ toPosix(statePath) +
86
+ " could not be read (" +
87
+ error.message +
88
+ "); delete " +
89
+ shown(alignDirectory) +
90
+ " and run eds align again.",
91
+ 2
92
+ );
48
93
  }
49
94
  if (state?.schemaVersion !== stateSchemaVersion) {
50
- throw createExitError(toPosix(statePath) + " was written by another eds align version; delete " + shown(alignDirectory) + " and run eds align again.", 2);
95
+ throw createExitError(
96
+ toPosix(statePath) +
97
+ " was written by another eds align version; delete " +
98
+ shown(alignDirectory) +
99
+ " and run eds align again.",
100
+ 2
101
+ );
51
102
  }
52
103
  return state;
53
104
  }
@@ -58,7 +109,10 @@ function resolveFrontendDirectory(consumerRoot, frontendOption) {
58
109
  if (frontendOption) {
59
110
  const directory = path.resolve(consumerRoot, frontendOption);
60
111
  if (!fs.existsSync(path.join(directory, "package.json"))) {
61
- throw createExitError("--frontend " + frontendOption + " has no package.json.", 2);
112
+ throw createExitError(
113
+ "--frontend " + frontendOption + " has no package.json.",
114
+ 2
115
+ );
62
116
  }
63
117
  return directory;
64
118
  }
@@ -69,15 +123,19 @@ function resolveFrontendDirectory(consumerRoot, frontendOption) {
69
123
  if (own !== null && declaredDependencies(own).has("react")) {
70
124
  return consumerRoot;
71
125
  }
72
- const candidates = findFrontendPackages(consumerRoot).filter((candidate) => candidate !== ".");
126
+ const candidates = findFrontendPackages(consumerRoot).filter(
127
+ (candidate) => candidate !== "."
128
+ );
73
129
  if (candidates.length === 1) {
74
130
  return path.join(consumerRoot, candidates[0]);
75
131
  }
76
132
  throw createExitError(
77
133
  candidates.length === 0
78
134
  ? "No package here or below declares react; run eds align inside the frontend package."
79
- : "Several React packages (" + candidates.join(", ") + "); rerun with --frontend <dir>.",
80
- 2,
135
+ : "Several React packages (" +
136
+ candidates.join(", ") +
137
+ "); rerun with --frontend <dir>.",
138
+ 2
81
139
  );
82
140
  }
83
141
 
@@ -89,6 +147,78 @@ function changedSince(gitRoot, commit) {
89
147
  ];
90
148
  }
91
149
 
150
+ function normalizeRepositoryPaths(paths, alignRelative) {
151
+ if (!Array.isArray(paths)) {
152
+ throw createExitError(
153
+ "state.json `preservedPaths` must be an array of paths relative to the git root.",
154
+ 2
155
+ );
156
+ }
157
+ const normalized = [];
158
+ for (const entry of paths) {
159
+ if (typeof entry !== "string" || entry.trim() === "") {
160
+ throw createExitError(
161
+ "state.json `preservedPaths` must contain non-empty paths relative to the git root.",
162
+ 2
163
+ );
164
+ }
165
+ const pathText = entry.trim().replaceAll("\\", "/");
166
+ const relativePath = path.posix.normalize(pathText);
167
+ if (
168
+ path.posix.isAbsolute(pathText) ||
169
+ /^[A-Za-z]:\//.test(pathText) ||
170
+ relativePath === "." ||
171
+ relativePath === ".." ||
172
+ relativePath.startsWith("../")
173
+ ) {
174
+ throw createExitError(
175
+ "state.json `preservedPaths` must use paths inside the git repository, relative to its root: " +
176
+ JSON.stringify(entry),
177
+ 2
178
+ );
179
+ }
180
+ if (
181
+ relativePath === alignRelative ||
182
+ relativePath.startsWith(alignRelative + "/")
183
+ ) {
184
+ throw createExitError(
185
+ "state.json `preservedPaths` cannot include " + alignRelative + ".",
186
+ 2
187
+ );
188
+ }
189
+ normalized.push(relativePath);
190
+ }
191
+ return [...new Set(normalized)].sort();
192
+ }
193
+
194
+ export function packagePathsFromRepository(paths, packageRelative) {
195
+ const prefix = packageRelative === "" ? "" : packageRelative + "/";
196
+ return paths.flatMap((relativePath) => {
197
+ if (packageRelative === "") return [relativePath];
198
+ if (relativePath === packageRelative) return ["."];
199
+ return relativePath.startsWith(prefix)
200
+ ? [relativePath.slice(prefix.length)]
201
+ : [];
202
+ });
203
+ }
204
+
205
+ function isAutomaticPreservedRepositoryPath(relativePath, packageRelative) {
206
+ const packagePath = packagePathsFromRepository(
207
+ [relativePath],
208
+ packageRelative
209
+ )[0];
210
+ return packagePath !== undefined && isPreservedSourcePath(packagePath);
211
+ }
212
+
213
+ function hashPaths(root, relativePaths) {
214
+ const hashes = {};
215
+ for (const relativePath of filesUnderPaths(root, relativePaths)) {
216
+ const hash = hashFile(path.join(root, relativePath));
217
+ if (hash !== null) hashes[relativePath] = hash;
218
+ }
219
+ return hashes;
220
+ }
221
+
92
222
  function readStrictOption(packageDirectory) {
93
223
  for (const name of ["tsconfig.json", "tsconfig.app.json"]) {
94
224
  const filePath = path.join(packageDirectory, name);
@@ -96,7 +226,10 @@ function readStrictOption(packageDirectory) {
96
226
  continue;
97
227
  }
98
228
  try {
99
- if (parseJsonc(fs.readFileSync(filePath, "utf8")).compilerOptions?.strict === true) {
229
+ if (
230
+ parseJsonc(fs.readFileSync(filePath, "utf8")).compilerOptions
231
+ ?.strict === true
232
+ ) {
100
233
  return true;
101
234
  }
102
235
  } catch {
@@ -107,23 +240,40 @@ function readStrictOption(packageDirectory) {
107
240
  }
108
241
 
109
242
  function joinPosix(...parts) {
110
- const joined = path.posix.join(...parts.filter((part) => part !== "" && part !== "."));
243
+ const joined = path.posix.join(
244
+ ...parts.filter((part) => part !== "" && part !== ".")
245
+ );
111
246
  return joined === "" ? "." : joined;
112
247
  }
113
248
 
114
249
  // Templates rendered with this repository's paths. Keys are target paths relative to the frontend package.
115
- export function renderTemplates({ app, protectedInside, packageJson, viteMajor, strict }) {
116
- const eslintFileName = packageJson.type === "module" ? "eslint.config.js" : "eslint.config.mjs";
117
- const viteTemplate = viteMajor !== null && viteMajor >= 8 ? "vite.config.rolldown.ts" : "vite.config.babel.ts";
250
+ export function renderTemplates({
251
+ app,
252
+ protectedInside,
253
+ packageJson,
254
+ viteMajor,
255
+ strict,
256
+ }) {
257
+ if (viteMajor !== null && viteMajor > maximumSupportedViteMajor) {
258
+ throw createExitError(
259
+ "eds align supports Vite 5.2.0–" + maximumSupportedViteMajor + ".x only.",
260
+ 2
261
+ );
262
+ }
263
+ const eslintFileName =
264
+ packageJson.type === "module" ? "eslint.config.js" : "eslint.config.mjs";
265
+ const viteTemplate =
266
+ viteMajor === 8 ? "vite.config.rolldown.ts" : "vite.config.babel.ts";
118
267
  const values = {
119
268
  srcDir: app.srcDir,
120
269
  strict: String(strict),
121
- eslintIgnores: ["dist", "coverage", ".eds", ...protectedInside].map((entry) => JSON.stringify(entry)).join(", "),
270
+ eslintIgnores: ["dist", "coverage", ".eds", ...protectedInside]
271
+ .map((entry) => JSON.stringify(entry))
272
+ .join(", "),
122
273
  prettierIgnores: [
123
274
  "dist",
124
275
  "coverage",
125
276
  ".eds",
126
- joinPosix(app.appDir, "public/mockServiceWorker.js"),
127
277
  toPosix(generatedCssRelativePath),
128
278
  "*.md",
129
279
  ...rootLockfiles,
@@ -133,15 +283,19 @@ export function renderTemplates({ app, protectedInside, packageJson, viteMajor,
133
283
  const rendered = new Map();
134
284
  for (const templatePath of walkFiles(templatesRoot)) {
135
285
  let target;
136
- if (templatePath === "vite.config.babel.ts" || templatePath === "vite.config.rolldown.ts") {
286
+ if (
287
+ templatePath === "vite.config.babel.ts" ||
288
+ templatePath === "vite.config.rolldown.ts"
289
+ ) {
137
290
  if (templatePath !== viteTemplate) continue;
138
291
  target = "vite.config.ts";
139
292
  } else if (templatePath === "eslint.config.js") {
140
293
  target = eslintFileName;
141
- } else if (templatePath === "prettierrc.json" || templatePath === "prettierignore") {
294
+ } else if (
295
+ templatePath === "prettierrc.json" ||
296
+ templatePath === "prettierignore"
297
+ ) {
142
298
  target = "." + templatePath;
143
- } else if (templatePath === "env.mock") {
144
- target = joinPosix(app.appDir, ".env.mock");
145
299
  } else if (templatePath.startsWith("src/")) {
146
300
  target = joinPosix(app.srcDir, templatePath.slice("src/".length));
147
301
  } else if (templatePath.startsWith("public/")) {
@@ -149,12 +303,16 @@ export function renderTemplates({ app, protectedInside, packageJson, viteMajor,
149
303
  } else {
150
304
  target = templatePath;
151
305
  }
152
- const contents = fs.readFileSync(path.join(templatesRoot, templatePath), "utf8").replace(placeholderPattern, (match, name) => {
153
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
154
- throw new Error("Unknown template placeholder " + match + " in " + templatePath);
155
- }
156
- return values[name];
157
- });
306
+ const contents = fs
307
+ .readFileSync(path.join(templatesRoot, templatePath), "utf8")
308
+ .replace(placeholderPattern, (match, name) => {
309
+ if (!Object.prototype.hasOwnProperty.call(values, name)) {
310
+ throw new Error(
311
+ "Unknown template placeholder " + match + " in " + templatePath
312
+ );
313
+ }
314
+ return values[name];
315
+ });
158
316
  rendered.set(target, contents);
159
317
  }
160
318
  return rendered;
@@ -171,18 +329,39 @@ function logFindings(findings, log) {
171
329
  continue;
172
330
  }
173
331
  shown.set(key, count + 1);
174
- log("[EDS] " + (finding.level === "warning" ? "warning " : "") + finding.kind + ": " + finding.message);
332
+ log(
333
+ "[EDS] " +
334
+ (finding.level === "warning" ? "warning " : "") +
335
+ finding.kind +
336
+ ": " +
337
+ finding.message
338
+ );
175
339
  }
176
340
  for (const [key, count] of hidden) {
177
- log("[EDS] … " + count + " more " + key + " findings (fix the ones above and run the check again)");
341
+ log(
342
+ "[EDS] … " +
343
+ count +
344
+ " more " +
345
+ key +
346
+ " findings (fix the ones above and run the check again)"
347
+ );
178
348
  }
179
349
  }
180
350
 
181
351
  // The rules file as an agent reaches it from the frontend package: through node_modules (the frontend's own or a hoisted
182
352
  // one up to the git root) when EDS is installed there, else the resolved package directory (pnpm store, linked install).
183
353
  function conventionsPathFrom(packageDirectory, gitRoot, packageRoot) {
184
- for (let directory = packageDirectory; ; directory = path.dirname(directory)) {
185
- const candidate = path.join(directory, "node_modules", packageName, conventionsFileName);
354
+ for (
355
+ let directory = packageDirectory;
356
+ ;
357
+ directory = path.dirname(directory)
358
+ ) {
359
+ const candidate = path.join(
360
+ directory,
361
+ "node_modules",
362
+ packageName,
363
+ conventionsFileName
364
+ );
186
365
  if (fs.existsSync(candidate)) {
187
366
  return toPosix(path.relative(packageDirectory, candidate));
188
367
  }
@@ -190,29 +369,42 @@ function conventionsPathFrom(packageDirectory, gitRoot, packageRoot) {
190
369
  break;
191
370
  }
192
371
  }
193
- return toPosix(path.join(path.relative(packageDirectory, packageRoot), conventionsFileName));
372
+ return toPosix(
373
+ path.join(path.relative(packageDirectory, packageRoot), conventionsFileName)
374
+ );
194
375
  }
195
376
 
196
377
  function verificationCommands(commands) {
197
- return ["format:check", "lint", "typecheck", "build", "eds:doctor"].map((script) => commands.run(script)).join(", ");
378
+ return ["format:check", "lint", "typecheck", "build", "eds:doctor"]
379
+ .map((script) => commands.run(script))
380
+ .join(", ");
198
381
  }
199
382
 
200
383
  function locateFrontend({ consumerRoot, frontend }) {
201
- const packageDirectory = fs.realpathSync(resolveFrontendDirectory(consumerRoot, frontend));
384
+ const packageDirectory = fs.realpathSync(
385
+ resolveFrontendDirectory(consumerRoot, frontend)
386
+ );
202
387
  const packageJson = readPackageJson(packageDirectory);
203
388
  const framework = detectFramework(declaredDependencies(packageJson));
204
389
  if (framework.unsupported !== null) {
205
390
  throw createExitError(
206
- framework.unsupported + " found: moving it to Vite is a framework migration, not alignment. eds align supports Vite + React single-page apps.",
207
- 2,
391
+ framework.unsupported +
392
+ " found: moving it to Vite is a framework migration, not alignment. eds align supports Vite + React single-page apps.",
393
+ 2
208
394
  );
209
395
  }
210
396
  if (!framework.vite || !framework.react) {
211
- throw createExitError("eds align needs a Vite + React app; " + toPosix(path.join(packageDirectory, "package.json")) + " must declare vite and react.", 2);
397
+ throw createExitError(
398
+ "eds align needs a Vite + React app; " +
399
+ toPosix(path.join(packageDirectory, "package.json")) +
400
+ " must declare vite and react.",
401
+ 2
402
+ );
212
403
  }
213
404
  const topLevel = git(packageDirectory, ["rev-parse", "--show-toplevel"]);
214
405
  const gitRoot = topLevel === null ? null : fs.realpathSync(topLevel.trim());
215
- const packageRelative = gitRoot === null ? "" : toPosix(path.relative(gitRoot, packageDirectory));
406
+ const packageRelative =
407
+ gitRoot === null ? "" : toPosix(path.relative(gitRoot, packageDirectory));
216
408
  return { packageDirectory, packageJson, gitRoot, packageRelative };
217
409
  }
218
410
 
@@ -222,63 +414,217 @@ function requireGit(gitRoot) {
222
414
  "eds align needs git: the base commit is how --check proves the backend did not change. Ask the user to create a git repository at " +
223
415
  "the project root (the directory that holds both frontend and backend) with a .gitignore covering node_modules, build output and " +
224
416
  ".env files, commit everything once, then rerun eds align.",
225
- 2,
417
+ 2
226
418
  );
227
419
  }
228
420
  }
229
421
 
230
- export function runAlign({ consumerRoot, env = process.env, check = false, frontend = null, dryRun = false, log = console.log }) {
422
+ export function runAlign({
423
+ consumerRoot,
424
+ env = process.env,
425
+ check = false,
426
+ frontend = null,
427
+ dryRun = false,
428
+ nodeVersion = process.versions.node,
429
+ log = console.log,
430
+ }) {
231
431
  const located = locateFrontend({ consumerRoot, frontend });
232
432
  // Paths in the output are relative to the current directory, which differs from the frontend package with --frontend.
233
- const fromCwd = toPosix(path.relative(consumerRoot, located.packageDirectory));
234
- const shown = (relativePath) => (fromCwd === "" ? relativePath : fromCwd + "/" + relativePath);
433
+ const fromCwd = toPosix(
434
+ path.relative(consumerRoot, located.packageDirectory)
435
+ );
436
+ const shown = (relativePath) =>
437
+ fromCwd === "" ? relativePath : fromCwd + "/" + relativePath;
235
438
  // With --frontend the rerun hint keeps the flag, or the check could not locate the package again.
236
439
  const frontendFlag = frontend ? " --frontend " + frontend : "";
237
- return check ? runCheck({ ...located, env, log, shown, frontendFlag }) : runPlan({ ...located, env, dryRun, log, fromCwd, shown });
440
+ return check
441
+ ? runCheck({ ...located, env, log, shown, frontendFlag, nodeVersion })
442
+ : runPlan({ ...located, env, dryRun, log, fromCwd, shown, nodeVersion });
238
443
  }
239
444
 
240
- function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env, dryRun, log, fromCwd, shown }) {
445
+ function runPlan({
446
+ packageDirectory,
447
+ packageJson,
448
+ gitRoot,
449
+ packageRelative,
450
+ env,
451
+ dryRun,
452
+ log,
453
+ fromCwd,
454
+ shown,
455
+ nodeVersion,
456
+ }) {
241
457
  requireGit(gitRoot);
242
458
  let pkg;
243
459
  try {
244
460
  pkg = resolvePackage(packageDirectory);
245
461
  } catch {
246
- throw createExitError("Install " + packageName + " in " + (packageRelative || ".") + " first (" + installCommand(detectPackageManager(packageDirectory, env)) + "), then run eds align there.", 2);
462
+ throw createExitError(
463
+ "Install " +
464
+ packageName +
465
+ " in " +
466
+ (packageRelative || ".") +
467
+ " first (" +
468
+ installCommand(detectPackageManager(packageDirectory, env)) +
469
+ "), then run eds align there.",
470
+ 2
471
+ );
247
472
  }
248
473
  if (fs.realpathSync(pkg.packageRoot) !== fs.realpathSync(packageRoot)) {
249
- log("[EDS] note: this is eds from " + toPosix(packageRoot) + ", but the frontend has " + packageName + "@" + pkg.version + " installed; run its own CLI (" + cliCommand(detectPackageManager(packageDirectory, env), "align") + ") so the plan matches the installed documents.");
474
+ log(
475
+ "[EDS] note: this is eds from " +
476
+ toPosix(packageRoot) +
477
+ ", but the frontend has " +
478
+ packageName +
479
+ "@" +
480
+ pkg.version +
481
+ " installed; run its own CLI (" +
482
+ cliCommand(detectPackageManager(packageDirectory, env), "align") +
483
+ ") so the plan matches the installed documents."
484
+ );
250
485
  }
251
486
  const packageManager = detectPackageManager(packageDirectory, env);
252
487
  const alignPath = path.join(packageDirectory, alignDirectory);
253
- const alignRelative = packageRelative === "" ? alignDirectory : packageRelative + "/" + alignDirectory;
488
+ const alignRelative =
489
+ packageRelative === ""
490
+ ? alignDirectory
491
+ : packageRelative + "/" + alignDirectory;
254
492
  const previous = readState(path.join(alignPath, stateFileName), shown);
255
-
256
- const analysis = analyzeFrontend({ packageDirectory, gitRoot, protectedInfo: previous?.protected ?? null });
493
+ const previousPreservedPaths = normalizeRepositoryPaths(
494
+ previous?.preservedPaths ?? [],
495
+ alignRelative
496
+ );
497
+ const previousAutoPreservedPaths = normalizeRepositoryPaths(
498
+ previous?.autoPreservedPaths ??
499
+ previousPreservedPaths.filter((relativePath) =>
500
+ isAutomaticPreservedRepositoryPath(relativePath, packageRelative)
501
+ ),
502
+ alignRelative
503
+ );
504
+ const previousAutoSet = new Set(previousAutoPreservedPaths);
505
+ const manualPreservedPaths = previousPreservedPaths.filter(
506
+ (relativePath) => !previousAutoSet.has(relativePath)
507
+ );
508
+ const analysis = analyzeFrontend({
509
+ packageDirectory,
510
+ gitRoot,
511
+ protectedInfo: previous?.protected ?? null,
512
+ preservedPaths: packagePathsFromRepository(
513
+ previousPreservedPaths,
514
+ packageRelative
515
+ ),
516
+ nodeVersion,
517
+ });
518
+ if (analysis.dependencies.unsupportedVite !== null) {
519
+ const vite = analysis.dependencies.unsupportedVite;
520
+ throw createExitError(
521
+ "eds align supports Vite 5.2.0–" +
522
+ maximumSupportedViteMajor +
523
+ ".x only; " +
524
+ (vite === "unknown"
525
+ ? "the declared Vite version cannot be verified"
526
+ : vite === "range"
527
+ ? "the declared Vite range must guarantee Vite 5.2.0 or later within one supported major"
528
+ : "Vite " + vite + " has not been verified") +
529
+ ".",
530
+ 2
531
+ );
532
+ }
533
+ if (analysis.dependencies.unsupportedNode !== null) {
534
+ throw createExitError(
535
+ "Vite 8 React Compiler setup requires Node " +
536
+ minimumVersions.nodeForVite8 +
537
+ " or later; eds align is running on Node " +
538
+ analysis.dependencies.unsupportedNode +
539
+ ".",
540
+ 2
541
+ );
542
+ }
543
+ const currentAutoPreservedPaths = analysis.autoPreservedPaths.map(
544
+ (relativePath) =>
545
+ packageRelative === ""
546
+ ? relativePath
547
+ : packageRelative + "/" + relativePath
548
+ );
549
+ const autoPreservedPaths = [
550
+ ...new Set([...previousAutoPreservedPaths, ...currentAutoPreservedPaths]),
551
+ ].sort();
552
+ const preservedRepositoryPaths = [
553
+ ...new Set([...manualPreservedPaths, ...autoPreservedPaths]),
554
+ ].sort();
257
555
  let base;
258
556
  let baseline;
259
557
  if (previous !== null) {
260
558
  base = previous.git.base;
261
- baseline = previous.git.baseline;
559
+ baseline = { ...(previous.git.baseline ?? {}) };
262
560
  } else {
263
561
  base = git(gitRoot, ["rev-parse", "--verify", "HEAD"])?.trim() ?? null;
264
562
  if (!base) {
265
- throw createExitError("The repository has no commit yet, so --check has no base. Ask the user to commit the current files once " +
266
- "(with a .gitignore covering node_modules, build output and .env files), then rerun eds align.", 2);
563
+ throw createExitError(
564
+ "The repository has no commit yet, so --check has no base. Ask the user to commit the current files once " +
565
+ "(with a .gitignore covering node_modules, build output and .env files), then rerun eds align.",
566
+ 2
567
+ );
267
568
  }
268
- // Uncommitted changes that already exist are the baseline. Only paths the check guards are recorded:
269
- // everything else inside the frontend package may change freely.
569
+ // Record the frontend's existing changes so later-added preserved paths can use the original plan baseline.
570
+ // The check still ignores ordinary in-package paths that are not protected or preserved.
270
571
  baseline = {};
271
572
  const guarded = (relativePath) =>
272
- analysis.protectedInfo.repository.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/")) ||
273
- (packageRelative !== "" && relativePath !== packageRelative && !relativePath.startsWith(packageRelative + "/"));
573
+ analysis.protectedInfo.repository.some(
574
+ (entry) =>
575
+ relativePath === entry || relativePath.startsWith(entry + "/")
576
+ ) ||
577
+ preservedRepositoryPaths.some(
578
+ (entry) =>
579
+ relativePath === entry || relativePath.startsWith(entry + "/")
580
+ ) ||
581
+ (packageRelative !== "" &&
582
+ relativePath !== packageRelative &&
583
+ !relativePath.startsWith(packageRelative + "/"));
274
584
  for (const relativePath of changedSince(gitRoot, "HEAD")) {
275
- if (relativePath !== alignRelative && !relativePath.startsWith(alignRelative + "/") && guarded(relativePath)) {
276
- baseline[relativePath] = hashFile(path.join(gitRoot, relativePath));
585
+ const inPackage =
586
+ packageRelative === "" ||
587
+ relativePath === packageRelative ||
588
+ relativePath.startsWith(packageRelative + "/");
589
+ if (
590
+ relativePath !== alignRelative &&
591
+ !relativePath.startsWith(alignRelative + "/") &&
592
+ (inPackage || guarded(relativePath))
593
+ ) {
594
+ const hash = hashFile(path.join(gitRoot, relativePath));
595
+ if (hash !== null) baseline[relativePath] = hash;
596
+ }
597
+ }
598
+ Object.assign(baseline, hashPaths(gitRoot, preservedRepositoryPaths));
599
+ }
600
+ if (previous !== null) {
601
+ // A refreshed plan adopts newly added preserved files while keeping earlier file hashes unchanged.
602
+ for (const [relativePath, hash] of Object.entries(
603
+ hashPaths(gitRoot, preservedRepositoryPaths)
604
+ )) {
605
+ if (!Object.hasOwn(baseline, relativePath)) baseline[relativePath] = hash;
606
+ }
607
+ }
608
+ // Upgrade older plans that did not record preserved paths, then report later edits against this baseline.
609
+ if (previous?.preservedPaths === undefined) {
610
+ for (const relativePath of changedSince(gitRoot, base)) {
611
+ if (
612
+ preservedRepositoryPaths.some(
613
+ (entry) =>
614
+ relativePath === entry || relativePath.startsWith(entry + "/")
615
+ ) &&
616
+ !Object.hasOwn(baseline, relativePath)
617
+ ) {
618
+ const hash = hashFile(path.join(gitRoot, relativePath));
619
+ if (hash !== null) baseline[relativePath] = hash;
277
620
  }
278
621
  }
279
622
  }
280
623
 
281
- analysis.structure = summarizeStructure(analysis.allFiles);
624
+ analysis.structure = summarizeStructure(
625
+ analysis.allFiles,
626
+ analysis.preservedSourcePaths
627
+ );
282
628
  const templates = renderTemplates({
283
629
  app: analysis.app,
284
630
  protectedInside: analysis.protectedInfo.inside,
@@ -287,7 +633,11 @@ function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env,
287
633
  strict: readStrictOption(packageDirectory),
288
634
  });
289
635
  const createdAt = previous?.createdAt ?? new Date().toISOString();
290
- const frontendState = { packageRelative, appDir: analysis.app.appDir, srcDir: analysis.app.srcDir };
636
+ const frontendState = {
637
+ packageRelative,
638
+ appDir: analysis.app.appDir,
639
+ srcDir: analysis.app.srcDir,
640
+ };
291
641
  const state = {
292
642
  schemaVersion: stateSchemaVersion,
293
643
  createdBy: packageName + "@" + pkg.version,
@@ -295,6 +645,14 @@ function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env,
295
645
  frontend: frontendState,
296
646
  git: { base, baseline },
297
647
  protected: analysis.protectedInfo,
648
+ preservedPaths: preservedRepositoryPaths,
649
+ autoPreservedPaths,
650
+ manualPreservedPaths,
651
+ preservedTsconfigSettings:
652
+ previous?.preservedTsconfigSettings &&
653
+ !isLegacyPreservedTsconfigSettings(previous.preservedTsconfigSettings)
654
+ ? previous.preservedTsconfigSettings
655
+ : analysis.preservedTsconfigSettings,
298
656
  };
299
657
  const plan = renderPlan({
300
658
  version: pkg.version,
@@ -305,25 +663,71 @@ function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env,
305
663
  analysis,
306
664
  templates: [...templates.keys()],
307
665
  alignLabel: alignDirectory,
308
- conventionsPath: conventionsPathFrom(packageDirectory, gitRoot, pkg.packageRoot),
666
+ conventionsPath: conventionsPathFrom(
667
+ packageDirectory,
668
+ gitRoot,
669
+ pkg.packageRoot
670
+ ),
309
671
  });
310
672
  const counts = countFindings(analysis.findings);
311
673
 
312
- log("[EDS] align: frontend " + (packageRelative || ".") + " (Vite " + (analysis.majors.vite ?? "?") + ", React " + (analysis.majors.react ?? "?") +
313
- ", " + packageManager.name + "), sources " + analysis.app.srcDir);
674
+ log(
675
+ "[EDS] align: frontend " +
676
+ (packageRelative || ".") +
677
+ " (Vite " +
678
+ (analysis.majors.vite ?? "?") +
679
+ ", React " +
680
+ (analysis.majors.react ?? "?") +
681
+ ", " +
682
+ packageManager.name +
683
+ "), sources " +
684
+ analysis.app.srcDir
685
+ );
314
686
  const protectedList = analysis.protectedInfo.repository;
315
- log("[EDS] align: protected paths: " + (protectedList.length > 0 ? protectedList.join(", ") : "none detected") +
316
- (packageRelative === "" ? "" : ", plus everything outside " + packageRelative));
317
- log("[EDS] align: " + counts.errors + " " + plural(counts.errors, "error") + (counts.errorSummary ? " (" + counts.errorSummary + ")" : "") + ", " +
318
- counts.warnings + " " + plural(counts.warnings, "warning") + " to resolve");
687
+ log(
688
+ "[EDS] align: protected paths: " +
689
+ (protectedList.length > 0 ? protectedList.join(", ") : "none detected") +
690
+ (packageRelative === ""
691
+ ? ""
692
+ : ", plus files outside " + packageRelative + " (except repository root lockfiles)")
693
+ );
694
+ log(
695
+ "[EDS] align: " +
696
+ counts.errors +
697
+ " " +
698
+ plural(counts.errors, "error") +
699
+ (counts.errorSummary ? " (" + counts.errorSummary + ")" : "") +
700
+ ", " +
701
+ counts.warnings +
702
+ " " +
703
+ plural(counts.warnings, "warning") +
704
+ " to resolve"
705
+ );
319
706
  if (previous !== null) {
320
- log("[EDS] align: kept the base commit " + base.slice(0, 12) + " from " + createdAt.slice(0, 10) + "; delete " + shown(alignDirectory) + " to start over.");
707
+ log(
708
+ "[EDS] align: kept the base commit " +
709
+ base.slice(0, 12) +
710
+ " from " +
711
+ createdAt.slice(0, 10) +
712
+ "; delete " +
713
+ shown(alignDirectory) +
714
+ " to start over."
715
+ );
321
716
  }
322
717
  const planPath = path.join(alignPath, planFileName);
323
718
  if (dryRun) {
324
719
  const notesExist = fs.existsSync(path.join(alignPath, notesFileName));
325
- log("[EDS] would write " + shown(alignDirectory + "/" + planFileName) + ", " + (notesExist ? "" : shown(alignDirectory + "/" + notesFileName) + ", ") +
326
- shown(alignDirectory + "/" + stateFileName) + " and " + templates.size + " templates in " + shown(alignDirectory + "/" + templatesDirectoryName));
720
+ log(
721
+ "[EDS] would write " +
722
+ shown(alignDirectory + "/" + planFileName) +
723
+ ", " +
724
+ (notesExist ? "" : shown(alignDirectory + "/" + notesFileName) + ", ") +
725
+ shown(alignDirectory + "/" + stateFileName) +
726
+ " and " +
727
+ templates.size +
728
+ " templates in " +
729
+ shown(alignDirectory + "/" + templatesDirectoryName)
730
+ );
327
731
  return 0;
328
732
  }
329
733
 
@@ -332,7 +736,10 @@ function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env,
332
736
  fs.mkdirSync(alignPath, { recursive: true });
333
737
  // The directory ignores itself, so the plan never lands in a commit or in the base comparison.
334
738
  fs.writeFileSync(path.join(alignPath, ".gitignore"), "*\n");
335
- fs.writeFileSync(path.join(alignPath, stateFileName), JSON.stringify(state, null, 2) + "\n");
739
+ fs.writeFileSync(
740
+ path.join(alignPath, stateFileName),
741
+ JSON.stringify(state, null, 2) + "\n"
742
+ );
336
743
  fs.writeFileSync(planPath, plan);
337
744
  const notesPath = path.join(alignPath, notesFileName);
338
745
  if (!fs.existsSync(notesPath)) {
@@ -344,55 +751,184 @@ function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env,
344
751
  fs.writeFileSync(targetPath, contents);
345
752
  }
346
753
  const commands = packageManagerCommands(packageManager);
347
- log("[EDS] wrote " + shown(alignDirectory + "/" + planFileName) + ", " + notesFileName + ", " + stateFileName + " and " + templates.size + " templates (base " + base.slice(0, 12) + ")");
348
- log("[EDS] Next: " + (fromCwd === "" ? "" : "cd " + fromCwd + ", then ") + "follow " + alignDirectory + "/" + planFileName + " step by step; " +
349
- commands.eds("align --check") + " verifies each step" + (fromCwd === "" ? "." : " (run every PLAN.md command from " + fromCwd + ")."));
754
+ log(
755
+ "[EDS] wrote " +
756
+ shown(alignDirectory + "/" + planFileName) +
757
+ ", " +
758
+ notesFileName +
759
+ ", " +
760
+ stateFileName +
761
+ " and " +
762
+ templates.size +
763
+ " templates (base " +
764
+ base.slice(0, 12) +
765
+ ")"
766
+ );
767
+ log(
768
+ "[EDS] Next: " +
769
+ (fromCwd === "" ? "" : "cd " + fromCwd + ", then ") +
770
+ "follow " +
771
+ alignDirectory +
772
+ "/" +
773
+ planFileName +
774
+ " step by step; " +
775
+ commands.eds("align --check") +
776
+ " verifies each step" +
777
+ (fromCwd === ""
778
+ ? "."
779
+ : " (run every PLAN.md command from " + fromCwd + ").")
780
+ );
350
781
  return 0;
351
782
  }
352
783
 
353
- // With a plan, the check also proves that no protected or out-of-package file changed since its base commit.
784
+ // With a plan, the check also proves that no protected, preserved or out-of-package file except repository root lockfiles changed since its base commit.
354
785
  // Without one (after step 8 removed .eds/align), it checks the conventions only.
355
- function runCheck({ packageDirectory, gitRoot, packageRelative, env, log, shown, frontendFlag = "" }) {
356
- const state = readState(path.join(packageDirectory, alignDirectory, stateFileName), shown);
357
- const commands = packageManagerCommands(detectPackageManager(packageDirectory, env));
786
+ function runCheck({
787
+ packageDirectory,
788
+ gitRoot,
789
+ packageRelative,
790
+ env,
791
+ log,
792
+ shown,
793
+ frontendFlag = "",
794
+ nodeVersion,
795
+ }) {
796
+ const state = readState(
797
+ path.join(packageDirectory, alignDirectory, stateFileName),
798
+ shown
799
+ );
800
+ const commands = packageManagerCommands(
801
+ detectPackageManager(packageDirectory, env)
802
+ );
358
803
  const findings = [];
359
804
  let analysis;
360
805
  if (state === null) {
361
- log("[EDS] note: no " + shown(alignDirectory + "/" + stateFileName) + ": checking the conventions only (the protected-path check needs the base commit of eds align).");
362
- analysis = analyzeFrontend({ packageDirectory, gitRoot: gitRoot ?? packageDirectory });
806
+ log(
807
+ "[EDS] note: no " +
808
+ shown(alignDirectory + "/" + stateFileName) +
809
+ ": checking the conventions only (the protected-path check needs the base commit of eds align)."
810
+ );
811
+ analysis = analyzeFrontend({
812
+ packageDirectory,
813
+ gitRoot: gitRoot ?? packageDirectory,
814
+ nodeVersion,
815
+ });
363
816
  } else {
364
817
  requireGit(gitRoot);
365
- if (git(gitRoot, ["cat-file", "-e", state.git.base + "^{commit}"]) === null) {
366
- throw createExitError("The base commit " + state.git.base.slice(0, 12) + " is not in this repository; delete " + shown(alignDirectory) + " and run eds align again.", 2);
818
+ if (
819
+ git(gitRoot, ["cat-file", "-e", state.git.base + "^{commit}"]) === null
820
+ ) {
821
+ throw createExitError(
822
+ "The base commit " +
823
+ state.git.base.slice(0, 12) +
824
+ " is not in this repository; delete " +
825
+ shown(alignDirectory) +
826
+ " and run eds align again.",
827
+ 2
828
+ );
367
829
  }
368
- const alignRelative = packageRelative === "" ? alignDirectory : packageRelative + "/" + alignDirectory;
369
- findings.push(...classifyChangedPaths({
370
- changed: changedSince(gitRoot, state.git.base),
371
- baseline: state.git.baseline ?? {},
372
- hashOf: (relativePath) => hashFile(path.join(gitRoot, relativePath)),
373
- packageRelative,
374
- protectedPaths: state.protected.repository ?? [],
375
- alignRelative,
376
- base: state.git.base,
377
- }));
378
- analysis = analyzeFrontend({ packageDirectory, gitRoot, protectedInfo: state.protected });
379
- findings.push(...checkBackendPackage({ packageJson: analysis.packageJson, state }));
830
+ const alignRelative =
831
+ packageRelative === ""
832
+ ? alignDirectory
833
+ : packageRelative + "/" + alignDirectory;
834
+ const statePreservedPaths = normalizeRepositoryPaths(
835
+ state.preservedPaths ?? [],
836
+ alignRelative
837
+ );
838
+ findings.push(
839
+ ...classifyChangedPaths({
840
+ changed: changedSince(gitRoot, state.git.base),
841
+ baseline: state.git.baseline ?? {},
842
+ hashOf: (relativePath) => hashFile(path.join(gitRoot, relativePath)),
843
+ packageRelative,
844
+ protectedPaths: state.protected.repository ?? [],
845
+ preservedPaths: statePreservedPaths,
846
+ baseTrackedPaths: gitPaths(gitRoot, [
847
+ "ls-tree",
848
+ "-r",
849
+ "-z",
850
+ "--name-only",
851
+ state.git.base,
852
+ ]),
853
+ alignRelative,
854
+ base: state.git.base,
855
+ })
856
+ );
857
+ analysis = analyzeFrontend({
858
+ packageDirectory,
859
+ gitRoot,
860
+ protectedInfo: state.protected,
861
+ preservedPaths: packagePathsFromRepository(
862
+ statePreservedPaths,
863
+ packageRelative
864
+ ),
865
+ nodeVersion,
866
+ });
867
+ findings.push(
868
+ ...checkBackendPackage({ packageJson: analysis.packageJson, state })
869
+ );
870
+ findings.push(
871
+ ...checkPreservedTsconfigSettings({
872
+ baseline: state.preservedTsconfigSettings,
873
+ current: analysis.preservedTsconfigSettings,
874
+ })
875
+ );
380
876
  }
381
877
  findings.push(...analysis.findings);
382
878
  const counts = countFindings(findings);
383
879
  const since = state === null ? "" : " since " + state.git.base.slice(0, 12);
384
880
  logFindings(findings, log);
385
881
  if (counts.errors === 0) {
386
- log("[EDS] align: " + analysis.app.srcDir + " follows the EVA frontend conventions" +
387
- (state === null ? "" : ", and no protected or out-of-package file changed" + since) +
388
- (counts.warnings > 0 ? " (" + counts.warnings + " " + plural(counts.warnings, "warning") + ": fix them or record why)" : "") + ".");
389
- log("[EDS] Also required: " + verificationCommands(commands) + " (each must exit 0, and eds:doctor must print [EDS] synced with)" +
390
- (state === null ? "." : ", then step 8 of " + shown(alignDirectory + "/" + planFileName) + "."));
882
+ log(
883
+ "[EDS] align: " +
884
+ analysis.app.srcDir +
885
+ " follows the EVA frontend conventions" +
886
+ (state === null
887
+ ? ""
888
+ : ", and no protected, preserved or out-of-package file changed (repository root lockfiles are excluded)" +
889
+ since) +
890
+ (counts.warnings > 0
891
+ ? " (" +
892
+ counts.warnings +
893
+ " " +
894
+ plural(counts.warnings, "warning") +
895
+ ": fix them or record why)"
896
+ : "") +
897
+ "."
898
+ );
899
+ log(
900
+ "[EDS] Also required: " +
901
+ verificationCommands(commands) +
902
+ " (each must exit 0, and eds:doctor must print [EDS] synced with)" +
903
+ (state === null
904
+ ? "."
905
+ : ", then step 8 of " +
906
+ shown(alignDirectory + "/" + planFileName) +
907
+ ".")
908
+ );
391
909
  return 0;
392
910
  }
393
- log("[EDS] align: " + counts.errors + " " + plural(counts.errors, "error") + " (" + counts.errorSummary + "), " + counts.warnings + " " +
394
- plural(counts.warnings, "warning") + since + ". Fix the errors and run " + commands.eds("align --check" + frontendFlag) + " again" +
395
- (state === null ? "." : "; " + shown(alignDirectory + "/" + planFileName) + " maps each kind to a step, and protected, outside and backend-* errors mean revert that change."));
911
+ log(
912
+ "[EDS] align: " +
913
+ counts.errors +
914
+ " " +
915
+ plural(counts.errors, "error") +
916
+ " (" +
917
+ counts.errorSummary +
918
+ "), " +
919
+ counts.warnings +
920
+ " " +
921
+ plural(counts.warnings, "warning") +
922
+ since +
923
+ ". Fix the errors and run " +
924
+ commands.eds("align --check" + frontendFlag) +
925
+ " again" +
926
+ (state === null
927
+ ? "."
928
+ : "; " +
929
+ shown(alignDirectory + "/" + planFileName) +
930
+ " maps each kind to a step; protected, preserved, outside and backend-* errors mean revert that change.")
931
+ );
396
932
  return 1;
397
933
  }
398
934