sillyspec 3.17.8 → 3.17.10

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.8",
3
+ "version": "3.17.10",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/modules.js CHANGED
@@ -47,7 +47,18 @@ function parseModuleCardFrontmatter(content) {
47
47
  }
48
48
 
49
49
  /**
50
- * rebuild: 从模块卡片 + 源码重建 _module-map.yaml
50
+ * 从模块卡片 + 源码重建 _module-map.yaml
51
+ *
52
+ * schema_version 2 扩展字段:
53
+ * - role: 模块职责描述
54
+ * - core_files: 核心源文件列表
55
+ * - test_files: 测试文件列表
56
+ * - entrypoints: 入口点
57
+ * - depends_on: 依赖的其他模块
58
+ * - used_by: 被哪些模块使用
59
+ * - risk_level: 风险等级(low/medium/high)
60
+ * - verify_commands: 验证命令(build/test/lint)
61
+ * - related_docs: 关联文档路径
51
62
  */
52
63
  export async function rebuildModuleMap(cwd) {
53
64
  const mapPath = findModuleMapPath(cwd);
@@ -107,10 +118,12 @@ export async function rebuildModuleMap(cwd) {
107
118
  headCommit = execSync('git rev-parse --short HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
108
119
  } catch { /* ignore */ }
109
120
 
110
- let yaml = `schema_version: 1\n`;
121
+ let yaml = `schema_version: 2\n`;
111
122
  yaml += `generated_at: ${now}\n`;
112
123
  yaml += `generator: sillyspec-modules-rebuild\n`;
113
124
  if (headCommit) yaml += `source_commit: ${headCommit}\n`;
125
+ yaml += `\n# Module Context Index — 机器可读的模块上下文索引\n`;
126
+ yaml += `# scan 阶段自动生成,brainstorm/plan/execute 阶段按任务命中模块精准注入上下文\n`;
114
127
  yaml += `\nmodules:\n`;
115
128
 
116
129
  // 合并:已有映射 + 新卡片
@@ -121,13 +134,31 @@ export async function rebuildModuleMap(cwd) {
121
134
 
122
135
  for (const moduleId of allModuleIds) {
123
136
  const card = cards.find(c => c.moduleId === moduleId);
137
+ const cardContent = card ? readFileSync(join(modulesDir, card.filename), 'utf8') : '';
138
+
139
+ // 从模块卡片提取 context index 字段
140
+ const role = extractSection(cardContent, '定位') || '';
141
+ const contract = extractSection(cardContent, '契约摘要') || '';
142
+ const logic = extractSection(cardContent, '关键逻辑') || '';
143
+ const notes = extractSection(cardContent, '注意事项') || '';
144
+
124
145
  yaml += ` ${moduleId}:\n`;
125
146
  yaml += ` status: active\n`;
126
147
  if (card) yaml += ` doc: modules/${card.filename}\n`;
127
148
  else yaml += ` doc: modules/${moduleId}.md\n`;
128
- // 保留已有 _module-map.yaml 中的 paths 等字段(如果有的话)
129
149
  yaml += ` needs_review: false\n`;
130
150
  yaml += ` review_reasons: []\n`;
151
+ // context index 字段(v2)
152
+ if (role) yaml += ` role: "${escapeYamlString(role)}"\n`;
153
+ // core_files / test_files / entrypoints 从已有 _module-map 保留,或从卡片文件名推导
154
+ const existingPaths = existingModules[moduleId]?.paths || [];
155
+ if (existingPaths.length > 0) {
156
+ yaml += ` core_files:\n`;
157
+ for (const p of existingPaths) yaml += ` - ${p}\n`;
158
+ }
159
+ if (card) yaml += ` verify_commands: []\n`;
160
+ yaml += ` risk_level: low\n`;
161
+ if (contract || logic) yaml += ` related_docs: []\n`;
131
162
  yaml += `\n`;
132
163
  }
133
164
 
@@ -138,6 +169,7 @@ export async function rebuildModuleMap(cwd) {
138
169
  console.log(` - ${id}`);
139
170
  }
140
171
  console.log(`\n⚠️ 注意:rebuild 只重建骨架。tags/entrypoints/main_symbols/depends_on/used_by 需要重新运行 scan 或手动补充。`);
172
+ console.log(`ℹ️ schema 已升级到 v2,支持 role/core_files/test_files/entrypoints/depends_on/risk_level/verify_commands 等字段。`);
141
173
  }
142
174
 
143
175
  /**
@@ -163,13 +195,17 @@ export async function showModuleStatus(cwd) {
163
195
  const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/);
164
196
  if (moduleMatch) {
165
197
  if (currentModule) modules.push(currentModule);
166
- currentModule = { id: moduleMatch[1], hasTags: false, hasEntryPoints: false, hasDepends: false, needsReview: false };
198
+ currentModule = { id: moduleMatch[1], hasTags: false, hasEntryPoints: false, hasDepends: false, needsReview: false, hasRole: false, hasCoreFiles: false, hasRisk: false, hasVerify: false };
167
199
  }
168
200
  if (currentModule) {
169
201
  if (line.includes('tags:')) currentModule.hasTags = true;
170
202
  if (line.includes('entrypoints:')) currentModule.hasEntryPoints = true;
171
203
  if (line.includes('depends_on:')) currentModule.hasDepends = true;
172
204
  if (line.includes('needs_review: true')) { currentModule.needsReview = true; needsReview = true; }
205
+ if (line.includes('role:')) currentModule.hasRole = true;
206
+ if (line.includes('core_files:')) currentModule.hasCoreFiles = true;
207
+ if (line.includes('risk_level:')) currentModule.hasRisk = true;
208
+ if (line.includes('verify_commands:')) currentModule.hasVerify = true;
173
209
  }
174
210
  }
175
211
  if (currentModule) modules.push(currentModule);
@@ -178,14 +214,18 @@ export async function showModuleStatus(cwd) {
178
214
  console.log(`模块数量:${modules.length}\n`);
179
215
 
180
216
  const maxId = Math.max(5, ...modules.map(m => m.id.length));
181
- console.log(` ${'模块'.padEnd(maxId)} tags entry deps review`);
182
- console.log(` ${''.repeat(maxId)} ──── ───── ──── ──────`);
217
+ console.log(` ${'模块'.padEnd(maxId)} tags entry deps role core risk review`);
218
+ console.log(` ${''.padEnd(maxId, '─')} ──── ───── ──── ──── ──── ──── ──────`);
183
219
  for (const m of modules) {
184
220
  const tags = m.hasTags ? '✅' : '⬜';
185
221
  const entry = m.hasEntryPoints ? '✅' : '⬜';
186
222
  const deps = m.hasDepends ? '✅' : '⬜';
223
+ const role = m.hasRole ? '✅' : '⬜';
224
+ const core = m.hasCoreFiles ? '✅' : '⬜';
225
+ const risk = m.hasRisk ? '✅' : '⬜';
226
+ const verify = m.hasVerify ? '✅' : '⬜';
187
227
  const review = m.needsReview ? '⚠️' : '✅';
188
- console.log(` ${m.id.padEnd(maxId)} ${tags} ${entry} ${deps} ${review}`);
228
+ console.log(` ${m.id.padEnd(maxId)} ${tags} ${entry} ${deps} ${role} ${core} ${risk} ${verify} ${review}`);
189
229
  }
190
230
 
191
231
  if (needsReview) {
@@ -194,7 +234,22 @@ export async function showModuleStatus(cwd) {
194
234
  }
195
235
 
196
236
  /**
197
- * _module-map.yaml 聚合生成 dependencies.md
237
+ * 从模块卡片提取指定 section 的内容
238
+ */
239
+ function extractSection(content, sectionName) {
240
+ const match = content.match(new RegExp(`## ${escapeRegExp(sectionName)}\\n([\\s\\S]*?)(?=\\n##|$)`));
241
+ return match ? match[1].trim().split('\n')[0] : ''; // 只取第一行作为摘要
242
+ }
243
+
244
+ function escapeRegExp(s) {
245
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ }
247
+
248
+ function escapeYamlString(s) {
249
+ return s.replace(/"/g, '\\"').replace(/\n/g, ' ').slice(0, 200); // 截断避免过长
250
+ }
251
+
252
+ /**
198
253
  */
199
254
  export async function generateDependenciesMd(cwd) {
200
255
  const mapPath = findModuleMapPath(cwd);
package/src/run.js CHANGED
@@ -64,6 +64,149 @@ import { buildExecuteSteps } from './stages/execute.js'
64
64
  import { buildPlanSteps } from './stages/plan.js'
65
65
  import { formatExecuteSummary } from './worktree-apply.js'
66
66
 
67
+ /**
68
+ * 从 _module-map.yaml 读取模块上下文索引
69
+ * 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
70
+ *
71
+ * @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
72
+ * @param {string} projectName - 项目名
73
+ * @returns {object|null} 解析后的模块索引,null 表示无索引
74
+ */
75
+ function loadModuleContextIndex(specBase, projectName) {
76
+ try {
77
+ const { existsSync, readFileSync } = require('fs')
78
+ const { join } = require('path')
79
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
80
+ if (!existsSync(mapPath)) return null
81
+ const content = readFileSync(mapPath, 'utf8')
82
+ return parseModuleMapSimple(content)
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
90
+ * 匹配策略:模块 id / role / doc 路径中的关键词
91
+ *
92
+ * @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
93
+ * @param {object} moduleIndex - loadModuleContextIndex 返回值
94
+ * @param {string} specBase - 规范目录
95
+ * @param {string} projectName - 项目名
96
+ * @returns {string} 上下文注入文本,空字符串表示无匹配模块
97
+ */
98
+ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
99
+ if (!moduleIndex || !taskDescription) return ''
100
+ const { existsSync } = require('fs')
101
+ const { join } = require('path')
102
+
103
+ const taskLower = taskDescription.toLowerCase()
104
+ const matched = []
105
+
106
+ for (const [moduleId, data] of Object.entries(moduleIndex)) {
107
+ let score = 0
108
+ let matchReasons = []
109
+ // 模块 id 匹配
110
+ if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
111
+ // role 描述匹配
112
+ if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
113
+ // core_files 路径匹配
114
+ const coreFiles = data.paths || data.core_files || []
115
+ for (const p of coreFiles) {
116
+ if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break }
117
+ }
118
+ if (score > 0) matched.push({ moduleId, data, score, matchReasons })
119
+ }
120
+
121
+ if (matched.length === 0) return ''
122
+
123
+ matched.sort((a, b) => b.score - a.score)
124
+
125
+ let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
126
+ injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
127
+ injection += `> Matched modules: ${matched.map(m => m.moduleId).join(', ')}\n`
128
+ injection += `> Reasons: ${matched.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
129
+
130
+ for (const { moduleId, data } of matched) {
131
+ injection += `#### ${moduleId}\n`
132
+ if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
133
+ const riskLevel = data.risk_level || 'medium'
134
+ injection += `- **风险等级**: ${riskLevel}\n`
135
+ const coreFiles = data.paths || data.core_files || []
136
+ if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
137
+ if (data.doc) {
138
+ const docPath = join(specBase, 'docs', projectName, data.doc)
139
+ const exists = existsSync(docPath)
140
+ injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
141
+ }
142
+ const deps = data.depends_on || []
143
+ if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
144
+ const usedBy = data.used_by || []
145
+ if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
146
+ injection += '\n'
147
+ }
148
+
149
+ return injection
150
+ }
151
+
152
+ // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
153
+ function parseModuleMapSimple(content) {
154
+ const modules = {}
155
+ let currentModule = null
156
+ let currentKey = null
157
+ let currentArray = null
158
+
159
+ for (const line of content.split('\n')) {
160
+ const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
161
+ if (moduleMatch) {
162
+ if (currentArray && currentModule && currentKey) {
163
+ modules[currentModule][currentKey] = currentArray
164
+ }
165
+ currentModule = moduleMatch[1]
166
+ modules[currentModule] = {}
167
+ currentKey = null
168
+ currentArray = null
169
+ continue
170
+ }
171
+ if (!currentModule) continue
172
+
173
+ const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
174
+ if (arrayFieldMatch) {
175
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
176
+ currentKey = arrayFieldMatch[1]
177
+ currentArray = []
178
+ continue
179
+ }
180
+
181
+ const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
182
+ if (inlineArrayMatch) {
183
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
184
+ const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
185
+ modules[currentModule][inlineArrayMatch[1]] = vals
186
+ currentKey = null
187
+ currentArray = null
188
+ continue
189
+ }
190
+
191
+ const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
192
+ if (scalarMatch) {
193
+ if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
194
+ modules[currentModule][scalarMatch[1]] = scalarMatch[2]
195
+ continue
196
+ }
197
+
198
+ const itemMatch = line.match(/^ - (.+)$/)
199
+ if (itemMatch && currentArray !== null) {
200
+ currentArray.push(itemMatch[1].trim())
201
+ continue
202
+ }
203
+ }
204
+ if (currentArray && currentModule && currentKey) {
205
+ modules[currentModule][currentKey] = currentArray
206
+ }
207
+ return modules
208
+ }
209
+
67
210
  /**
68
211
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
69
212
  */
@@ -487,6 +630,19 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
487
630
  }
488
631
  }
489
632
 
633
+ // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
634
+ if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
635
+ const moduleIndex = loadModuleContextIndex(specBase || join(cwd, '.sillyspec'), projectName)
636
+ if (moduleIndex && Object.keys(moduleIndex).length > 0) {
637
+ // 尝试从 step prompt / changeName 匹配模块
638
+ const taskDesc = step.prompt || changeName || ''
639
+ const injection = buildModuleContextInjection(taskDesc, moduleIndex, specBase || join(cwd, '.sillyspec'), projectName)
640
+ if (injection) {
641
+ promptText = injection + '\n' + promptText
642
+ }
643
+ }
644
+ }
645
+
490
646
  // 平台模式 prompt 自检:确保没有裸相对输出路径
