@lark-apaas/miaoda-cli 0.1.46 → 0.1.48

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.
@@ -11,7 +11,26 @@ const node_path_1 = __importDefault(require("node:path"));
11
11
  const sync_rule_1 = require("./sync-rule");
12
12
  const spark_meta_1 = require("./spark-meta");
13
13
  const codemod_client_toolkit_lite_1 = require("./codemod-client-toolkit-lite");
14
+ const migrate_adapters_1 = require("./migrate-adapters");
14
15
  const logger_1 = require("./logger");
16
+ const TYPE_MISMATCH = 'file/directory type mismatch';
17
+ /** 可能返回 conflict 的规则类型;dryRun 只评估这些规则 */
18
+ const CONFLICT_CAPABLE_RULES = new Set([
19
+ 'move-directory',
20
+ 'move-file',
21
+ 'move-capabilities',
22
+ 'ensure-scripts',
23
+ 'adapt-tsconfig',
24
+ 'adapt-html-entry',
25
+ 'adapt-nest-app-module',
26
+ 'remove-known-default',
27
+ 'carry-eslint-ignores',
28
+ 'codemod-vite-config-fullstack',
29
+ ]);
30
+ /** dryRun 下规则通过冲突判定、本应写入时的结果(未写盘) */
31
+ function planned(rule, rel) {
32
+ return { rule, action: 'skipped', path: rel, detail: 'dry-run: no conflict' };
33
+ }
15
34
  /**
16
35
  * 顺序执行 MigrateRule[]:
17
36
  * - move-* / set-stack 是 migrate 专有 rule,本模块直接处理。
@@ -19,41 +38,83 @@ const logger_1 = require("./logger");
19
38
  * applySyncRules —— 单条调用,逻辑跟 sync 完全对齐,避免行为漂移。
20
39
  *
21
40
  * 一条 rule 报错不影响后续(applySyncRules 内部已有 try/catch;本模块的 applyMove / applySetStack
22
- * 当前不 catch —— 如果 move 失败说明状态已经被破坏,应当直接抛错让 handler 决定回滚或上报)。
41
+ * 当前不 catch —— 如果 move 失败说明状态已经被破坏,应当直接抛错让调用方上报)。
23
42
  */
