sillyspec 3.17.6 → 3.17.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.17.6",
3
+ "version": "3.17.8",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/run.js CHANGED
@@ -10,6 +10,24 @@ import { createRequire } from 'module'
10
10
  const require = createRequire(import.meta.url)
11
11
  import { ProgressManager } from './progress.js'
12
12
 
13
+ /**
14
+ * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
15
+ * 使用 -c safe.directory= 临时参数,不污染全局 git config。
16
+ * @param {string} cwd - 仓库根目录
17
+ * @param {string[]} args - git 子命令及参数,如 ['rev-parse', 'HEAD']
18
+ * @returns {{ value: string, error: string|null }}
19
+ */
20
+ function safeGit(cwd, args) {
21
+ const { execSync } = require('child_process')
22
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
23
+ try {
24
+ const value = execSync(['git', ...fullArgs].join(' '), { encoding: 'utf8', timeout: 5000 }).trim()
25
+ return { value, error: null }
26
+ } catch (e) {
27
+ return { value: null, error: e.message.split('\n')[0] }
28
+ }
29
+ }
30
+
13
31
  // ── Wait State Constants ──
14
32
  // 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
15
33
  const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
@@ -129,6 +147,21 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
129
147
  result.status = 'warning'
130
148
  }
131
149
 
150
+ // quicklog 存在性检查
151
+ try {
152
+ const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
153
+ if (existsSync(quicklogDir)) {
154
+ const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
155
+ if (qlFiles.length === 0) {
156
+ result.reasons.push('quicklog 目录为空(无任务记录)')
157
+ if (result.status === 'safe') result.status = 'warning'
158
+ }
159
+ } else {
160
+ result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
161
+ if (result.status === 'safe') result.status = 'warning'
162
+ }
163
+ } catch {}
164
+
132
165
  // --confirm 模式:展示 diff 并等待确认
133
166
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
134
167
  console.log(`\n📋 quick 变更概览:`)
