sillyspec 3.33.8 → 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.
@@ -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.8",
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",
@@ -72,12 +72,24 @@ export const LOCAL_YAML_SCHEMA = {
72
72
  { path: 'commands.lint', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'runQuickTestLintGate (src/run/quick-audit.js)', 'validateCommands (src/scan-postcheck.js)'], desc: 'lint 命令——quick --done 触及 src/test 时 CLI 亲自执行(P0-2 门禁,失败阻断完成)。', example: 'npm run lint' },
73
73
  { path: 'commands.test_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['resolveTestTimeoutMs (src/verify-postcheck.js)'], desc: 'commands.test 超时秒数(R9 实证 2026-09-23:全量套件实测可达 27min,固定 600s 帽必杀)——优先级 local.yaml 本键 > env SILLYSPEC_TEST_TIMEOUT_MS > 缺省 600。慢仓在此提帽,防真慢套件被当超时失败。', example: '2400' },
74
74
  { path: 'commands.lint_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyLintCheck (src/verify-postcheck.js)'], desc: 'commands.lint 超时秒数——优先级 显式调用参 > 本键 > env SILLYSPEC_LINT_TIMEOUT_MS > 缺省 180(快照内 junction I/O 慢,快照路径调用侧显式传 300)。', example: '600' },
75
+ { path: 'commands.metrics', type: 'string', optional: true, status: 'live', readers: ['runVerifyMetricsCheck (src/verify-postcheck.js)', 'runQuickTestLintGate (src/run/quick-audit.js)'], desc: '【2026-10-29-open-metrics-gate】可选指标门命令——收口实测在隔离快照内执行,非零退出=门红阻断收口;未配置或标 unavailable 跳过留痕(fail-open)。适合复杂度上限/重复率/死代码/覆盖率阈值类秒级检查:命令开放不绑定工具,生态/构建工具检查任务(npx 类零安装、mvn/gradle 检查目标等)或仓内自写脚本均可;执行时注入 SILLYSPEC_CHANGE_FILES(本变更文件清单,POSIX 相对路径换行分隔)供大仓增量门只扫变更面(历史存量债不拦新变更);变异测试等慢工具建议提帽或放 pre-push/CI 层。', example: 'npx jscpd src' },
76
+ { path: 'commands.metrics_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyMetricsCheck (src/verify-postcheck.js)'], desc: 'commands.metrics 超时秒数——优先级 显式调用参 > 本键 > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒) > 缺省 180(快照内 junction I/O 慢,快照路径调用侧显式传 300)。', example: '600' },
75
77
  { path: 'commands.smoke', type: 'string', optional: true, status: 'live', readers: ['executeVerifyQualityScan (src/run/verify-quality-scan.js)'], desc: '接口冒烟命令——verify 阶段 CLI 亲跑(脚本自理服务生命周期:后台起服+轮询就绪+finally 杀),300s 超时帽,失败/超时只记失败态不阻断本步、触发 PASS 封顶(D-002);指纹含此键,未变不重跑。', example: 'node scripts/smoke.mjs' },
76
78
  { path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
77
79
  { path: 'commands.knowledge-gate', type: 'boolean', optional: true, status: 'live', readers: ['completeStep (src/run/complete.js)'], desc: '【2026-09-28-unclear-req-to-brainstorm FR-03/D-004】brainstorm 方案步(提出 2-3 种方案)--done 知识检索命中回显开关——缺省 true=开(对 --output 与 decisions.md 条目跑既有检索匹配器,命中 warn 回显「须 evidence 回应或说明不复潮」不阻断);false(或 off)关回显,无命中时输出与现状一致。', example: 'false' },
78
80
  { path: 'commands.scan:check', type: 'string', optional: true, status: 'live', readers: ['scan 漂移检查(scripts/scan-drift-check.py 经 scan 流程调用)'], desc: '【2026-10-23-schema-registration-debt 平台仓 dogfood 补登】scan 产物漂移自检命令。', example: 'python scripts/scan-drift-check.py' },
79
81
  ],
80
82
  },
83
+ {
84
+ id: 'metrics_routes',
85
+ title: '指标门路由(多语言/monorepo)',
86
+ note: '【2026-10-29-metrics-routes】与 commands.metrics 单命令门共存:按变更文件面 ∩ gate.paths 路径前缀路由,仅执行命中 gate(串行);语言中立(路径集合,不枚举语言/框架)。段未配置=零变化。',
87
+ keys: [
88
+ { path: 'metrics_routes.<name>.paths', type: 'array', optional: true, status: 'live', readers: ['extractMetricsRoutes (src/verify-postcheck.js)'], desc: 'gate 的路径前缀集合(仓根相对)——段全等匹配:frontend/ 不命中 frontend-v2/;引号数组 `["a/", "b/"]` 或逗分裸串均可。', example: '["frontend/", "packages/web/"]' },
89
+ { path: 'metrics_routes.<name>.command', type: 'string', optional: true, status: 'live', readers: ['runVerifyMetricsRoutesCheck (src/verify-postcheck.js)'], desc: 'gate 的指标门命令——gate 命中(变更文件面 ∩ paths 非空)才执行,非零退出=门红;gate 子进程注入 SILLYSPEC_CHANGE_FILES=该 gate 命中子集(gate 内工具可增量扫)。', example: 'npx --prefix frontend eslint . --max-warnings 0' },
90
+ { path: 'metrics_routes.<name>.timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyMetricsRoutesCheck (src/verify-postcheck.js)'], desc: 'gate 级超时秒数——优先级 本键 > 调用侧快照帽 > commands.metrics_timeout_sec > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒)> 缺省 180。', example: '300' },
91
+ ],
92
+ },
81
93
  {
82
94
  id: 'checkpoint',
83
95
  title: '影子存档(task tick 快照)',
@@ -432,6 +444,16 @@ commands:
432
444
  # install: npm install # worktree 依赖安装命令
433
445
  # test_timeout_sec: 2400 # commands.test 超时秒数(慢仓提帽;缺省 600,env SILLYSPEC_TEST_TIMEOUT_MS 同效)
434
446
  # lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
447
+ # metrics: npx jscpd src # 可选指标门:收口实测非零退出阻断(复杂度/重复/死代码/覆盖率阈值类秒级检查;执行注入 SILLYSPEC_CHANGE_FILES=本变更文件清单可只扫变更面;变异测试类慢工具建议 pre-push/CI)
448
+ # metrics_timeout_sec: 600 # commands.metrics 超时秒数(缺省 180)
449
+ # metrics_routes: # 多语言/monorepo 路由门(与上面 metrics 单命令可共存;仅跑变更命中的 gate)
450
+ # web:
451
+ # paths: ["frontend/", "packages/web/"] # 路径前缀集合(段全等:frontend/ 不蹭 frontend-v2/)
452
+ # command: npx --prefix frontend eslint . --max-warnings 0
453
+ # jvm:
454
+ # paths: ["backend/"]
455
+ # command: mvn -B -f backend/pom.xml pmd:check
456
+ # timeout_sec: 300 # gate 级超时(缺省 180);gate 子进程注入 SILLYSPEC_CHANGE_FILES=命中子集
435
457
  # knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
436
458
 
437
459
  # ── 影子存档(task tick 快照;缺省开启零配置,仅在显式调优时写)──