491
647
  // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
492
648
  if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
@@ -1749,7 +1905,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1749
1905
  try { unlinkSync(platformOptsFile) } catch {}
1750
1906
 
1751
1907
  // CLI 层 post-check(替代旧的简单检查)
1752
- const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1908
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
1753
1909
  const postResult = runScanPostCheck({
1754
1910
  cwd,
1755
1911
  specDir: platformOpts.specRoot,
@@ -1761,6 +1917,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1761
1917
  })
1762
1918
  printScanPostCheckResult(postResult)
1763
1919
 
1920
+ // 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
1921
+ const structured = formatStructuredResult(postResult, {
1922
+ workspace_id: platformOpts.workspaceId,
1923
+ scan_run_id: platformOpts.scanRunId,
1924
+ source_root: cwd,
1925
+ spec_root: platformOpts.specRoot,
1926
+ runtime_root: platformOpts.runtimeRoot,
1927
+ })
1928
+ const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
1929
+ runtimeRoot: platformOpts.runtimeRoot,
1930
+ scanRunId: platformOpts.scanRunId,
1931
+ })
1932
+ if (postcheckJsonPath) {
1933
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
1934
+ }
1935
+
1764
1936
  // 将 post-check 结果写入 manifest
1765
1937
  manifest.scan_post_check = {
1766
1938
  status: postResult.status,
@@ -1788,11 +1960,17 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1788
1960
  }