@@ -351,7 +384,7 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
351
384
  if (promptText.includes('<git-user>')) {
352
385
  const { execSync } = await import('child_process')
353
386
  try {
354
- const gitUser = execSync('git config user.name', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
387
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
355
388
  promptText = promptText.replace(/<git-user>/g, gitUser)
356
389
  } catch {
357
390
  promptText = promptText.replace(/<git-user>/g, 'unknown')
@@ -377,9 +410,14 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
377
410
  const docsRoot = join(specSillyspec, 'docs', projectName)
378
411
  const projectsRoot = join(specSillyspec, 'projects')
379
412
  const changesRoot = join(specSillyspec, 'changes')
413
+ const workflowsRoot = join(specSillyspec, 'workflows')
414
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
380
415
 
381
416
  promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
382
417
  promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
418
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
419
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
420
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
383
421
 
384
422
  const platformDirectives = []
385
423
  platformDirectives.push(
@@ -389,6 +427,8 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
389
427
  `- 文档根目录: \`${docsRoot}/\`\n` +
390
428
  `- 项目注册表: \`${projectsRoot}/\`\n` +
391
429
  `- 变更目录: \`${changesRoot}/\`\n` +
430
+ `- 工作流目录: \`${workflowsRoot}/\`\n` +
431
+ `- 术语目录: \`${knowledgeRoot}/\`\n` +
392
432
  `\n` +
393
433
  `### ⛔ 写入规则\n` +
394
434
  `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
@@ -437,8 +477,25 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
437
477
  const specSillyspec = join(cwd, '.sillyspec')
438
478
  const docsRoot = join(specSillyspec, 'docs', projectName)
439
479
  const projectsRoot = join(specSillyspec, 'projects')
480
+ const workflowsRoot = join(specSillyspec, 'workflows')
481
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
440
482
  promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
441
483
  promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
484
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
485
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
486
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
487
+ }
488
+ }
489
+
490
+ // 平台模式 prompt 自检:确保没有裸相对输出路径
491
+ // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
492
+ if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
493
+ const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
494
+ if (writeCtx.test(promptText)) {
495
+ console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
496
+ console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
497
+ console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
498
+ process.exit(1)
442
499
  }
443
500
  }
444
501
 
@@ -651,7 +708,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
651
708
  const manifestDir = platformOpts.specRoot
652
709
  let sourceCommit = null
653
710
  try {
654
- sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
711
+ const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
655
712
  } catch {}
656
713
  mkdirSync(manifestDir, { recursive: true })
657
714
  const manifest = {
@@ -665,6 +722,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
665
722
  workspace_id: platformOpts.workspaceId || null,
666
723
  scan_run_id: platformOpts.scanRunId || null,
667
724
  source_commit: sourceCommit,
725
+ source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
668
726
  generated_at: new Date().toISOString(),
669
727
  schema_version: 2,
670
728
  }
@@ -1074,7 +1132,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1074
1132
  const allowNew = quickOpts?.isAllowNew || false
1075
1133
  const forceBaseline = quickOpts?.isForceBaseline || false
1076
1134
  progress.quickGuard = {
1077
- baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
1135
+ baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
1078
1136
  baselineFiles,
1079
1137
  allowedFiles,
1080
1138
  allowNew,
@@ -1515,7 +1573,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1515
1573
  const numbered = outputText.match(/^\s*\d+\.\s+(\S+)/gm)
1516
1574
  if (numbered) {
1517
1575
  projectNames = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/[—\-:].*$/, '').trim())
1518
- if (projectNames.length > 0) _scanProjectListParsed = true
1576
+ if (projectNames.length > 0) { _scanProjectListParsed = true; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
1519
1577
  }
1520
1578
  // 匹配方式 2: 括号枚举 "子项目frontend/order-service/user-service" 或 "项目: a, b, c"
1521
1579
  if (projectNames.length === 0) {
@@ -1525,7 +1583,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1525
1583
  .split(/[\/、,,]+/)
1526
1584
  .map(s => s.trim())
1527
1585
  .filter(Boolean)
1528
- if (projectNames.length > 0) _scanProjectListParsed = true
1586
+ if (projectNames.length > 0) { _scanProjectListParsed = true; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
1529
1587
  }
1530
1588
  }
1531
1589
  // 匹配方式 3: 结构化 YAML block "scan_projects:\n - id: name"
@@ -1533,14 +1591,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1533
1591
  const yamlMatch = outputText.match(/scan_projects:\s*\n((?:\s+-\s+id:\s+\S+\s*\n?)+)/)
1534
1592
  if (yamlMatch) {
1535
1593
  projectNames = [...yamlMatch[1].matchAll(/-\s+id:\s*(\S+)/g)].map(m => m[1])
1536
- if (projectNames.length > 0) _scanProjectListParsed = true
1594
+ if (projectNames.length > 0) { _scanProjectListParsed = true; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
1537
1595
  }
1538
1596
  }
1539
1597
  }
1540
1598
  if (projectNames.length === 0) {
1541
1599
  // 回退:读取所有已注册项目
1542
1600
  console.warn('⚠️ 未能从 step 2 输出解析项目列表,回退扫描所有注册项目')
1543
- _scanProjectListParsed = false
1601
+ _scanProjectListParsed = false; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = false;
1544
1602
  const projectsDir = join(specBase, 'projects')
1545
1603
  if (existsSync(projectsDir)) {
1546
1604
  projectNames = readdirSync(projectsDir)
@@ -1660,7 +1718,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1660
1718
  // 平台模式:scan 完成后生成 manifest.json + post-check
1661
1719
  if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
1662
1720
  try {
1663
- _scanManifestWritten = false // 默认失败
1721
+ _scanManifestWritten = false; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
1664
1722
  const { mkdirSync, writeFileSync } = await import('fs')
1665
1723
  const { join } = await import('path')
1666
1724
  const { execSync } = await import('child_process')
@@ -1668,19 +1726,20 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1668
1726
  mkdirSync(manifestDir, { recursive: true })
1669
1727
  let sourceCommit = null
1670
1728
  try {
1671
- sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
1729
+ const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
1672
1730
  } catch {}
1673
1731
  const manifest = {
1674
1732
  workspace_id: platformOpts.workspaceId || null,
1675
1733
  scan_run_id: platformOpts.scanRunId || null,
1676
1734
  source_commit: sourceCommit,
1735
+ source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
1677
1736
  generated_at: new Date().toISOString(),
1678
1737
  schema_version: 1,
1679
1738
  }
1680
1739
  const manifestPath = join(manifestDir, 'manifest.json')
1681
1740
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
1682
1741
  console.log(`📄 manifest.json 已写入: ${manifestPath}`)
1683
- _scanManifestWritten = true
1742
+ _scanManifestWritten = true; stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = true;
1684
1743
  if (!sourceCommit) {
1685
1744
  console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
1686
1745
  }
@@ -1696,8 +1755,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1696
1755
  specDir: platformOpts.specRoot,
1697
1756
  outputText,
1698
1757
  scanMeta: {
1699
- projectListParsed: _scanProjectListParsed,
1700
- manifestWritten: _scanManifestWritten,
1758
+ projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
1759
+ manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
1701
1760
  },
1702
1761
  })
1703
1762
  printScanPostCheckResult(postResult)
@@ -52,19 +52,33 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
52
52
 
53
53
  const projectName = basename(cwd)
54
54
 
55
- // 1. source_root 污染检查
56
- const localDocsDir = join(cwd, '.sillyspec', 'docs')
57
- if (existsSync(localDocsDir)) {
58
- try {
59
- const leaked = readdirSync(localDocsDir, { recursive: true }).filter(e => String(e).endsWith('.md'))
60
- if (leaked.length > 0) {
61
- checks.push({
62
- name: 'source_root_docs_leak',
63
- severity: 'failed',
64
- detail: `source_root 下存在 ${leaked.length} 个文档文件(${localDocsDir}/),agent 可能写入到了错误路径`
65
- })
66
- }
67
- } catch {}
55
+ // 1. source_root 污染检查(docs/projects/workflows/knowledge/manifest/local)
56
+ const pollutePaths = ['docs', 'projects', 'workflows', 'knowledge']
57
+ const polluteFiles = ['manifest.json', 'local.yaml']
58
+ for (const sub of pollutePaths) {
59
+ const localSub = join(cwd, '.sillyspec', sub)
60
+ if (existsSync(localSub)) {
61
+ try {
62
+ const leaked = readdirSync(localSub, { recursive: true }).filter(e => String(e).endsWith('.md') || String(e).endsWith('.yaml') || String(e).endsWith('.json'))
63
+ if (leaked.length > 0) {
64
+ checks.push({
65
+ name: 'source_root_leak',
66
+ severity: 'failed',
67
+ detail: `source_root/.sillyspec/${sub}/ 下存在 ${leaked.length} 个文件(${localSub}/),agent 写入到了错误路径`
68
+ })
69
+ }
70
+ } catch {}
71
+ }
72
+ }
73
+ for (const file of polluteFiles) {
74
+ const filePath = join(cwd, '.sillyspec', file)
75
+ if (existsSync(filePath)) {
76
+ checks.push({
77
+ name: 'source_root_leak',
78
+ severity: 'failed',
79
+ detail: `source_root/.sillyspec/${file} 存在,agent 写入到了错误路径(${filePath})`
80
+ })
81
+ }
68
82
  }
69
83
 
70
84
  // 2. spec_root 检查 7 份必需文档
@@ -22,8 +22,8 @@ export const definition = {
22
22
  9. 根据任务描述初步判断可能涉及的模块
23
23
  10. 读取匹配到的 \`.sillyspec/docs/<project>/modules/<module>.md\`
24
24
 
25
- ### 创建任务记录(必须执行)
26
- 理解完任务后,立即创建记录文件:
25
+ ### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
26
+ 理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
27
27
  1. 使用预注入的 git 用户名:\`<git-user>\`
28
28
  2. 无 \`--change\`:创建 .sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`(已存在则追加),写入:
29
29
  \`\`\`
@@ -41,7 +41,9 @@ export const definition = {
41
41
  这样 Gate 检测到 .sillyspec/\` 下有变更,就不会拦截后续的代码修改。
42
42
 
43
43
  ### 输出
44
- 任务理解 + 上下文摘要 + quicklog 已创建`,
44
+ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
45
+
46
+ ⚠️ **先创建 quicklog,再输出任务理解。** 如果 quicklog 未创建,CLI post-check 会报 warning。`,
45
47
  outputHint: '任务理解',
46
48
  optional: false
47
49
  },
@@ -103,13 +103,13 @@ export const definition = {
103
103
  {
104
104
  name: '深度扫描 — 7 份文档(子代理并行)',
105
105
  perProject: true,
106
- prompt: `按照 \`.sillyspec/workflows/scan-docs.yaml\` 中定义的角色和检查规则,使用子代理并行生成当前项目的 7 份扫描文档。
106
+ prompt: `按照 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 中定义的角色和检查规则,使用子代理并行生成当前项目的 7 份扫描文档。
107
107
 
108
108
  **你必须使用子代理执行,不要自己写文档。**
109
109
  **对扫描列表中的每个项目分别执行以下流程。**
110
110
 
111
111
  ### 操作
112
- 1. 读取 \`.sillyspec/workflows/scan-docs.yaml\`,了解角色定义、输出要求和检查规则
112
+ 1. 读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\`,了解角色定义、输出要求和检查规则
113
113
  2. 对每个项目(扫描列表中标记为需生成/覆盖的项目):
114
114
  a. 将 \`<project>\` 替换为实际项目名,得到该项目的目标文件路径
115
115
  b. 为每个角色启动独立子代理(可并行),每个子代理负责 1-2 份文档
@@ -141,16 +141,16 @@ export const definition = {
141
141
  },
142
142
  {
143
143
  name: '生成本地配置',
144
- prompt: `自动生成 .sillyspec/local.yaml 本地配置文件。
144
+ prompt: `自动生成 local.yaml 本地配置文件。
145
145
 
146
146
  ### 操作
147
- 1. 检查 .sillyspec/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
147
+ 1. 检查 {SPEC_ROOT}/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
148
148
  2. 根据项目类型生成默认配置:
149
149
  - **Node.js**(有 package.json):build: "npm run build", test: "npm test", lint: "npm run lint", type: nodejs
150
150
  - **Maven**(有 pom.xml):build: "mvn compile", test: "mvn test", lint: "mvn checkstyle:check", type: maven
151
151
  - **Gradle**(有 build.gradle):build: "./gradlew build", test: "./gradlew test", type: gradle
152
152
  - **通用项目**:只写注释模板, type: generic
153
- 3. 确保目录存在:mkdir -p .sillyspec
153
+ 3. 确保目录存在:mkdir -p {SPEC_ROOT}
154
154
  4. 原子写入(先写 tmp 文件再 rename)
155
155
 
156
156
  ### 文件格式
@@ -454,7 +454,7 @@ step1 → step2 → step3
454
454
  3. 自检门控:ARCHITECTURE(技术栈+Schema摘要)、CONVENTIONS(隐形规则+代码风格)、STRUCTURE(目录结构)、INTEGRATIONS(外部依赖)、TESTING(测试现状)、CONCERNS(技术债务)、PROJECT(项目概览)
455
455
  4. 检查 flows/ 和 glossary.md 是否已生成(如有)
456
456
  5. 清理:\`rm -f {DOCS_ROOT}/scan/_env-detect.md\`
457
- 6. \`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)
457
+ 6. 如果非平台模式:\`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)。如果平台模式:跳过 git add(specRoot 不在 sourceRoot 的 git repo 内)。
458
458
 
459
459
  ### ⛔ 路径合规检查(平台模式下必须执行)
460
460
  7. 确认所有文档都写入 \`{DOCS_ROOT}/\`(spec-root 下),**而非源码目录下的 .sillyspec/**
@@ -0,0 +1,172 @@
1
+ /**
2
+ * P0 补丁验证 — 4 类测试
3
+ *
4
+ * 1. 平台 scan prompt 不含正向写入 .sillyspec/ 指令
5
+ * 2. 安全说明文字不被 prompt 自检误杀
6
+ * 3. 平台模式不执行 git add {SPEC_ROOT}
7
+ * 4. source_root 污染检查覆盖所有子目录/文件
8
+ * 5. run.js 占位符替换补齐(平台+本地)
9
+ *
10
+ * 跑法: node test/platform-scan-p0.test.mjs
11
+ */
12
+
13
+ import { join, basename } from 'path'
14
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readdirSync } from 'fs'
15
+ import { execSync } from 'child_process'
16
+ import { readFile } from 'fs/promises'
17
+ import { fileURLToPath } from 'url'
18
+
19
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
20
+ const passed = []
21
+ const failed = []
22
+
23
+ function assert(label, condition, detail) {
24
+ if (condition) {
25
+ passed.push(label)
26
+ } else {
27
+ failed.push({ label, detail })
28
+ }
29
+ }
30
+
31
+ // ── 测试 1:scan.js 模板不含裸 .sillyspec 输出路径 ──
32
+ {
33
+ const { definition } = await import('../src/stages/scan.js')
34
+ const prompts = definition.steps.map(s => s.prompt).join('\n')
35
+
36
+ // 所有 .sillyspec 出现都应该是「禁止说明」,不是写入指令
37
+ const writePatterns = [
38
+ /write.*\.sillyspec\/docs/i,
39
+ /save.*\.sillyspec\/docs/i,
40
+ /create.*\.sillyspec\/docs/i,
41
+ /mkdir.*\.sillyspec\/docs/i,
42
+ ]
43
+ for (const p of writePatterns) {
44
+ assert(`scan 模板无写入 .sillyspec/docs: ${p}`, !p.test(prompts),
45
+ p.test(prompts) ? `命中: ${prompts.match(p)[0]}` : '')
46
+ }
47
+
48
+ // 应该使用占位符
49
+ assert('scan 模板使用 {WORKFLOWS_ROOT}', prompts.includes('{WORKFLOWS_ROOT}'))
50
+ assert('scan 模板使用 {SPEC_ROOT}', prompts.includes('{SPEC_ROOT}'))
51
+ assert('scan 模板使用 {DOCS_ROOT}', prompts.includes('{DOCS_ROOT}'))
52
+
53
+ // 平台模式下 git add 应该是条件判断
54
+ assert('scan 模板平台模式跳过 git add', prompts.includes('如果平台模式:跳过 git add'))
55
+ assert('scan 模板非平台模式 git add .sillyspec/', prompts.includes('git add .sillyspec/'))
56
+ }
57
+
58
+ // ── 测试 2:prompt 自检不误杀安全说明 ──
59
+ {
60
+ // 导入 run.js 中的正则
61
+ const writeCtxRe = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
62
+
63
+ // 安全说明 — 不应命中
64
+ const safeLines = [
65
+ '严禁写入源码目录或相对路径 `.sillyspec/`',
66
+ '⚠️ 不要写入 .sillyspec/docs',
67
+ 'source_root 下存在 .sillyspec/docs 文件',
68
+ '不允许从 cwd 推导 .sillyspec 路径',
69
+ ]
70
+ for (const line of safeLines) {
71
+ assert(`安全说明不误杀: "${line.slice(0, 40)}"`, !writeCtxRe.test(line),
72
+ '误杀! 命中了安全说明文字')
73
+ }
74
+
75
+ // 写入指令 — 应该命中
76
+ const badLines = [
77
+ '写入 `.sillyspec/docs/ARCHITECTURE.md`',
78
+ 'save to `.sillyspec/docs/ARCHITECTURE.md`',
79
+ 'create `.sillyspec/docs/ARCH.md`',
80
+ 'git add .sillyspec/docs/',
81
+ 'write 到 .sillyspec/docs/',
82
+ ]
83
+ for (const line of badLines) {
84
+ assert(`写入指令应命中: "${line.slice(0, 40)}"`, writeCtxRe.test(line),
85
+ '漏杀! 没有捕获写入指令')
86
+ }
87
+ }
88
+
89
+ // ── 测试 3:safeGit 使用 -c safe.directory(不污染全局 config) ──
90
+ {
91
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
92
+
93
+ assert('safeGit 不含 --global', !runSrc.includes('git config --global'),
94
+ '发现 --global,会污染容器 git config')
95
+
96
+ assert('safeGit 使用 -c safe.directory', runSrc.includes("safe.directory=${cwd}"),
97
+ '未发现 -c safe.directory per-command 参数')
98
+
99
+ assert('safeGit 使用 -C cwd', runSrc.includes("-C', cwd"),
100
+ '未发现 -C cwd 参数')
101
+
102
+ assert('safeGit 返回 { value, error }', runSrc.includes("return { value, error: "),
103
+ 'safeGit 返回值不是 { value, error } 结构')
104
+
105
+ assert('manifest 包含 source_commit_error 字段',
106
+ runSrc.includes("source_commit_error:"), 'manifest 缺少 source_commit_error')
107
+ }
108
+
109
+ // ── 测试 4:postcheck 污染检查覆盖所有子目录 ──
110
+ {
111
+ const postcheckSrc = await readFile(join(__dirname, '..', 'src', 'scan-postcheck.js'), 'utf8')
112
+
113
+ const requiredSubs = ['docs', 'projects', 'workflows', 'knowledge']
114
+ for (const sub of requiredSubs) {
115
+ assert(`postcheck 检查 ${sub} 污染`,
116
+ postcheckSrc.includes(`'${sub}'`) && postcheckSrc.includes("'.sillyspec', sub)"),
117
+ `postcheck 未检查 .sillyspec/${sub}/ 污染`)
118
+ }
119
+
120
+ assert('postcheck 检查 manifest.json', postcheckSrc.includes('manifest.json'))
121
+ assert('postcheck 检查 local.yaml', postcheckSrc.includes('local.yaml'))
122
+ assert('污染 severity 为 failed', postcheckSrc.includes("severity: 'failed'"))
123
+ }
124
+
125
+ // ── 测试 5:run.js 占位符替换补齐 ──
126
+ {
127
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
128
+
129
+ // 平台模式块
130
+ const platformMarker = 'promptText.replace(/\\{WORKFLOWS_ROOT\\}'
131
+ assert('平台模式替换 {WORKFLOWS_ROOT}', runSrc.includes('{WORKFLOWS_ROOT}') && runSrc.includes('workflowsRoot'))
132
+ assert('平台模式替换 {KNOWLEDGE_ROOT}', runSrc.includes('{KNOWLEDGE_ROOT}') && runSrc.includes('knowledgeRoot'))
133
+ assert('平台模式替换 {SPEC_ROOT}', runSrc.includes('{SPEC_ROOT}') && runSrc.includes("specSillyspec"))
134
+
135
+ // 非平台模式块
136
+ assert('非平台模式替换 {WORKFLOWS_ROOT}', runSrc.includes('workflowsRoot'))
137
+ assert('非平台模式替换 {KNOWLEDGE_ROOT}', runSrc.includes('knowledgeRoot'))
138
+ assert('非平台模式替换 {SPEC_ROOT}', runSrc.includes('{SPEC_ROOT}'))
139
+ }
140
+
141
+ // ── 测试 6:quick step 1 prompt 强制要求 quicklog ──
142
+ {
143
+ const { definition } = await import('../src/stages/quick.js')
144
+ const step1Prompt = definition.steps[0].prompt
145
+
146
+ assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
147
+ assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
148
+ assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
149
+ assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
150
+
151
+ // run.js 审计包含 quicklog 检查
152
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
153
+ assert('quick 审计检查 quicklog 目录存在', runSrc.includes('quicklog 目录不存在'))
154
+ assert('quick 审计检查 quicklog 为空', runSrc.includes('quicklog 目录为空'))
155
+ }
156
+
157
+ // ── 结果 ──
158
+ console.log(`\n${'='.repeat(50)}`)
159
+ console.log(`✅ 通过: ${passed.length}`)
160
+ console.log(`❌ 失败: ${failed.length}`)
161
+ console.log(`${'='.repeat(50)}`)
162
+
163
+ if (failed.length > 0) {
164
+ console.log('\n失败详情:')
165
+ for (const f of failed) {
166
+ console.log(` ❌ ${f.label}`)
167
+ if (f.detail) console.log(` ${f.detail}`)
168
+ }
169
+ process.exit(1)
170
+ } else {
171
+ console.log('\n🎉 全部 P0 测试通过!')
172
+ }