24
43
  function applyMigrateRules(rules, opts) {
25
44
  const results = [];
26
45
  for (const rule of rules) {
27
- if (rule.type === 'move-directory' || rule.type === 'move-file') {
28
- results.push(applyMove(rule, opts));
29
- continue;
30
- }
31
- if (rule.type === 'set-stack') {
32
- results.push(applySetStack(rule, opts));
46
+ if (opts.mode === 'repair' && rule.migrateOnly === true) {
47
+ results.push({ rule, action: 'skipped', detail: 'migrate-only rule skipped in repair mode' });
33
48
  continue;
34
49
  }
35
- if (rule.type === 'delete-json-keys') {
36
- results.push(applyDeleteJsonKeys(rule, opts));
50
+ if (opts.dryRun === true && !CONFLICT_CAPABLE_RULES.has(rule.type)) {
51
+ results.push({ rule, action: 'skipped', detail: 'dry-run: rule cannot conflict' });
37
52
  continue;
38
53
  }
39
- if (rule.type === 'codemod-client-toolkit-lite') {
40
- results.push(applyCodemodLite(rule, opts));
41
- continue;
54
+ let result = applyOne(rule, opts);
55
+ if (result.action === 'conflict' && opts.conflictPolicy === 'keep') {
56
+ // 冲突规则在判定冲突前不写盘,整条跳过即保持用户内容原样
57
+ result = {
58
+ rule,
59
+ action: 'skipped',
60
+ path: result.path,
61
+ detail: 'conflict kept as-is',
62
+ kept: result.conflicts,
63
+ };
42
64
  }
43
- if (rule.type === 'codemod-vite-config-fullstack') {
44
- results.push(applyCodemodViteConfigFullstack(rule, opts));
45
- continue;
46
- }
47
- if (rule.type === 'add-deps-from-template') {
48
- results.push(applyAddDepsFromTemplate(rule, opts));
49
- continue;
50
- }
51
- // 前面 if 已经把 migrate 专用 rule 都 narrow 掉,剩下的必然是 SyncRule
52
- const [syncResult] = (0, sync_rule_1.applySyncRules)([rule], opts);
53
- results.push(syncResult);
65
+ results.push(result);
66
+ if (opts.stopOnConflict === true && result.action === 'conflict')
67
+ break;
54
68
  }
55
69
  return results;
56
70
  }
71
+ function applyOne(rule, opts) {
72
+ if (rule.type === 'move-capabilities') {
73
+ return applyMoveCapabilities(rule, opts);
74
+ }
75
+ if (rule.type === 'ensure-scripts') {
76
+ return applyEnsureScripts(rule, opts);
77
+ }
78
+ if (rule.type === 'adapt-tsconfig') {
79
+ return applyAdaptTsconfig(rule, opts);
80
+ }
81
+ if (rule.type === 'adapt-html-entry') {
82
+ return applyAdaptHtmlEntry(rule, opts);
83
+ }
84
+ if (rule.type === 'adapt-nest-app-module') {
85
+ return applyAdaptNestAppModule(rule, opts);
86
+ }
87
+ if (rule.type === 'remove-known-default') {
88
+ return applyRemoveKnownDefault(rule, opts);
89
+ }
90
+ if (rule.type === 'carry-eslint-ignores') {
91
+ return applyCarryEslintIgnores(rule, opts);
92
+ }
93
+ if (rule.type === 'relax-eslint-rules') {
94
+ return applyRelaxEslintRules(rule, opts);
95
+ }
96
+ if (rule.type === 'move-directory' || rule.type === 'move-file') {
97
+ return applyMove(rule, opts);
98
+ }
99
+ if (rule.type === 'set-stack') {
100
+ return applySetStack(rule, opts);
101
+ }
102
+ if (rule.type === 'delete-json-keys') {
103
+ return applyDeleteJsonKeys(rule, opts);
104
+ }
105
+ if (rule.type === 'codemod-client-toolkit-lite') {
106
+ return applyCodemodLite(rule, opts);
107
+ }
108
+ if (rule.type === 'codemod-vite-config-fullstack') {
109
+ return applyCodemodViteConfigFullstack(rule, opts);
110
+ }
111
+ if (rule.type === 'add-deps-from-template') {
112
+ return applyAddDepsFromTemplate(rule, opts);
113
+ }
114
+ // 前面 if 已经把 migrate 专用 rule 都 narrow 掉,剩下的必然是 SyncRule
115
+ const [syncResult] = (0, sync_rule_1.applySyncRules)([rule], opts);
116
+ return syncResult;
117
+ }
57
118
  function applyMove(rule, opts) {
58
119
  const logPrefix = opts.logPrefix ?? 'migrate';
59
120
  const src = node_path_1.default.join(opts.targetDir, rule.from);
@@ -64,10 +125,41 @@ function applyMove(rule, opts) {
64
125
  return { rule, action: 'skipped', path: rule.to };
65
126
  }
66
127
  if (node_fs_1.default.existsSync(dest)) {
128
+ if (onConflict === 'fail') {
129
+ const found = findMergeConflicts(src, dest, rule.from, rule.to);
130
+ // 覆盖策略下同路径不同内容由搬迁源覆盖;类型不一致没有确定的合并结果,仍是冲突
131
+ const overwrite = opts.conflictPolicy === 'overwrite';
132
+ const conflicts = overwrite ? found.filter((c) => c.reason === TYPE_MISMATCH) : found;
133
+ const overwritten = overwrite
134
+ ? found
135
+ .filter((c) => c.reason !== TYPE_MISMATCH)
136
+ .map((c) => ({ ...c, reason: 'existing file replaced by the moved file' }))
137
+ : [];
138
+ if (conflicts.length > 0) {
139
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.from} → ${rule.to} (${conflicts.length.toString()} conflict(s))`);
140
+ return { rule, action: 'conflict', path: rule.to, detail: rule.from, conflicts };
141
+ }
142
+ if (opts.dryRun === true)
143
+ return planned(rule, rule.to);
144
+ mergeInto(src, dest, overwritten.length > 0);
145
+ node_fs_1.default.rmSync(src, { recursive: true, force: true });
146
+ (0, logger_1.log)(logPrefix, overwritten.length > 0
147
+ ? ` ✓ ${rule.from} → ${rule.to} (merged, ${overwritten.length.toString()} overwritten)`
148
+ : ` ✓ ${rule.from} → ${rule.to} (merged, no conflicts)`);
149
+ return {
150
+ rule,
151
+ action: 'moved',
152
+ path: rule.to,
153
+ detail: rule.from,
154
+ ...(overwritten.length > 0 ? { overwritten } : {}),
155
+ };
156
+ }
67
157
  if (onConflict === 'skip') {
68
158
  (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (already exists, skip per onConflict=skip)`);
69
159
  return { rule, action: 'skipped', path: rule.to };
70
160
  }
161
+ if (opts.dryRun === true)
162
+ return planned(rule, rule.to);
71
163
  if (onConflict === 'overwrite') {
72
164
  node_fs_1.default.rmSync(dest, { recursive: true, force: true });
73
165
  moveAtomic(src, dest);
@@ -81,6 +173,8 @@ function applyMove(rule, opts) {
81
173
  return { rule, action: 'moved', path: rule.to, detail: rule.from };
82
174
  }
83
175
  // 目标不存在 —— 直接 rename
176
+ if (opts.dryRun === true)
177
+ return planned(rule, rule.to);
84
178
  const destDir = node_path_1.default.dirname(dest);
85
179
  if (destDir !== '.' && !node_fs_1.default.existsSync(destDir)) {
86
180
  node_fs_1.default.mkdirSync(destDir, { recursive: true });
@@ -123,6 +217,591 @@ function mergeOverlay(src, dest) {
123
217
  mergeOverlay(node_path_1.default.join(src, entry), node_path_1.default.join(dest, entry));
124
218
  }
125
219
  }
220
+ function posixJoin(a, b) {
221
+ return a === '' || a === '.' ? b : `${a}/${b}`;
222
+ }
223
+ /**
224
+ * 比对 src 合并进 dest 时的冲突:同路径不同内容、文件/目录类型不一致。
225
+ * 同内容文件不算冲突(幂等合并)。路径以项目根相对路径报告。
226
+ */
227
+ function findMergeConflicts(src, dest, relFrom, relTo) {
228
+ const conflicts = [];
229
+ const srcStat = node_fs_1.default.statSync(src);
230
+ if (!node_fs_1.default.existsSync(dest))
231
+ return conflicts;
232
+ const destStat = node_fs_1.default.statSync(dest);
233
+ if (srcStat.isFile() || destStat.isFile()) {
234
+ if (!srcStat.isFile() || !destStat.isFile()) {
235
+ conflicts.push({ path: relTo, with: relFrom, reason: TYPE_MISMATCH });
236
+ }
237
+ else if (!node_fs_1.default.readFileSync(src).equals(node_fs_1.default.readFileSync(dest))) {
238
+ conflicts.push({ path: relTo, with: relFrom, reason: 'same path with different content' });
239
+ }
240
+ return conflicts;
241
+ }
242
+ for (const entry of node_fs_1.default.readdirSync(src).sort()) {
243
+ conflicts.push(...findMergeConflicts(node_path_1.default.join(src, entry), node_path_1.default.join(dest, entry), posixJoin(relFrom, entry), posixJoin(relTo, entry)));
244
+ }
245
+ return conflicts;
246
+ }
247
+ /**
248
+ * 把 src 的文件逐个合并进 dest。overwrite=false 时 dest 已有的文件(调用方已确认同内容)保持不动;
249
+ * overwrite=true 时 src 覆盖 dest 同路径文件(调用方已排除文件 / 目录类型不一致)。
250
+ */
251
+ function mergeInto(src, dest, overwrite) {
252
+ const stat = node_fs_1.default.statSync(src);
253
+ if (stat.isFile()) {
254
+ if (!overwrite && node_fs_1.default.existsSync(dest))
255
+ return;
256
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
257
+ node_fs_1.default.copyFileSync(src, dest);
258
+ node_fs_1.default.chmodSync(dest, stat.mode & 0o777);
259
+ return;
260
+ }
261
+ node_fs_1.default.mkdirSync(dest, { recursive: true });
262
+ for (const entry of node_fs_1.default.readdirSync(src)) {
263
+ mergeInto(node_path_1.default.join(src, entry), node_path_1.default.join(dest, entry), overwrite);
264
+ }
265
+ }
266
+ function listFilesRecursive(root, rel = '') {
267
+ const out = [];
268
+ for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(root, rel)).sort()) {
269
+ const childRel = rel === '' ? entry : `${rel}/${entry}`;
270
+ if (node_fs_1.default.statSync(node_path_1.default.join(root, childRel)).isDirectory()) {
271
+ out.push(...listFilesRecursive(root, childRel));
272
+ }
273
+ else {
274
+ out.push(childRel);
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ function readCapabilities(root, dirRel, conflicts) {
280
+ const caps = [];
281
+ const others = [];
282
+ const dirAbs = node_path_1.default.join(root, dirRel);
283
+ if (!node_fs_1.default.existsSync(dirAbs))
284
+ return { caps, others };
285
+ for (const rel of listFilesRecursive(dirAbs)) {
286
+ const relPath = `${dirRel}/${rel}`;
287
+ if (!rel.endsWith('.json')) {
288
+ others.push(rel);
289
+ continue;
290
+ }
291
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(dirAbs, rel));
292
+ let id = node_path_1.default.basename(rel, '.json');
293
+ try {
294
+ const parsed = JSON.parse(content.toString('utf-8'));
295
+ if (typeof parsed.id === 'string' && parsed.id !== '')
296
+ id = parsed.id;
297
+ }
298
+ catch {
299
+ conflicts.push({ path: relPath, reason: 'invalid capability JSON' });
300
+ continue;
301
+ }
302
+ caps.push({ path: relPath, rel, id, content });
303
+ }
304
+ return { caps, others };
305
+ }
306
+ function applyMoveCapabilities(rule, opts) {
307
+ const logPrefix = opts.logPrefix ?? 'migrate';
308
+ const srcAbs = node_path_1.default.join(opts.targetDir, rule.from);
309
+ if (!node_fs_1.default.existsSync(srcAbs)) {
310
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.from} (source not found, skip)`);
311
+ return { rule, action: 'skipped', path: rule.to };
312
+ }
313
+ const conflicts = [];
314
+ const src = readCapabilities(opts.targetDir, rule.from, conflicts);
315
+ const dest = readCapabilities(opts.targetDir, rule.to, conflicts);
316
+ // 同侧重复 ID
317
+ for (const side of [src.caps, dest.caps]) {
318
+ const seen = new Map();
319
+ for (const cap of side) {
320
+ const prev = seen.get(cap.id);
321
+ if (prev !== undefined) {
322
+ conflicts.push({
323
+ path: cap.path,
324
+ with: prev.path,
325
+ id: cap.id,
326
+ reason: 'duplicate capability id',
327
+ });
328
+ }
329
+ else {
330
+ seen.set(cap.id, cap);
331
+ }
332
+ }
333
+ }
334
+ // 跨侧同 ID:同文件同内容是幂等重跑,其他一律冲突
335
+ const destById = new Map(dest.caps.map((c) => [c.id, c]));
336
+ const destByRel = new Map(dest.caps.map((c) => [c.rel, c]));
337
+ for (const cap of src.caps) {
338
+ const sameId = destById.get(cap.id);
339
+ if (sameId !== undefined && !(sameId.rel === cap.rel && sameId.content.equals(cap.content))) {
340
+ conflicts.push({
341
+ path: sameId.path,
342
+ with: cap.path,
343
+ id: cap.id,
344
+ reason: 'capability id exists in both locations with different definition',
345
+ });
346
+ continue;
347
+ }
348
+ const sameRel = destByRel.get(cap.rel);
349
+ if (sameRel !== undefined && sameRel.id !== cap.id) {
350
+ conflicts.push({
351
+ path: sameRel.path,
352
+ with: cap.path,
353
+ reason: 'same file name with different capability id',
354
+ });
355
+ }
356
+ }
357
+ // 非 JSON 文件按同路径不同内容判定
358
+ for (const rel of src.others) {
359
+ const destFile = node_path_1.default.join(opts.targetDir, rule.to, rel);
360
+ if (!node_fs_1.default.existsSync(destFile))
361
+ continue;
362
+ if (!node_fs_1.default.readFileSync(node_path_1.default.join(srcAbs, rel)).equals(node_fs_1.default.readFileSync(destFile))) {
363
+ conflicts.push({
364
+ path: `${rule.to}/${rel}`,
365
+ with: `${rule.from}/${rel}`,
366
+ reason: 'same path with different content',
367
+ });
368
+ }
369
+ }
370
+ if (conflicts.length > 0 && opts.conflictPolicy === 'overwrite') {
371
+ return moveCapabilitiesOverwriting(rule, opts, conflicts, src.caps.length);
372
+ }
373
+ if (conflicts.length > 0) {
374
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.from} → ${rule.to} (${conflicts.length.toString()} conflict(s))`);
375
+ return { rule, action: 'conflict', path: rule.to, detail: rule.from, conflicts };
376
+ }
377
+ if (opts.dryRun === true)
378
+ return planned(rule, rule.to);
379
+ mergeInto(srcAbs, node_path_1.default.join(opts.targetDir, rule.to), false);
380
+ node_fs_1.default.rmSync(srcAbs, { recursive: true, force: true });
381
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.from} → ${rule.to} (${src.caps.length.toString()} capability file(s))`);
382
+ return {
383
+ rule,
384
+ action: 'moved',
385
+ path: rule.to,
386
+ detail: `${rule.from}; ${src.caps.length.toString()} capability file(s)`,
387
+ };
388
+ }
389
+ /**
390
+ * 覆盖策略下的插件搬迁:逐文件搬入,同路径由源覆盖(overwritten)。不同文件的同 ID、非法 JSON
391
+ * 没有可替换的模板内容、删任一侧都会丢用户插件,原样搬入并记 kept;类型不一致仍是冲突。
392
+ */
393
+ function moveCapabilitiesOverwriting(rule, opts, idConflicts, capCount) {
394
+ const logPrefix = opts.logPrefix ?? 'migrate';
395
+ const srcAbs = node_path_1.default.join(opts.targetDir, rule.from);
396
+ const destAbs = node_path_1.default.join(opts.targetDir, rule.to);
397
+ const found = findMergeConflicts(srcAbs, destAbs, rule.from, rule.to);
398
+ const blocking = found.filter((c) => c.reason === TYPE_MISMATCH);
399
+ if (blocking.length > 0) {
400
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.from} → ${rule.to} (${blocking.length.toString()} conflict(s))`);
401
+ return { rule, action: 'conflict', path: rule.to, detail: rule.from, conflicts: blocking };
402
+ }
403
+ const overwritten = found.map((c) => ({
404
+ ...c,
405
+ reason: 'existing file replaced by the moved file',
406
+ }));
407
+ const replaced = new Set(overwritten.map((c) => c.path));
408
+ const kept = idConflicts.filter((c) => !replaced.has(c.path));
409
+ if (opts.dryRun === true)
410
+ return planned(rule, rule.to);
411
+ mergeInto(srcAbs, destAbs, true);
412
+ node_fs_1.default.rmSync(srcAbs, { recursive: true, force: true });
413
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.from} → ${rule.to} (${capCount.toString()} capability file(s); ${overwritten.length.toString()} overwritten, ${kept.length.toString()} kept)`);
414
+ return {
415
+ rule,
416
+ action: 'moved',
417
+ path: rule.to,
418
+ detail: `${rule.from}; ${capCount.toString()} capability file(s)`,
419
+ ...(overwritten.length > 0 ? { overwritten } : {}),
420
+ ...(kept.length > 0 ? { kept } : {}),
421
+ };
422
+ }
423
+ function readJsonFile(abs) {
424
+ const text = node_fs_1.default.readFileSync(abs, 'utf-8');
425
+ try {
426
+ const json = JSON.parse(text);
427
+ if (json === null || typeof json !== 'object' || Array.isArray(json))
428
+ return undefined;
429
+ return { text, json: json };
430
+ }
431
+ catch {
432
+ return undefined;
433
+ }
434
+ }
435
+ function applyEnsureScripts(rule, opts) {
436
+ const logPrefix = opts.logPrefix ?? 'migrate';
437
+ const rel = rule.to ?? 'package.json';
438
+ const abs = node_path_1.default.join(opts.targetDir, rel);
439
+ if (!node_fs_1.default.existsSync(abs)) {
440
+ return {
441
+ rule,
442
+ action: 'conflict',
443
+ path: rel,
444
+ conflicts: [{ path: rel, reason: 'package.json not found' }],
445
+ };
446
+ }
447
+ const parsed = readJsonFile(abs);
448
+ if (parsed === undefined) {
449
+ return {
450
+ rule,
451
+ action: 'conflict',
452
+ path: rel,
453
+ conflicts: [{ path: rel, reason: 'package.json is not a valid JSON object' }],
454
+ };
455
+ }
456
+ const current = (parsed.json.scripts ?? {});
457
+ const outcome = (0, migrate_adapters_1.ensureScripts)(current, rule.scripts);
458
+ const policy = opts.conflictPolicy ?? 'stop';
459
+ if (outcome.conflicts.length > 0 && policy === 'stop') {
460
+ const conflicts = outcome.conflicts.map((c) => ({
461
+ path: `${rel}#scripts.${c.name}`,
462
+ reason: `custom script ${JSON.stringify(c.current)} differs from required ${JSON.stringify(c.expected)}; keep business steps in another script name or align it manually`,
463
+ }));
464
+ (0, logger_1.log)(logPrefix, ` ✗ ${rel} scripts (${conflicts.length.toString()} conflict(s))`);
465
+ return { rule, action: 'conflict', path: rel, conflicts };
466
+ }
467
+ const overwritten = [];
468
+ const kept = [];
469
+ for (const c of outcome.conflicts) {
470
+ const spec = rule.scripts.find((s) => s.name === c.name);
471
+ const at = `${rel}#scripts.${c.name}`;
472
+ if (policy === 'overwrite' && spec?.keepCustom !== true) {
473
+ outcome.scripts[c.name] = c.expected;
474
+ outcome.changed.push(c.name);
475
+ overwritten.push({
476
+ path: at,
477
+ reason: `custom script ${JSON.stringify(c.current)} replaced with ${JSON.stringify(c.expected)}`,
478
+ });
479
+ }
480
+ else {
481
+ kept.push({
482
+ path: at,
483
+ reason: `custom script ${JSON.stringify(c.current)} kept; platform expects ${JSON.stringify(c.expected)}`,
484
+ });
485
+ }
486
+ }
487
+ const extra = {
488
+ ...(overwritten.length > 0 ? { overwritten } : {}),
489
+ ...(kept.length > 0 ? { kept } : {}),
490
+ };
491
+ const keptNote = kept.length > 0 ? `; kept custom: ${kept.length.toString()}` : '';
492
+ if (outcome.changed.length === 0) {
493
+ (0, logger_1.log)(logPrefix, ` ○ ${rel} scripts (already aligned${keptNote})`);
494
+ return { rule, action: 'noop', path: rel, ...extra };
495
+ }
496
+ if (opts.dryRun === true)
497
+ return planned(rule, rel);
498
+ parsed.json.scripts = outcome.scripts;
499
+ const trailing = parsed.text.endsWith('\n') ? '\n' : '';
500
+ node_fs_1.default.writeFileSync(abs, JSON.stringify(parsed.json, null, 2) + trailing);
501
+ (0, logger_1.log)(logPrefix, ` ✓ ${rel} scripts (${outcome.changed.join(', ')}${keptNote})`);
502
+ return { rule, action: 'patched', path: rel, detail: outcome.changed.join(','), ...extra };
503
+ }
504
+ function applyAdaptTsconfig(rule, opts) {
505
+ const logPrefix = opts.logPrefix ?? 'migrate';
506
+ const tplAbs = node_path_1.default.join(opts.sourceRoot, rule.from);
507
+ const userAbs = node_path_1.default.join(opts.targetDir, rule.to);
508
+ if (!node_fs_1.default.existsSync(tplAbs)) {
509
+ (0, logger_1.log)(logPrefix, ` ⚠ template ${rule.from} not found, skip`);
510
+ return { rule, action: 'skipped', path: rule.to };
511
+ }
512
+ const userExists = node_fs_1.default.existsSync(userAbs);
513
+ if (!userExists && rule.role === 'app') {
514
+ // app 侧缺失:直接采用模板(无用户内容可保留)
515
+ if (opts.dryRun === true)
516
+ return planned(rule, rule.to);
517
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(userAbs), { recursive: true });
518
+ node_fs_1.default.copyFileSync(tplAbs, userAbs);
519
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (created from template)`);
520
+ return { rule, action: 'created', path: rule.to };
521
+ }
522
+ const result = (0, migrate_adapters_1.adaptTsconfig)({
523
+ role: rule.role,
524
+ userText: userExists ? node_fs_1.default.readFileSync(userAbs, 'utf-8') : undefined,
525
+ templateText: node_fs_1.default.readFileSync(tplAbs, 'utf-8'),
526
+ legacyExtends: rule.legacyExtends,
527
+ });
528
+ if (!result.ok && opts.conflictPolicy === 'overwrite') {
529
+ if (opts.dryRun === true)
530
+ return planned(rule, rule.to);
531
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(userAbs), { recursive: true });
532
+ node_fs_1.default.copyFileSync(tplAbs, userAbs);
533
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (${result.conflict}; replaced with template)`);
534
+ return {
535
+ rule,
536
+ action: 'synced',
537
+ path: rule.to,
538
+ detail: 'replaced with template',
539
+ overwritten: [{ path: rule.to, reason: `${result.conflict}; replaced with template` }],
540
+ };
541
+ }
542
+ if (!result.ok) {
543
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.to} (${result.conflict})`);
544
+ return {
545
+ rule,
546
+ action: 'conflict',
547
+ path: rule.to,
548
+ conflicts: [{ path: rule.to, reason: result.conflict }],
549
+ };
550
+ }
551
+ if (!result.changed) {
552
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (already adapted)`);
553
+ return { rule, action: 'noop', path: rule.to };
554
+ }
555
+ if (opts.dryRun === true)
556
+ return planned(rule, rule.to);
557
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(userAbs), { recursive: true });
558
+ node_fs_1.default.writeFileSync(userAbs, result.text);
559
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (${result.detail ?? 'adapted'})`);
560
+ return { rule, action: userExists ? 'patched' : 'created', path: rule.to, detail: result.detail };
561
+ }
562
+ function applyAdaptHtmlEntry(rule, opts) {
563
+ const logPrefix = opts.logPrefix ?? 'migrate';
564
+ const fromAbs = node_path_1.default.join(opts.targetDir, rule.from);
565
+ const toAbs = node_path_1.default.join(opts.targetDir, rule.to);
566
+ const tplAbs = node_path_1.default.join(opts.sourceRoot, rule.template);
567
+ if (!node_fs_1.default.existsSync(tplAbs)) {
568
+ (0, logger_1.log)(logPrefix, ` ⚠ template ${rule.template} not found, skip`);
569
+ return { rule, action: 'skipped', path: rule.to };
570
+ }
571
+ if (!node_fs_1.default.existsSync(fromAbs)) {
572
+ if (node_fs_1.default.existsSync(toAbs)) {
573
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (already present)`);
574
+ return { rule, action: 'noop', path: rule.to };
575
+ }
576
+ if (opts.dryRun === true)
577
+ return planned(rule, rule.to);
578
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(toAbs), { recursive: true });
579
+ node_fs_1.default.copyFileSync(tplAbs, toAbs);
580
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (created from template)`);
581
+ return { rule, action: 'created', path: rule.to };
582
+ }
583
+ const templateHtml = node_fs_1.default.readFileSync(tplAbs, 'utf-8');
584
+ const result = (0, migrate_adapters_1.adaptHtmlEntry)({
585
+ userHtml: node_fs_1.default.readFileSync(fromAbs, 'utf-8'),
586
+ templateHtml,
587
+ legacyEntry: rule.legacyEntry,
588
+ });
589
+ const overwrite = opts.conflictPolicy === 'overwrite';
590
+ const overwritten = [];
591
+ if (!result.ok) {
592
+ if (!overwrite) {
593
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.from} (${result.conflict})`);
594
+ return {
595
+ rule,
596
+ action: 'conflict',
597
+ path: rule.from,
598
+ conflicts: [{ path: rule.from, reason: result.conflict }],
599
+ };
600
+ }
601
+ overwritten.push({
602
+ path: rule.from,
603
+ reason: `${result.conflict}; replaced with template ${rule.to}`,
604
+ });
605
+ }
606
+ const text = result.ok ? result.text : templateHtml;
607
+ if (node_fs_1.default.existsSync(toAbs) && node_fs_1.default.readFileSync(toAbs, 'utf-8') !== text) {
608
+ const reason = 'both legacy and new HTML entries exist with different content';
609
+ if (!overwrite) {
610
+ return {
611
+ rule,
612
+ action: 'conflict',
613
+ path: rule.to,
614
+ conflicts: [{ path: rule.to, with: rule.from, reason }],
615
+ };
616
+ }
617
+ overwritten.push({ path: rule.to, with: rule.from, reason: `${reason}; replaced` });
618
+ }
619
+ if (opts.dryRun === true)
620
+ return planned(rule, rule.to);
621
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(toAbs), { recursive: true });
622
+ node_fs_1.default.writeFileSync(toAbs, text);
623
+ node_fs_1.default.unlinkSync(fromAbs);
624
+ const detail = result.ok ? result.detail : 'replaced with template';
625
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.from} → ${rule.to} (${detail ?? 'adapted'})`);
626
+ return {
627
+ rule,
628
+ action: 'patched',
629
+ path: rule.to,
630
+ detail,
631
+ ...(overwritten.length > 0 ? { overwritten } : {}),
632
+ };
633
+ }
634
+ function applyAdaptNestAppModule(rule, opts) {
635
+ const logPrefix = opts.logPrefix ?? 'migrate';
636
+ const rel = rule.to ?? 'server/app.module.ts';
637
+ const mainRel = rule.checkMain ?? 'server/main.ts';
638
+ const conflicts = [];
639
+ // 覆盖策略下冲突文件换成模板同路径文件;模板缺该文件时无可替换内容,仍是冲突
640
+ const replaceable = (fileRel) => opts.conflictPolicy === 'overwrite' && node_fs_1.default.existsSync(node_path_1.default.join(opts.sourceRoot, fileRel));
641
+ const replaced = [];
642
+ const mainAbs = node_path_1.default.join(opts.targetDir, mainRel);
643
+ if (node_fs_1.default.existsSync(mainAbs) &&
644
+ !node_fs_1.default.readFileSync(mainAbs, 'utf-8').includes('@lark-apaas/fullstack-nestjs-core')) {
645
+ const reason = 'server bootstrap does not use @lark-apaas/fullstack-nestjs-core (configureApp)';
646
+ if (replaceable(mainRel)) {
647
+ replaced.push({ path: mainRel, reason: `${reason}; replaced with template` });
648
+ }
649
+ else {
650
+ conflicts.push({ path: mainRel, reason });
651
+ }
652
+ }
653
+ const abs = node_path_1.default.join(opts.targetDir, rel);
654
+ let moduleText;
655
+ let detail;
656
+ if (node_fs_1.default.existsSync(abs)) {
657
+ const includeView = node_fs_1.default.existsSync(node_path_1.default.join(node_path_1.default.dirname(abs), 'modules', 'view', 'view.module.ts'));
658
+ const result = (0, migrate_adapters_1.adaptNestAppModule)(node_fs_1.default.readFileSync(abs, 'utf-8'), { includeView });
659
+ if (result.ok) {
660
+ if (result.changed)
661
+ moduleText = result.text;
662
+ detail = result.detail;
663
+ }
664
+ else if (replaceable(rel)) {
665
+ replaced.push({ path: rel, reason: `${result.conflict}; replaced with template` });
666
+ }
667
+ else {
668
+ conflicts.push({ path: rel, reason: result.conflict });
669
+ }
670
+ }
671
+ if (conflicts.length > 0) {
672
+ (0, logger_1.log)(logPrefix, ` ✗ ${rel} (${conflicts.length.toString()} conflict(s))`);
673
+ return { rule, action: 'conflict', path: rel, conflicts };
674
+ }
675
+ if (replaced.length === 0 && moduleText === undefined) {
676
+ if (!node_fs_1.default.existsSync(abs)) {
677
+ (0, logger_1.log)(logPrefix, ` ○ ${rel} (not found, skip)`);
678
+ return { rule, action: 'skipped', path: rel };
679
+ }
680
+ (0, logger_1.log)(logPrefix, ` ○ ${rel} (platform modules already registered)`);
681
+ return { rule, action: 'noop', path: rel };
682
+ }
683
+ if (opts.dryRun === true)
684
+ return planned(rule, rel);
685
+ for (const c of replaced) {
686
+ node_fs_1.default.copyFileSync(node_path_1.default.join(opts.sourceRoot, c.path), node_path_1.default.join(opts.targetDir, c.path));
687
+ }
688
+ if (moduleText !== undefined)
689
+ node_fs_1.default.writeFileSync(abs, moduleText);
690
+ const summary = [
691
+ ...(detail !== undefined && moduleText !== undefined ? [detail] : []),
692
+ ...replaced.map((c) => `${c.path} replaced with template`),
693
+ ].join('; ');
694
+ (0, logger_1.log)(logPrefix, ` ✓ ${rel} (${summary})`);
695
+ return {
696
+ rule,
697
+ action: 'patched',
698
+ path: rel,
699
+ detail: summary,
700
+ ...(replaced.length > 0 ? { overwritten: replaced } : {}),
701
+ };
702
+ }
703
+ function applyRemoveKnownDefault(rule, opts) {
704
+ const logPrefix = opts.logPrefix ?? 'migrate';
705
+ const abs = node_path_1.default.join(opts.targetDir, rule.to);
706
+ if (!node_fs_1.default.existsSync(abs)) {
707
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (not found, skip)`);
708
+ return { rule, action: 'skipped', path: rule.to };
709
+ }
710
+ const customized = !(0, migrate_adapters_1.matchesKnownDefault)(node_fs_1.default.readFileSync(abs, 'utf-8'), rule.knownDefaults);
711
+ if (customized && opts.conflictPolicy !== 'overwrite') {
712
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.to} (customized)`);
713
+ return {
714
+ rule,
715
+ action: 'conflict',
716
+ path: rule.to,
717
+ conflicts: [{ path: rule.to, reason: rule.reason }],
718
+ };
719
+ }
720
+ if (opts.dryRun === true)
721
+ return planned(rule, rule.to);
722
+ node_fs_1.default.unlinkSync(abs);
723
+ if (customized) {
724
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.to} (customized, removed)`);
725
+ return {
726
+ rule,
727
+ action: 'deleted',
728
+ path: rule.to,
729
+ overwritten: [{ path: rule.to, reason: 'customized legacy config removed' }],
730
+ };
731
+ }
732
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.to} (platform default removed)`);
733
+ return { rule, action: 'deleted', path: rule.to };
734
+ }
735
+ function applyCarryEslintIgnores(rule, opts) {
736
+ const logPrefix = opts.logPrefix ?? 'migrate';
737
+ const legacyAbs = node_path_1.default.join(opts.targetDir, rule.from);
738
+ if (!node_fs_1.default.existsSync(legacyAbs)) {
739
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.from} (not found, no ignores to carry)`);
740
+ return { rule, action: 'skipped', path: rule.to };
741
+ }
742
+ // 预检时新配置可能还没由前序 file 规则补齐:按将要落地的模板版本判定
743
+ let targetAbs = node_path_1.default.join(opts.targetDir, rule.to);
744
+ if (!node_fs_1.default.existsSync(targetAbs) && opts.dryRun === true) {
745
+ targetAbs = node_path_1.default.join(opts.sourceRoot, rule.to);
746
+ }
747
+ if (!node_fs_1.default.existsSync(targetAbs)) {
748
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (not found, skip carrying ignores)`);
749
+ return { rule, action: 'skipped', path: rule.to };
750
+ }
751
+ const result = (0, migrate_adapters_1.carryEslintIgnores)({
752
+ legacyText: node_fs_1.default.readFileSync(legacyAbs, 'utf-8'),
753
+ targetText: node_fs_1.default.readFileSync(targetAbs, 'utf-8'),
754
+ });
755
+ if (!result.ok) {
756
+ const found = { path: rule.to, reason: result.conflict, with: rule.from };
757
+ if (opts.conflictPolicy === 'overwrite') {
758
+ // 没有可替换的模板内容:新配置是用户自己的,保留原样
759
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (${result.conflict}, kept)`);
760
+ return { rule, action: 'skipped', path: rule.to, kept: [found] };
761
+ }
762
+ (0, logger_1.log)(logPrefix, ` ✗ ${rule.to} (${result.conflict})`);
763
+ return { rule, action: 'conflict', path: rule.to, conflicts: [found] };
764
+ }
765
+ if (!result.changed) {
766
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (${result.detail ?? 'nothing to carry'})`);
767
+ return { rule, action: 'noop', path: rule.to, detail: result.detail };
768
+ }
769
+ if (opts.dryRun === true)
770
+ return planned(rule, rule.to);
771
+ node_fs_1.default.writeFileSync(targetAbs, result.text);
772
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (${result.detail ?? 'ignores carried'})`);
773
+ return { rule, action: 'patched', path: rule.to, detail: result.detail };
774
+ }
775
+ function applyRelaxEslintRules(rule, opts) {
776
+ const logPrefix = opts.logPrefix ?? 'migrate';
777
+ const abs = node_path_1.default.join(opts.targetDir, rule.to);
778
+ if (!node_fs_1.default.existsSync(abs)) {
779
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (not found, skip relaxing rules)`);
780
+ return { rule, action: 'skipped', path: rule.to };
781
+ }
782
+ const result = (0, migrate_adapters_1.relaxEslintRules)({
783
+ text: node_fs_1.default.readFileSync(abs, 'utf-8'),
784
+ files: rule.files,
785
+ rules: rule.rules,
786
+ });
787
+ if (!result.ok) {
788
+ // 用户自己的配置形态:不改,记 kept;规则仍按 preset 生效,交给提交 hooks 判定
789
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (${result.conflict}, kept)`);
790
+ return {
791
+ rule,
792
+ action: 'skipped',
793
+ path: rule.to,
794
+ kept: [{ path: rule.to, reason: result.conflict }],
795
+ };
796
+ }
797
+ if (!result.changed) {
798
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to} (${result.detail ?? 'unchanged'})`);
799
+ return { rule, action: 'noop', path: rule.to, detail: result.detail };
800
+ }
801
+ node_fs_1.default.writeFileSync(abs, result.text);
802
+ (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (${result.detail ?? 'rules relaxed'})`);
803
+ return { rule, action: 'patched', path: rule.to, detail: result.detail };
804
+ }
126
805
  function applySetStack(rule, opts) {
127
806
  const logPrefix = opts.logPrefix ?? 'migrate';
128
807
  (0, spark_meta_1.writeSparkMeta)(opts.targetDir, { stack: rule.stack });
@@ -179,23 +858,77 @@ function applyCodemodViteConfigFullstack(rule, opts) {
179
858
  const rel = rule.to ?? 'vite.config.ts';
180
859
  const dest = node_path_1.default.join(opts.targetDir, rel);
181
860
  if (!node_fs_1.default.existsSync(dest)) {
861
+ const others = [
862
+ 'vite.config.js',
863
+ 'vite.config.mjs',
864
+ 'vite.config.mts',
865
+ 'vite.config.cjs',
866
+ ].filter((f) => node_fs_1.default.existsSync(node_path_1.default.join(opts.targetDir, node_path_1.default.dirname(rel), f)));
867
+ if (others.length > 0) {
868
+ const found = [{ path: others[0], reason: `unsupported vite config file; expected ${rel}` }];
869
+ if (opts.conflictPolicy === 'overwrite') {
870
+ (0, logger_1.log)(logPrefix, ` ○ ${others[0]} (unsupported vite config file, kept)`);
871
+ return { rule, action: 'skipped', path: others[0], kept: found };
872
+ }
873
+ return { rule, action: 'conflict', path: others[0], conflicts: found };
874
+ }
875
+ const tplAbs = rule.template !== undefined ? node_path_1.default.join(opts.sourceRoot, rule.template) : '';
876
+ if (tplAbs !== '' && node_fs_1.default.existsSync(tplAbs)) {
877
+ if (opts.dryRun === true)
878
+ return planned(rule, rel);
879
+ node_fs_1.default.copyFileSync(tplAbs, dest);
880
+ (0, logger_1.log)(logPrefix, ` ✓ ${rel} (created from template)`);
881
+ return { rule, action: 'created', path: rel };
882
+ }
182
883
  (0, logger_1.log)(logPrefix, ` ○ ${rel} (not found, skip)`);
183
884
  return { rule, action: 'skipped', path: rel };
184
885
  }
185
- const before = node_fs_1.default.readFileSync(dest, 'utf-8');
886
+ const original = node_fs_1.default.readFileSync(dest, 'utf-8');
887
+ const { text: before, replaced: presetReplaced } = replaceLegacyPreset(original, rule);
888
+ const kept = [];
889
+ if (rule.requirePreset !== undefined && !before.includes(rule.requirePreset)) {
890
+ const found = {
891
+ path: rel,
892
+ reason: `vite config does not use ${rule.requirePreset}; fullstack dev proxy cannot be enabled automatically`,
893
+ };
894
+ if (opts.conflictPolicy !== 'overwrite') {
895
+ (0, logger_1.log)(logPrefix, ` ✗ ${rel} (does not use ${rule.requirePreset})`);
896
+ return { rule, action: 'conflict', path: rel, conflicts: [found] };
897
+ }
898
+ kept.push(found);
899
+ }
900
+ const extra = kept.length > 0 ? { kept } : {};
186
901
  const { text, aliasRewriteCount } = rewriteViteConfigForFullstack(before);
187
- if (aliasRewriteCount === 0) {
902
+ if (aliasRewriteCount === 0 && presetReplaced === undefined) {
188
903
  (0, logger_1.log)(logPrefix, ` ○ ${rel} (no src alias path to rewrite)`);
189
- return { rule, action: 'noop', path: rel };
904
+ return { rule, action: 'noop', path: rel, ...extra };
190
905
  }
906
+ if (opts.dryRun === true)
907
+ return planned(rule, rel);
191
908
  node_fs_1.default.writeFileSync(dest, text);
192
- (0, logger_1.log)(logPrefix, ` ✓ ${rel} (rewrote ${aliasRewriteCount.toString()} alias path(s) src → client/src)`);
193
- return {
194
- rule,
195
- action: 'patched',
196
- path: rel,
197
- detail: `${aliasRewriteCount.toString()} alias path(s)`,
198
- };
909
+ const details = [`${aliasRewriteCount.toString()} alias path(s)`];
910
+ if (presetReplaced !== undefined)
911
+ details.push(`preset ${presetReplaced} → ${rule.requirePreset ?? ''}`);
912
+ (0, logger_1.log)(logPrefix, ` ✓ ${rel} (${details.join('; ')})`);
913
+ return { rule, action: 'patched', path: rel, detail: details.join('; '), ...extra };
914
+ }
915
+ /**
916
+ * 配置从 legacyPresets 之一 import(且没用 requirePreset)时,把模块名改写为 requirePreset。
917
+ * 只改 import / require 的模块名字符串,引号原样保留。
918
+ */
919
+ function replaceLegacyPreset(source, rule) {
920
+ const target = rule.requirePreset;
921
+ if (target === undefined || source.includes(target))
922
+ return { text: source };
923
+ for (const legacy of rule.legacyPresets ?? []) {
924
+ const escaped = legacy.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
925
+ const re = new RegExp(`((?:\\bfrom\\s*|\\b(?:require|import)\\s*\\(\\s*)(['"]))${escaped}\\2`, 'g');
926
+ if (!re.test(source))
927
+ continue;
928
+ re.lastIndex = 0;
929
+ return { text: source.replace(re, `$1${target}$2`), replaced: legacy };
930
+ }
931
+ return { text: source };
199
932
  }
200
933
  /**
201
934
  * 把 vite.config.ts 里 alias 字符串里 `src` 起头的路径重写到 `client/src`。
@@ -264,6 +997,8 @@ function applyAddDepsFromTemplate(rule, opts) {
264
997
  const userText = node_fs_1.default.readFileSync(userPath, 'utf-8');
265
998
  const userJson = JSON.parse(userText);
266
999
  const tplJson = JSON.parse(node_fs_1.default.readFileSync(tplPath, 'utf-8'));
1000
+ const exact = new Set(rule.exactFromLock ?? []);
1001
+ const locked = exact.size > 0 ? readLockedVersions(node_path_1.default.join(node_path_1.default.dirname(tplPath), 'package-lock.json')) : {};
267
1002
  const added = [];
268
1003
  const missingInTpl = [];
269
1004
  for (const grp of ['dependencies', 'devDependencies']) {
@@ -274,18 +1009,25 @@ function applyAddDepsFromTemplate(rule, opts) {
274
1009
  for (const dep of whitelist) {
275
1010
  // 在 template 里找版本号:优先 tpl 同 grp,其次另一 grp(template 可能把 dep 写在
276
1011
  // dependencies/devDependencies 任一处,跟 user 当前 grp 不一定一致)
277
- const tplVersion = tplJson[grp]?.[dep] ??
1012
+ const tplRange = tplJson[grp]?.[dep] ??
278
1013
  (grp === 'dependencies' ? tplJson.devDependencies?.[dep] : tplJson.dependencies?.[dep]);
1014
+ const tplVersion = tplRange !== undefined && exact.has(dep) ? (locked[dep] ?? tplRange) : tplRange;
279
1015
  if (tplVersion === undefined) {
280
1016
  missingInTpl.push(dep);
281
1017
  continue;
282
1018
  }
1019
+ const existing = userJson.dependencies?.[dep] ?? userJson.devDependencies?.[dep];
1020
+ if (existing === tplVersion)
1021
+ continue;
1022
+ // repair 模式只补缺失依赖:用户在已转换工程上主动升级过的版本不回退
1023
+ if (opts.mode === 'repair' && existing !== undefined)
1024
+ continue;
283
1025
  userJson[grp][dep] = tplVersion;
284
1026
  added.push(`${grp}.${dep}`);
285
1027
  }
286
1028
  }
287
- if (added.length === 0 && missingInTpl.length === 0) {
288
- (0, logger_1.log)(logPrefix, ` ○ ${rule.to ?? 'package.json'} (no deps to add)`);
1029
+ if (added.length === 0) {
1030
+ (0, logger_1.log)(logPrefix, ` ○ ${rule.to ?? 'package.json'} (no deps to add${missingInTpl.length > 0 ? `; missing in template: ${missingInTpl.join(', ')}` : ''})`);
289
1031
  return { rule, action: 'noop', path: rule.to ?? 'package.json' };
290
1032
  }
291
1033
  const trailing = userText.endsWith('\n') ? '\n' : '';
@@ -298,6 +1040,29 @@ function applyAddDepsFromTemplate(rule, opts) {
298
1040
  detail: added.join(','),
299
1041
  };
300
1042
  }
1043
+ /**
1044
+ * 读模板 lockfile(npm v2/v3 的 packages 结构)里顶层安装的包版本:`node_modules/<name>` →
1045
+ * version。lockfile 缺失或不可解析时返回空表,调用方退回 package.json 的 range。
1046
+ */
1047
+ function readLockedVersions(lockPath) {
1048
+ if (!node_fs_1.default.existsSync(lockPath))
1049
+ return {};
1050
+ try {
1051
+ const lock = JSON.parse(node_fs_1.default.readFileSync(lockPath, 'utf-8'));
1052
+ const versions = {};
1053
+ for (const [key, entry] of Object.entries(lock.packages ?? {})) {
1054
+ const name = key.startsWith('node_modules/') ? key.slice('node_modules/'.length) : undefined;
1055
+ if (name === undefined || name.includes('/node_modules/'))
1056
+ continue;
1057
+ if (typeof entry.version === 'string')
1058
+ versions[name] = entry.version;
1059
+ }
1060
+ return versions;
1061
+ }
1062
+ catch {
1063
+ return {};
1064
+ }
1065
+ }
301
1066
  /**
302
1067
  * 按 dot path 删除嵌套对象里的一个 leaf key。返回是否真的删了一项。
303
1068
  * 不支持包含 `.` 的字段名(实际 dep 名 / scripts 名不会含 `.`,够用)。