sillyspec 3.33.7 → 3.33.9

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/AGENTS.md +3 -2
  2. package/eval/knowledge/README.md +119 -0
  3. package/eval/knowledge/baseline.json +907 -0
  4. package/eval/knowledge/datasets/organic.jsonl +79 -0
  5. package/eval/knowledge/fixtures/index.mjs +132 -0
  6. package/eval/knowledge/lib/dataset.mjs +171 -0
  7. package/eval/knowledge/lib/engine.mjs +136 -0
  8. package/eval/knowledge/lib/faces.mjs +88 -0
  9. package/eval/knowledge/lib/metrics.mjs +56 -0
  10. package/eval/knowledge/lib/probes.mjs +98 -0
  11. package/eval/knowledge/lib/report.mjs +82 -0
  12. package/eval/knowledge/lib/repos.mjs +41 -0
  13. package/eval/knowledge/lib/snapshot.mjs +55 -0
  14. package/eval/knowledge/run.mjs +206 -0
  15. package/package.json +3 -2
  16. package/src/change-delete.js +24 -1
  17. package/src/checkpoint.js +258 -0
  18. package/src/config-schema.js +36 -0
  19. package/src/flow-parity.js +10 -50
  20. package/src/flow-review.js +4 -0
  21. package/src/flow.js +44 -10
  22. package/src/foreign-declared.js +2 -1
  23. package/src/index.js +100 -46
  24. package/src/knowledge-graph.js +243 -25
  25. package/src/knowledge-match.js +130 -21
  26. package/src/knowledge-stats.js +3 -0
  27. package/src/knowledge-vector.js +29 -4
  28. package/src/overlap-detect.js +68 -0
  29. package/src/redomain-unmapped.js +163 -2
  30. package/src/run/complete.js +20 -1
  31. package/src/run/gate-snapshot.js +3 -1
  32. package/src/run/prompt.js +67 -37
  33. package/src/run/quick-audit.js +58 -10
  34. package/src/run/shared.js +7 -1
  35. package/src/run/verify-quality-scan.js +52 -7
  36. package/src/stages/execute.js +64 -16
  37. package/src/stages/knowledge.js +57 -28
  38. package/src/task-tick.js +16 -0
  39. package/src/verify-postcheck.js +330 -3
  40. package/templates/agents-instruction.md +1 -1
  41. package/src/knowledge-quicklog.js +0 -261
