sillyspec 3.32.1 → 3.32.3

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.
@@ -0,0 +1,918 @@
1
+ /**
2
+ * knowledge-graph.js — 知识图谱引擎(2026-10-08-knowledge-graph)
3
+ *
4
+ * 设计锚:.sillyspec/changes/2026-10-08-knowledge-graph/design.md(本体表=核心契约)。
5
+ * 原则(D-003 墓碑切割):md/yaml 是唯一真相源,图是 buildKnowledgeGraph() 解析时内存派生
6
+ * ——不落盘任何缓存文件、无刷新机制;只读不改任何知识文件。
7
+ *
8
+ * 本体(10 节点 / 16 边 / 三档强度):
9
+ * 节点:entry | decision | fr | module | file | test | change | ql | doc | project
10
+ * 边(强度 strong=可传播 / medium=仅展示 / weak=仅展示与自检;可传递仅 supersedes、module-dep):
11
+ * anchors / supersedes / from-change / belongs-module / module-dep / module-files /
12
+ * deliverables / change-modules / test-binding / describes / changelog-of /
13
+ * changelog-entry / doc-refs / scan-refs / route / entry-link(预留,本模块不采集)
14
+ *
15
+ * 解析纪律:字段契约复用既有 parser import 不复制——decisions 走 knowledge-match
16
+ * parseDecisionEntries(去重键 file+id+change,跨变更同号不折叠)、INDEX 路由走
17
+ * parseKnowledgeIndex、模块图走 modules.parseModuleMapSimple。fr/changelog/文档引用为本
18
+ * 模块域内解析(沿 fr-index renderFrLines / module-changelog 行形态的机械契约)。
19
+ */
20
+
21
+ import { existsSync, readFileSync, readdirSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { parseKnowledgeIndex, parseDecisionEntries, anchorFilePaths } from './knowledge-match.js'
24
+ import { parseModuleMapSimple } from './modules.js'
25
+
26
+ // ── 本体常量(design.md 总体方案 §1 的代码化) ──
27
+
28
+ export const EDGE_STRENGTH = {
29
+ 'anchors': 'strong', 'supersedes': 'strong', 'from-change': 'strong',
30
+ 'belongs-module': 'strong', 'module-dep': 'strong', 'module-files': 'strong',
31
+ 'deliverables': 'strong', 'change-modules': 'strong', 'test-binding': 'strong',
32
+ 'describes': 'strong', 'changelog-of': 'strong', 'changelog-entry': 'strong',
33
+ 'doc-refs': 'medium', 'scan-refs': 'medium',
34
+ 'route': 'weak', 'entry-link': 'weak',
35
+ }
36
+ // 可传递边(推理规则:其余强边一跳、传递边沿链)
37
+ export const TRANSMISSIVE_EDGES = new Set(['supersedes', 'module-dep'])
38
+
39
+ const ANCHOR_RE = /[\w./-]+\.(?:mjs|cjs|jsx|tsx|js|ts|py|go|java|rs|yaml|yml|json|md)/g
40
+
41
+ /** POSIX 归一(Windows 反斜杠/尾斜杠)。 */
42
+ const posix = (p) => String(p || '').trim().replace(/\\/g, '/').replace(/\/+$/, '')
43
+
44
+ /** 文本中的代码路径 token 提取(doc-refs/scan-refs 采集器——中强度引用边)。
45
+ * 与 knowledge-match anchorFilePaths 同款剥法,但面向正文全量文本;cap 限边数防噪。
46
+ * 产物守卫(2026-10-09-graph-docrefs-noise):剥 :line/:sym 后缀后的空串/纯数字丢弃
47
+ * (`413.js` 形 token 剥出数字段、`.js` 裸扩展段——解析噪声不建边)。 */
48
+ function extractFilePaths(text, cap = 15) {
49
+ const raw = String(text || '').replace(/\r\n/g, '\n').match(ANCHOR_RE) || []
50
+ const out = []
51
+ for (const t of raw) {
52
+ const stripped = t.replace(/:(?:\d+(?:-\d+)?|[A-Za-z_$][A-Za-z0-9_$]*)$/, '').replace(/^\/+/, '')
53
+ if (!stripped || /^\d+$/.test(stripped) || stripped.startsWith('.')) continue
54
+ const p = posix(stripped)
55
+ if (p && !out.includes(p)) out.push(p)
56
+ if (out.length >= cap) break
57
+ }
58
+ return out
59
+ }
60
+
61
+ // ── 构建器 ──
62
+
63
+ export function buildKnowledgeGraph(specRoot) {
64
+ const nodes = new Map()
65
+ const edges = []
66
+ const byEdgeType = new Map()
67
+
68
+ const addNode = (id, type, label, attrs = {}) => {
69
+ const key = String(id)
70
+ if (!nodes.has(key)) nodes.set(key, { id: key, type, label: label || key, attrs })
71
+ return nodes.get(key)
72
+ }
73
+ const addEdge = (s, t, type, note = '') => {
74
+ if (!nodes.has(String(s)) || !nodes.has(String(t))) return false
75
+ if (s === t) return false
76
+ const key = `${s}|${t}|${type}`
77
+ if (byEdgeType.get(type)?.some((e) => `${e.s}|${e.t}|${e.type}` === key)) return false
78
+ const e = { s: String(s), t: String(t), type, note: String(note || '') }
79
+ edges.push(e)
80
+ if (!byEdgeType.has(type)) byEdgeType.set(type, [])
81
+ byEdgeType.get(type).push(e)
82
+ return true
83
+ }
84
+
85
+ const knowledgeDir = join(specRoot, 'knowledge')
86
+ const docsDir = join(specRoot, 'docs')
87
+ const changesDir = join(specRoot, 'changes')
88
+ const archiveRoot = join(changesDir, 'archive')
89
+
90
+ // ── ① INDEX 手册条目(弱边 route;INDEX 行不建 keyword 节点——防图爆炸,路由行由
91
+ // INDEX 文档节点挂接) ──
92
+ const indexEntries = existsSync(join(knowledgeDir, 'INDEX.md')) ? parseKnowledgeIndex(knowledgeDir) : []
93
+ let routeCount = 0
94
+ if (existsSync(join(knowledgeDir, 'INDEX.md'))) {
95
+ addNode('doc:INDEX', 'doc', 'knowledge/INDEX.md', { kind: 'manual-index' })
96
+ for (const ent of indexEntries) {
97
+ if (/^(decisions|fr|proposed)\//.test(posix(ent.file))) continue // 决策/FR 有自己的节点面
98
+ const id = ent.anchor ? `entry:${ent.file}#${ent.anchor}` : `entry:${ent.file}`
99
+ addNode(id, 'entry', ent.display || ent.anchor || ent.file, { file: ent.file, keywords: ent.keywords })
100
+ if (addEdge('doc:INDEX', id, 'route', ent.keywords[0] || '')) routeCount++
101
+ }
102
+ }
103
+
104
+ // ── ② 决策库(parseDecisionEntries:去重键 file+id+change 跨变更同号不折叠) ──
105
+ const decisionHits = existsSync(join(knowledgeDir, 'INDEX.md')) ? parseDecisionEntries(knowledgeDir) : []
106
+ for (const h of decisionHits) {
107
+ const id = `decision:${h.file}#${h.id}${h.change ? `@${h.change}` : ''}`
108
+ addNode(id, 'decision', `${h.id} ${h.title || ''}`.trim(), {
109
+ // attrs 保留 parseDecisionEntries 全量字段——scopeRecall 构造 decisionHits 时零重解析
110
+ hit: h, status: h.status || '', domain: String(h.file || '').replace(/^decisions\//, '').replace(/\.md$/, ''),
111
+ })
112
+ for (const p of anchorFilePaths(h.anchor || '')) {
113
+ addNode(p, 'file', p.split('/').pop())
114
+ addEdge(id, p, 'anchors', String(h.anchor || '').slice(0, 40))
115
+ }
116
+ // 「文件:」字段(matchDecisionsByFiles 语义同源)
117
+ for (const p of h.files || []) {
118
+ addNode(p, 'file', p.split('/').pop())
119
+ addEdge(id, p, 'anchors', '文件:')
120
+ }
121
+ if (h.change) {
122
+ if (/^ql-/.test(h.change)) addNode(h.change, 'ql', h.change)
123
+ else addNode(h.change, 'change', h.change)
124
+ addEdge(id, h.change, 'from-change')
125
+ }
126
+ // supersedes:同域 D-id 解析(「supersedes:无(…)」等非 id 值被正则白名单滤掉;跨变更同号时取同变更优先,再退首个)
127
+ const supIds = String(h.supersedesText || '').split(/[,,、\s]+/).filter((s) => /^D-\d+@v\d+$/.test(s))
128
+ for (const sid of supIds) {
129
+ const target = decisionHits.find((x) => x.id === sid && x.file === h.file && (x.change || '') === (h.change || ''))
130
+ || decisionHits.find((x) => x.id === sid && x.file === h.file)
131
+ if (target) {
132
+ const tid = `decision:${target.file}#${target.id}${target.change ? `@${target.change}` : ''}`
133
+ addEdge(id, tid, 'supersedes')
134
+ }
135
+ }
136
+ }
137
+
138
+ // ── ③ FR 索引(fr/<域>.md:变更/状态/superseded_by/取代链/测试绑定子块) ──
139
+ const frDir = join(knowledgeDir, 'fr')
140
+ const frRows = []
141
+ if (existsSync(frDir)) {
142
+ for (const f of readdirSync(frDir)) {
143
+ if (!f.endsWith('.md')) continue
144
+ const domain = f.replace(/\.md$/, '')
145
+ const content = readFileSync(join(frDir, f), 'utf8').replace(/\r\n/g, '\n')
146
+ let cur = null
147
+ const flush = () => { if (cur) frRows.push(cur); cur = null }
148
+ for (const line of content.split('\n')) {
149
+ const h = line.match(/^##\s+(FR-[A-Za-z0-9-]+-\d+)\s*(.*)$/)
150
+ if (h) { flush(); cur = { domain, id: h[1], title: h[2].trim(), change: '', supersedes: [], supersededBy: '', tests: [] }; continue }
151
+ if (!cur) continue
152
+ let m
153
+ if ((m = line.match(/^变更\s*[::]\s*(\S+)/))) cur.change = m[1]
154
+ else if ((m = line.match(/^superseded_by\s*[::]\s*(\S+)/))) cur.supersededBy = m[1]
155
+ else if ((m = line.match(/^取代链\s*[::]\s*(.*)$/))) {
156
+ // 「FR-x-001, FR-x-002 ← 本条目(变更 承接)」——箭头左侧为被取代 id 列表
157
+ cur.supersedes = (m[1].split('←')[0].match(/FR-[A-Za-z0-9-]+-\d+/g) || [])
158
+ } else if ((m = line.match(/^\s+tests\s*[::]\s*([^\s「]+)/))) {
159
+ // 剥 #锚后缀(tests: test/x.test.mjs#① ——# 起是用例名注记非路径)
160
+ cur.tests.push(posix(m[1].replace(/#.*$/, '')))
161
+ }
162
+ }
163
+ flush()
164
+ }
165
+ }
166
+ for (const r of frRows) {
167
+ addNode(r.id, 'fr', `${r.id} ${r.title}`.trim(), { domain: r.domain, status: r.supersededBy ? 'superseded' : 'active' })
168
+ if (r.domain) { addNode(r.domain, 'module', r.domain, { fromFrDomain: true }); addEdge(r.id, r.domain, 'belongs-module') }
169
+ if (r.change) { addNode(r.change, 'change', r.change); addEdge(r.id, r.change, 'from-change') }
170
+ for (const old of r.supersedes) {
171
+ if (frRows.some((x) => x.id === old)) addEdge(r.id, old, 'supersedes', '取代链')
172
+ }
173
+ if (r.supersededBy && frRows.some((x) => x.id === r.supersededBy) && !byEdgeType.get('supersedes')?.some((e) => e.t === r.id)) {
174
+ addEdge(r.supersededBy, r.id, 'supersedes', 'superseded_by 反向补链')
175
+ }
176
+ for (const t of r.tests.slice(0, 3)) { addNode(t, 'test', t.split('/').pop()); addEdge(r.id, t, 'test-binding') }
177
+ }
178
+
179
+ // ── ④ 模块图 + 模块卡 + changelog(docs/<项目>/modules/) ──
180
+ if (existsSync(docsDir)) {
181
+ for (const proj of readdirSync(docsDir, { withFileTypes: true })) {
182
+ if (!proj.isDirectory()) continue
183
+ const mapDir = join(docsDir, proj.name, 'modules')
184
+ const mapPath = join(mapDir, '_module-map.yaml')
185
+ const projectNode = `project:${proj.name}`
186
+ addNode(projectNode, 'project', proj.name, {})
187
+ if (!existsSync(mapPath)) continue
188
+ const mods = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
189
+ // 模块 paths 扁平索引(change-modules 最长前缀派生用)
190
+ const modulePathIndex = []
191
+ for (const [modId, mod] of Object.entries(mods || {})) {
192
+ const mid = `module:${modId}`
193
+ addNode(mid, 'module', modId, { project: proj.name })
194
+ for (const p of (mod.paths || []).map(posix)) {
195
+ // glob 形态(docs/** 等)不建边不判存在——模式非文件(2026-10-09-graph-docrefs-noise)
196
+ if (/[?*[{]/.test(p)) continue
197
+ addNode(p, 'file', p.endsWith('/') ? p : p.split('/').pop(), { isDir: p.endsWith('/') })
198
+ addEdge(mid, p, 'module-files')
199
+ modulePathIndex.push([mid, p])
200
+ }
201
+ for (const d of (mod.depends_on || []).map(posix)) { addNode(`module:${d}`, 'module', d); addEdge(mid, `module:${d}`, 'module-dep') }
202
+ if (mod.doc) {
203
+ const cardId = `doc:card:${proj.name}/${modId}`
204
+ addNode(cardId, 'doc', mod.doc.split('/').pop(), { kind: 'module-card', project: proj.name })
205
+ addEdge(cardId, mid, 'describes', 'doc: 字段')
206
+ // doc: 字段相对 docs/<项目>/(值形如 modules/core.md),非相对 modules/ 目录
207
+ const cardPath = join(docsDir, proj.name, posix(mod.doc))
208
+ if (existsSync(cardPath)) {
209
+ for (const p of extractFilePaths(readFileSync(cardPath, 'utf8'), 15)) {
210
+ addNode(p, 'file', p.split('/').pop())
211
+ addEdge(cardId, p, 'doc-refs', '卡片正文路径')
212
+ }
213
+ }
214
+ }
215
+ const clPath = join(mapDir, `${modId}.changelog.md`)
216
+ if (existsSync(clPath)) {
217
+ const clId = `doc:chlog:${proj.name}/${modId}`
218
+ addNode(clId, 'doc', `${modId}.changelog.md`, { kind: 'module-changelog', project: proj.name })
219
+ addEdge(clId, mid, 'changelog-of')
220
+ for (const name of parseChangelogEntries(readFileSync(clPath, 'utf8'))) {
221
+ if (/^ql-/.test(name)) addNode(name, 'ql', name)
222
+ else addNode(name, 'change', name)
223
+ addEdge(clId, name, 'changelog-entry')
224
+ }
225
+ }
226
+ }
227
+ // 扫描文档(docs/<项目>/scan/*.md——中强度引用边,project 归属进节点属性非边)
228
+ const scanDir = join(docsDir, proj.name, 'scan')
229
+ if (existsSync(scanDir)) {
230
+ for (const f of readdirSync(scanDir)) {
231
+ if (!f.endsWith('.md')) continue
232
+ const sid = `doc:scan:${proj.name}/${f}`
233
+ addNode(sid, 'doc', `${proj.name}/scan/${f}`, { kind: 'scan', project: proj.name })
234
+ for (const p of extractFilePaths(readFileSync(join(scanDir, f), 'utf8'), 12)) {
235
+ addNode(p, 'file', p.split('/').pop())
236
+ addEdge(sid, p, 'scan-refs', '正文路径提取')
237
+ }
238
+ }
239
+ }
240
+ // ⑤ 变更交付面(deliverables 双源:design.md 交付表 ∪ change-patch.json files——D-005)
241
+ // + change-modules(deliverables ∩ 模块 paths 最长前缀派生)
242
+ if (existsSync(archiveRoot)) {
243
+ modulePathIndex.sort((a, b) => b[1].length - a[1].length)
244
+ const moduleOf = (p) => {
245
+ const v = posix(p)
246
+ for (const [mid, mp] of modulePathIndex) {
247
+ // 模块 paths 是目录/glob 语义:剥尾斜杠后按「相等 或 path/ 前缀」匹配
248
+ const stem = posix(mp).replace(/\/+$/, '')
249
+ if (v === stem || v.startsWith(stem + '/')) return mid
250
+ }
251
+ return null
252
+ }
253
+ for (const n of [...nodes.values()].filter((n) => n.type === 'change')) {
254
+ const dir = join(archiveRoot, n.id)
255
+ const addFile = (f) => {
256
+ // 剥反引号与 NEW: 前缀(fr-index deliverableFilesFromDesignText 同款契约——29.4% 厚道条目带壳)
257
+ // + 尾部中文括号注记(`test/x.test.mjs`(或同名)形态);glob 形态(docs/** 等)跳过——
258
+ // 模式非文件,存在性判定必假悬空(2026-10-09-graph-docrefs-noise)
259
+ const v = posix(String(f || '')
260
+ .replace(/^`+|`+$/g, '')
261
+ .replace(/^NEW:/, '')
262
+ .replace(/[((][^()()]*[))]\s*$/, ''))
263
+ if (!v || v.startsWith('.sillyspec/') || /[?*[{]/.test(v)) return
264
+ addNode(v, 'file', v.split('/').pop())
265
+ addEdge(n.id, v, 'deliverables')
266
+ const mid = moduleOf(v)
267
+ if (mid) { addNode(mid, 'module', mid.replace(/^module:/, '')); addEdge(n.id, mid, 'change-modules') }
268
+ }
269
+ const dp = join(dir, 'design.md')
270
+ if (existsSync(dp)) {
271
+ for (const line of readFileSync(dp, 'utf8').split(/\r?\n/)) {
272
+ const m = line.match(/^\|\s*(?:新增|修改|删除)\s*\|\s*(?:NEW:)?([^\s|]+)\s*\|/)
273
+ if (m) addFile(m[1])
274
+ }
275
+ }
276
+ const pp = join(dir, 'change-patch.json')
277
+ if (existsSync(pp)) {
278
+ try {
279
+ const j = JSON.parse(readFileSync(pp, 'utf8'))
280
+ for (const f of (Array.isArray(j?.files) ? j.files : [])) addFile(f)
281
+ } catch { /* 坏 JSON fail-soft */ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // ── ⑥ quicklog 测试绑定侧车(真源②:ql 锚 → tests) ──
289
+ const tbPath = join(specRoot, 'quicklog', 'test-bindings.json')
290
+ if (existsSync(tbPath)) {
291
+ try {
292
+ const rows = JSON.parse(readFileSync(tbPath, 'utf8')).rows || {}
293
+ for (const arr of Object.values(rows)) {
294
+ for (const r of (Array.isArray(arr) ? arr : [])) {
295
+ if (!r || r.status === 'superseded') continue
296
+ for (const t of (Array.isArray(r.tests) ? r.tests : []).slice(0, 3)) {
297
+ const tp = posix(t)
298
+ addNode(tp, 'test', tp.split('/').pop())
299
+ if (r.anchor) { addNode(r.anchor, 'ql', r.anchor); addEdge(r.anchor, tp, 'test-binding') }
300
+ }
301
+ }
302
+ }
303
+ } catch { /* 坏 JSON fail-soft */ }
304
+ }
305
+
306
+ const stats = {
307
+ nodeCount: nodes.size,
308
+ edgeCount: edges.length,
309
+ byNodeType: [...nodes.values()].reduce((a, n) => (a[n.type] = (a[n.type] || 0) + 1, a), {}),
310
+ byEdgeType: edges.reduce((a, e) => (a[e.type] = (a[e.type] || 0) + 1, a), {}),
311
+ }
312
+ return { root: specRoot, nodes, edges, byEdgeType, stats }
313
+ }
314
+
315
+ /** changelog 三态行解析(表格行 ∪ 列表行 ∪ 标题态——backend 侧主形态;坏行跳过 fail-soft)。 */
316
+ export function parseChangelogEntries(content) {
317
+ const out = []
318
+ const seen = new Set()
319
+ // 名字归一(2026-10-09-graph-docrefs-noise):先剥尾括号段再验形态((P2)/(quick)类注记后缀;
320
+ // 旧实现前缀校验放行了「日期名(P2)」整串——归档名查存在性必假悬空。变更名合法字符集不含括号,
321
+ // 剥离优先安全;仅当剥离后匹配日期/ql 形态才接受,原样形态兜底防误剥。
322
+ const push = (raw) => {
323
+ const raw0 = String(raw || '').trim()
324
+ const stripped = raw0.replace(/[((][^()()]*[))]\s*$/, '').trim() || raw0
325
+ for (const n0 of [stripped, raw0]) {
326
+ const n = n0.replace(/[((]quick[))]/, '').trim()
327
+ if (!n || seen.has(n)) continue
328
+ if (/^ql-/.test(n) || /^\d{4}-\d{2}-\d{2}-/.test(n)) { seen.add(n); out.push(n); return }
329
+ }
330
+ }
331
+ for (const line of String(content || '').replace(/\r\n/g, '\n').split('\n')) {
332
+ let m
333
+ if ((m = line.match(/^\|\s*\d{4}-\d{2}-\d{2}\s*\|\s*([^|]+?)\s*\|/))) push(m[1]) // 表格行
334
+ else if ((m = line.match(/^-\s+(ql-[\w-]+|\d{4}-\d{2}-\d{2}-[\w.-]+)/))) push(m[1]) // 列表行
335
+ else if ((m = line.match(/^-\s+(.+?)\s*\|/))) push(m[1]) // 列表行带注记后缀(- 名(P2) | 摘要)
336
+ else if ((m = line.match(/^##\s+\d{4}-\d{2}-\d{2}\s*[—-]\s+.*[((](\d{4}-\d{2}-\d{2}-[\w.-]+)(?:\s+task-\d+)?[))]/))) push(m[1]) // 标题态(backend 主形态)
337
+ }
338
+ return out
339
+ }
340
+
341
+ // ═══════════════════════════════════════════════════════════════
342
+ // 查询面(task-02)——遍历类只在强边子集上扩展(推理规则,D-002)
343
+ // ═══════════════════════════════════════════════════════════════
344
+
345
+ /** 锚点模糊解析:节点 id 精确 → basename/label 尾段匹配 → FR/D 裸号匹配(CLI 人用入口)。 */
346
+ export function resolveGraphNode(graph, key) {
347
+ const k = String(key || '').trim()
348
+ if (!k) return null
349
+ if (graph.nodes.has(k)) return graph.nodes.get(k)
350
+ const bare = k.replace(/^.*\//, '')
351
+ for (const n of graph.nodes.values()) {
352
+ if (n.id.endsWith('/' + bare) || n.label === k) return n
353
+ }
354
+ // 裸号形态:D-001@v1 / D-001@v1@变更 / FR-域-NNN / 变更名 / ql-id
355
+ for (const n of graph.nodes.values()) {
356
+ if (n.type === 'decision' && (n.attrs.hit?.id === k || n.id.endsWith(`#${k}`))) return n
357
+ if (n.type === 'fr' && n.id === k) return n
358
+ }
359
+ return null
360
+ }
361
+
362
+ function edgesOf(graph, { edgeClass = 'all', edgeType } = {}) {
363
+ if (edgeType) return graph.byEdgeType.get(edgeType) || []
364
+ if (edgeClass === 'strong') return graph.edges.filter((e) => EDGE_STRENGTH[e.type] === 'strong')
365
+ return graph.edges
366
+ }
367
+
368
+ /** 一跳(或多跳)邻域。depth>1 沿限定边集扩展;返回 {nodes, edges}(含起点)。 */
369
+ export function graphNeighbors(graph, key, { depth = 1, edgeClass = 'all', edgeType } = {}) {
370
+ const start = resolveGraphNode(graph, key)
371
+ if (!start) return { found: false, nodes: [], edges: [] }
372
+ const es = edgesOf(graph, { edgeClass, edgeType })
373
+ const nset = new Set([start.id])
374
+ const eset = new Set()
375
+ let front = [start.id]
376
+ for (let d = 0; d < depth && front.length; d++) {
377
+ const nf = []
378
+ for (const id of front) {
379
+ for (const e of es) {
380
+ let other = null
381
+ if (e.s === id && graph.nodes.has(e.t)) other = e.t
382
+ else if (e.t === id && graph.nodes.has(e.s)) other = e.s
383
+ if (other === null || other === id) continue
384
+ eset.add(e)
385
+ if (!nset.has(other)) { nset.add(other); nf.push(other) }
386
+ }
387
+ }
388
+ front = nf
389
+ }
390
+ return {
391
+ found: true,
392
+ nodes: [...nset].map((id) => graph.nodes.get(id)),
393
+ edges: [...eset],
394
+ }
395
+ }
396
+
397
+ /** 强边 BFS 寻路(中/弱边不参与——推理规则)。不可达显式 found:false。 */
398
+ export function graphPath(graph, keyA, keyB) {
399
+ const a = resolveGraphNode(graph, keyA), b = resolveGraphNode(graph, keyB)
400
+ if (!a || !b) return { found: false, reason: !a ? `起点未命中:${keyA}` : `终点未命中:${keyB}`, hops: [] }
401
+ const strong = graph.edges.filter((e) => EDGE_STRENGTH[e.type] === 'strong')
402
+ const prev = new Map([[a.id, null]])
403
+ const q = [a.id]
404
+ while (q.length) {
405
+ const id = q.shift()
406
+ if (id === b.id) break
407
+ for (const e of strong) {
408
+ let nx = null
409
+ if (e.s === id && graph.nodes.has(e.t)) nx = e.t
410
+ else if (e.t === id && graph.nodes.has(e.s)) nx = e.s
411
+ if (nx && !prev.has(nx)) { prev.set(nx, { from: id, e }); q.push(nx) }
412
+ }
413
+ }
414
+ if (!prev.has(b.id)) return { found: false, reason: '强边子集上不可达(中/弱边不参与寻路)', hops: [] }
415
+ const hops = []
416
+ let cur = b.id
417
+ while (prev.get(cur)) { const p = prev.get(cur); hops.unshift(p.e); cur = p.from }
418
+ return { found: true, hops }
419
+ }
420
+
421
+ /** 强边闭包(impact):深度≤2 为设计契约上限;supersedes/module-dep 传递例外沿链多追一跳。
422
+ * 返回 {closure, modules, decisionsAndFrs, rejectedReachable}——防复潮计数为 rejected ∪ 死路可达集。 */
423
+ export function graphImpact(graph, key) {
424
+ const start = resolveGraphNode(graph, key)
425
+ if (!start) return { found: false }
426
+ const strong = graph.edges.filter((e) => EDGE_STRENGTH[e.type] === 'strong')
427
+ // 主闭包:深度 2
428
+ const closure = new Set([start.id])
429
+ let front = [start.id]
430
+ for (let d = 0; d < 2 && front.length; d++) {
431
+ const nf = []
432
+ for (const id of front) for (const e of strong) {
433
+ let nx = null
434
+ if (e.s === id && graph.nodes.has(e.t)) nx = e.t
435
+ else if (e.t === id && graph.nodes.has(e.s)) nx = e.s
436
+ if (nx && !closure.has(nx)) { closure.add(nx); nf.push(nx) }
437
+ }
438
+ front = nf
439
+ }
440
+ // 传递例外:闭包内节点上的 supersedes/module-dep 边再沿一跳(版本链/依赖闭包语义)
441
+ for (const id of [...closure]) {
442
+ for (const e of strong) {
443
+ if (!TRANSMISSIVE_EDGES.has(e.type)) continue
444
+ let nx = null
445
+ if (e.s === id && closure.has(e.t)) continue
446
+ if (e.s === id) nx = e.t
447
+ else if (e.t === id) nx = e.s
448
+ if (nx && !closure.has(nx)) closure.add(nx)
449
+ }
450
+ }
451
+ const nodesOf = (type) => [...closure].filter((id) => graph.nodes.get(id)?.type === type).map((id) => graph.nodes.get(id))
452
+ const rejectedReachable = [...closure]
453
+ .map((id) => graph.nodes.get(id))
454
+ .filter((n) => n?.type === 'decision' && (n.attrs.status === 'rejected' || n.attrs.hit?.deathPath))
455
+ return {
456
+ found: true,
457
+ closure: [...closure],
458
+ modules: nodesOf('module').map((n) => n.id),
459
+ decisionsAndFrs: [...closure].filter((id) => ['decision', 'fr'].includes(graph.nodes.get(id)?.type)).map((id) => graph.nodes.get(id)),
460
+ rejectedReachable,
461
+ }
462
+ }
463
+
464
+ /** 孤儿检测:零度节点 + entry 型无路由无强边(doctor: graph-orphan-entry 消费)。 */
465
+ export function graphOrphans(graph) {
466
+ const deg = new Set()
467
+ for (const e of graph.edges) { deg.add(e.s); deg.add(e.t) }
468
+ const out = []
469
+ for (const n of graph.nodes.values()) {
470
+ if (n.type === 'project') continue // project 恒零度(归属在 doc 节点属性,非边)
471
+ if (n.type === 'entry') {
472
+ const hasRoute = graph.edges.some((e) => e.t === n.id && e.type === 'route')
473
+ const hasStrong = graph.edges.some((e) => (e.s === n.id || e.t === n.id) && EDGE_STRENGTH[e.type] === 'strong')
474
+ if (!hasRoute && !hasStrong) out.push({ node: n, kind: 'entry-no-route-no-strong' })
475
+ } else if (!deg.has(n.id)) {
476
+ out.push({ node: n, kind: 'zero-degree' })
477
+ }
478
+ }
479
+ return out
480
+ }
481
+
482
+ /** 悬空检测:路由/引用/锚点边指向不存在文件(doctor: graph-*-dangling-* 消费)。 */
483
+ /**
484
+ * 悬空检测(doctor 消费)。口径(2026-10-09-graph-docrefs-noise 分层):
485
+ * - 文档引用面(doc-refs/scan-refs,中强度):裸文件名(无 /)不判——文档短引用合法且
486
+ * 438 种裸名中 304 种多义不可盲连(config.py 各模块同名);跨仓前缀(顶级目录本仓不存在,
487
+ * 如 backend/frontend——平台代码在独立仓)单独计 crossRepo 不计本仓悬空。
488
+ * - 结构面(anchors/deliverables/test-binding/module-files,强边):保持全判(跨仓目标
489
+ * aggregated 计入——锚点是机器声明,跨仓也有对账价值),但顶级目录不存在时标 crossRepo
490
+ * 供 doctor 文案分流。
491
+ * 返回条目 { edge, missing, strength, crossRepo }。
492
+ */
493
+ export function graphDangling(graph, { existsFn = existsSync } = {}) {
494
+ const out = []
495
+ const rootDir = String(graph.root || '')
496
+ const topExists = (p) => {
497
+ const top = String(p).split('/')[0]
498
+ if (!top || top === '.') return true
499
+ return existsFn(join(rootDir, top)) || existsFn(top)
500
+ }
501
+ for (const e of graph.edges) {
502
+ if (!['route', 'doc-refs', 'scan-refs', 'anchors', 'deliverables', 'test-binding', 'module-files'].includes(e.type)) continue
503
+ const t = graph.nodes.get(e.t)
504
+ const s = graph.nodes.get(e.s)
505
+ const target = t?.type === 'file' || t?.type === 'test' ? t : (s?.type === 'file' ? s : null)
506
+ if (!target) continue
507
+ if (target.attrs?.isDir) continue
508
+ const id = String(target.id)
509
+ const isDocRef = e.type === 'doc-refs' || e.type === 'scan-refs'
510
+ if (isDocRef && !id.includes('/')) continue // 裸文件名短引用:文档语境合法,不判悬空
511
+ const crossRepo = !topExists(id)
512
+ if (isDocRef && crossRepo) continue // 跨仓文档引用:目标活在独立仓,不计本仓悬空
513
+ if (!existsFn(join(rootDir, id)) && !existsFn(id)) {
514
+ out.push({ edge: e, missing: id, strength: EDGE_STRENGTH[e.type], crossRepo })
515
+ }
516
+ }
517
+ return out
518
+ }
519
+
520
+ // ═══ 2026-10-08-graph-summary-nodes:summary 聚合 + nodes 搜索(平台仓阶段三依赖契约)═══
521
+ // 平台侧(multi-agent-platform 2026-10-08-platform-knowledge-graph)经 daemon RPC 直采本面。
522
+ // doctor 同源三判定(2026-10-08-graph-summary-consistency 收敛为真单一源):graphModuleDocGaps /
523
+ // graphChangelogDanglings / graphOrphans+graphDangling 的判定逻辑只在 knowledge-graph.js 定义,
524
+ // doctor 六检查与 summary 四计数消费同一函数——「口径一处定义两处消费」由测试交叉断言钉死。
525
+
526
+ /** 模块文档缺口判定(doctor graph-module-doc-gap 单一源):map 模块缺 describes 或 changelog-of 入边。 */
527
+ export function graphModuleDocGaps(graph) {
528
+ const hasDescribes = new Set((graph.byEdgeType.get('describes') || []).map((e) => e.t))
529
+ const hasChlog = new Set((graph.byEdgeType.get('changelog-of') || []).map((e) => e.t))
530
+ const mapModules = [...graph.nodes.values()].filter((n) => n.type === 'module' && n.attrs.project && !n.attrs.fromFrDomain)
531
+ return mapModules.filter((n) => !hasDescribes.has(n.id) || !hasChlog.has(n.id))
532
+ }
533
+
534
+ /** changelog 行悬空判定(doctor graph-changelog-dangling 单一源):日期名目标不在 archive/ 也不在
535
+ * 活跃 changes/(ql 目标豁免——quicklog 条目无目录形态,其家在 QUICKLOG md 文件)。 */
536
+ export function graphChangelogDanglings(graph, { existsFn = existsSync } = {}) {
537
+ const specRoot = String(graph.root || '')
538
+ const out = []
539
+ for (const id of new Set((graph.byEdgeType.get('changelog-entry') || []).map((e) => e.t))) {
540
+ if (/^\d{4}-\d{2}-\d{2}-/.test(id) && !existsFn(join(specRoot, 'changes', 'archive', id)) && !existsFn(join(specRoot, 'changes', id))) out.push(id)
541
+ }
542
+ return out
543
+ }
544
+
545
+ /** 节点 → 簇域(lite 总览聚类键 type×domain;缺省占位防散簇)。 */
546
+ function graphNodeDomain(graph, n) {
547
+ switch (n.type) {
548
+ case 'fr': case 'decision':
549
+ return n.attrs.domain || '_unmapped'
550
+ case 'file': {
551
+ // module-files 精确挂接反查(多挂取最长模块 id——窄域优先)
552
+ const mods = graph.edges.filter((e) => e.type === 'module-files' && e.t === n.id).map((e) => e.s.replace(/^module:/, ''))
553
+ return mods.sort((a, b) => b.length - a.length)[0] || '_unmapped'
554
+ }
555
+ case 'module':
556
+ return n.id.replace(/^module:/, '')
557
+ case 'doc':
558
+ return n.attrs.kind || 'doc'
559
+ case 'entry':
560
+ return n.attrs.file || '_unmapped'
561
+ case 'test': {
562
+ const fr = graph.edges.find((e) => e.type === 'test-binding' && e.t === n.id)
563
+ return (fr && graph.nodes.get(fr.s)?.attrs?.domain) || '_tests'
564
+ }
565
+ default: // project/change/ql:id 即域
566
+ return n.id
567
+ }
568
+ }
569
+
570
+ /** 全图聚合:规模/分布 + doctor 同源四计数 + clusters 簇代表(度数 top-5,度=全边入+出)。
571
+ * clustersLimit>0 时仅返回 count 前 N 簇(真图 800+ 簇,平台 lite 画布摆不下——消费方按需截断)。 */
572
+ export function graphSummary(graph, { existsFn = existsSync, clustersLimit = 0 } = {}) {
573
+ const orphans = graphOrphans(graph).length
574
+ const dangling = graphDangling(graph, { existsFn })
575
+ const danglingRefs = dangling.length
576
+ // 分强度计数(附加字段——dangling_refs 是两类之和,消费方对账 doctor 时按 breakdown 拆)
577
+ const danglingBreakdown = dangling.reduce((a, d) => {
578
+ if (EDGE_STRENGTH[d.edge.type] === 'strong') a.strong_anchors++
579
+ else if (EDGE_STRENGTH[d.edge.type] === 'medium') a.medium_doc_refs++
580
+ return a
581
+ }, { strong_anchors: 0, medium_doc_refs: 0 })
582
+ const moduleDocGaps = graphModuleDocGaps(graph).length
583
+ const changelogDanglings = graphChangelogDanglings(graph, { existsFn }).length
584
+ // clusters:type×domain 聚簇,representatives 度数 top-5(同度按 id 字典序稳序)
585
+ const deg = new Map()
586
+ for (const e of graph.edges) {
587
+ deg.set(e.s, (deg.get(e.s) || 0) + 1)
588
+ deg.set(e.t, (deg.get(e.t) || 0) + 1)
589
+ }
590
+ const buckets = new Map()
591
+ for (const n of graph.nodes.values()) {
592
+ const key = `${n.type}:${graphNodeDomain(graph, n)}`
593
+ if (!buckets.has(key)) buckets.set(key, [])
594
+ buckets.get(key).push(n)
595
+ }
596
+ const allClusters = [...buckets.entries()].map(([key, ns]) => ({
597
+ key,
598
+ label: key.slice(key.indexOf(':') + 1),
599
+ count: ns.length,
600
+ representatives: [...ns]
601
+ .sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || a.id.localeCompare(b.id))
602
+ .slice(0, 5)
603
+ .map((n) => ({ id: n.id, type: n.type, label: n.label })),
604
+ })).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
605
+ const clusters = clustersLimit > 0 ? allClusters.slice(0, clustersLimit) : allClusters
606
+ return {
607
+ nodes: graph.stats.nodeCount,
608
+ edges: graph.stats.edgeCount,
609
+ byType: graph.stats.byNodeType,
610
+ byEdge: graph.stats.byEdgeType,
611
+ orphans,
612
+ module_doc_gaps: moduleDocGaps,
613
+ changelog_danglings: changelogDanglings,
614
+ dangling_refs: danglingRefs,
615
+ // dangling_refs 构成(附加字段,向后兼容):doctor graph-dangling-anchor(强)+ graph-doc-dangling-ref(中)之和
616
+ dangling_refs_breakdown: danglingBreakdown,
617
+ clusters,
618
+ }
619
+ }
620
+
621
+ /** 全图粗分组(原型 prototype-data-gen.cjs comm() 逐行移植——2026-10-09-knowledge-graph-fullmap Grill F-01 钉死口径:
622
+ * decision/fr 按域、module/doc 单组、file 按顶级目录、change、ql、其余"其他";星系数=comm() 实际分组数(真图实测 150-175,原型视觉即如此)。
623
+ * 禁用 summary 细簇(883 簇会把主环撑到 ~8900px 退化均匀散点)。 */
624
+ function graphCommunity(n) {
625
+ if (n.type === 'decision') return '决策域/' + (n.attrs.domain || '_unmapped')
626
+ if (n.type === 'fr') return 'FR域/' + (n.attrs.domain || '_unmapped')
627
+ if (n.type === 'module') return '模块'
628
+ if (n.type === 'file') return '文件/' + (n.id.includes('/') ? n.id.split('/')[0] : '.')
629
+ if (n.type === 'doc') return '文档'
630
+ if (n.type === 'change') return '变更'
631
+ if (n.type === 'ql') return 'quicklog'
632
+ return '其他'
633
+ }
634
+
635
+ /** 全图确定性布局(原型 sunflower 摆位逐行移植,常量固化):
636
+ * 簇按 count 降序(同数按组名字典序稳序);主环半径 sqrt(gi+1)*300、簇相位 gi*2.39999;
637
+ * 簇内半径 16*sqrt(j+1)(j=节点序)、角 j*2.39999+gi;坐标 Math.round。
638
+ * 确定性为硬约束(同输入逐位一致);与原型生成器逐位等价非约束(稳序差异)。 */
639
+ export function layoutFullGraph(graph) {
640
+ const groups = new Map()
641
+ for (const n of graph.nodes.values()) {
642
+ const k = graphCommunity(n)
643
+ if (!groups.has(k)) groups.set(k, [])
644
+ groups.get(k).push(n)
645
+ }
646
+ const gArr = [...groups.entries()]
647
+ .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
648
+ const pos = new Map()
649
+ gArr.forEach(([k, arr], gi) => {
650
+ const gc = Math.sqrt(gi + 1) * 300, ga = gi * 2.39999
651
+ const cx = Math.cos(ga) * gc, cy = Math.sin(ga) * gc
652
+ arr.forEach((n, j) => {
653
+ const r = 16 * Math.sqrt(j + 1), a = j * 2.39999 + gi
654
+ pos.set(n.id, { x: Math.round(cx + Math.cos(a) * r), y: Math.round(cy + Math.sin(a) * r) })
655
+ })
656
+ })
657
+ return { pos, groups: gArr.map(([k, a]) => [k, a.length]) }
658
+ }
659
+
660
+ /** 节点搜索:id/label 不区分大小写包含匹配,limit 钳 1-50(平台锚点自动补全数据源)。 */
661
+ export function graphNodesSearch(graph, search, limit = 20) {
662
+ const q = String(search || '').toLowerCase()
663
+ const n = Math.min(Math.max(Number.isInteger(limit) ? limit : 20, 1), 50)
664
+ const nodes = []
665
+ if (q) {
666
+ for (const node of graph.nodes.values()) {
667
+ if (node.id.toLowerCase().includes(q) || node.label.toLowerCase().includes(q)) {
668
+ nodes.push({ id: node.id, type: node.type, label: node.label })
669
+ if (nodes.length >= n) break
670
+ }
671
+ }
672
+ }
673
+ return { count: nodes.length, nodes }
674
+ }
675
+
676
+ // ═══════════════════════════════════════════════════════════════
677
+ // scope 遍历召回(task-03,FR-04 承接 FR-cli-entry-234)
678
+ // 层序:路由 → 平台向量 → scope 遍历 → 本地词片 → 空。
679
+ // 入口键=机器算(D-004):scope 文件集由调用方传(变更面 touched / decisions.md 锚点提取),
680
+ // 不解析 agent 自由文本。三闸(D-006):scope 缺省/空→整层跳过;只走强边;decisionHits≤20、entries≤3。
681
+ // ═══════════════════════════════════════════════════════════════
682
+
683
+ /** scope 文件集 → 可达知识集(decisionHits 形态 + entries 素材)。
684
+ * 遍历:file ←anchors← decision(+supersedes 链一跳)→ from-change/belongs-module 侧线;
685
+ * FR 侧:file ←deliverables(变更维)与 anchors 直连。rejected ∪ 死路 保底进场(防复潮)。 */
686
+ export function scopeRecall(graph, scopeFiles) {
687
+ const scopes = [...new Set((Array.isArray(scopeFiles) ? scopeFiles : [scopeFiles]).map(posix).filter(Boolean))]
688
+ if (scopes.length === 0) return null
689
+ const strong = graph.edges.filter((e) => EDGE_STRENGTH[e.type] === 'strong')
690
+ const reachable = new Set()
691
+ // 一跳:文件 ←anchors← 决策/FR
692
+ for (const e of strong) {
693
+ if (e.type === 'anchors' && scopes.includes(e.t)) reachable.add(e.s)
694
+ // 变更维:scope 命中变更交付面(scope 可能传变更名而非文件——调用方宽松契约)
695
+ if (e.type === 'deliverables' && scopes.includes(e.s)) reachable.add(e.t)
696
+ }
697
+ // supersedes 链一跳(版本链追新/旧)
698
+ for (const id of [...reachable]) {
699
+ for (const e of strong) {
700
+ if (e.type !== 'supersedes') continue
701
+ if (e.s === id && graph.nodes.has(e.t)) reachable.add(e.t)
702
+ else if (e.t === id && graph.nodes.has(e.s)) reachable.add(e.s)
703
+ }
704
+ }
705
+ const decisions = []
706
+ const frs = []
707
+ for (const id of reachable) {
708
+ const n = graph.nodes.get(id)
709
+ if (!n) continue
710
+ if (n.type === 'decision') decisions.push(n)
711
+ else if (n.type === 'fr') frs.push(n)
712
+ }
713
+ // decisionHits:parseDecisionEntries 原生形态(attrs.hit 直出——零重解析),rejected∪死路优先
714
+ const hits = decisions.map((n) => ({ ...n.attrs.hit, file: n.attrs.hit.file }))
715
+ const death = (h) => h.status === 'rejected' || h.deathPath
716
+ hits.sort((a, b) => ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0)) || ((b.status === 'rejected' ? 1 : 0) - (a.status === 'rejected' ? 1 : 0)))
717
+ const rejectedFirst = [...hits.filter(death), ...hits.filter((h) => !death(h))]
718
+ if (rejectedFirst.length > 20) rejectedFirst.length = 20
719
+ const entries = rejectedFirst.slice(0, 3).map((h) => ({
720
+ category: 'Decisions',
721
+ keywords: ['scope'],
722
+ file: h.file,
723
+ anchor: h.id,
724
+ display: `${h.file}#${h.id}(scope 遍历召回)`,
725
+ }))
726
+ return { decisionHits: rejectedFirst, entries, frCount: frs.length, scopeSize: scopes.length }
727
+ }
728
+
729
+ /** scopeRecall → matchKnowledge 同构四键返回(knowledge-vector 消费——策略面全本地解析)。 */
730
+ export function buildScopeRecallResult(recall) {
731
+ if (!recall || recall.decisionHits.length === 0) return null
732
+ return {
733
+ matched: true,
734
+ entries: recall.entries,
735
+ report: `Status: scope-traversal matched (${recall.decisionHits.length} entries from ${recall.scopeSize} scope files)`,
736
+ json: {
737
+ matched: true, entry_count: recall.entries.length, scope: true,
738
+ entries: recall.entries.map((e) => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
739
+ },
740
+ decisionHits: recall.decisionHits,
741
+ }
742
+ }
743
+
744
+ /** 变更 decisions.md(九字段形态)「锚点:」字段 → scope 文件集(complete 门的需求期机器键,D-004)。
745
+ * 兼容列表行(`- 锚点:src/x.js:12`)与裸行(`锚点:src/x.js`——归档蒸馏域文件形态)。 */
746
+ export function scopeFromDecisionsMd(text) {
747
+ const out = new Set()
748
+ for (const m of String(text || '').replace(/\r\n/g, '\n').matchAll(/^(?:-\s*)?锚点\s*[::]\s*(.+)$/gm)) {
749
+ for (const p of anchorFilePaths(m[1])) out.add(p)
750
+ }
751
+ return [...out]
752
+ }
753
+
754
+ /** decisions.md「模块域:」字段 → 模块 id 集(剥 NEW: 前缀——规划中模块按裸名入键,未入图由调用方跳过)。 */
755
+ function moduleIdsFromDecisionsMd(text) {
756
+ const out = new Set()
757
+ for (const m of String(text || '').replace(/\r\n/g, '\n').matchAll(/^(?:-\s*)?模块域\s*[::]\s*(.+)$/gm)) {
758
+ for (const raw of m[1].split(/[,,、]/)) {
759
+ const v = raw.trim().replace(/^NEW:/, '')
760
+ if (v) out.add(v)
761
+ }
762
+ }
763
+ return [...out]
764
+ }
765
+
766
+ /** 方案步防复潮可达集(2026-10-09-brainstorm-impact-antirevival):decisions.md 结构键
767
+ * (锚点:路径 ∪ 模块域:模块 id——均为 CLI 校验字段,D-004 机器键纪律)→ 逐键 graphImpact
768
+ * 闭包 → rejected∪死路 条目去重合并。条目=parseDecisionEntries 原生形态 + viaImpact:true
769
+ * (消费方零重解析;结构可达即防复潮先验,与词面 score 无关)。键未入图(NEW: 未落地模块/
770
+ * 幻路径)静默跳过;空文本/零可达 → []。 */
771
+ export function impactFromDecisionsMd(graph, text) {
772
+ const keys = [...new Set([...scopeFromDecisionsMd(text), ...moduleIdsFromDecisionsMd(text).map((m) => `module:${m}`)])]
773
+ const out = []
774
+ const seen = new Set()
775
+ for (const k of keys) {
776
+ const node = resolveGraphNode(graph, k)
777
+ if (!node) continue
778
+ const imp = graphImpact(graph, node.id)
779
+ for (const n of imp.rejectedReachable || []) {
780
+ const dedupe = `${n.attrs.hit?.file}#${n.attrs.hit?.id}#${n.attrs.hit?.change || ''}`
781
+ if (seen.has(dedupe)) continue
782
+ seen.add(dedupe)
783
+ out.push({ ...n.attrs.hit, viaImpact: true, impactKey: k })
784
+ }
785
+ }
786
+ return out
787
+ }
788
+
789
+ // ═══════════════════════════════════════════════════════════════
790
+ // CLI 子命令(task-02)——stages/knowledge.js 懒加载(classify 同款)。
791
+ // 输出沿知识命令族全 JSON 约定({ok,...data});人类可读摘要行内嵌 summary 字段。
792
+ // ═══════════════════════════════════════════════════════════════
793
+
794
+ const GRAPH_USAGE = '用法:sillyspec knowledge graph <summary|nodes|dump|neighbors|path|impact|orphans|dangling> [锚点...] [--search <模糊>] [--limit N] [--edges <边型>] [--depth N];dump 需 --layout'
795
+
796
+ export async function cmdKnowledgeGraph(dir, args, opts = {}) {
797
+ const out = (ok, data, error) => console.log(JSON.stringify({ ok, ...(data || {}) , ...(error ? { error } : {}) }, null, 2))
798
+ const rest = Array.isArray(args) ? args.filter((a) => !String(a).startsWith('--')) : []
799
+ const flag = (name) => {
800
+ const i = args.indexOf(name)
801
+ return i >= 0 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null
802
+ }
803
+ const sub = rest[0] || ''
804
+ const edgeType = flag('--edges')
805
+ const depth = parseInt(flag('--depth') || '1', 10)
806
+ // resolveKnowledgeDir 同款口径(stages/knowledge.js):specDir 优先,缺省 <cwd>/.sillyspec
807
+ const specRoot = opts.specDir || join(dir, '.sillyspec')
808
+ const knowledgeDir = join(specRoot, 'knowledge')
809
+
810
+ if (!['summary', 'nodes', 'dump', 'neighbors', 'path', 'impact', 'orphans', 'dangling'].includes(sub)) {
811
+ out(false, {}, { code: 'graph_usage', usage: GRAPH_USAGE, subcommand: sub })
812
+ return
813
+ }
814
+ const graph = buildKnowledgeGraph(specRoot)
815
+ const brief = (n) => `${n.type} ${n.label}`
816
+
817
+ if (sub === 'summary') {
818
+ const cl = parseInt(flag('--clusters') || '0', 10)
819
+ const s = graphSummary(graph, { clustersLimit: Number.isInteger(cl) && cl > 0 ? cl : 0 })
820
+ return out(true, {
821
+ query: { sub, clusters: s.clusters.length },
822
+ stats: s,
823
+ summary: [
824
+ `节点 ${s.nodes} · 边 ${s.edges}`,
825
+ `孤儿 ${s.orphans} · 模块文档缺口 ${s.module_doc_gaps} · changelog 悬空 ${s.changelog_danglings} · 悬空引用 ${s.dangling_refs}`,
826
+ `簇 ${s.clusters.length} 个(最大 ${s.clusters[0]?.key ?? '—'} × ${s.clusters[0]?.count ?? 0})`,
827
+ ],
828
+ })
829
+ }
830
+ if (sub === 'dump') {
831
+ if (!args.includes('--layout')) return out(false, {}, { code: 'layout_required', usage: GRAPH_USAGE })
832
+ const { pos, groups } = layoutFullGraph(graph)
833
+ const stats = graphSummary(graph)
834
+ return out(true, {
835
+ query: { sub, layout: true },
836
+ nodes: [...graph.nodes.values()].map((n) => {
837
+ const p = pos.get(n.id)
838
+ return { id: n.id, type: n.type, label: n.label, x: p.x, y: p.y }
839
+ }),
840
+ edges: graph.edges.map((e) => ({ s: e.s, t: e.t, type: e.type, strength: EDGE_STRENGTH[e.type] })),
841
+ stats,
842
+ summary: [`节点 ${stats.nodes} · 边 ${stats.edges}`, `星系 ${groups.length} 个(最大 ${groups[0]?.[0] ?? '—'} × ${groups[0]?.[1] ?? 0})`],
843
+ })
844
+ }
845
+ if (sub === 'nodes') {
846
+ const search = flag('--search')
847
+ const limit = parseInt(flag('--limit') || '20', 10)
848
+ if (!search) return out(false, {}, { code: 'search_required', usage: GRAPH_USAGE })
849
+ const r = graphNodesSearch(graph, search, limit)
850
+ return out(true, {
851
+ query: { sub, search, limit: Math.min(Math.max(Number.isInteger(limit) ? limit : 20, 1), 50) },
852
+ count: r.count,
853
+ summary: r.nodes.slice(0, 5).map((n) => `→ ${brief(n)}`),
854
+ nodes: r.nodes,
855
+ })
856
+ }
857
+ if (sub === 'neighbors') {
858
+ const key = rest[1]
859
+ if (!key) return out(false, {}, { code: 'anchor_required', usage: GRAPH_USAGE })
860
+ const r = graphNeighbors(graph, key, { depth: Number.isInteger(depth) && depth > 0 ? Math.min(depth, 3) : 1, edgeType: edgeType || undefined })
861
+ if (!r.found) return out(false, {}, { code: 'node_not_found', key })
862
+ return out(true, {
863
+ query: { sub, key, edges: edgeType || 'all' },
864
+ summary: r.nodes.slice(1).map((n) => `→ ${brief(n)}`),
865
+ nodes: r.nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
866
+ edges: r.edges.map((e) => ({ s: e.s, t: e.t, type: e.type, strength: EDGE_STRENGTH[e.type] })),
867
+ })
868
+ }
869
+ if (sub === 'path') {
870
+ const a = rest[1], b = rest[2]
871
+ if (!a || !b) return out(false, {}, { code: 'two_anchors_required', usage: GRAPH_USAGE })
872
+ const r = graphPath(graph, a, b)
873
+ return out(true, {
874
+ query: { sub, from: a, to: b },
875
+ found: r.found,
876
+ reason: r.reason || '',
877
+ hop_count: r.hops.length,
878
+ summary: r.found ? r.hops.map((e, i) => `${i === 0 ? resolveGraphNode(graph, a)?.label ?? a : ''} —${e.type}→ ${graph.nodes.get(e.t)?.label ?? e.t}`) : [r.reason],
879
+ hops: r.hops.map((e) => ({ s: e.s, t: e.t, type: e.type })),
880
+ })
881
+ }
882
+ if (sub === 'impact') {
883
+ const key = rest[1]
884
+ if (!key) return out(false, {}, { code: 'anchor_required', usage: GRAPH_USAGE })
885
+ const r = graphImpact(graph, key)
886
+ if (!r.found) return out(false, {}, { code: 'node_not_found', key })
887
+ return out(true, {
888
+ query: { sub, key, rule: '强边闭包 深度≤2 + supersedes/module-dep 传递例外' },
889
+ summary: [
890
+ `闭包 ${r.closure.length} 节点`,
891
+ `触及模块:${r.modules.map((m) => m.replace(/^module:/, '')).join('、') || '—'}`,
892
+ `锚定决策/FR ${r.decisionsAndFrs.length}`,
893
+ `防复潮可达(rejected∪死路)${r.rejectedReachable.length}`,
894
+ ],
895
+ closure: r.closure,
896
+ modules: r.modules,
897
+ decisions_and_frs: r.decisionsAndFrs.map((n) => ({ id: n.id, type: n.type, status: n.attrs.status || n.attrs.hit?.status || '' })),
898
+ rejected_reachable: r.rejectedReachable.map((n) => ({ id: n.id, title: n.label, reason: (n.attrs.hit?.reason || '').slice(0, 80) })),
899
+ })
900
+ }
901
+ if (sub === 'orphans') {
902
+ const list = graphOrphans(graph)
903
+ return out(true, {
904
+ query: { sub },
905
+ count: list.length,
906
+ summary: list.map((f) => `⚠️ ${f.kind} ${f.node.label}`),
907
+ orphans: list.map((f) => ({ id: f.node.id, type: f.node.type, kind: f.kind })),
908
+ })
909
+ }
910
+ // dangling
911
+ const list = graphDangling(graph)
912
+ return out(true, {
913
+ query: { sub },
914
+ count: list.length,
915
+ summary: list.map((d) => `⚠️ ${d.edge.type}(${d.strength}) ${d.edge.s} → ${d.missing}`),
916
+ dangling: list.map((d) => ({ edge: d.edge, missing: d.missing, strength: d.strength })),
917
+ })
918
+ }