sillyspec 3.15.0 → 3.16.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.
@@ -92,9 +92,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
92
92
  // 同时检测 untracked 新文件(git diff 不包含 untracked)
93
93
  let changedFiles;
94
94
  try {
95
- // tracked 文件的变更(modified/deleted)
96
- const trackedRaw = git(worktreePath, `diff --name-only ${diffBase}`);
97
- const trackedFiles = trackedRaw ? trackedRaw.split('\n').filter(Boolean) : [];
95
+ // --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
96
+ const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
97
+ const statusFiles = new Set();
98
+ if (statusRaw) {
99
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
100
+ const parts = line.split('\t');
101
+ // R100 old.txt new.txt → 提取两个文件
102
+ if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
103
+ if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
104
+ }
105
+ }
98
106
 
99
107
  // untracked 新文件(diffBase 中不存在的文件)
100
108
  const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
@@ -102,7 +110,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
102
110
  ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
103
111
  : [];
104
112
 
105
- changedFiles = [...new Set([...trackedFiles, ...untrackedFiles])];
113
+ changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
106
114
  } catch (e) {
107
115
  result.errors.push(`获取变更文件列表失败: ${e.message}`);
108
116
  return result;
@@ -216,8 +224,11 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
216
224
 
217
225
  // 分 tracked 变更和 untracked 新文件生成 patch
218
226
  const trackedFiles = patchFiles.filter(f => {
219
- // untracked 文件在 baseHash 的 tree 中不存在
220
- return gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null;
227
+ // 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
228
+ if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
229
+ // 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
230
+ if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
231
+ return false;
221
232
  });
222
233
  const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
223
234
 
@@ -290,3 +301,105 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
290
301
 
291
302
  return result;
292
303
  }
304
+
305
+ /**
306
+ * 格式化 execute run summary(人类可读)
307
+ *
308
+ * 只展示 CLI 真实掌握的信息,不声称知道 per-task 状态。
309
+ * @param {object} opts
310
+ * @param {string} opts.changeName - 变更名
311
+ * @param {number} opts.stepsCompleted - 已完成步骤数
312
+ * @param {number} opts.stepsTotal - 总步骤数
313
+ * @param {string} opts.agentSummary - Agent 最终输出摘要
314
+ * @param {string} [opts.cwd] - 项目根目录(默认 process.cwd())
315
+ * @returns {string} 格式化的 summary 文本
316
+ */
317
+ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, agentSummary, cwd }) {
318
+ const wm = new WorktreeManager({ cwd });
319
+ const meta = wm.getMeta(changeName);
320
+ const lines = [];
321
+
322
+ const SEPARATOR = '─'.repeat(32);
323
+
324
+ // --- Header ---
325
+ lines.push(`Execute Summary`);
326
+ lines.push(SEPARATOR);
327
+
328
+ // --- Status ---
329
+ if (!meta) {
330
+ // worktree 不存在(可能已 cleanup 或没有用过 worktree)
331
+ lines.push(`Status: COMPLETED`);
332
+ lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
333
+ lines.push(`Apply: N/A`);
334
+ } else {
335
+ const hasBaseline = meta.baselineCommit != null;
336
+ const wtExists = existsSync(meta.worktreePath);
337
+
338
+ const applyStatus = wtExists ? 'pending' : 'applied';
339
+ const baselineCount = meta.baselineFiles?.length || 0;
340
+ const baselineStatus = hasBaseline
341
+ ? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
342
+ : 'clean';
343
+
344
+ // Worktree 最终状态
345
+ const mode = meta.mode || 'worktree';
346
+ let worktreeStatus;
347
+ if (mode === 'native-worktree') {
348
+ worktreeStatus = 'kept (external worktree)';
349
+ } else if (mode === 'in-place-fallback') {
350
+ worktreeStatus = 'none (in-place)';
351
+ } else if (!wtExists) {
352
+ worktreeStatus = 'cleaned';
353
+ } else {
354
+ worktreeStatus = 'exists';
355
+ }
356
+
357
+ lines.push(`Status: COMPLETED`);
358
+ lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
359
+ lines.push(`Baseline: ${baselineStatus}`);
360
+ lines.push(`Apply: ${applyStatus}`);
361
+ lines.push(`Worktree: ${worktreeStatus}`);
362
+ }
363
+
364
+ // --- Changed files ---
365
+ // 从主工作区 diff 获取(worktree 已 apply)或从 worktree diff 获取
366
+ if (meta && existsSync(meta.worktreePath)) {
367
+ // worktree 还在,用 baselineCommit 或 baseHash 做 diff
368
+ try {
369
+ const diffBase = meta.baselineCommit || meta.baseHash;
370
+ const { execSync: es } = require('child_process');
371
+ const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
372
+ const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
373
+ if (files.length > 0) {
374
+ lines.push(``);
375
+ const maxShow = 10;
376
+ const showFiles = files.slice(0, maxShow);
377
+ const remain = files.length - maxShow;
378
+ lines.push(`Changed Files (${files.length})`);
379
+ showFiles.forEach(f => lines.push(` ${f}`));
380
+ if (remain > 0) {
381
+ lines.push(` ... ${remain} more`);
382
+ }
383
+ }
384
+ } catch {}
385
+ }
386
+
387
+ // --- Agent Summary ---
388
+ if (agentSummary) {
389
+ lines.push(``);
390
+ lines.push(`Agent Summary`);
391
+ // 缩进每行,截断过长内容
392
+ const maxLen = 200;
393
+ const summary = agentSummary.length > maxLen
394
+ ? agentSummary.slice(0, maxLen) + '...'
395
+ : agentSummary;
396
+ summary.split('\n').forEach(l => lines.push(` ${l}`));
397
+ }
398
+
399
+ // --- Next ---
400
+ lines.push(``);
401
+ lines.push(`Next`);
402
+ lines.push(` → sillyspec run verify`);
403
+
404
+ return lines.join('\n');
405
+ }
package/src/worktree.js CHANGED
@@ -17,6 +17,45 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
17
17
  const BRANCH_PREFIX = 'sillyspec/';