@@ -0,0 +1,56 @@
1
+ /**
2
+ * eval/knowledge/lib/metrics.mjs — 检索评测指标纯函数(零 IO 零全局状态,可单测)
3
+ *
4
+ * 候选与期望的统一单位是「命中键」字符串(file#anchor / D-id / FR-id / 文件路径),
5
+ * 由 engine 层归一成本模块的输入;本模块只做排名数学。
6
+ * 浮点输出统一 4 位小数、键序固定——baseline.json 可 diff 的前提。
7
+ */
8
+
9
+ /** 期望条目在有序命中列表中的最佳名次(1-based;未命中 = Infinity)。 */
10
+ export function rankOf(hits, expectedKeys) {
11
+ const want = new Set(expectedKeys || [])
12
+ let best = Infinity
13
+ for (let i = 0; i < hits.length; i++) {
14
+ if (want.has(hits[i])) best = Math.min(best, i + 1)
15
+ }
16
+ return best
17
+ }
18
+
19
+ /** Top-k 命中判定(rank 为 Infinity 视为未命中)。 */
20
+ export function hitAtK(rank, k) {
21
+ return Number.isFinite(rank) && rank <= k
22
+ }
23
+
24
+ /** 平均倒数排名(未命中记 0)。 */
25
+ export function mrr(ranks) {
26
+ if (ranks.length === 0) return 0
27
+ return ranks.reduce((s, r) => s + (Number.isFinite(r) ? 1 / r : 0), 0) / ranks.length
28
+ }
29
+
30
+ /** precision@k:前 k 个命中里属于期望集的占比(top 为空记 0)。 */
31
+ export function precisionAtK(hits, expectedKeys, k) {
32
+ const want = new Set(expectedKeys || [])
33
+ const top = hits.slice(0, k)
34
+ if (top.length === 0) return 0
35
+ return top.filter((h) => want.has(h)).length / top.length
36
+ }
37
+
38
+ /** 汇总一组 case 的分面指标(键序固定 top1/top3/mrr/n;空集全零)。 */
39
+ export function summarize(ranks) {
40
+ const n = ranks.length
41
+ const r4 = (x) => Math.round(x * 10000) / 10000
42
+ return {
43
+ top1: n > 0 ? r4(ranks.filter((r) => hitAtK(r, 1)).length / n) : 0,
44
+ top3: n > 0 ? r4(ranks.filter((r) => hitAtK(r, 3)).length / n) : 0,
45
+ mrr: r4(mrr(ranks)),
46
+ n,
47
+ }
48
+ }
49
+
50
+ /** 稳定排序:按 score 降序,同分保持原始序(评测输出确定性的前提)。 */
51
+ export function stableSortByScore(items, scoreOf) {
52
+ return items
53
+ .map((it, i) => ({ it, i, s: scoreOf(it) }))
54
+ .sort((a, b) => (b.s - a.s) || (a.i - b.i))
55
+ .map((x) => x.it)
56
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * eval/knowledge/lib/probes.mjs — 六类缺陷探针执行器
3
+ *
4
+ * fixture 物化到 mkdtemp 仓根(<root>/.sillyspec/knowledge/...)后跑真实引擎函数,
5
+ * 断言按 evaluateCase 语义评估。pass = 修复后期望;缺陷存在时 pass=false(检出缺陷)。
6
+ * 临时目录 finally 清理;不写仓、不触网络/遥测。
7
+ */
8
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, dirname } from 'node:path'
11
+ import { PROBE_FIXTURES } from '../fixtures/index.mjs'
12
+ import { runEntries, runDecisions, searchView, runScope, catalogHealth } from './engine.mjs'
13
+
14
+ /** expected 匹配:含 "#" 精确;否则文件级前缀(hit === exp 或 hit.startsWith(exp + "#"))。 */
15
+ export function hitMatches(hit, expected) {
16
+ if (String(expected).includes('#')) return hit === expected
17
+ return hit === expected || String(hit).startsWith(`${expected}#`)
18
+ }
19
+
20
+ function containsAll(hits, expected) {
21
+ return (expected || []).every((e) => hits.some((h) => hitMatches(h, e)))
22
+ }
23
+
24
+ function excludesAll(hits, expected) {
25
+ return (expected || []).every((e) => !hits.some((h) => hitMatches(h, e)))
26
+ }
27
+
28
+ /** 物化 fixture 到 mkdtemp 仓根;写入中途失败时清理临时目录再抛(不泄漏)。 */
29
+ export function materialize(fixture) {
30
+ const root = mkdtempSync(join(tmpdir(), `ek-probe-${fixture.defect}-`))
31
+ try {
32
+ const kdir = join(root, '.sillyspec', 'knowledge')
33
+ for (const [rel, content] of Object.entries(fixture.files || {})) {
34
+ const p = join(kdir, rel)
35
+ mkdirSync(dirname(p), { recursive: true })
36
+ writeFileSync(p, content)
37
+ }
38
+ } catch (e) {
39
+ rmSync(root, { recursive: true, force: true })
40
+ throw e
41
+ }
42
+ return root
43
+ }
44
+
45
+ /** 单 case 评估 → { name, pass, detail, hits }。 */
46
+ export async function evaluateCase(repoRoot, c) {
47
+ const expect = c.expect || {}
48
+ const fails = []
49
+ let hits = []
50
+ if (c.kind === 'entries' || c.kind === 'search' || c.kind === 'decisions' || c.kind === 'scope') {
51
+ if (c.kind === 'entries') hits = (await runEntries(repoRoot, c.query)).hits
52
+ else if (c.kind === 'decisions') hits = (await runDecisions(repoRoot, c.query)).hits
53
+ else if (c.kind === 'scope') hits = runScope(repoRoot, c.scope_files || []).merged
54
+ else {
55
+ const rows = await searchView(repoRoot, c.query, Infinity)
56
+ hits = rows.map((r) => r.file)
57
+ if (expect.score_contract) {
58
+ const bad = rows.filter((r) => r.score !== r.matchedTags.length)
59
+ if (bad.length > 0) fails.push(`score 契约违反:${bad.map((r) => `${r.file} score=${r.score} 命中=${r.matchedTags.length}`).join(';')}`)
60
+ }
61
+ }
62
+ if (!containsAll(hits, expect.contains)) fails.push(`缺少期望命中:${(expect.contains || []).filter((e) => !hits.some((h) => hitMatches(h, e))).join('、')}`)
63
+ if (!excludesAll(hits, expect.excludes)) fails.push(`出现禁止命中:${(expect.excludes || []).filter((e) => hits.some((h) => hitMatches(h, e))).join('、')}`)
64
+ if (expect.empty === true && hits.length > 0) fails.push(`期望空命中但得到 ${hits.length} 条`)
65
+ if (expect.empty === false && hits.length === 0) fails.push('期望非空但零命中')
66
+ } else if (c.kind === 'catalog') {
67
+ const health = catalogHealth(repoRoot)
68
+ hits = [`total=${health.total}`, `anchored=${health.anchored}`, `files=${health.files}`, `linked=${health.linked}`]
69
+ for (const [k, v] of Object.entries(expect.health || {})) {
70
+ if (health[k] !== v) fails.push(`catalog.${k}=${health[k]} ≠ 期望 ${v}`)
71
+ }
72
+ } else {
73
+ fails.push(`未知 kind:${c.kind}`)
74
+ }
75
+ return {
76
+ name: c.name || c.kind,
77
+ pass: fails.length === 0,
78
+ detail: fails.length === 0 ? `ok(hits=${hits.length})` : fails.join(';'),
79
+ hits: hits.slice(0, 8),
80
+ }
81
+ }
82
+
83
+ /** 全量探针(六个 fixture 顺序执行;tmp 目录 finally 清理)。 */
84
+ export async function runAllProbes() {
85
+ const out = []
86
+ for (const fixture of PROBE_FIXTURES) {
87
+ let root = ''
88
+ try {
89
+ root = materialize(fixture)
90
+ const cases = []
91
+ for (const c of fixture.cases) cases.push(await evaluateCase(root, c))
92
+ out.push({ defect: fixture.defect, pass: cases.every((c) => c.pass), cases })
93
+ } finally {
94
+ if (root) rmSync(root, { recursive: true, force: true })
95
+ }
96
+ }
97
+ return out
98
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * eval/knowledge/lib/report.mjs — 控制台表与 baseline 对比
3
+ */
4
+
5
+ /** 四舍五入 4 位(与 metrics 同口径)。 */
6
+ const r4 = (x) => Math.round(Number(x) * 10000) / 10000
7
+
8
+ /** 控制台表:每仓每面的 raw/rendered 指标 + probes + self smoke。 */
9
+ export function renderTable(result) {
10
+ const lines = []
11
+ lines.push('知识检索评测基线(eval/knowledge)')
12
+ for (const repo of Object.keys(result.metrics || {})) {
13
+ const c = result.cases?.[repo] || {}
14
+ lines.push(`\n[${repo}] organic=${c.organic ?? 0} self=${c.self ?? 0}`)
15
+ for (const [face, m] of Object.entries(result.metrics[repo])) {
16
+ lines.push(` ${face.padEnd(9)} raw top1=${m.raw.top1} top3=${m.raw.top3} mrr=${m.raw.mrr} n=${m.raw.n}`
17
+ + ` | rendered top1=${m.rendered.top1} top3=${m.rendered.top3} | truncated=${(m.truncated || []).length}`)
18
+ if (m.subsets) {
19
+ const parts = Object.entries(m.subsets).map(([k, v]) => `${k}(n=${v.n},top1=${v.top1},top3=${v.top3},reach=${v.reachable})`).join(' ')
20
+ lines.push(` subsets: ${parts}`)
21
+ }
22
+ if (Array.isArray(m.scope_unreachable) && m.scope_unreachable.length > 0) {
23
+ lines.push(` scope_unreachable: ${m.scope_unreachable.join('、')}`)
24
+ }
25
+ }
26
+ const ss = result.self_smoke?.[repo]
27
+ if (ss && ss.cases > 0) {
28
+ lines.push(` self-smoke cases=${ss.cases} own_hit_top3=${ss.own_hit_top3} name_only=${ss.name_only_top3} leak_gap=${ss.leak_gap}`)
29
+ }
30
+ }
31
+ lines.push('\n缺陷探针:')
32
+ for (const [defect, pass] of Object.entries(result.probes || {})) {
33
+ lines.push(` ${pass ? 'GREEN' : 'RED '} ${defect}`)
34
+ }
35
+ return lines.join('\n')
36
+ }
37
+
38
+ /**
39
+ * baseline 对比:快照指纹不一致 → snapshot_mismatch;各面 raw.top3 与 probe 状态变化逐项列出;
40
+ * baselineSummary 返回基线侧每面 raw/rendered 全量指标(mismatch 时两套指标都要可见)。
41
+ * @returns {{mismatches: string[], changes: string[], baselineSummary: object}}
42
+ */
43
+ export function diffBaseline(cur, base) {
44
+ const mismatches = []
45
+ const changes = []
46
+ const baselineSummary = {}
47
+ if (!base || typeof base !== 'object') return { mismatches, changes, baselineSummary }
48
+ for (const repo of Object.keys(cur.snapshot || {})) {
49
+ const b = base.snapshot?.[repo]
50
+ const c = cur.snapshot[repo]
51
+ if (!b) continue
52
+ if (b.head !== c.head || b.knowledge_hash !== c.knowledge_hash) {
53
+ mismatches.push(`${repo}: snapshot_mismatch(head ${String(b.head).slice(0, 8)}→${String(c.head).slice(0, 8)})`)
54
+ }
55
+ }
56
+ for (const repo of Object.keys(cur.metrics || {})) {
57
+ const bm = base.metrics?.[repo] || {}
58
+ for (const face of Object.keys(cur.metrics[repo])) {
59
+ const bmFace = bm[face]
60
+ const c3 = cur.metrics[repo][face].raw.top3
61
+ if (bmFace && bmFace.raw) {
62
+ baselineSummary[`${repo}/${face}`] = { raw: bmFace.raw, rendered: bmFace.rendered }
63
+ const b3 = bmFace.raw.top3
64
+ if (typeof b3 === 'number' && r4(b3) !== r4(c3)) changes.push(`${repo}/${face} raw.top3 ${b3} → ${c3}`)
65
+ }
66
+ }
67
+ }
68
+ for (const defect of Object.keys(cur.probes || {})) {
69
+ if (typeof base.probes?.[defect] === 'boolean' && base.probes[defect] !== cur.probes[defect]) {
70
+ changes.push(`probe ${defect}: ${base.probes[defect] ? 'GREEN' : 'RED'} → ${cur.probes[defect] ? 'GREEN' : 'RED'}`)
71
+ }
72
+ }
73
+ // self 趋势指标(不进质量门槛,仅 diff 可见——own_hit_top3 变化反映蒸馏→检索链路质量)
74
+ for (const repo of Object.keys(cur.self_smoke || {})) {
75
+ const b = base.self_smoke?.[repo]
76
+ const c = cur.self_smoke[repo]
77
+ if (b && c && typeof b.own_hit_top3 === 'number' && typeof c.own_hit_top3 === 'number' && r4(b.own_hit_top3) !== r4(c.own_hit_top3)) {
78
+ changes.push(`self[${repo}] own_hit_top3 ${b.own_hit_top3} → ${c.own_hit_top3}(趋势,非门槛)`)
79
+ }
80
+ }
81
+ return { mismatches, changes, baselineSummary }
82
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * eval/knowledge/lib/repos.mjs — 评测仓参数解析(项目中立:不枚举任何具体项目)
3
+ *
4
+ * 约定:--repo 接受逗号分隔的路径列表;每项可写 `<label>:<path>` 显式指定仓标签
5
+ * (label 用于 organic 数据集匹配与 baseline 键),省略时取路径 basename。
6
+ * 缺省(不传 --repo)= 当前仓。Windows 盘符(C:/、C:\)不当作 label 分隔符。
7
+ */
8
+ import { basename, resolve } from 'node:path'
9
+
10
+ /** 解析单项:`label:path` 或裸 path → { label, path }。盘符形态(`C:\` / `C:/`)不当作 label。 */
11
+ export function parseRepoSpec(item) {
12
+ const raw = String(item || '').trim()
13
+ if (!raw) throw new Error('--repo 含空项')
14
+ if (/^[A-Za-z]:[\\/]/.test(raw)) {
15
+ const abs = resolve(raw)
16
+ return { label: basename(abs) || abs, path: abs }
17
+ }
18
+ const m = /^([^:/\\]+):(.+)$/.exec(raw)
19
+ if (m) return { label: m[1], path: resolve(m[2]) }
20
+ const abs = resolve(raw)
21
+ return { label: basename(abs) || abs, path: abs }
22
+ }
23
+
24
+ /**
25
+ * 解析 --repo 参数 → Map<label, absPath>。
26
+ * @param {string|null} arg 原始值(逗号分隔;null/''/'all' = 缺省当前仓)
27
+ * @param {string} cwd 当前仓根
28
+ * @returns {Map<string,string>}
29
+ */
30
+ export function parseRepoArgs(arg, cwd) {
31
+ const items = arg && arg !== 'all' ? String(arg).split(',').map((s) => s.trim()).filter(Boolean) : []
32
+ const specs = items.length > 0 ? items.map(parseRepoSpec) : [parseRepoSpec(cwd)]
33
+ const out = new Map()
34
+ for (const { label, path } of specs) {
35
+ if (out.has(label) && out.get(label) !== path) {
36
+ throw new Error(`仓标签冲突:${label}(${out.get(label)} 与 ${path})——请用 label:path 显式区分`)
37
+ }
38
+ out.set(label, path)
39
+ }
40
+ return out
41
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * eval/knowledge/lib/snapshot.mjs — 评测快照指纹(git HEAD + knowledge 树内容 sha256)
3
+ *
4
+ * 指纹口径:相对路径 POSIX 归一后按字典序排序,逐文件喂 "路径\0内容\0" 到单一 sha256。
5
+ * 目录缺失 = 空树哈希(合法形态:平台仓可能有 knowledge 但无 fr/ 子目录等)。
6
+ * baseline 对比只在指纹一致时可信——不一致必须显式告警(runner 输出 snapshot_mismatch)。
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { execFileSync } from 'node:child_process'
10
+ import { readdirSync, readFileSync } from 'node:fs'
11
+ import { join, relative } from 'node:path'
12
+
13
+ export function gitHead(repoRoot) {
14
+ try {
15
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
16
+ cwd: repoRoot, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
17
+ }).trim()
18
+ } catch {
19
+ return ''
20
+ }
21
+ }
22
+
23
+ /** 递归收集文件相对路径(POSIX 归一、字典序稳定;目录缺失 = 空数组)。 */
24
+ function listFiles(dir, base = dir, out = []) {
25
+ let entries
26
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
27
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
28
+ const p = join(dir, e.name)
29
+ if (e.isDirectory()) listFiles(p, base, out)
30
+ else if (e.isFile()) out.push(relative(base, p).replace(/\\/g, '/'))
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** knowledge 树内容哈希(相对路径排序 + \0 分隔喂 sha256)。 */
36
+ export function hashKnowledgeTree(knowledgeDir) {
37
+ const h = createHash('sha256')
38
+ for (const rel of listFiles(knowledgeDir).sort()) {
39
+ h.update(rel)
40
+ h.update('\0')
41
+ try { h.update(readFileSync(join(knowledgeDir, rel))) } catch { h.update('<unreadable>') }
42
+ h.update('\0')
43
+ }
44
+ return h.digest('hex')
45
+ }
46
+
47
+ /** 仓快照指纹(head + knowledge_hash + 文件数,供 baseline 比对与输出)。 */
48
+ export function snapshotFingerprint(repoRoot) {
49
+ const knowledgeDir = join(repoRoot, '.sillyspec', 'knowledge')
50
+ return {
51
+ head: gitHead(repoRoot),
52
+ knowledge_hash: hashKnowledgeTree(knowledgeDir),
53
+ knowledge_files: listFiles(knowledgeDir).length,
54
+ }
55
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * eval/knowledge/run.mjs — 知识检索评测 runner(变更 0:评测面)
4
+ *
5
+ * 通用性(工具中立):不枚举任何具体项目——`--repo` 接受任意仓路径(逗号分隔,
6
+ * 可 `<label>:<path>` 显式标签),缺省评当前仓;organic 数据集的 repo 字段是标签,
7
+ * 与 `--repo` 的 label 或路径 basename 对应。
8
+ *
9
+ * 用法:
10
+ * node eval/knowledge/run.mjs [--repo <path|label:path>[,<path>…]] [--face entries|decisions|search|scope|all]
11
+ * [--set-baseline] [--json] [--smoke]
12
+ *
13
+ * 设计锚:.sillyspec/changes/2026-11-02-knowledge-eval/design.md
14
+ * 零网络零遥测:强制 SILLYSPEC_KNOWLEDGE_VECTOR=off / SILLYSPEC_HITS_PUSH=0,只调纯读引擎;
15
+ * 唯一写点是 --set-baseline 的 baseline.json(临时文件+rename 原子落盘)与图派生缓存(可再生)。
16
+ * 面:entries@3(flow 📚)、decisions@5(rejected 优先)、search@10(cmdSearch 等价)、
17
+ * scope@3/20(图遍历);raw=引擎全量、rendered=消费面切片,per-case 名次进 details。
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdtempSync, rmSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { tmpdir } from 'node:os'
23
+ import { loadOrganic, buildSelfCases, validateLabels } from './lib/dataset.mjs'
24
+ import { runDecisions } from './lib/engine.mjs'
25
+ import { snapshotFingerprint } from './lib/snapshot.mjs'
26
+ import { runAllProbes } from './lib/probes.mjs'
27
+ import { renderTable, diffBaseline } from './lib/report.mjs'
28
+ import { runFace, rankWithMatches, FACES, subsetSummaries } from './lib/faces.mjs'
29
+ import { summarize } from './lib/metrics.mjs'
30
+ import { parseRepoArgs } from './lib/repos.mjs'
31
+
32
+ process.env.SILLYSPEC_KNOWLEDGE_VECTOR = 'off'
33
+ process.env.SILLYSPEC_HITS_PUSH = '0'
34
+
35
+ const here = fileURLToPath(new URL('.', import.meta.url))
36
+ const repoRoot = join(here, '..', '..')
37
+ const DATASET = join(here, 'datasets', 'organic.jsonl')
38
+ const BASELINE = join(here, 'baseline.json')
39
+
40
+ const args = process.argv.slice(2)
41
+ const has = (n) => args.includes(n)
42
+ const val = (n) => {
43
+ const i = args.indexOf(n)
44
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
45
+ }
46
+
47
+ const facesArg = val('--face') || 'all'
48
+ if (!['all', ...FACES].includes(facesArg)) {
49
+ console.error(`❌ 未知面:${facesArg}(可选 ${['all', ...FACES].join('|')})`)
50
+ process.exit(2)
51
+ }
52
+ let REPO_ROOTS
53
+ try {
54
+ REPO_ROOTS = parseRepoArgs(val('--repo'), repoRoot)
55
+ } catch (e) {
56
+ console.error(`❌ ${e.message}`)
57
+ process.exit(2)
58
+ }
59
+ const wantFaces = facesArg === 'all' ? FACES : [facesArg]
60
+
61
+ /** self 集 smoke:own 命中率 + name-only 对照(leak_gap = full − name_only,越接近 0 越靠变更名泄漏)。 */
62
+ async function runSelfSmoke(repoPath, repo) {
63
+ const { cases, skipped } = buildSelfCases(repoPath, repo)
64
+ if (cases.length === 0) return { cases: 0, skipped: skipped.length }
65
+ let full = 0
66
+ let nameOnly = 0
67
+ for (const c of cases) {
68
+ const name = c.id.replace(/^self-/, '')
69
+ const ownHit = async (q) => rankWithMatches((await runDecisions(repoPath, q)).hits, c.own) <= 3
70
+ if (await ownHit(c.query)) full++
71
+ if (await ownHit(name)) nameOnly++
72
+ }
73
+ const n = cases.length
74
+ const r4 = (x) => Math.round(x * 10000) / 10000
75
+ return {
76
+ cases: n,
77
+ skipped: skipped.length,
78
+ own_hit_top3: r4(full / n),
79
+ name_only_top3: r4(nameOnly / n),
80
+ leak_gap: r4((full - nameOnly) / n),
81
+ }
82
+ }
83
+
84
+ /** --smoke:零数据依赖冒烟(probes + baseline 原子读写往返于 mkdtemp)。 */
85
+ async function runSmoke() {
86
+ const probes = Object.fromEntries((await runAllProbes()).map((p) => [p.defect, p.pass]))
87
+ const dir = mkdtempSync(join(tmpdir(), 'ek-smoke-'))
88
+ try {
89
+ const f = join(dir, 'baseline.json')
90
+ const tmp = `${f}.tmp`
91
+ const payload = { version: 1, probes }
92
+ writeFileSync(tmp, JSON.stringify(payload, null, 2))
93
+ renameSync(tmp, f)
94
+ const back = JSON.parse(readFileSync(f, 'utf8'))
95
+ if (JSON.stringify(back) !== JSON.stringify(payload)) throw new Error('baseline 读写往返失败')
96
+ } finally {
97
+ rmSync(dir, { recursive: true, force: true })
98
+ }
99
+ return probes
100
+ }
101
+
102
+ async function main() {
103
+ if (has('--smoke')) {
104
+ const probes = await runSmoke()
105
+ const detected = Object.entries(probes).filter(([, p]) => p === false).map(([d]) => d)
106
+ const out = { ok: true, mode: 'smoke', probes, detected_defects: detected }
107
+ if (has('--json')) {
108
+ console.log(JSON.stringify(out, null, 2))
109
+ return
110
+ }
111
+ console.log(renderTable({ metrics: {}, probes }))
112
+ console.log(detected.length
113
+ ? `\nℹ️ 检出缺陷 ${detected.length} 类(probe RED = 缺陷仍在,修复后转 GREEN):${detected.join('、')}`
114
+ : '\n✅ 全部探针 GREEN')
115
+ return
116
+ }
117
+
118
+ const { cases: organic, skipped } = loadOrganic(DATASET)
119
+ const labelIssues = validateLabels(organic, REPO_ROOTS)
120
+ const result = {
121
+ version: 1,
122
+ generated_by: 'eval/knowledge/run.mjs',
123
+ snapshot: {},
124
+ cases: {},
125
+ metrics: {},
126
+ self_smoke: {},
127
+ probes: {},
128
+ label_issues: labelIssues,
129
+ warnings: [],
130
+ }
131
+ for (const [repo, path] of REPO_ROOTS) {
132
+ const repoCases = organic.filter((c) => c.repo === repo)
133
+ result.cases[repo] = { organic: repoCases.length, self: 0 }
134
+ if (!existsSync(join(path, '.sillyspec', 'knowledge'))) {
135
+ result.warnings.push(existsSync(path)
136
+ ? `仓无 .sillyspec/knowledge:${repo} → ${path}`
137
+ : `仓路径不存在:${repo} → ${path}`)
138
+ result.snapshot[repo] = { head: '', knowledge_hash: '', knowledge_files: 0, missing: true }
139
+ result.metrics[repo] = {}
140
+ continue
141
+ }
142
+ result.snapshot[repo] = snapshotFingerprint(path)
143
+ result.metrics[repo] = {}
144
+ for (const face of wantFaces) {
145
+ let faceCases = []
146
+ if (face === 'entries') faceCases = repoCases.filter((c) => c.face === 'entries' && c.subset !== 'fusion')
147
+ else if (face === 'decisions') faceCases = repoCases.filter((c) => c.face === 'decisions' && c.subset !== 'fusion')
148
+ else if (face === 'search') faceCases = repoCases.filter((c) => c.face === 'entries' && c.subset !== 'fusion')
149
+ else if (face === 'fr') faceCases = repoCases.filter((c) => c.face === 'fr')
150
+ else if (face === 'scope') faceCases = repoCases.filter((c) => Array.isArray(c.scope_files) && c.scope_files.length > 0 && c.face !== 'fr')
151
+ const faceResult = await runFace(path, face, faceCases)
152
+ if (face === 'scope') {
153
+ // 子集单列(FR-05):frozen(原 5 条)与 added 分开报,禁止用新增 case 掩盖冻结子集
154
+ faceResult.subsets = subsetSummaries(faceResult, faceCases)
155
+ faceResult.scope_unreachable = faceResult.details.filter((d) => d.raw_rank === null).map((d) => d.id)
156
+ } else if (face === 'entries' || face === 'decisions' || face === 'fr') {
157
+ // fusion / fr prod-approx 子集单列:新增 case 不混入旧 case 聚合解读
158
+ if (faceCases.some((c) => c.subset)) faceResult.subsets = subsetSummaries(faceResult, faceCases)
159
+ }
160
+ result.metrics[repo][face] = faceResult
161
+ }
162
+ const self = await runSelfSmoke(path, repo)
163
+ result.self_smoke[repo] = self
164
+ result.cases[repo].self = self.cases
165
+ }
166
+ const unmatched = organic.filter((c) => !REPO_ROOTS.has(c.repo))
167
+ result.probes = Object.fromEntries((await runAllProbes()).map((p) => [p.defect, p.pass]))
168
+
169
+ let diffs = { mismatches: [], changes: [], baselineSummary: {} }
170
+ if (has('--set-baseline')) {
171
+ const tmp = `${BASELINE}.tmp-${process.pid}`
172
+ writeFileSync(tmp, JSON.stringify(result, null, 2) + '\n')
173
+ renameSync(tmp, BASELINE) // 原子落盘(中途崩溃不产半截基线)
174
+ } else if (existsSync(BASELINE)) {
175
+ try {
176
+ diffs = diffBaseline(result, JSON.parse(readFileSync(BASELINE, 'utf8')))
177
+ } catch (e) {
178
+ diffs.error = `baseline 解析失败:${e.message}——按无基线处理(当前指标仍有效)`
179
+ }
180
+ }
181
+
182
+ if (has('--json')) {
183
+ console.log(JSON.stringify({ ok: true, skipped_cases: skipped.length, unmatched_cases: unmatched.length, ...result, diff: diffs }, null, 2))
184
+ return
185
+ }
186
+ console.log(renderTable(result))
187
+ if (result.warnings.length > 0) console.log(`\n⚠️ ${result.warnings.join(';')}`)
188
+ if (labelIssues.length > 0) console.log(`\n⚠️ 数据集标签自检 ${labelIssues.length} 条问题(结构性不可命中):${labelIssues.map((i) => `${i.id}:${i.reason}`).join(' ')}`)
189
+ if (skipped.length > 0) console.log(`\n⚠️ organic 跳过 ${skipped.length} 条(字段缺失/期望空):${skipped.map((s) => `${s.id || `L${s.line}`}:${s.reason}`).join(' ')}`)
190
+ if (unmatched.length > 0) console.log(`\n⚠️ organic 有 ${unmatched.length} 条未匹配本次仓标签(${[...new Set(unmatched.map((c) => c.repo))].join('、')})——用 --repo label:path 对齐标签或忽略`)
191
+ if (diffs.error) console.log(`\n⚠️ ${diffs.error}`)
192
+ if (diffs.mismatches.length > 0) {
193
+ console.log(`\n⚠️ ${diffs.mismatches.join(';')}——基线与当前快照不一致,两套指标并列如下`)
194
+ for (const [k, v] of Object.entries(diffs.baselineSummary || {})) {
195
+ console.log(` baseline ${k}: raw top1=${v.raw.top1} top3=${v.raw.top3} mrr=${v.raw.mrr}`
196
+ + ` | rendered top1=${v.rendered.top1} top3=${v.rendered.top3} mrr=${v.rendered.mrr}`)
197
+ }
198
+ }
199
+ if (diffs.changes.length > 0) console.log(`\n📊 相对 baseline 变化:\n ${diffs.changes.join('\n ')}`)
200
+ if (has('--set-baseline')) console.log(`\n✅ baseline 已写入 ${BASELINE}`)
201
+ }
202
+
203
+ main().catch((e) => {
204
+ console.error(`❌ 评测失败:${e && e.stack ? e.stack : e}`)
205
+ process.exit(1)
206
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.33.7",
3
+ "version": "3.33.9",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -28,7 +28,8 @@
28
28
  "test": "node test/run-tests.mjs",
29
29
  "lint": "node test/check-syntax.mjs",
30
30
  "eval:dry": "node eval/run.mjs --dry-run",
31
- "eval:pilot": "node eval/run.mjs --pilot"
31
+ "eval:pilot": "node eval/run.mjs --pilot",
32
+ "eval:knowledge": "node eval/knowledge/run.mjs"
32
33
  },
33
34
  "dependencies": {
34
35
  "@inquirer/prompts": "^7.10.1",
@@ -106,12 +106,23 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
106
106
 
107
107
  const dirFiles = dirExists ? countFiles(dirPath) : 0;
108
108
  const worktreeInfo = await probeWorktree(cwd, changeName);
109
+ // 影子存档探测(2026-10-28-shadow-checkpoint):dry-run 也报存档链规模——「删了丢多少存档」
110
+ // 与「删了丢代码」同级的决策信息。非 git 仓/无 ref 静默 null。
111
+ let checkpointInfo = null;
112
+ try {
113
+ const { hasCheckpointRef, listCheckpoints } = await import('./checkpoint.js');
114
+ if (hasCheckpointRef(cwd, changeName)) {
115
+ const list = await listCheckpoints({ repoRoot: cwd, changeName });
116
+ checkpointInfo = { ref: `refs/sillyspec/checkpoints/${changeName}`, count: list.length };
117
+ }
118
+ } catch { /* 非 git 仓/探测失败:无存档面 */ }
109
119
 
110
120
  if (!confirm) {
111
121
  return {
112
122
  ok: true, action: 'dry_run', change: changeName,
113
123
  db_row: row, dir: dirExists ? { path: dirPath, files: dirFiles } : null,
114
124
  worktree: worktreeInfo,
125
+ checkpoints: checkpointInfo,
115
126
  already_deleted: alreadyDeleted,
116
127
  };
117
128
  }
@@ -149,6 +160,18 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
149
160
  } catch (e) {
150
161
  warnings.push(`worktree 清理失败(不阻断删除):${e.message}`);
151
162
  }
163
+ // 3.5 影子存档 ref 清理(2026-10-28-shadow-checkpoint FR-05):删除是终态,存档链随变更
164
+ // 一并清 ref(对象交 git gc 自然回收);归档不删(审计面保留)。best-effort:失败仅警告。
165
+ let checkpointsRemoved = false;
166
+ try {
167
+ const { hasCheckpointRef, deleteCheckpointRef } = await import('./checkpoint.js');
168
+ if (hasCheckpointRef(cwd, changeName)) {
169
+ checkpointsRemoved = deleteCheckpointRef(cwd, changeName);
170
+ if (!checkpointsRemoved) warnings.push(`影子存档 ref 删除失败(不阻断,可手删 refs/sillyspec/checkpoints/${changeName})`);
171
+ }
172
+ } catch (e) {
173
+ warnings.push(`影子存档 ref 清理异常(不阻断删除):${e.message}`);
174
+ }
152
175
  // 4. git 暂存目录删除(best-effort:非 git 仓 / specBase 在仓外时跳过,git status 照常可见)
153
176
  try {
154
177
  const relDir = relative(cwd, dirPath).split('\\').join('/');
@@ -178,6 +201,6 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
178
201
  return {
179
202
  ok: true, action: 'deleted', change: changeName,
180
203
  db_updated: dbUpdated, dir_removed: dirExists, files_removed: dirFiles,
181
- worktree: worktreeInfo, warnings,
204
+ worktree: worktreeInfo, checkpoints_removed: checkpointsRemoved, warnings,
182
205
  };
183
206
  }