sillyspec 3.28.0 → 3.28.2

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/archive-delta.js +419 -0
  3. package/src/design-facts.js +354 -0
  4. package/src/docs-check.js +79 -8
  5. package/src/doctor-diagnostics.js +11 -2
  6. package/src/endpoint-baseline.js +109 -0
  7. package/src/git-helper.js +31 -0
  8. package/src/index.js +213 -4
  9. package/src/modules.js +41 -12
  10. package/src/progress/change-registry.js +20 -0
  11. package/src/progress/consistency-doctor.js +124 -5
  12. package/src/progress/stage-machine.js +13 -2
  13. package/src/progress.js +3 -0
  14. package/src/quicklog.js +51 -0
  15. package/src/run/command.js +70 -1
  16. package/src/run/complete-handlers.js +53 -4
  17. package/src/run/complete.js +46 -1
  18. package/src/run/gates.js +333 -2
  19. package/src/run/prompt.js +70 -0
  20. package/src/run/scan-profile.js +98 -6
  21. package/src/run/shared.js +12 -1
  22. package/src/run/stage.js +7 -1
  23. package/src/scan-diff.js +54 -1
  24. package/src/scan-facts.js +173 -0
  25. package/src/scan-postcheck.js +110 -3
  26. package/src/spec-sync.js +19 -2
  27. package/src/stage-review.js +62 -3
  28. package/src/stages/archive.js +5 -5
  29. package/src/stages/brainstorm.js +10 -10
  30. package/src/stages/execute.js +15 -13
  31. package/src/stages/plan-postcheck.js +194 -1
  32. package/src/stages/plan.js +6 -2
  33. package/src/stages/scan.js +44 -102
  34. package/src/stages/verify.js +9 -7
  35. package/src/taskcard.js +8 -1
  36. package/src/verify-postcheck.js +610 -2
  37. package/src/verify-probes.js +89 -10
  38. package/src/workflow.js +30 -0
  39. package/templates/agents-instruction.md +1 -1
  40. package/templates/prompts/taskcard-rules.md +5 -0
  41. package/templates/workflows/scan-docs.yaml +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.28.0",