18
18
  const META_FILE = 'meta.json';
19
19
 
20
+ /**
21
+ * 检测当前目录的隔离状态
22
+ * 返回 { inWorktree: boolean, inSubmodule: boolean }
23
+ *
24
+ * 用 git rev-parse --git-dir 和 --git-common-dir 判断:
25
+ * - GIT_DIR != GIT_COMMON 通常是 linked worktree
26
+ * - 但在 git submodule 里也会出现这种情况
27
+ * - 所以必须额外检查 --show-superproject-working-tree 排除 submodule
28
+ */
29
+ export function detectIsolation(cwd = process.cwd()) {
30
+ try {
31
+ const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
32
+ const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
33
+ const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
34
+
35
+ const inWorktree = gitDir !== gitCommonDir && !superProject;
36
+ const inSubmodule = !!superProject;
37
+
38
+ return { inWorktree, inSubmodule, gitDir, gitCommonDir };
39
+ } catch {
40
+ return { inWorktree: false, inSubmodule: false, gitDir: null, gitCommonDir: null };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 检查 worktree 存储目录是否被 .gitignore 忽略
46
+ * @param {string} cwd - 项目根目录
47
+ * @returns {{ ignored: boolean, path: string }}
48
+ */
49
+ export function checkWorktreeDirIgnored(cwd = process.cwd()) {
50
+ const relPath = WORKTREES_REL;
51
+ try {
52
+ execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
53
+ return { ignored: true, path: relPath };
54
+ } catch {
55
+ return { ignored: false, path: relPath };
56
+ }
57
+ }
58
+
20
59
  function git(cwd, args) {
21
60
  return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
22
61
  }
@@ -115,7 +154,37 @@ export class WorktreeManager {
115
154
  const worktreePath = this.getWorktreePath(name);
116
155
  const branch = BRANCH_PREFIX + name;
117
156
 
118
- // 1. 检查 worktree 是否已存在
157
+ // 0. 检测当前环境隔离状态(submodule guard)
158
+ const isolation = detectIsolation(this.cwd);
159
+ if (isolation.inSubmodule) {
160
+ throw new Error(
161
+ '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
162
+ '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
163
+ );
164
+ }
165
+ if (isolation.inWorktree) {
166
+ // 已在 linked worktree 中,复用当前目录作为 worktree 路径
167
+ console.log(`ℹ️ 已在 linked worktree 中(git-dir: ${isolation.gitDir}),复用当前隔离环境。`);
168
+ return this._createInPlaceMeta(name, {
169
+ worktreePath: this.cwd,
170
+ branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
171
+ mode: 'native-worktree',
172
+ base,
173
+ });
174
+ }
175
+
176
+ // 1. 检查 worktree 目录是否被 gitignore
177
+ const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
178
+ if (!ignoreStatus.ignored) {
179
+ throw new Error(
180
+ `worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
181
+ `创建 worktree 可能导致内容被误提交。\n` +
182
+ `请先在 .gitignore 中添加: ${ignoreStatus.path}/\n` +
183
+ `或运行 sillyspec doctor 检查修复。`
184
+ );
185
+ }
186
+
187
+ // 2. 检查 worktree 是否已存在
119
188
  if (existsSync(worktreePath)) {
120
189
  // 目录在但 meta.json 不存在(幽灵状态),自动清理
121
190
  if (!this.getMeta(name)) {
@@ -147,7 +216,7 @@ export class WorktreeManager {
147
216
  mkdirSync(this.worktreeBase, { recursive: true });
148
217
  }
149
218
 
150
- // 5. 创建 worktree(含版本检测)
219
+ // 5. 创建 worktree(含版本检测 + sandbox fallback)
151
220
  try {
152
221
  git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
153
222
  } catch (e) {
@@ -155,7 +224,16 @@ export class WorktreeManager {
155
224
  if (!check.supported) {
156
225
  throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
157
226
  }
158
- throw new Error(`git worktree add 失败: ${e.stderr || e.message}`);
227
+ // sandbox/permission fallback: 降级为 in-place + baseline protection
228
+ console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
229
+ console.log(` 原因: ${e.stderr || e.message}`);
230
+ return this._createInPlaceMeta(name, {
231
+ worktreePath: this.cwd,
232
+ branch,
233
+ baseBranch,
234
+ baseHash,
235
+ mode: 'in-place-fallback',
236
+ });
159
237
  }
160
238
 
161
239
  // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
@@ -206,15 +284,99 @@ export class WorktreeManager {
206
284
  actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
207
285
  createdAt: new Date().toISOString(),
208
286
  worktreePath,
287
+ mode: 'worktree',
209
288
  baselineFiles,
210
289
  baselineCommit,
211
- baselineHash, // 有 dirty baseline 时指向 checkpoint commit
290
+ baselineHash,
212
291
  };
213
292
 
214
293
  const metaPath = join(worktreePath, META_FILE);
215
294
  writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
216
295
 
217
- return { branch, worktreePath, baseHash };
296
+ return { branch, worktreePath, baseHash, mode: meta.mode };
297
+ }
298
+
299
+ /**
300
+ * 创建 in-place 模式的 meta.json(降级路径)
301
+ * 不创建 git worktree,直接在当前目录记录 baseline 并写入 meta
302
+ * @private
303
+ */
304
+ _createInPlaceMeta(name, { worktreePath, branch, baseBranch, baseHash, mode } = {}) {
305
+ // 解析 base
306
+ if (!baseHash) {
307
+ baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
308
+ baseHash = git(this.cwd, 'rev-parse HEAD');
309
+ }
310
+
311
+ const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
312
+ const baselineFiles = baselineResult.files;
313
+ const baselineHash = baselineResult.baselineHash;
314
+
315
+ let baselineCommit = null;
316
+ if (baselineFiles.length > 0) {
317
+ baselineCommit = this._createBaselineCheckpoint(this.cwd, name);
318
+ }
319
+
320
+ const meta = {
321
+ changeName: name,
322
+ branch: branch || BRANCH_PREFIX + name,
323
+ baseBranch,
324
+ baseHash,
325
+ actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
326
+ createdAt: new Date().toISOString(),
327
+ worktreePath,
328
+ mode: mode || 'in-place-fallback',
329
+ baselineFiles,
330
+ baselineCommit,
331
+ baselineHash,
332
+ };
333
+
334
+ // in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
335
+ if (!existsSync(this.worktreeBase)) {
336
+ mkdirSync(this.worktreeBase, { recursive: true });
337
+ }
338
+ const metaPath = join(this.worktreeBase, name, META_FILE);
339
+ const metaDir = join(this.worktreeBase, name);
340
+ if (!existsSync(metaDir)) {
341
+ mkdirSync(metaDir, { recursive: true });
342
+ }
343
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
344
+
345
+ return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
346
+ }
347
+
348
+ /**
349
+ * 构建 isolation 信息对象,用于写入 gate-status.json
350
+ * @param {string} changeName
351
+ * @returns {{ status: string, mode: string, path: string } | null}
352
+ */
353
+ getIsolationInfo(changeName) {
354
+ const meta = this.getMeta(changeName);
355
+ if (!meta) return null;
356
+
357
+ const mode = meta.mode || 'worktree';
358
+ const statusMap = {
359
+ 'worktree': 'verified',
360
+ 'native-worktree': 'verified',
361
+ 'in-place-fallback': 'degraded',
362
+ };
363
+
364
+ return {
365
+ status: statusMap[mode] || 'verified',
366
+ mode,
367
+ path: meta.worktreePath,
368
+ branch: meta.branch,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * 获取 worktree 的运行模式
374
+ * @param {string} changeName
375
+ * @returns {'worktree'|'native-worktree'|'in-place-fallback'|null}
376
+ */
377
+ getMode(changeName) {
378
+ const meta = this.getMeta(changeName);
379
+ return meta?.mode || null;
218
380
  }
219
381
 
220
382
  /**
@@ -239,6 +401,7 @@ export class WorktreeManager {
239
401
  baseBranch: meta.baseBranch,
240
402
  createdAt: meta.createdAt,
241
403
  worktreePath: meta.worktreePath,
404
+ mode: meta.mode || 'worktree',
242
405
  });
243
406
  }
244
407
 
@@ -246,24 +409,43 @@ export class WorktreeManager {
246
409
  }
247
410
 
248
411
  /**
249
- * 清理 worktree(强制删除,不 apply
412
+ * 清理 worktree(仅限 SillySpec 创建的临时 worktree
250
413
  * @param {string} changeName
251
- * @throws {Error} worktree 不存在
414
+ * @param {{ force?: boolean }} opts - force: 跳过 mode 安检(仅用于 worktree 目录本身)
415
+ * @throws {Error} worktree 不存在、不允许删除
416
+ * @returns {{ result: 'cleaned'|'skipped'|'kept', mode: string }}
252
417
  */
253
- cleanup(changeName) {
418
+ cleanup(changeName, { force = false } = {}) {
254
419
  const name = validateChangeName(changeName);
255
420
  const meta = this.getMeta(name);
256
421
  const worktreePath = this.getWorktreePath(name);
257
422
 
258
423
  if (!meta && !existsSync(worktreePath)) {
259
- throw new Error(`worktree not found: ${name}。meta.json 不存在,目录也不存在,可能已被清理或从未创建。`);
424
+ return { result: 'skipped', mode: null };
425
+ }
426
+
427
+ const mode = meta?.mode || 'worktree';
428
+
429
+ // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
430
+ if (!force) {
431
+ if (mode === 'native-worktree') {
432
+ throw new Error(
433
+ `当前 worktree 是外部/原生隔离环境(mode: native-worktree),SillySpec 不允许删除。\n` +
434
+ `此 worktree 不是由 SillySpec 创建的,请手动管理。\n` +
435
+ `如需强制清理,使用 --force 标志。`
436
+ );
437
+ }
438
+ if (mode === 'in-place-fallback') {
439
+ return { result: 'skipped', mode };
440
+ }
260
441
  }
261
442
 
262
443
  // 1. 尝试 git worktree remove
444
+ let gitRemoveOk = true;
263
445
  try {
264
446
  git(this.cwd, `worktree remove ${worktreePath} --force`);
265
- } catch {
266
- // git worktree remove 失败,尝试直接删除目录
447
+ } catch (e) {
448
+ gitRemoveOk = false;
267
449
  }
268
450
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
269
451
 
@@ -283,6 +465,14 @@ export class WorktreeManager {
283
465
  if (existsSync(worktreePath)) {
284
466
  rmSync(worktreePath, { recursive: true, force: true });
285
467
  }
468
+
469
+ // 5. 清除 meta 目录(如果 worktree 目录在 worktreeBase 下)
470
+ const metaDir = join(this.worktreeBase, name);
471
+ if (existsSync(metaDir)) {
472
+ rmSync(metaDir, { recursive: true, force: true });
473
+ }
474
+
475
+ return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode };
286
476
  }
287
477
 
288
478
  /**
@@ -302,10 +492,11 @@ export class WorktreeManager {
302
492
  const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
303
493
  if (staged) {
304
494
  try {
305
- const patchContent = execSync(`git diff --cached --binary`, { cwd: mainCwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
306
- if (patchContent) {
495
+ // Buffer 模式读取,避免二进制 patch UTF-8 解码损坏
496
+ const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
497
+ if (patchBuf && patchBuf.length > 0) {
307
498
  const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
308
- writeFileSync(patchFile, patchContent);
499
+ writeFileSync(patchFile, patchBuf);
309
500
  git(worktreePath, `apply --binary ${patchFile}`);
310
501
  rmSync(patchFile, { force: true });
311
502
  }
@@ -319,10 +510,11 @@ export class WorktreeManager {
319
510
  const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
320
511
  if (unstaged) {
321
512
  try {
322
- const patchContent = execSync(`git diff --binary`, { cwd: mainCwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
323
- if (patchContent) {
513
+ // Buffer 模式读取,避免二进制 patch UTF-8 解码损坏
514
+ const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
515
+ if (patchBuf && patchBuf.length > 0) {
324
516
  const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
325
- writeFileSync(patchFile, patchContent);
517
+ writeFileSync(patchFile, patchBuf);
326
518
  git(worktreePath, `apply --binary ${patchFile}`);
327
519
  rmSync(patchFile, { force: true });
328
520
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 防回归测试:scan.js 中不允许硬编码 .sillyspec/docs/<project>/ 作为写入路径
3
+ * 所有正式文档路径必须使用 {DOCS_ROOT} 占位符
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join, dirname } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url))
10
+ const scanPath = join(__dirname, '..', 'src', 'stages', 'scan.js')
11
+ const content = readFileSync(scanPath, 'utf8')
12
+
13
+ const banned = [
14
+ '.sillyspec/docs/<project>/scan/',
15
+ '.sillyspec/docs/<project>/modules/',
16
+ '.sillyspec/docs/<project>/flows/',
17
+ '.sillyspec/docs/<project>/glossary.md',
18
+ ]
19
+
20
+ const required = [
21
+ '{DOCS_ROOT}/scan/',
22
+ '{DOCS_ROOT}/modules/',
23
+ '{DOCS_ROOT}/flows/',
24
+ ]
25
+
26
+ let failed = false
27
+
28
+ // 禁止硬编码路径
29
+ for (const pattern of banned) {
30
+ if (content.includes(pattern)) {
31
+ console.error(`❌ FAIL: scan.js 仍包含硬编码路径 "${pattern}"`)
32
+ failed = true
33
+ } else {
34
+ console.log(`✅ PASS: 不包含 "${pattern}"`)
35
+ }
36
+ }
37
+
38
+ // 必须包含占位符
39
+ for (const pattern of required) {
40
+ if (content.includes(pattern)) {
41
+ console.log(`✅ PASS: 包含占位符 "${pattern}"`)
42
+ } else {
43
+ console.error(`❌ FAIL: scan.js 缺少占位符 "${pattern}"`)
44
+ failed = true
45
+ }
46
+ }
47
+
48
+ // 禁止硬编码 projects 路径
49
+ if (content.includes('.sillyspec/projects/')) {
50
+ console.error('❌ FAIL: scan.js 仍包含硬编码 ".sillyspec/projects/"')
51
+ failed = true
52
+ } else {
53
+ console.log('✅ PASS: 不包含 ".sillyspec/projects/"')
54
+ }
55
+
56
+ if (content.includes('{PROJECTS_ROOT}/')) {
57
+ console.log('✅ PASS: 包含占位符 "{PROJECTS_ROOT}/"')
58
+ } else {
59
+ console.error('❌ FAIL: scan.js 缺少占位符 "{PROJECTS_ROOT}/"')
60
+ failed = true
61
+ }
62
+
63
+ if (failed) {
64
+ console.error('\n💥 有测试失败!scan.js 路径占位符可能被回退为硬编码。')
65
+ process.exit(1)
66
+ } else {
67
+ console.log('\n✅ 全部通过 — scan.js 路径占位符防回归测试 OK')
68
+ }