1789
1961
  }
1790
1962
 
1791
- // 非 platform 模式 scan 也做轻量 post-check
1963
+ // 非 platform 模式 scan 也做轻量 post-check + 结构化输出
1792
1964
  if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
1793
- const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1965
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
1794
1966
  const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
1795
1967
  printScanPostCheckResult(postResult)
1968
+ // 结构化结果写入 .sillyspec/.runtime/
1969
+ const structured = formatStructuredResult(postResult, { source_root: cwd })
1970
+ const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
1971
+ if (postcheckJsonPath) {
1972
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
1973
+ }
1796
1974
  }
1797
1975
 
1798
1976
  validateMetadata(cwd, stageName, specBase)
@@ -5,7 +5,7 @@
5
5
  * 平台模式下必须通过所有 check 才能 success,否则降级。
6
6
  */
7
7
 
8
- import { existsSync, readdirSync, readFileSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
 
11
11
  const REQUIRED_SCAN_DOCS = [
@@ -198,6 +198,138 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
198
198
  return { status, checks }
199
199
  }
200
200
 
201
+ /**
202
+ * 将 postcheck 结果转换为结构化 JSON(SillyHub 可消费格式)
203
+ *
204
+ * failure_category 标准化:
205
+ * - warning : 非致命问题,不阻塞流程
206
+ * - error : 文档缺失/内容不完整,需要修复
207
+ * - critical : 安全问题(source_root 泄漏/路径污染)
208
+ *
209
+ * 结构化字段:
210
+ * - violations : 明确违反约束的条目(source_root 泄漏等)
211
+ * - missing_outputs : 预期文件不存在
212
+ * - path_pollution : 产物写入了错误路径
213
+ * - bad_references : 引用了不存在的命令/资源
214
+ * - quality_warnings: AI 输出中包含错误标记等质量信号
215
+ *
216
+ * @param {object} result - runScanPostCheck 返回值
217
+ * @param {object} [meta] - 附带元数据(workspace_id, scan_run_id, timestamp 等)
218
+ * @returns {object} 结构化 JSON
219
+ */
220
+ export function formatStructuredResult(result, meta = {}) {
221
+ const structured = {
222
+ schema_version: 1,
223
+ generated_at: new Date().toISOString(),
224
+ overall_status: result.status,
225
+ // 路径溯源(供平台消费)
226
+ ...(meta.workspace_id ? { workspace_id: meta.workspace_id } : {}),
227
+ ...(meta.scan_run_id ? { scan_run_id: meta.scan_run_id } : {}),
228
+ ...(meta.source_root ? { source_root: meta.source_root } : {}),
229
+ ...(meta.spec_root ? { spec_root: meta.spec_root } : {}),
230
+ ...(meta.runtime_root ? { runtime_root: meta.runtime_root } : {}),
231
+ summary: {
232
+ total_checks: result.checks.length,
233
+ critical: 0,
234
+ error: 0,
235
+ warning: 0,
236
+ },
237
+ failure_categories: {
238
+ violations: [],
239
+ missing_outputs: [],
240
+ path_pollution: [],
241
+ bad_references: [],
242
+ quality_warnings: [],
243
+ },
244
+ checks: result.checks.map(c => ({
245
+ name: c.name,
246
+ severity: c.severity === 'failed' ? 'critical' : c.severity,
247
+ detail: c.detail,
248
+ })),
249
+ }
250
+
251
+ // 分类到 failure_categories
252
+ for (const check of result.checks) {
253
+ const severity = check.severity === 'failed' ? 'critical' : check.severity
254
+ const entry = { name: check.name, detail: check.detail, severity }
255
+
256
+ // 路径污染类
257
+ if (check.name === 'source_root_leak') {
258
+ structured.failure_categories.path_pollution.push(entry)
259
+ structured.failure_categories.violations.push(entry)
260
+ }
261
+ // 文档缺失类
262
+ else if (check.name === 'all_docs_missing' || check.name === 'partial_docs_missing' || check.name === 'missing_docs') {
263
+ structured.failure_categories.missing_outputs.push(entry)
264
+ }
265
+ // 引用无效类
266
+ else if (check.name === 'local_config_invalid') {
267
+ structured.failure_categories.bad_references.push(entry)
268
+ }
269
+ // AI 输出质量类
270
+ else if (['tool_use_error', 'api_error_529', 'rate_limit_exhausted', 'fallback_or_skip'].includes(check.name)) {
271
+ structured.failure_categories.quality_warnings.push(entry)
272
+ }
273
+ // manifest/project 列表问题
274
+ else if (check.name === 'manifest_write_failed' || check.name === 'project_list_parse_failed') {
275
+ structured.failure_categories.violations.push(entry)
276
+ }
277
+ // 文档缺少 header
278
+ else if (check.name === 'docs_missing_header') {
279
+ structured.failure_categories.quality_warnings.push(entry)
280
+ }
281
+ // 兜底:归入 violations
282
+ else {
283
+ structured.failure_categories.violations.push(entry)
284
+ }
285
+ }
286
+
287
+ // 汇总计数
288
+ for (const check of result.checks) {
289
+ if (check.severity === 'failed') structured.summary.critical++
290
+ else structured.summary.warning++
291
+ }
292
+
293
+ return structured
294
+ }
295
+
296
+ /**
297
+ * 将结构化结果写入 JSON 文件(平台模式供 SillyHub 消费)
298
+ *
299
+ * 本地模式:写入 specDir/.runtime/postcheck-result.json
300
+ * 平台模式:写入 runtimeRoot/scan-runs/{scan_run_id}/postcheck-result.json
301
+ *
302
+ * @param {object} structured - formatStructuredResult 返回值
303
+ * @param {string} specDir - 规范目录(本地模式使用)
304
+ * @param {object} [opts] - 平台模式选项
305
+ * @param {string} [opts.runtimeRoot] - 平台模式运行时根目录
306
+ * @param {string} [opts.scanRunId] - scan run ID
307
+ * @returns {string|null} 写入的文件路径,失败时返回 null
308
+ */
309
+ export function writeStructuredResult(structured, specDir, opts = {}) {
310
+ if (!specDir && !opts.runtimeRoot) return null
311
+ try {
312
+ let outPath
313
+ if (opts.runtimeRoot && opts.scanRunId) {
314
+ const scanRunDir = join(opts.runtimeRoot, 'scan-runs', opts.scanRunId)
315
+ mkdirSync(scanRunDir, { recursive: true })
316
+ outPath = join(scanRunDir, 'postcheck-result.json')
317
+ } else if (specDir) {
318
+ const runtimeDir = join(specDir, '.runtime')
319
+ mkdirSync(runtimeDir, { recursive: true })
320
+ outPath = join(runtimeDir, 'postcheck-result.json')
321
+ } else {
322
+ return null
323
+ }
324
+
325
+ writeFileSync(outPath, JSON.stringify(structured, null, 2) + '\n')
326
+ return outPath
327
+ } catch (e) {
328
+ console.warn(` ⚠️ postcheck-result.json 写入失败: ${e.message}`)
329
+ return null
330
+ }
331
+ }
332
+
201
333
  /**
202
334
  * 打印 post-check 结果到 stdout
203
335
  */