3
+ "version": "3.28.2",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -0,0 +1,419 @@
1
+ /**
2
+ * archive-delta — 归档变更 delta 聚合器(Before/Delta/After 三段式 delta.md 纯函数)
3
+ * (change: 2026-09-07-ir-stage-p3d,task-01,D-001@v1/D-002@v1/D-003@v1)
4
+ *
5
+ * 职责(设计文档「总体方案」的五源采集 fail-soft + 三段式渲染,CLI/archive 接线在 task-02):
6
+ * 1. collectDeltaSources({ changeDir, specRoot, project, runtimeRoot, cwd }):五源采集——
7
+ * ①reconcile:runtimeRoot/verify-runs/<ts>/reconcile-result.json 按 ts 倒序、change 字段
8
+ * 过滤后取最新(R-01 跨变更串台防护:不同变更的 run 混存于同一 verify-runs/);
9
+ * 全无命中 → runtimeRoot/apply-pathspec-<change>.txt 逐行交付清单兜底(D-003,reconcile=null)。
10
+ * ②verifyFacts:changeDir/verify-facts.json(JSON.parse fail-soft → null)。
11
+ * ③moduleMap:复用 design-facts loadModuleMap;归属推导复用 deriveActualModules(P3d 导出)。
12
+ * ④decisions:changeDir/decisions.md 文本 → parseDecisionDomains(null-safe,文件缺 → null)。
13
+ * ⑤endpointBaseline + currentEndpoints(change: 2026-09-07-endpoint-baseline,task-03):
14
+ * 基线 runtimeRoot/endpoint-baselines/<change>.json(readJsonSafe fail-soft → null);
15
+ * 现算 scanBackendEndpoints(scanRoot)——capture 同口径但不落盘(scanRoot=cwd 显式传入,
16
+ * 缺省 dirname(specRoot):本地模式 specRoot=<repoRoot>/.sillyspec → 即主仓根),异常 → null。
17
+ * 2. buildDeltaReport(...):三段式 md(D-002)——Before(受影响模块 map 注册摘要 + 声明域并集)/
18
+ * Delta(交付文件×模块归属表 + missing/undeclared 差集附注 + 决策清单 + 探针 metrics 摘要)/
19
+ * After(module-impact.md「更新结果」表引用 + scan 刷新建议 + 端点基线提示节)。
20
+ * 缺源逐段降级注记(「(无 X:原因)」),不因缺源失败(兼容策略:存量变更仍生成)。
21
+ *
22
+ * 端点基线提示节(2026-09-07-endpoint-baseline Wave2):门控沿用 probe5 backendEndpoints>0
23
+ * (Gap-4:无端点变更不收噪音注记,条件不动防断言面扩大),节标题沿用「### 端点基线提示」;
24
+ * 节内三态——基线+现算可得 → diffEndpointSets 增删表(added/removed 各自格行,changed 不配对
25
+ * 天然呈独立行;空 → 「无增删」一行);基线缺失 → 「无基线(变更未拍 baseline)」降级注记
26
+ * (替代 P3d 旧「独立立项」提示行);基线在但现算不可得 → 现算失败降级注记。
27
+ *
28
+ * 依赖方向(单向防环):archive-delta → design-facts / modules / endpoint-baseline /
29
+ * endpoint-extractor,被依赖者均不反向 import 本模块。
30
+ */
31
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
32
+ import { basename, dirname, join } from 'node:path'
33
+ import { parseDecisionDomains, loadModuleMap, deriveActualModules } from './design-facts.js'
34
+ import { parseModuleMapSimple } from './modules.js'
35
+ import { scanBackendEndpoints } from './endpoint-extractor.js'
36
+ import { diffEndpointSets } from './endpoint-baseline.js'
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // 小工具(全部 fail-soft:读不到/解析失败 → null / [],不抛出)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** 读 JSON 文件 → 对象;不存在/解析失败 → null */
43
+ function readJsonSafe(p) {
44
+ if (!p || !existsSync(p)) return null
45
+ try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null }
46
+ }
47
+
48
+ /** 读文本文件 → 字符串;不存在/读失败 → null */
49
+ function readTextSafe(p) {
50
+ if (!p || !existsSync(p)) return null
51
+ try { return readFileSync(p, 'utf8') } catch { return null }
52
+ }
53
+
54
+ /** verify-runs/ 下 ts 形目录名(YYYYMMDDHHMMSS 纯数字)倒序列表 */
55
+ function listRunTsDescending(runsDir) {
56
+ try {
57
+ return readdirSync(runsDir).filter(n => /^\d+$/.test(n)).sort().reverse()
58
+ } catch { return [] }
59
+ }
60
+
61
+ /**
62
+ * 单文件 × prefixPairs 前缀匹配 → 全部命中模块 id(design-facts deriveActualModules 同款
63
+ * 语义:全等或目录前缀——但取全部命中而非首命中即断,供归属表展示一文件多模块的真实形态)。
64
+ * @returns {string[]} 命中模块 id(prefixPairs 顺序去重)
65
+ */
66
+ function matchFileModules(file, prefixPairs) {
67
+ const t = String(file || '').replace(/\\/g, '/').replace(/\/+$/, '')
68
+ const ids = []
69
+ if (!t) return ids
70
+ for (const { id, path: p } of prefixPairs) {
71
+ if ((t === p || t.startsWith(p + '/')) && !ids.includes(id)) ids.push(id)
72
+ }
73
+ return ids
74
+ }
75
+
76
+ /** 读 _module-map.yaml → Map<id, status>(Before 注册摘要的 status 列;loadModuleMap 只回
77
+ * ids/prefixPairs 不含标量字段,此处复用 modules.js canonical 解析再读一遍——只读不改) */
78
+ function loadModuleStatuses(specRoot, project) {
79
+ if (!specRoot || !project) return null
80
+ const text = readTextSafe(join(specRoot, 'docs', project, 'modules', '_module-map.yaml'))
81
+ if (text === null) return null
82
+ let parsed = {}
83
+ try { parsed = parseModuleMapSimple(text) || {} } catch { return null }
84
+ const st = new Map()
85
+ for (const [id, m] of Object.entries(parsed)) st.set(id, (m && m.status) || '未标注')
86
+ return st
87
+ }
88
+
89
+ /** 提取 module-impact.md「## 更新结果」小节正文(不含标题行本身,到下一 `## ` 标题或文末;
90
+ * 引用正文嵌入 After 段 H3 小节下,原 H2 标题混入会破坏标题层级);无文件/无小节 → null */
91
+ function extractUpdateResultSection(mdPath) {
92
+ const text = readTextSafe(mdPath)
93
+ if (text === null) return null
94
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
95
+ const start = lines.findIndex(l => /^##\s+更新结果/.test(l))
96
+ if (start < 0) return null
97
+ const out = []
98
+ for (let i = start + 1; i < lines.length; i++) {
99
+ if (/^##\s+/.test(lines[i])) break
100
+ out.push(lines[i])
101
+ }
102
+ const body = out.join('\n').trim()
103
+ return body || null
104
+ }
105
+
106
+ /** verifyFacts 探针 metrics 一行渲染:`key=value` 以「 / 」连接;probe5 形态随演进(P3b
107
+ * fail-soft 原则)——metrics 缺失/空时整行降级注记而非报错 */
108
+ function renderProbeLine(name, probe) {
109
+ const metrics = (probe && probe.metrics) || {}
110
+ const kv = Object.entries(metrics).map(([k, v]) => `${k}=${String(v)}`)
111
+ return kv.length > 0 ? `- ${name}:${kv.join(' / ')}` : `- ${name}:(无 metrics)`
112
+ }
113
+
114
+ const probe5BackendEndpoints = (verifyFacts) => {
115
+ const m = (verifyFacts && verifyFacts.probes && verifyFacts.probes.probe5
116
+ && verifyFacts.probes.probe5.metrics) || {}
117
+ return typeof m.backendEndpoints === 'number' ? m.backendEndpoints : null
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // 五源采集(Gap-2 兜底:deliverables 仅在 reconcile 全无命中时填充;
122
+ // ⑤ endpointBaseline/currentEndpoints 为 2026-09-07-endpoint-baseline task-03 第五源)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * 纯函数:聚合变更 delta 的五源数据(fail-soft,任一/全部缺源不抛出)。
127
+ * @param {{ changeDir?: string, specRoot?: string, project?: string, runtimeRoot?: string, cwd?: string }} opts
128
+ * changeDir=变更目录(basename 即 changeName,active changes/ 或 archive/ 下均可);
129
+ * specRoot=规范根(.sillyspec);project=子项目名;runtimeRoot=运行时根(.sillyspec/.runtime);
130
+ * cwd=⑤现算扫描根(缺省 dirname(specRoot)——本地模式即主仓根,capture 同口径锚定)
131
+ * @returns {{ reconcile: object|null, verifyFacts: object|null, moduleMap: object|null,
132
+ * decisions: Array<{id, domains}>|null, deliverables: string[],
133
+ * endpointBaseline: object|null, currentEndpoints: Array<{method,path,source,line}>|null }}
134
+ * reconcile=按 change 过滤取最新的对账产物;verifyFacts=探针底稿;moduleMap=loadModuleMap
135
+ * 返回形态;decisions=当前版本 D 条目模块域(decisions.md 缺 → null);deliverables=
136
+ * reconcile 全无命中时的 apply-pathspec 兜底清单(否则 []);endpointBaseline=基线文件
137
+ * payload(缺/损坏 → null);currentEndpoints=scanBackendEndpoints 现算端点集(不落盘,
138
+ * 扫描异常/无扫描根 → null)
139
+ */
140
+ export function collectDeltaSources({ changeDir, specRoot, project, runtimeRoot, cwd } = {}) {
141
+ const change = changeDir ? basename(changeDir) : ''
142
+
143
+ // ① reconcile(R-01:先按 change 字段过滤再取最新,跨变更 run 不串台)
144
+ let reconcile = null
145
+ if (runtimeRoot && change) {
146
+ for (const ts of listRunTsDescending(join(runtimeRoot, 'verify-runs'))) {
147
+ const data = readJsonSafe(join(runtimeRoot, 'verify-runs', ts, 'reconcile-result.json'))
148
+ if (data && data.change === change) { reconcile = data; break }
149
+ }
150
+ }
151
+
152
+ // 兜底(Gap-2/D-003):全无 reconcile → apply-pathspec-<change>.txt 逐行路径作交付清单
153
+ let deliverables = []
154
+ if (!reconcile && runtimeRoot && change) {
155
+ const text = readTextSafe(join(runtimeRoot, `apply-pathspec-${change}.txt`))
156
+ if (text !== null) {
157
+ deliverables = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
158
+ }
159
+ }
160
+
161
+ // ② verifyFacts(JSON.parse fail-soft null)
162
+ const verifyFacts = changeDir
163
+ ? readJsonSafe(join(changeDir, 'verify-facts.json'))
164
+ : null
165
+
166
+ // ③ moduleMap(design-facts 单一真相源)
167
+ const moduleMap = loadModuleMap(specRoot, project)
168
+
169
+ // ④ decisions(null-safe:文件缺 → null;存在但无条目 → [])
170
+ let decisions = null
171
+ if (changeDir) {
172
+ const text = readTextSafe(join(changeDir, 'decisions.md'))
173
+ if (text !== null) decisions = parseDecisionDomains(text)
174
+ }
175
+
176
+ // ⑤ endpointBaseline(2026-09-07-endpoint-baseline task-03):基线 JSON fail-soft → null
177
+ const endpointBaseline = (runtimeRoot && change)
178
+ ? readJsonSafe(join(runtimeRoot, 'endpoint-baselines', `${change}.json`))
179
+ : null
180
+
181
+ // ⑤ currentEndpoints:归档时现算(capture 同口径 scanBackendEndpoints,不落盘)。
182
+ // 扫描根三择一:显式 cwd > dirname(specRoot)(本地模式 specRoot=<repoRoot>/.sillyspec →
183
+ // 主仓根)> 无(→ null);扫描异常 fail-soft → null(报告端降级注记,不阻断)。
184
+ const scanRoot = cwd || (specRoot ? dirname(specRoot) : null)
185
+ let currentEndpoints = null
186
+ if (scanRoot) {
187
+ try { currentEndpoints = scanBackendEndpoints(scanRoot) } catch { currentEndpoints = null }
188
+ }
189
+
190
+ return { reconcile, verifyFacts, moduleMap, decisions, deliverables, endpointBaseline, currentEndpoints }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // 三段式渲染(Before/Delta/After,缺源逐段降级注记)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * 纯函数:生成 delta.md 全文(三段式,D-002;不落盘——写盘/幂等由 CLI task-02 负责)。
199
+ * @param {{ changeDir?: string, specRoot?: string, project?: string, runtimeRoot?: string, cwd?: string, now?: string }} opts
200
+ * 前五项同 collectDeltaSources(cwd=⑤现算扫描根,缺省 dirname(specRoot));now=生成时刻
201
+ * ISO(缺省当前时刻,测试可注入)
202
+ * @returns {string} delta.md 全文(LF 行尾,单一尾换行)
203
+ */
204
+ export function buildDeltaReport({ changeDir, specRoot, project, runtimeRoot, cwd, now } = {}) {
205
+ const change = changeDir ? basename(changeDir) : '<变更名>'
206
+ const {
207
+ reconcile, verifyFacts, moduleMap, decisions, deliverables,
208
+ endpointBaseline, currentEndpoints,
209
+ } = collectDeltaSources({ changeDir, specRoot, project, runtimeRoot, cwd })
210
+ const statuses = loadModuleStatuses(specRoot, project)
211
+
212
+ // 差集路径归一(missing={task,path,isNew?} / undeclared={path,suspectTask?|string}——
213
+ // 生产端 verify-postcheck.js :2087/:2088 钉死的形态;字符串形态按存量容忍)
214
+ const undeclaredPaths = (reconcile && Array.isArray(reconcile.undeclared))
215
+ ? reconcile.undeclared.map(u => (u && typeof u === 'object' ? u.path : u)).filter(Boolean)
216
+ : []
217
+ const affectedFiles = reconcile
218
+ ? [...(Array.isArray(reconcile.matched) ? reconcile.matched : []), ...undeclaredPaths]
219
+ : deliverables
220
+ const deliveredFiles = reconcile
221
+ ? (Array.isArray(reconcile.matched) ? reconcile.matched : [])
222
+ : deliverables
223
+
224
+ // 受影响模块集 + 未匹配文件(R-02:未命中任何模块 paths 的文件显式列出不猜)
225
+ const affectedModules = moduleMap ? [...deriveActualModules(affectedFiles, moduleMap.prefixPairs)] : null
226
+ const unmatchedFiles = moduleMap
227
+ ? affectedFiles.filter(f => matchFileModules(f, moduleMap.prefixPairs).length === 0)
228
+ : [...affectedFiles]
229
+
230
+ // 声明域并集(NEW: 前缀 → 「(新模块)」标记;decisions.md 的模块域可选字段)
231
+ const declaredDomains = []
232
+ if (Array.isArray(decisions)) {
233
+ for (const { domains } of decisions) {
234
+ for (const d of domains || []) if (!declaredDomains.includes(d)) declaredDomains.push(d)
235
+ }
236
+ }
237
+
238
+ const L = []
239
+
240
+ // ---- frontmatter(generated_at + 来源四项命中状态)----
241
+ L.push('---')
242
+ L.push(`generated_at: ${String(now || '').trim() || new Date().toISOString()}`)
243
+ L.push(`sources_reconcile: ${reconcile
244
+ ? `命中(ran_at=${reconcile.ran_at || '?'},verify-runs 按 change 过滤取最新)`
245
+ : `未命中(apply-pathspec 兜底,${deliverables.length} 项)`}`)
246
+ L.push(`sources_verify_facts: ${verifyFacts ? '命中' : '缺失'}`)
247
+ L.push(`sources_module_map: ${moduleMap ? '命中' : '缺失'}`)
248
+ L.push(`sources_decisions: ${decisions === null ? '缺失' : '命中'}`)
249
+ L.push('---')
250
+ L.push('')
251
+ L.push(`# 变更 Delta — ${change}`)
252
+ L.push('')
253
+
254
+ // ---- Before(变更前状态)----
255
+ L.push('## Before(变更前状态)')
256
+ L.push('')
257
+ L.push('### 受影响模块(module-map 注册摘要)')
258
+ L.push('')
259
+ if (!moduleMap) {
260
+ L.push(`(无 module-map:docs/${project || '?'}/modules/_module-map.yaml 不存在或不可解析——模块归属推导跳过,交付文件全部按未匹配列出)`)
261
+ L.push('')
262
+ } else if (affectedModules.length === 0) {
263
+ L.push('(受影响模块集为空——交付/差集文件均未命中任何模块 paths)')
264
+ L.push('')
265
+ } else {
266
+ L.push('| 模块 | status | paths+core_files 条目数 |')
267
+ L.push('|---|---|---|')
268
+ for (const id of affectedModules) {
269
+ const status = (statuses && statuses.get(id)) || '—'
270
+ const count = moduleMap.prefixPairs.filter(p => p.id === id).length
271
+ L.push(`| ${id} | ${status} | ${count} |`)
272
+ }
273
+ L.push('')
274
+ }
275
+ if (unmatchedFiles.length > 0) {
276
+ L.push(`未匹配文件(不归属任何模块 paths,人工裁量):${unmatchedFiles.join('、')}`)
277
+ L.push('')
278
+ }
279
+ L.push('### 声明域并集(decisions.md 模块域)')
280
+ L.push('')
281
+ if (decisions === null) {
282
+ L.push(`(无 decisions.md:${join(changeDir || '?', 'decisions.md')} 不存在——声明域缺位)`)
283
+ } else if (declaredDomains.length === 0) {
284
+ L.push(`(decisions.md 解析出 ${decisions.length} 条当前版本决策,均未填写模块域——声明域为空)`)
285
+ } else {
286
+ L.push(declaredDomains.map(d => (d.startsWith('NEW:') ? `${d}(新模块)` : d)).join('、'))
287
+ }
288
+ L.push('')
289
+
290
+ // ---- Delta(做了什么)----
291
+ L.push('## Delta(做了什么)')
292
+ L.push('')
293
+ L.push('### 交付文件 × 模块归属')
294
+ L.push('')
295
+ if (!reconcile && deliveredFiles.length === 0) {
296
+ L.push(`(无 reconcile 产物且无 apply-pathspec-${change}.txt——交付文件清单不可得,本节缺位)`)
297
+ L.push('')
298
+ } else {
299
+ if (!reconcile) {
300
+ L.push(`(无 reconcile 产物(变更先于 P3a 或 verify 未落盘),清单取 apply-pathspec——文件级,无 missing/undeclared 差集)`)
301
+ L.push('')
302
+ }
303
+ L.push('| 交付文件 | 模块归属 |')
304
+ L.push('|---|---|')
305
+ for (const f of deliveredFiles) {
306
+ const ids = moduleMap ? matchFileModules(f, moduleMap.prefixPairs) : []
307
+ const cell = !moduleMap ? '—(无 module-map)' : (ids.length > 0 ? ids.join('、') : '—(未匹配)')
308
+ L.push(`| ${f} | ${cell} |`)
309
+ }
310
+ L.push('')
311
+ }
312
+ if (reconcile) {
313
+ L.push(`- 对账基线:status=${reconcile.status || '?'} / form=${reconcile.form || '?'} / sources=${(Array.isArray(reconcile.sources) ? reconcile.sources : []).join('、') || '?'}`)
314
+ const missing = Array.isArray(reconcile.missing) ? reconcile.missing : []
315
+ if (missing.length > 0) {
316
+ L.push(`- missing(声明未落盘,${missing.length} 项):${missing.map(m => (m && typeof m === 'object'
317
+ ? `${m.task}:${m.path}${m.isNew ? '(NEW: 声明,路径已剥前缀)' : ''}`
318
+ : String(m))).join(';')}`)
319
+ } else {
320
+ L.push('- missing(声明未落盘):无')
321
+ }
322
+ if (undeclaredPaths.length > 0) {
323
+ const items = (Array.isArray(reconcile.undeclared) ? reconcile.undeclared : [])
324
+ .map(u => (u && typeof u === 'object' ? `${u.path}${u.suspectTask ? `(疑似归因 ${u.suspectTask})` : ''}` : String(u)))
325
+ L.push(`- undeclared(落盘未声明,${undeclaredPaths.length} 项):${items.join(';')}`)
326
+ } else {
327
+ L.push('- undeclared(落盘未声明):无')
328
+ }
329
+ L.push('')
330
+ }
331
+ L.push('### 决策清单(id × 模块域)')
332
+ L.push('')
333
+ if (decisions === null) {
334
+ L.push(`(无 decisions.md:${join(changeDir || '?', 'decisions.md')} 不存在——决策清单缺位)`)
335
+ L.push('')
336
+ } else if (decisions.length === 0) {
337
+ L.push('(decisions.md 无当前版本 D 条目——决策清单为空)')
338
+ L.push('')
339
+ } else {
340
+ L.push('| 决策 | 模块域 |')
341
+ L.push('|---|---|')
342
+ for (const { id, domains } of decisions) {
343
+ L.push(`| ${id} | ${(domains || []).join('、') || '(未填写)'} |`)
344
+ }
345
+ L.push('')
346
+ }
347
+ L.push('### 探针 metrics 摘要(验证结论表的机器半边)')
348
+ L.push('')
349
+ if (!verifyFacts || !verifyFacts.probes) {
350
+ L.push(`(无 verify-facts.json:${join(changeDir || '?', 'verify-facts.json')} 不存在或不可解析——探针指标快照缺位,可跑 sillyspec verify-probes --change ${change} --init 补)`)
351
+ } else {
352
+ if (verifyFacts.generatedAt) L.push(`快照时刻:${verifyFacts.generatedAt}`)
353
+ const order = ['probe1', 'probe3', 'probe5', 'probe6']
354
+ const names = [...order, ...Object.keys(verifyFacts.probes).filter(n => !order.includes(n))]
355
+ for (const n of names) L.push(renderProbeLine(n, verifyFacts.probes[n]))
356
+ }
357
+ L.push('')
358
+
359
+ // ---- After(建议动作)----
360
+ L.push('## After(建议动作)')
361
+ L.push('')
362
+ L.push('### 模块卡同步状态(module-impact.md「更新结果」)')
363
+ L.push('')
364
+ const updateResult = changeDir ? extractUpdateResultSection(join(changeDir, 'module-impact.md')) : null
365
+ if (updateResult) {
366
+ L.push('(引自变更目录 module-impact.md,人工维护为准)')
367
+ L.push('')
368
+ L.push(updateResult)
369
+ } else if (changeDir && existsSync(join(changeDir, 'module-impact.md'))) {
370
+ L.push('(module-impact.md 存在但无「## 更新结果」小节——模块卡同步状态引用缺位)')
371
+ } else {
372
+ L.push(`(无 module-impact.md:${join(changeDir || '?', 'module-impact.md')} 不存在——模块卡同步状态引用缺位)`)
373
+ }
374
+ L.push('')
375
+ L.push('### scan 刷新建议')
376
+ L.push('')
377
+ if (affectedModules && affectedModules.length > 0) {
378
+ L.push(`- \`sillyspec scan facts\` 下次刷新重点关注:${affectedModules.join('、')}(共 ${affectedModules.length} 个模块)`)
379
+ } else if (!moduleMap) {
380
+ L.push(`- (无 module-map:docs/${project || '?'}/modules/_module-map.yaml 不存在——受影响模块无法推导,建议先跑 sillyspec modules 同步补索引)`)
381
+ } else {
382
+ L.push('- (受影响模块集为空——无重点刷新面)')
383
+ }
384
+ if (unmatchedFiles.length > 0) {
385
+ L.push(`- 未匹配文件补录提示:以下文件未命中任何模块 paths——建议补录 _module-map.yaml(新文件)或核对归属(人工裁量):${unmatchedFiles.join('、')}`)
386
+ }
387
+ L.push('')
388
+ // 端点基线提示(2026-09-07-endpoint-baseline task-03):门控沿用 probe5 backendEndpoints>0
389
+ // 不动(Gap-4:无端点变更不收噪音注记),节标题沿用防断言面扩大;节内三态(基线×现算 →
390
+ // diffEndpointSets 增删表 / 基线缺失 → 降级注记 / 现算不可得 → 降级注记)。
391
+ const backendEndpoints = probe5BackendEndpoints(verifyFacts)
392
+ if (backendEndpoints !== null && backendEndpoints > 0) {
393
+ L.push('### 端点基线提示')
394
+ L.push('')
395
+ const baselineEps = (endpointBaseline && Array.isArray(endpointBaseline.endpoints))
396
+ ? endpointBaseline.endpoints
397
+ : null
398
+ if (!baselineEps) {
399
+ L.push(`- 无基线(变更未拍 baseline):${join(runtimeRoot || '?', 'endpoint-baselines', `${change}.json`)} 不存在或不可解析——端点增删不可比(backendEndpoints=${backendEndpoints}(>0))`)
400
+ } else {
401
+ const endpointDiff = diffEndpointSets(baselineEps, currentEndpoints)
402
+ if (!endpointDiff) {
403
+ L.push(`- 基线已拍(${baselineEps.length} 端点)但现算不可得(scanBackendEndpoints 扫描失败)——端点增删不可比`)
404
+ } else if (endpointDiff.added.length === 0 && endpointDiff.removed.length === 0) {
405
+ L.push(`- 端点增删:无增删(基线 ${baselineEps.length} 端点 × 现算 ${currentEndpoints.length} 端点,method+归一 path 全一致)`)
406
+ } else {
407
+ L.push(`- 端点 diff:基线 ${baselineEps.length} 端点 × 现算 ${currentEndpoints.length} 端点(method+归一 path 集合运算;changed 不配对,天然呈独立行)`)
408
+ L.push('')
409
+ L.push('| 增删 | method | path | source |')
410
+ L.push('|---|---|---|---|')
411
+ for (const e of endpointDiff.added) L.push(`| + 新增 | ${e.method} | ${e.path} | ${e.source} |`)
412
+ for (const e of endpointDiff.removed) L.push(`| - 删除 | ${e.method} | ${e.path} | ${e.source} |`)
413
+ }
414
+ }
415
+ L.push('')
416
+ }
417
+
418
+ return L.join('\n')
419
+ }