sillyspec 3.28.12 → 3.28.13
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/.claude/skills/sillyspec-execute/SKILL.md +222 -223
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
- package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
- package/package.json +1 -1
- package/src/change-risk-profile.js +3 -3
- package/src/decision-distill.js +32 -25
- package/src/doctor-diagnostics.js +29 -0
- package/src/fr-index.js +359 -0
- package/src/knowledge-stats.js +99 -2
- package/src/run/archive-distill.js +29 -0
- package/src/run/prompt.js +32 -1
- package/src/stage-contract.js +70 -17
- package/src/stages/brainstorm.js +6 -1
- package/src/verify-probes.js +847 -20
package/src/decision-distill.js
CHANGED
|
@@ -211,8 +211,9 @@ function parseModulePathsSubset(content) {
|
|
|
211
211
|
/** 未注入 moduleIndex 时的尽力发现:knowledgeRoot 同级 docs/<项目>/modules/_module-map.yaml。
|
|
212
212
|
* 多子项目仓(如本仓 sillyspec+dashboard)各项目有自己的 map——dogfood 实证(2026-08-24):
|
|
213
213
|
* 只取首个命中会把主项目条目全部误落 unmapped(dashboard 的 map 按字母序先命中)。
|
|
214
|
-
* 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。
|
|
215
|
-
|
|
214
|
+
* 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。
|
|
215
|
+
* 2026-09-18-fr-index-l1:导出供 fr-index 域解析复用。 */
|
|
216
|
+
export function discoverModuleIndex(knowledgeRoot) {
|
|
216
217
|
try {
|
|
217
218
|
const docsDir = join(dirname(knowledgeRoot), 'docs')
|
|
218
219
|
if (!existsSync(docsDir)) return null
|
|
@@ -319,15 +320,19 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
319
320
|
return lines
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
/** 知识文件切段:preamble 行 +
|
|
323
|
+
/** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
|
|
323
324
|
* 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
|
|
324
|
-
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
|
|
325
|
-
|
|
325
|
+
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
|
|
326
|
+
* 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
|
|
327
|
+
* 正则与恒等 id),decisions 调用点零改动。 */
|
|
328
|
+
export function splitKnowledgeSections(content, opts = {}) {
|
|
329
|
+
const sectionRegex = opts.sectionRegex || /^## (D-\d+)@v(\d+)\s*(.*)$/
|
|
330
|
+
const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
|
|
326
331
|
const sections = []
|
|
327
332
|
const preamble = []
|
|
328
333
|
let cur = null
|
|
329
334
|
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
|
330
|
-
const m = line.match(
|
|
335
|
+
const m = line.match(sectionRegex)
|
|
331
336
|
if (m) {
|
|
332
337
|
if (cur) sections.push(cur)
|
|
333
338
|
cur = { number: m[1], version: parseInt(m[2], 10), title: m[3].trim(), lines: [line] }
|
|
@@ -338,7 +343,7 @@ function splitKnowledgeSections(content) {
|
|
|
338
343
|
}
|
|
339
344
|
if (cur) sections.push(cur)
|
|
340
345
|
for (const s of sections) {
|
|
341
|
-
s.id =
|
|
346
|
+
s.id = buildId(s.number, s.version)
|
|
342
347
|
const cm = s.lines.find(l => /^变更[::]/.test(l))
|
|
343
348
|
s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
|
|
344
349
|
}
|
|
@@ -346,7 +351,7 @@ function splitKnowledgeSections(content) {
|
|
|
346
351
|
}
|
|
347
352
|
|
|
348
353
|
/** 段落重组落盘文本(幂等归一:段间恰好一个空行 + 文件单一尾换行) */
|
|
349
|
-
function joinKnowledgeFile(preamble, sections) {
|
|
354
|
+
export function joinKnowledgeFile(preamble, sections) {
|
|
350
355
|
const parts = []
|
|
351
356
|
const pre = preamble.join('\n').replace(/\s+$/, '')
|
|
352
357
|
if (pre) parts.push(pre)
|
|
@@ -394,19 +399,21 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
394
399
|
// INDEX.md decisions 路由行幂等维护(FR-05:写入责任在本模块;只动 ## Decisions 段内指向 decisions/ 的行)
|
|
395
400
|
// ---------------------------------------------------------------------------
|
|
396
401
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
402
|
+
/**
|
|
403
|
+
* 域文件实况 ↔ INDEX 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行。
|
|
404
|
+
* 2026-09-18-fr-index-l1 参数化:section/subdir/makeLine/matchTarget 经参(decisions 缺省不变),
|
|
405
|
+
* fr 侧传 'FR 需求索引' 段与 fr/ 子目录——两类路由行各守各段互不干扰。
|
|
406
|
+
*/
|
|
407
|
+
export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
|
|
408
|
+
const sectionName = opts.section || 'Decisions'
|
|
409
|
+
const subdir = opts.subdir || 'decisions'
|
|
410
|
+
const makeLine = opts.makeLine || ((d) => `- ${d}|decision|决策 → [${subdir}/${d}.md](${subdir}/${d}.md)`)
|
|
411
|
+
const esc = subdir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
412
|
+
const targetRe = opts.matchTarget || new RegExp(`^-\\s+.*→\\s*\\[[^\\]]*\\]\\(${esc}/([^)#]+?)(?:\\.md)?(?:#[^)]*)?\\)\\s*$`)
|
|
413
|
+
const domainDir = join(knowledgeRoot, subdir)
|
|
407
414
|
let domains
|
|
408
415
|
try {
|
|
409
|
-
domains = readdirSync(
|
|
416
|
+
domains = readdirSync(domainDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
|
|
410
417
|
} catch { return false }
|
|
411
418
|
if (domains.length === 0) return false
|
|
412
419
|
|
|
@@ -417,29 +424,29 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
417
424
|
}
|
|
418
425
|
const lines = original ? original.replace(/\r\n/g, '\n').split('\n') : ['# Knowledge Index', '']
|
|
419
426
|
|
|
420
|
-
//
|
|
427
|
+
// 定位段边界(参数化段名)
|
|
421
428
|
let secStart = -1
|
|
422
429
|
let secEnd = lines.length
|
|
423
430
|
for (let i = 0; i < lines.length; i++) {
|
|
424
431
|
const h = lines[i].match(/^##\s+(.+?)\s*$/)
|
|
425
432
|
if (!h) continue
|
|
426
433
|
if (secStart === -1) {
|
|
427
|
-
if (h[1] ===
|
|
434
|
+
if (h[1] === sectionName) secStart = i + 1
|
|
428
435
|
} else { secEnd = i; break }
|
|
429
436
|
}
|
|
430
437
|
|
|
431
|
-
const desired = domains.map(
|
|
438
|
+
const desired = domains.map(makeLine)
|
|
432
439
|
if (secStart === -1) {
|
|
433
440
|
// 无段则追加(保留原文件其余内容)
|
|
434
441
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
435
|
-
lines.push('',
|
|
442
|
+
lines.push('', `## ${sectionName}`, ...desired)
|
|
436
443
|
} else {
|
|
437
444
|
const body = lines.slice(secStart, secEnd)
|
|
438
445
|
const kept = []
|
|
439
446
|
const seen = new Set()
|
|
440
447
|
const domainSet = new Set(domains)
|
|
441
448
|
for (const l of body) {
|
|
442
|
-
const m = l.match(
|
|
449
|
+
const m = l.match(targetRe)
|
|
443
450
|
if (!m) { kept.push(l); continue } // 非路由行(注释/空行)原样保留
|
|
444
451
|
// 目标域文件仍存在的路由行原样保留(关键词可能被人工扩充);失效行(文件已不存在)与重复行删
|
|
445
452
|
if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(l) }
|
|
@@ -448,7 +455,7 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
448
455
|
const tail = []
|
|
449
456
|
while (kept.length > 0 && kept[kept.length - 1].trim() === '') tail.unshift(kept.pop())
|
|
450
457
|
for (const d of desired) {
|
|
451
|
-
const m = d.match(
|
|
458
|
+
const m = d.match(targetRe)
|
|
452
459
|
if (m && !seen.has(m[1])) kept.push(d) // 缺行补
|
|
453
460
|
}
|
|
454
461
|
kept.push(...tail)
|
|
@@ -33,6 +33,9 @@ import { pruneTimestampedEntries } from './runtime-hygiene.js';
|
|
|
33
33
|
import { CHECK_SEVERITY } from './constants.js';
|
|
34
34
|
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE, resolveRuntimeRoot } from './run/shared.js';
|
|
35
35
|
import { readFrictionLedger } from './friction-ledger.js';
|
|
36
|
+
// FR 索引消费(2026-09-18-fr-index-l1 L1,D-007):D14 第四检查——epoch 后归档的索引在场+取代完整。
|
|
37
|
+
// fr-index 是纯 fs 叶子链(仅 decision-distill 底座),doctor 静态引入无环。
|
|
38
|
+
import { FR_INDEX_EPOCH, scanFrIndex, parseChangeRequirements } from './fr-index.js';
|
|
36
39
|
|
|
37
40
|
// db 角色标签
|
|
38
41
|
const DB_ROLE = {
|
|
@@ -1162,6 +1165,10 @@ function detectArchiveIntegrity(cwd, authoritySpecDir) {
|
|
|
1162
1165
|
return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
|
|
1163
1166
|
}
|
|
1164
1167
|
const offenders = [];
|
|
1168
|
+
// FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
|
|
1169
|
+
const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
|
|
1170
|
+
const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
|
|
1171
|
+
const frEntries = frEnabled ? scanFrIndex(frKnowledgeRoot) : [];
|
|
1165
1172
|
for (const name of names) {
|
|
1166
1173
|
const dir = join(archiveDir, name);
|
|
1167
1174
|
const reasons = [];
|
|
@@ -1204,6 +1211,28 @@ function detectArchiveIntegrity(cwd, authoritySpecDir) {
|
|
|
1204
1211
|
reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
|
|
1205
1212
|
}
|
|
1206
1213
|
}
|
|
1214
|
+
// ── ④ FR 索引在场与取代完整(2026-09-18-fr-index-l1 L1,D-007:epoch 分界第四检查)──
|
|
1215
|
+
// 豁免面:fr/ 目录不存在(仓未启用索引,全跳);无日期前缀/epoch 前归档(存量不回填);
|
|
1216
|
+
// quick-* 与无 requirements.md(quick/scale:small 无索引义务)。违者并入 offenders(豁免走既有账本)。
|
|
1217
|
+
if (frEnabled) {
|
|
1218
|
+
const dm = name.match(/^(\d{4}-\d{2}-\d{2})-/);
|
|
1219
|
+
if (dm && dm[1] >= FR_INDEX_EPOCH && existsSync(join(dir, 'requirements.md'))) {
|
|
1220
|
+
if (!frEntries.some((e) => e.change === name)) {
|
|
1221
|
+
reasons.push('FR 索引缺失(epoch 后归档应有索引条目——归档时 indexRequirements 未落,查 archive 步降级日志)');
|
|
1222
|
+
} else {
|
|
1223
|
+
try {
|
|
1224
|
+
const reqParsed = parseChangeRequirements(dir);
|
|
1225
|
+
const refIds = new Set();
|
|
1226
|
+
for (const fr of reqParsed.frs) for (const r of fr.supersedes) refIds.add(r);
|
|
1227
|
+
for (const refId of refIds) {
|
|
1228
|
+
if (!frEntries.some((e) => e.id === refId && e.supersededBy)) {
|
|
1229
|
+
reasons.push(`承接未翻取代(${refId} 被引用但索引中未标 superseded)`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
} catch { /* requirements 解析失败归任务注册表口径,此处不重复报 */ }
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1207
1236
|
if (reasons.length > 0) offenders.push({ name, reasons });
|
|
1208
1237
|
}
|
|
1209
1238
|
|
package/src/fr-index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fr-index.js — FR 稳定索引核心(L1:稳定 id + knowledge 索引 + 取代标记)
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-18-fr-index-l1(三轮评审+战略定位定稿):requirements.md 归档后首次被语义消费——
|
|
5
|
+
* 全局稳定 id(FR-<域>-NNN,archive 时 CLI 发号)+ knowledge/fr/<域>.md 索引 + 承接引用驱动的
|
|
6
|
+
* 取代链。定位=**L3 的证据发生器**:四类遥测事件(fr-inject/fr-supersede/fr-duplicate-warning/
|
|
7
|
+
* fr-unreferenced,由消费方经 knowledge-hits.js 落盘)供 20-30 change 后裁决活规格真源——
|
|
8
|
+
* 含证伪出口(design 实验裁决条款:承接引用率趋零/链无人跟/重复靠人眼 ⇒ 可杀可冻 L3,索引降级检索面)。
|
|
9
|
+
*
|
|
10
|
+
* 纪律:
|
|
11
|
+
* - CLI 单一写入方:索引只在 indexRequirements 内部写(archive noAI 步调用);
|
|
12
|
+
* - 幂等键=全局 id(「来源变更」字段变更名命中即 no-op——同变更重跑零新增零漂移);
|
|
13
|
+
* - 承接 id 不存在 → warnings 收集不阻断(typo 不炸归档);
|
|
14
|
+
* - 未引用旧 FR 的删除/修改零判定(D-002 边界——undeclared deletion 是 L3 合并门禁领地,
|
|
15
|
+
* L1 只经 unreferenced 计数采观察信号,显式「不算 L3 门禁」);
|
|
16
|
+
* - 域文件读写复用 decision-distill 参数化底座(splitKnowledgeSections/joinKnowledgeFile/
|
|
17
|
+
* syncIndexRoutingLines/discoverModuleIndex),不复制实现。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import {
|
|
23
|
+
splitKnowledgeSections,
|
|
24
|
+
joinKnowledgeFile,
|
|
25
|
+
syncIndexRoutingLines,
|
|
26
|
+
discoverModuleIndex,
|
|
27
|
+
} from './decision-distill.js';
|
|
28
|
+
|
|
29
|
+
/** epoch:此前归档不检查/不索引(存量 93 份不回填——不伪造历史,D-007)。 */
|
|
30
|
+
export const FR_INDEX_EPOCH = '2026-09-18';
|
|
31
|
+
|
|
32
|
+
const FR_DIR = 'fr';
|
|
33
|
+
/** FR 节头:## FR-<域>-NNN 标题(域=[a-z0-9-] **含连字符**——真实模块 id 多为 cli-entry/core-engine 等连字符形态,
|
|
34
|
+
* R1 审查阻断①实证:漏 - 会让连字符域的发号/幂等/解析/承接全断。第二组为非捕获可选占位,
|
|
35
|
+
* 三组结构与底座 splitKnowledgeSections 的 decisions 正则对齐(m[3]=标题,勿改组序)。 */
|
|
36
|
+
const FR_SECTION_RE = /^## (FR-[a-z0-9-]+-\d+)(?:@v(\d+))?\s*(.*)$/;
|
|
37
|
+
const FR_GLOBAL_ID_RE = /^FR-[a-z0-9-]+-\d+$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析变更 requirements.md 的 FR 块。
|
|
41
|
+
* 形态(brainstorm step8 指引):`### FR-NN: 标题` + 可选 `承接: FR-<域>-NNN[, ...]` 行 +
|
|
42
|
+
* Given/When/Then 块(场景名取 `#### 场景:X` 或 `**场景:X**` 行;无场景名的 GWT 块按序号占位)。
|
|
43
|
+
* @returns {{ missing: boolean, frs: Array<{local:string,title:string,supersedes:string[],scenarios:string[]}>, malformed: string[] }}
|
|
44
|
+
*/
|
|
45
|
+
export function parseChangeRequirements(changeDir) {
|
|
46
|
+
const p = join(changeDir, 'requirements.md');
|
|
47
|
+
if (!existsSync(p)) return { missing: true, frs: [], malformed: [] };
|
|
48
|
+
let content;
|
|
49
|
+
try {
|
|
50
|
+
content = readFileSync(p, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return { missing: true, frs: [], malformed: [] };
|
|
53
|
+
}
|
|
54
|
+
const frs = [];
|
|
55
|
+
const malformed = [];
|
|
56
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
57
|
+
let cur = null;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const h = line.match(/^###\s+(FR-\d+)\s*[::]\s*(.*)$/);
|
|
60
|
+
if (h) {
|
|
61
|
+
if (cur) frs.push(cur);
|
|
62
|
+
cur = { local: h[1], title: (h[2] || '').trim(), supersedes: [], scenarios: [] };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!cur) continue;
|
|
66
|
+
const s = line.match(/^\s*承接\s*[::]\s*(.+)$/);
|
|
67
|
+
if (s) {
|
|
68
|
+
for (const tok of s[1].split(/[,,、]/)) {
|
|
69
|
+
const id = tok.trim();
|
|
70
|
+
if (!id) continue;
|
|
71
|
+
if (FR_GLOBAL_ID_RE.test(id)) cur.supersedes.push(id);
|
|
72
|
+
else malformed.push(`${cur.local} 的承接行含非全局 id 形态「${id}」(应为 FR-<域>-NNN)`);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const sc = line.match(/^\s*(?:#{2,4}\s*场景\s*[::]\s*(.+)|\*\*场景\s*[::]\s*([^*]+)\*\*)\s*$/);
|
|
77
|
+
if (sc) {
|
|
78
|
+
const name = ((sc[1] || sc[2] || '')).trim();
|
|
79
|
+
cur.scenarios.push(name || `场景${cur.scenarios.length + 1}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (cur) frs.push(cur);
|
|
83
|
+
return { missing: false, frs, malformed };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 域解析(导出供注入/软门消费):变更自身 design.md 文件变更清单表行(剥 NEW: 前缀)× moduleIndex paths 前缀匹配;无匹配 → unmapped。 */
|
|
87
|
+
export function resolveTouchedDomains(changeDir, moduleIndex) {
|
|
88
|
+
const domains = new Set();
|
|
89
|
+
const designPath = join(changeDir, 'design.md');
|
|
90
|
+
if (moduleIndex && existsSync(designPath)) {
|
|
91
|
+
let design;
|
|
92
|
+
try {
|
|
93
|
+
design = readFileSync(designPath, 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
design = '';
|
|
96
|
+
}
|
|
97
|
+
for (const line of design.replace(/\r\n/g, '\n').split('\n')) {
|
|
98
|
+
const m = line.match(/^\|\s*(?:新增|修改|删除)\s*\|\s*(?:NEW:)?([^\s|]+)\s*\|/);
|
|
99
|
+
if (!m) continue;
|
|
100
|
+
const filePath = m[1].trim();
|
|
101
|
+
for (const [modId, mod] of Object.entries(moduleIndex)) {
|
|
102
|
+
const paths = (mod && Array.isArray(mod.paths)) ? mod.paths : [];
|
|
103
|
+
if (paths.some((pp) => filePath === pp || filePath.startsWith(pp.endsWith('/') ? pp : pp + '/'))) {
|
|
104
|
+
domains.add(modId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (domains.size === 0) domains.add('unmapped');
|
|
110
|
+
return [...domains];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 域文件路径与域索引读写(复用参数化底座;FR 节无版本段,id 恒等)。 */
|
|
114
|
+
function frDirPath(knowledgeRoot) {
|
|
115
|
+
return join(knowledgeRoot, FR_DIR);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function loadDomainSections(knowledgeRoot, domain) {
|
|
119
|
+
const p = join(frDirPath(knowledgeRoot), `${domain}.md`);
|
|
120
|
+
if (!existsSync(p)) {
|
|
121
|
+
return {
|
|
122
|
+
preamble: [
|
|
123
|
+
'---',
|
|
124
|
+
`author: sillyspec-fr-index`,
|
|
125
|
+
`created_at: ${new Date().toISOString()}`,
|
|
126
|
+
'---',
|
|
127
|
+
'',
|
|
128
|
+
`# FR 索引 — ${domain}`,
|
|
129
|
+
'',
|
|
130
|
+
'> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
|
|
131
|
+
'> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
|
|
132
|
+
'',
|
|
133
|
+
],
|
|
134
|
+
sections: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return splitKnowledgeSections(readFileSync(p, 'utf8'), {
|
|
139
|
+
sectionRegex: FR_SECTION_RE,
|
|
140
|
+
buildId: (n) => n,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
return { preamble: [], sections: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderFrLines(entry, headHash) {
|
|
148
|
+
const lines = [`## ${entry.id} ${entry.title}`];
|
|
149
|
+
// 归属行键用「变更:」——与 decision-distill splitKnowledgeSections 的机械解析契约对齐
|
|
150
|
+
// (design 接口定义示例中的「来源变更」即本字段,语义等价,取解析器认的字面)
|
|
151
|
+
lines.push(`变更:${entry.change}`);
|
|
152
|
+
lines.push(`状态:${entry.supersededBy ? 'superseded' : 'active'}`);
|
|
153
|
+
if (entry.supersededBy) lines.push(`superseded_by:${entry.supersededBy}`);
|
|
154
|
+
if (entry.supersededOf) lines.push(`取代链:${entry.supersededOf} ← 本条目(${entry.change} 承接)`);
|
|
155
|
+
lines.push(`摘要:${(entry.scenarios || []).slice(0, 5).join(';') || '(无场景名)'}`);
|
|
156
|
+
lines.push(`最近确认:${headHash || ''}`);
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 全域条目扫描:domain → sections(供发号取 max、承接翻链、在场性检查)。 */
|
|
161
|
+
function scanAllDomains(knowledgeRoot) {
|
|
162
|
+
const out = new Map();
|
|
163
|
+
try {
|
|
164
|
+
for (const f of readdirSync(frDirPath(knowledgeRoot))) {
|
|
165
|
+
if (!f.endsWith('.md')) continue;
|
|
166
|
+
const domain = f.replace(/\.md$/, '');
|
|
167
|
+
out.set(domain, loadDomainSections(knowledgeRoot, domain));
|
|
168
|
+
}
|
|
169
|
+
} catch { /* 目录不存在 → 空索引 */ }
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nextIdForDomain(domain, allSections) {
|
|
174
|
+
let max = 0;
|
|
175
|
+
const re = new RegExp(`^FR-${domain.replace(/[-]/g, '-')}-(\\d+)$`);
|
|
176
|
+
for (const st of allSections.values()) {
|
|
177
|
+
for (const s of st.sections) {
|
|
178
|
+
const m = (s.number || '').match(re);
|
|
179
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return `FR-${domain}-${String(max + 1).padStart(3, '0')}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 归档索引主入口(幂等):解析 → 域解析 → 发号 → 承接翻链 → 写域文件 → INDEX 路由。
|
|
187
|
+
* @param {{ changeDir: string, knowledgeRoot: string, headHash?: string, cwd?: string }} args
|
|
188
|
+
* @returns {{ skipped?: string, written: Array<{file,id,action}>, superseded: Array<{from,to,change}>, unreferenced: Array<{domain,count}>, warnings: string[] }}
|
|
189
|
+
*/
|
|
190
|
+
export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
|
|
191
|
+
const changeName = changeDir.split(/[\\/]/).pop();
|
|
192
|
+
const parsed = parseChangeRequirements(changeDir);
|
|
193
|
+
if (parsed.missing) {
|
|
194
|
+
return { skipped: 'requirements.md 不存在(quick/scale:small 无索引义务)', written: [], superseded: [], unreferenced: [], warnings: [] };
|
|
195
|
+
}
|
|
196
|
+
if (parsed.frs.length === 0) {
|
|
197
|
+
return { skipped: 'requirements.md 无 FR 块', written: [], superseded: [], unreferenced: [], warnings: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const warnings = [...parsed.malformed];
|
|
201
|
+
const moduleIndex = discoverModuleIndex(knowledgeRoot);
|
|
202
|
+
const domains = resolveTouchedDomains(changeDir, moduleIndex);
|
|
203
|
+
const all = scanAllDomains(knowledgeRoot);
|
|
204
|
+
|
|
205
|
+
// 幂等闸门:任一域文件已有本变更「来源变更」条目 → 全量 no-op(同变更重跑零新增零漂移)
|
|
206
|
+
for (const st of all.values()) {
|
|
207
|
+
if (st.sections.some((s) => s.change === changeName)) {
|
|
208
|
+
return { written: [], superseded: [], unreferenced: [], warnings };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 承接校验:引用的全局 id 必须在现有索引中存在(typo → warn 不阻断)
|
|
213
|
+
const knownIds = new Set();
|
|
214
|
+
for (const st of all.values()) for (const s of st.sections) knownIds.add(s.number);
|
|
215
|
+
const refIds = new Set();
|
|
216
|
+
for (const fr of parsed.frs) for (const id of fr.supersedes) refIds.add(id);
|
|
217
|
+
for (const id of refIds) {
|
|
218
|
+
if (!knownIds.has(id)) warnings.push(`承接引用的 ${id} 不在索引中(域拼错或号不存在)——该引用不翻链,请核对`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 发号 + 写入(每 FR 落其首个触达域;多域变更按域去重逐条落)
|
|
222
|
+
const written = [];
|
|
223
|
+
const superseded = [];
|
|
224
|
+
const perDomainCounter = new Map();
|
|
225
|
+
const ensureDomain = (domain) => {
|
|
226
|
+
if (!all.has(domain)) all.set(domain, loadDomainSections(knowledgeRoot, domain));
|
|
227
|
+
return all.get(domain);
|
|
228
|
+
};
|
|
229
|
+
// 发号与翻链:每条 FR 落**首个触达域**(primary domain)取一个全局 id——幂等键=全局 id 单一身份
|
|
230
|
+
// (R1 审查缺口:per-domain × per-FR 会给同一 FR 发 N 个 id,违背 D-001 单一身份);
|
|
231
|
+
// 承接翻链可跨全域命中目标条目(新条目在 primary 域,旧条目可在任何域)。
|
|
232
|
+
const dirtyDomains = new Set();
|
|
233
|
+
const primaryDomain = domains[0];
|
|
234
|
+
const st = ensureDomain(primaryDomain);
|
|
235
|
+
for (const fr of parsed.frs) {
|
|
236
|
+
const id = nextIdForDomain(primaryDomain, all);
|
|
237
|
+
perDomainCounter.set(primaryDomain, (perDomainCounter.get(primaryDomain) || 0) + 1);
|
|
238
|
+
st.sections.push({
|
|
239
|
+
number: id,
|
|
240
|
+
version: NaN,
|
|
241
|
+
id,
|
|
242
|
+
title: fr.title,
|
|
243
|
+
change: changeName,
|
|
244
|
+
lines: renderFrLines({ id, title: fr.title, change: changeName, scenarios: fr.scenarios }, headHash),
|
|
245
|
+
});
|
|
246
|
+
dirtyDomains.add(primaryDomain);
|
|
247
|
+
written.push({ file: `${FR_DIR}/${primaryDomain}.md`, id, action: 'added' });
|
|
248
|
+
// 本 FR 的承接引用 → 翻旧条目(跨全域扫描目标段;就地补丁不动原摘要——R1 审查缺口:整段重写会抹掉摘要)
|
|
249
|
+
for (const refId of fr.supersedes) {
|
|
250
|
+
if (!knownIds.has(refId)) continue;
|
|
251
|
+
for (const [st2Domain, st2] of all.entries()) {
|
|
252
|
+
const target = st2.sections.find((s) => s.number === refId && !s.lines.some((l) => l.startsWith('superseded_by:')));
|
|
253
|
+
if (!target) continue;
|
|
254
|
+
// 就地补丁:状态翻 superseded + 插 superseded_by + 追取代链注记 + 刷新最近确认(摘要/标题保留)
|
|
255
|
+
target.lines = target.lines
|
|
256
|
+
.map((l) => (l.startsWith('状态:') ? `状态:superseded` : l.startsWith('最近确认:') ? `最近确认:${headHash || ''}` : l))
|
|
257
|
+
.filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:'));
|
|
258
|
+
const stateIdx = target.lines.findIndex((l) => l.startsWith('状态:'));
|
|
259
|
+
target.lines.splice(stateIdx + 1, 0, `superseded_by:${id}`, `取代链:${refId} ← ${id}(${changeName} 承接)`);
|
|
260
|
+
dirtyDomains.add(st2Domain);
|
|
261
|
+
superseded.push({ from: refId, to: id, change: changeName });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// unreferenced 探针(D-008 护栏③):触达域 active 条目未被本次承接引用的计数——观察信号,不算 L3 门禁
|
|
267
|
+
const unreferenced = [];
|
|
268
|
+
for (const domain of domains) {
|
|
269
|
+
const stx = all.get(domain);
|
|
270
|
+
if (!stx) continue;
|
|
271
|
+
const activeIds = stx.sections
|
|
272
|
+
.filter((s) => !s.lines.some((l) => l.startsWith('superseded_by:')))
|
|
273
|
+
.map((s) => s.number)
|
|
274
|
+
.filter((n) => n && !written.some((w) => w.id === n));
|
|
275
|
+
const count = activeIds.filter((id) => !refIds.has(id)).length;
|
|
276
|
+
if (count > 0) unreferenced.push({ domain, count });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 落盘(dirty 集=新条目域 ∪ 翻链命中域——R1 审查阻断②:漏翻链域会返回值谎报成功但盘上丢失)
|
|
280
|
+
mkdirSync(frDirPath(knowledgeRoot), { recursive: true });
|
|
281
|
+
for (const d of dirtyDomains) {
|
|
282
|
+
const stx = all.get(d);
|
|
283
|
+
writeFileSync(join(frDirPath(knowledgeRoot), `${d}.md`), joinKnowledgeFile(stx.preamble, stx.sections));
|
|
284
|
+
}
|
|
285
|
+
if (written.length > 0 || superseded.length > 0) {
|
|
286
|
+
syncIndexRoutingLines(knowledgeRoot, {
|
|
287
|
+
section: 'FR 需求索引',
|
|
288
|
+
subdir: FR_DIR,
|
|
289
|
+
makeLine: (d) => `- ${d}|FR|需求|承接 → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { written, superseded, unreferenced, warnings };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 全量索引扫描(doctor D14 第四检查消费):全部域的全部条目(含 superseded)拍平。
|
|
298
|
+
* @returns {Array<{domain,id,title,change,supersededBy:string|null}>} fr/ 目录不存在 → []
|
|
299
|
+
*/
|
|
300
|
+
export function scanFrIndex(knowledgeRoot) {
|
|
301
|
+
const out = [];
|
|
302
|
+
let files = [];
|
|
303
|
+
try {
|
|
304
|
+
files = readdirSync(frDirPath(knowledgeRoot)).filter((f) => f.endsWith('.md'));
|
|
305
|
+
} catch {
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
for (const f of files) {
|
|
309
|
+
const domain = f.replace(/\.md$/, '');
|
|
310
|
+
const st = loadDomainSections(knowledgeRoot, domain);
|
|
311
|
+
for (const s of st.sections) {
|
|
312
|
+
const sup = s.lines.find((l) => l.startsWith('superseded_by:'));
|
|
313
|
+
out.push({
|
|
314
|
+
domain,
|
|
315
|
+
id: s.number,
|
|
316
|
+
title: s.title || '',
|
|
317
|
+
change: s.change || '',
|
|
318
|
+
supersededBy: sup ? sup.replace(/^superseded_by:\s*/, '').trim() : null,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 注入源:触达域的 active 条目(superseded 默认藏——D-004)。
|
|
327
|
+
* @returns {Array<{domain,id,title,change,scenarios:string[]}>}
|
|
328
|
+
*/
|
|
329
|
+
export function readActiveFrDigest(knowledgeRoot, domains) {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const domain of domains || []) {
|
|
332
|
+
const st = loadDomainSections(knowledgeRoot, domain);
|
|
333
|
+
for (const s of st.sections) {
|
|
334
|
+
if (s.lines.some((l) => l.startsWith('superseded_by:'))) continue;
|
|
335
|
+
const scenarioLine = s.lines.find((l) => l.startsWith('摘要:'));
|
|
336
|
+
const scenarios = scenarioLine ? scenarioLine.replace(/^摘要:\s*/, '').split(';').map((x) => x.trim()).filter(Boolean) : [];
|
|
337
|
+
out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 标题 bigram 重叠率(中英混排零依赖):字符 bigram 集合 |A∩B| / |A∪B|。
|
|
345
|
+
*/
|
|
346
|
+
export function frTitleOverlap(a, b) {
|
|
347
|
+
const grams = (s) => {
|
|
348
|
+
const t = String(s || '').replace(/\s+/g, '');
|
|
349
|
+
const set = new Set();
|
|
350
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
|
|
351
|
+
return set;
|
|
352
|
+
};
|
|
353
|
+
const A = grams(a);
|
|
354
|
+
const B = grams(b);
|
|
355
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
356
|
+
let inter = 0;
|
|
357
|
+
for (const g of A) if (B.has(g)) inter++;
|
|
358
|
+
return inter / (A.size + B.size - inter);
|
|
359
|
+
}
|
package/src/knowledge-stats.js
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* 「窗口内无命中」两种空矩阵——后者死重对照无意义,人类可读模式同样略过 neverHit 段)。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync } from 'fs'
|
|
26
|
+
import { existsSync, readdirSync } from 'fs'
|
|
27
27
|
import { join } from 'path'
|
|
28
28
|
import { readKnowledgeHits } from './knowledge-hits.js'
|
|
29
29
|
import { parseKnowledgeIndex } from './knowledge-match.js'
|
|
30
|
+
import { scanFrIndex } from './fr-index.js'
|
|
30
31
|
|
|
31
32
|
// ── 聚合核心 ──
|
|
32
33
|
|
|
@@ -95,6 +96,85 @@ export function buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}
|
|
|
95
96
|
return { matrix, neverHit, totalInjects, totalClassifies }
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// ── FR 索引实验聚合(2026-09-18-fr-index-l1 后续钩子·L3 裁决仪表盘)──
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 聚合 L1 四类 fr-* 遥测事件 + knowledge/fr/ 索引面统计——L3 裁决(design 实验证伪条款)
|
|
103
|
+
* 的出数面:观察期满一眼读出 注入命中/取代链跟随/重复拦截/删除缺口信号 与 承接引用率。
|
|
104
|
+
*
|
|
105
|
+
* 口径:
|
|
106
|
+
* - 事件面(jsonl,sinceDays 窗口):fr-inject 计数+涉及变更数;fr-supersede 计数+涉及变更数;
|
|
107
|
+
* fr-duplicate-warning 计数+命中候选数;fr-unreferenced 按域聚合 count 总和;
|
|
108
|
+
* - 索引面(knowledge/fr/*.md,全量——索引是累积账本非窗口流):条目/active/superseded 计数、
|
|
109
|
+
* 来源变更数(承接引用率分母——事件流无 fr-added 事件,分母从索引读,不加归档管线事件);
|
|
110
|
+
* - 承接引用率 = fr-supersede 涉及变更数 / 索引来源变更数(分母 0 → null)。
|
|
111
|
+
* 注意分子是 sinceDays 窗口内、分母是全量——窗口≠全量时比率偏低是口径事实,
|
|
112
|
+
* 裁决时用大窗口(--since-days 9999)读全口径。
|
|
113
|
+
*
|
|
114
|
+
* @returns {{ present: boolean, events: {...}, index: {...}, supersedeRate: number|null }}
|
|
115
|
+
* present=false(无 fr/ 目录且零 fr-* 事件)→ 其余字段零值,消费方可整体略过。
|
|
116
|
+
*/
|
|
117
|
+
export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}) {
|
|
118
|
+
const records = readKnowledgeHits(runtimeDir, { sinceDays })
|
|
119
|
+
const frEvents = records.filter((r) => typeof r.type === 'string' && r.type.startsWith('fr-'))
|
|
120
|
+
|
|
121
|
+
const injectChanges = new Set()
|
|
122
|
+
const supersedeChanges = new Set()
|
|
123
|
+
const duplicateCandidates = new Set()
|
|
124
|
+
const unreferencedByDomain = new Map()
|
|
125
|
+
let frInject = 0
|
|
126
|
+
let frSupersede = 0
|
|
127
|
+
let frDuplicateWarning = 0
|
|
128
|
+
for (const r of frEvents) {
|
|
129
|
+
if (r.type === 'fr-inject') {
|
|
130
|
+
frInject += 1
|
|
131
|
+
if (r.change) injectChanges.add(r.change)
|
|
132
|
+
} else if (r.type === 'fr-supersede') {
|
|
133
|
+
frSupersede += 1
|
|
134
|
+
if (r.change) supersedeChanges.add(r.change)
|
|
135
|
+
} else if (r.type === 'fr-duplicate-warning') {
|
|
136
|
+
frDuplicateWarning += 1
|
|
137
|
+
if (r.candidate) duplicateCandidates.add(r.candidate)
|
|
138
|
+
} else if (r.type === 'fr-unreferenced') {
|
|
139
|
+
const domain = r.domain || 'unknown'
|
|
140
|
+
const row = unreferencedByDomain.get(domain) || { domain, events: 0, count: 0 }
|
|
141
|
+
row.events += 1
|
|
142
|
+
row.count += Number.isFinite(Number(r.count)) ? Number(r.count) : 0
|
|
143
|
+
unreferencedByDomain.set(domain, row)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const frDir = join(knowledgeDir, 'fr')
|
|
148
|
+
const frDirExists = existsSync(frDir)
|
|
149
|
+
const entries = frDirExists ? scanFrIndex(knowledgeDir) : []
|
|
150
|
+
const sourceChanges = new Set(entries.map((e) => e.change).filter(Boolean))
|
|
151
|
+
const supersededCount = entries.filter((e) => e.supersededBy).length
|
|
152
|
+
|
|
153
|
+
const denominator = sourceChanges.size
|
|
154
|
+
const supersedeRate = denominator > 0 ? supersedeChanges.size / denominator : null
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
present: frDirExists || frEvents.length > 0,
|
|
158
|
+
events: {
|
|
159
|
+
frInject,
|
|
160
|
+
frInjectChanges: injectChanges.size,
|
|
161
|
+
frSupersede,
|
|
162
|
+
frSupersedeChanges: supersedeChanges.size,
|
|
163
|
+
frDuplicateWarning,
|
|
164
|
+
frDuplicateCandidates: duplicateCandidates.size,
|
|
165
|
+
frUnreferenced: [...unreferencedByDomain.values()].sort((a, b) => b.count - a.count),
|
|
166
|
+
},
|
|
167
|
+
index: {
|
|
168
|
+
entries: entries.length,
|
|
169
|
+
active: entries.length - supersededCount,
|
|
170
|
+
superseded: supersededCount,
|
|
171
|
+
sourceChanges: denominator,
|
|
172
|
+
domains: frDirExists ? readdirSync(frDir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')) : [],
|
|
173
|
+
},
|
|
174
|
+
supersedeRate,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
98
178
|
// ── CLI 入口(形态对齐 src/stages/knowledge.js cmdSearch / src/knowledge-classify.js cmdKnowledgeClassify)──
|
|
99
179
|
|
|
100
180
|
function outputJson(ok, data, error) {
|
|
@@ -139,9 +219,10 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
|
|
|
139
219
|
// 遥测在场判定用全量读取(不加窗口):文件缺失/只有坏行 → 无遥测
|
|
140
220
|
const hasTelemetry = readKnowledgeHits(runtimeDir).length > 0
|
|
141
221
|
const result = buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays })
|
|
222
|
+
const frIndex = buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays })
|
|
142
223
|
|
|
143
224
|
if (asJson) {
|
|
144
|
-
outputJson(true, { sinceDays, hasTelemetry, ...result })
|
|
225
|
+
outputJson(true, { sinceDays, hasTelemetry, ...result, frIndex })
|
|
145
226
|
return
|
|
146
227
|
}
|
|
147
228
|
|
|
@@ -176,5 +257,21 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
|
|
|
176
257
|
}
|
|
177
258
|
|
|
178
259
|
lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次`)
|
|
260
|
+
|
|
261
|
+
// FR 索引实验(L3 裁决仪表盘——证伪条款出数面;裁决时建议 --since-days 大窗口读全口径)
|
|
262
|
+
if (frIndex.present) {
|
|
263
|
+
const e = frIndex.events
|
|
264
|
+
const i = frIndex.index
|
|
265
|
+
lines.push('')
|
|
266
|
+
lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)`)
|
|
267
|
+
lines.push(` 注入 fr-inject ${e.frInject} 次(${e.frInjectChanges} 变更) | 取代链 fr-supersede ${e.frSupersede} 次(${e.frSupersedeChanges} 变更) | 重复拦截 fr-duplicate-warning ${e.frDuplicateWarning} 次(${e.frDuplicateCandidates} 候选)`)
|
|
268
|
+
if (e.frUnreferenced.length > 0) {
|
|
269
|
+
const top = e.frUnreferenced.slice(0, 5).map((u) => `${u.domain}×${u.count}`).join(',')
|
|
270
|
+
lines.push(` 删除缺口信号 fr-unreferenced:${top}${e.frUnreferenced.length > 5 ? ' …' : ''}(观察信号·不算 L3 门禁)`)
|
|
271
|
+
}
|
|
272
|
+
lines.push(` 索引面:${i.entries} 条(active ${i.active} / superseded ${i.superseded})| 来源变更 ${i.sourceChanges} 个 | 域 ${i.domains.join(', ') || '—'}`)
|
|
273
|
+
lines.push(` 承接引用率:${frIndex.supersedeRate === null ? '—(索引空)' : `${(frIndex.supersedeRate * 100).toFixed(0)}%(${e.frSupersedeChanges}/${i.sourceChanges},分子窗口内/分母全量——裁决用大窗口)`}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
179
276
|
console.log(lines.join('\n'))
|
|
180
277
|
}
|