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
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, readdirSync, mkdirSync, statSync } from 'fs'
20
20
  import { join, basename } from 'path'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
- import { parseKnowledgeIndex } from '../knowledge-match.js'
22
+ import { parseKnowledgeIndex, keywordMatchesContext, normalizeAnchorForCompare } from '../knowledge-match.js'
23
23
  import { matchKnowledgeHybrid } from '../knowledge-vector.js'
24
24
 
25
25
  // ── 工具函数 ──
@@ -29,16 +29,6 @@ function resolveKnowledgeDir(dir, specDir) {
29
29
  return join(base, 'knowledge')
30
30
  }
31
31
 
32
- /**
33
- * 锚点/标题容错归一(坑 knowledge-hits-anchor-drift T4):锚点三代规则并存(CLI slug /
34
- * INDEX 手写 / 平台 slugify_anchor)对 emoji 前缀、点号、斜杠、首尾中划线的处理互不一致
35
- * ——归一到「仅字母数字 + 小写」后比对,三代产物归一相等;截断(60 字符)由调用方前缀
36
- * 容忍处理。仅用于 validate 告警,不参与命中判定(单一源方向见 T2)。
37
- */
38
- function normalizeAnchorForCompare(s) {
39
- return String(s || '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase()
40
- }
41
-
42
32
  function output(ok, data, error) {
43
33
  const result = { ok, ...data }
44
34
  if (error) result.error = error
@@ -107,8 +97,11 @@ export async function cmdSearch(dir, args, opts = {}) {
107
97
  return
108
98
  }
109
99
 
100
+ // 已修复条目显式包含(FR-03,2026-11-02-knowledge-rank-denoise)——默认排除
101
+ const includeResolved = args.includes('--include-resolved')
102
+
110
103
  // 复用 knowledge-match 引擎(三层 hybrid:路由→平台向量→本地词片,2026-09-29-knowledge-vector-recall)
111
- const result = await matchKnowledgeHybrid(knowledgeDir, query, { cwd: dir })
104
+ const result = await matchKnowledgeHybrid(knowledgeDir, query, { cwd: dir, includeResolved })
112
105
 
113
106
  // 向量开关发现性提示(2026-10-15-vector-switch-discoverability):平台已连接+开关未开
114
107
  // (缺省关)→ hint 键(vectorSwitchHint 单源,flow 注入零命中行同款文案);其余空串零输出。
@@ -122,24 +115,60 @@ export async function cmdSearch(dir, args, opts = {}) {
122
115
  // 文件但被检索核心滤除且最终零结果时,披露被滤文件——去噪不丢可见性,人工按需直查。
123
116
  const parkingFiltered = !result.matched && Array.isArray(result.json && result.json.parkingFiltered)
124
117
  ? result.json.parkingFiltered : []
118
+ const payload = buildSearchPayload(result, knowledgeDir, limit, query)
125
119
 
126
- if (!result.matched) {
127
- output(true, {
128
- query, matches: [],
129
- ...(vectorHint ? { hint: vectorHint } : {}),
130
- ...(parkingFiltered.length > 0
131
- ? { parkingFiltered, parkingNote: `停车场文件已过滤(${parkingFiltered.join('、')})——按需直查文件` }
132
- : {}),
133
- })
134
- return
120
+ output(true, {
121
+ query,
122
+ ...payload,
123
+ ...(vectorHint ? { hint: vectorHint } : {}),
124
+ ...(parkingFiltered.length > 0
125
+ ? { parkingFiltered, parkingNote: `停车场文件已过滤(${parkingFiltered.join('、')})——按需直查文件` }
126
+ : {}),
127
+ })
128
+ }
129
+
130
+ /**
131
+ * search 面输出载荷(matches + routeHits + 已修复披露)——生产与评测共用单一构造。
132
+ * routeHits 承载 fr/decisions 路由行指针:FR-04 要求 search 消费面不静默丢失 implemented
133
+ * 决策/FR 可见性(entries 已移出路由行,只命中路由行的查询不能返回空 matches 了事)。
134
+ */
135
+ export function buildSearchPayload(result, knowledgeDir, limit, query) {
136
+ const matches = result && result.matched ? buildSearchMatches(result, knowledgeDir, limit, query) : []
137
+ const routeHits = ((result && result.routeHits) || []).map(e => ({
138
+ file: e.file,
139
+ anchor: e.anchor || '',
140
+ category: e.category || '',
141
+ }))
142
+ const resolvedFiltered = Array.isArray(result && result.json && result.json.resolvedFiltered)
143
+ ? result.json.resolvedFiltered : []
144
+ return {
145
+ matches,
146
+ ...(routeHits.length > 0
147
+ ? { routeHits, routeNote: '决策/FR 路由命中(专用通道,按需 Read)' }
148
+ : {}),
149
+ ...(resolvedFiltered.length > 0
150
+ ? { resolvedFiltered, resolvedNote: `已修复条目已过滤(${resolvedFiltered.join('、')})——需查看加 --include-resolved` }
151
+ : {}),
135
152
  }
153
+ }
136
154
 
137
- // 增强:为每个匹配项补充 path、title、summary、score
138
- const matches = result.entries.slice(0, limit).map(entry => {
155
+ /**
156
+ * 检索结果 → search 面展示行(title/summary 增强 + 命中数评分)。
157
+ * 导出为单一源:cmdSearch 与评测适配层(eval/knowledge)共用同一构造。
158
+ * score = tags 中实际命中 query 的标签数(keywordMatchesContext 同一判定)——关键词表长
159
+ * 不再冒充命中数(2026-11-02-knowledge-rank-denoise FR-01)。
160
+ * channel 标明命中通道:routing(INDEX 路由)/ fallback(词片回退)/ vector / scope。
161
+ */
162
+ export function buildSearchMatches(result, knowledgeDir, limit, query) {
163
+ const ctx = String(query || '').toLowerCase()
164
+ const channel = result && result.json && result.json.fallback ? 'fallback'
165
+ : result && result.json && result.json.vector ? 'vector'
166
+ : result && result.json && result.json.scope ? 'scope'
167
+ : 'routing'
168
+ return result.entries.slice(0, limit).map(entry => {
139
169
  const filePath = join(knowledgeDir, entry.file)
140
170
  let title = entry.display
141
171
  let summary = ''
142
- let score = entry.keywords.length // 简单评分:匹配关键词数量
143
172
 
144
173
  if (existsSync(filePath)) {
145
174
  const content = readFileSync(filePath, 'utf8')
@@ -147,18 +176,18 @@ export async function cmdSearch(dir, args, opts = {}) {
147
176
  summary = extractSummary(content)
148
177
  }
149
178
 
179
+ const tags = entry.keywords
150
180
  return {
151
181
  id: pathToId(entry.file, knowledgeDir),
152
182
  path: join('knowledge', entry.file),
153
183
  title,
154
184
  summary,
155
- score,
156
- tags: entry.keywords,
185
+ score: tags.filter(k => keywordMatchesContext(k, ctx)).length,
186
+ tags,
187
+ channel,
157
188
  category: entry.category,
158
189
  }
159
190
  })
160
-
161
- output(true, { query, matches, ...(vectorHint ? { hint: vectorHint } : {}) })
162
191
  }
163
192
 
164
193
  // ── inspect ──
package/src/task-tick.js CHANGED
@@ -158,6 +158,22 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase, evidence
158
158
  } catch (e) {
159
159
  console.warn(`⚠️ 勾选留痕写失败(task-ticks.jsonl:${e && e.code ? e.code : e})——翻格不受影响,timeline 勾选时刻退事件流推断`)
160
160
  }
161
+ // 影子存档(2026-10-28-shadow-checkpoint):勾选即快照——tasks.md 翻格+侧车落盘后落一个
162
+ // git 影子提交到 refs/sillyspec/checkpoints/<change>,断点恢复从进度级升到内容级。
163
+ // best-effort fail-open(模块内零抛出,此处再兜一层):任何失败只回显一行,绝不影响翻格。
164
+ try {
165
+ const { createCheckpoint } = await import('./checkpoint.js')
166
+ const cp = await createCheckpoint({
167
+ repoRoot: cwd, changeName,
168
+ session: process.env.SILLYSPEC_SESSION_ID,
169
+ taskId: normalizeTaskId(taskId) || taskId,
170
+ })
171
+ if (cp.kind === 'created') console.log(`🗂️ 影子存档 ${String(cp.sha).slice(0, 8)}(${cp.files} 文件变更)→ refs/sillyspec/checkpoints/${changeName}`)
172
+ else if (cp.kind === 'noop') console.log(`🗂️ 影子存档 noop(工作区无变化,零对象)`)
173
+ else console.log(`🗂️ 影子存档跳过:${cp.reason}${cp.detail ? `(${cp.detail})` : ''}`)
174
+ } catch (e) {
175
+ console.warn(`⚠️ 影子存档失败(不阻断勾选):${e && e.message ? e.message : e}`)
176
+ }
161
177
  // 工件即时重推(2026-10-07-allticked-gate-docs-resync):翻格即任务面变更——顺带触发
162
178
  // spec-sync,平台不再停留 start 时点快照(真实会话实证:改写后平台恒显初稿)。best-effort。
163
179
  try {
@@ -344,6 +344,328 @@ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
344
344
  }
345
345
  }
346
346
 
347
+ /**
348
+ * 从 local.yaml 文本提取 commands.metrics(2026-10-29-open-metrics-gate,task-02)。
349
+ * 与 extractLintCommand 同风格同容错(带引号/不带引号/unavailable)。
350
+ * 模块内私有(runVerifyMetricsCheck 消费)。
351
+ */
352
+ function extractMetricsCommand(yamlText) {
353
+ if (!yamlText) return null
354
+ const doubleQuoted = yamlText.match(/^\s*metrics:\s*"([^"]+)"\s*(?:#.*)?$/m)
355
+ const singleQuoted = yamlText.match(/^\s*metrics:\s*'([^']+)'\s*(?:#.*)?$/m)
356
+ const quoted = doubleQuoted || singleQuoted
357
+ if (quoted && quoted[1]) {
358
+ return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
359
+ }
360
+ const bare = yamlText.match(/^\s*metrics:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
361
+ if (bare && bare[1]) {
362
+ const cmd = bare[1].trim()
363
+ return cmd.toLowerCase() === 'unavailable' ? null : cmd
364
+ }
365
+ return null
366
+ }
367
+
368
+ /**
369
+ * 收口指标门实测:跑 local.yaml commands.metrics(2026-10-29-open-metrics-gate,task-02——
370
+ * ub-mode「确定性质量门」采纳件)。与 runVerifyLintCheck 同构(fail-open 缺省/超时链/CNF 降档/
371
+ * 输出尾部),刻意差异(design.md 风险节声明的边界):
372
+ * - 非 zero/超时即 failed:通过标准由命令自身定义(用户写 crap-check --fail-over 10 src 就是
373
+ * 他的标准),首版即硬门,无 lint 式观察期
374
+ * - 无 tally(lint 的 verify-lint-tally 是共享写面,指标门无观察期升级消费方)
375
+ * - 无 failureFiles/归属鉴定(指标输出无标准路径形态——分数表/JSON/正文,误判比不判更糟,
376
+ * 扫描范围控制责任交给命令本身)
377
+ * - CNF 环境缺件降档无「输出含路径」护栏(lint 有 extractLintFailureFiles 非空维持硬拦的
378
+ * 护栏;指标输出形态开放无从提取——CNF 签名即环境信号)
379
+ * - 可选 changeFiles → 注入 SILLYSPEC_CHANGE_FILES(POSIX 相对路径换行分隔):大仓增量门
380
+ * 只扫变更面(与测试面动态子集同哲学,存量债不拦新变更);不读该变量的命令零感知
381
+ * 信任边界与 runVerifyLintCheck 一致:命令只来源于主仓 .sillyspec/local.yaml。
382
+ */
383
+ export function runVerifyMetricsCheck({ cwd, specBase, timeoutMs, changeFiles } = {}) {
384
+ const localYamlPath = join(specBase, 'local.yaml')
385
+ const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
386
+ const command = extractMetricsCommand(yamlText)
387
+
388
+ if (!command) {
389
+ return {
390
+ status: 'skipped',
391
+ command: null,
392
+ exitCode: null,
393
+ durationMs: null,
394
+ outputTail: null,
395
+ // skipped reason 自带配置引导(引导面③,FR-01):收口输出与证据文件即引导面,不另设说教输出
396
+ reason: yamlText
397
+ ? 'local.yaml 未配置 commands.metrics(或标记 unavailable)——可选指标门:配置后收口实测该命令,非零退出阻断(适合复杂度/重复率/死代码/覆盖率阈值类秒级检查;执行时注入 SILLYSPEC_CHANGE_FILES 变更清单可只扫变更面;变异测试等慢工具建议 pre-push/CI 层)'
398
+ : `local.yaml 不存在(${localYamlPath})`,
399
+ }
400
+ }
401
+
402
+ // 超时链同 lint:显式参 > local.yaml metrics_timeout_sec > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒)> 缺省 180s
403
+ const yamlMetricsTimeoutMs = (() => {
404
+ const m = String(yamlText || '').match(/^\s*metrics_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
405
+ return m && Number(m[1]) > 0 ? Number(m[1]) * 1000 : 0
406
+ })()
407
+ const METRICS_TIMEOUT_MS = timeoutMs || yamlMetricsTimeoutMs || Number(process.env.SILLYSPEC_METRICS_TIMEOUT_MS) || 3 * 60 * 1000
408
+ const startedAt = Date.now()
409
+ let exitCode = 0
410
+ let output = ''
411
+ let reason = null
412
+ // 增量门注入(FR-01):changeFiles 非空时随子进程环境注入——命令侧按需消费,跨平台 POSIX 路径
413
+ const envExtra = Array.isArray(changeFiles) && changeFiles.length > 0
414
+ ? { SILLYSPEC_CHANGE_FILES: changeFiles.map((f) => String(f).replace(/\\/g, '/')).join('\n') }
415
+ : null
416
+ try {
417
+ output = decodeShellOutput(execSync(command, {
418
+ cwd,
419
+ encoding: 'buffer',
420
+ timeout: METRICS_TIMEOUT_MS,
421
+ maxBuffer: 32 * 1024 * 1024,
422
+ stdio: ['ignore', 'pipe', 'pipe'],
423
+ windowsHide: true,
424
+ ...(envExtra ? { env: { ...process.env, ...envExtra } } : null),
425
+ }))
426
+ } catch (e) {
427
+ exitCode = typeof e.status === 'number' ? e.status : 1
428
+ output = [decodeShellOutput(e.stdout), decodeShellOutput(e.stderr)].filter(Boolean).join('\n') || e.message
429
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= METRICS_TIMEOUT_MS
430
+ ? `metrics 命令超时(>${METRICS_TIMEOUT_MS / 1000}s)`
431
+ : `metrics 命令退出码 ${exitCode}`
432
+ }
433
+ const durationMs = Date.now() - startedAt
434
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
435
+ let status = exitCode === 0 ? 'passed' : 'failed'
436
+ let finalReason = exitCode === 0 ? null : reason
437
+ // 环境缺件降档(复用 lint 语义,2026-09-23 实证先例):CNF 是环境信号非代码失败,硬拦只会逼
438
+ // 逃生口;指标输出形态开放,无从做 lint 的「输出含路径维持硬拦」护栏——CNF 签名即降档依据。
439
+ if (status === 'failed') {
440
+ const cnf = detectCommandMissingFailure(output)
441
+ if (cnf.matched) {
442
+ status = 'skipped'
443
+ finalReason = `环境缺件:${cnf.binary ? `二进制「${cnf.binary}」` : '命令二进制'}不存在(${cnf.evidence})——非代码失败不拦门;安装对应工具后 metrics 恢复实测`
444
+ console.warn(`⚠️ metrics 实测环境缺件降档为跳过:${cnf.evidence}——修复环境(安装工具)而非改代码`)
445
+ }
446
+ }
447
+
448
+ return {
449
+ status,
450
+ command,
451
+ exitCode,
452
+ durationMs,
453
+ outputTail,
454
+ reason: finalReason,
455
+ }
456
+ }
457
+
458
+ /**
459
+ * metrics_routes 的 paths 值解析:`["a/", "b/"]` 引号数组 / `a/, b/` 逗分裸串 / `"a/"` 单值
460
+ * 均归一为 string[](2026-10-29-metrics-routes task-01)。模块内私有。
461
+ */
462
+ function parseMetricsPathsValue(raw) {
463
+ if (!raw) return null
464
+ let s = String(raw).trim()
465
+ const arrM = s.match(/^\[(.*)\]$/s)
466
+ if (arrM) s = arrM[1]
467
+ const parts = s.split(',').map((p) => p.trim().replace(/^["']|["']$/g, '').trim()).filter(Boolean)
468
+ return parts.length > 0 ? parts : null
469
+ }
470
+
471
+ /**
472
+ * 路径段全等前缀匹配(2026-10-29-metrics-routes task-01):file 归一 POSIX 后判断
473
+ * `file === base || file.startsWith(base + '/')`——`frontend/` 不命中 `frontend-v2/x`
474
+ * (段全等,防字符级误蹭),深路径子目录命中。双侧反斜杠归一(Windows 变更文件/配置同口径)。
475
+ * 模块内私有。
476
+ */
477
+ function matchesPathPrefix(file, prefix) {
478
+ const f = String(file || '').replace(/\\/g, '/')
479
+ const base = String(prefix || '').replace(/\\/g, '/').replace(/\/+$/, '')
480
+ if (!f || !base) return false
481
+ return f === base || f.startsWith(base + '/')
482
+ }
483
+
484
+ /**
485
+ * 从 local.yaml 文本提取 metrics_routes 段(2026-10-29-metrics-routes task-01)。
486
+ * 形态随 modules 块先例(extractModules 同风格,坑史同源:任意非零缩进、inline flow 与
487
+ * 子块展开双形态)。每条目需 paths(前缀集合)与 command(缺一跳过该条目——路由无 paths
488
+ * 无从匹配);timeout_sec 可选。轻量行扫描,不引 yaml 依赖。
489
+ *
490
+ * metrics_routes:
491
+ * web:
492
+ * paths: ["frontend/", "packages/web/"]
493
+ * command: "npx --prefix frontend eslint . --max-warnings 0"
494
+ * timeout_sec: 300
495
+ * web-inline: { paths: "frontend/, packages/web/", command: "make web-check" }
496
+ *
497
+ * @param {string} yamlText
498
+ * @returns {Record<string, {paths: string[], command: string, timeoutSec: number|null}>|null}
499
+ * 无段或无有效条目 → null(调用方按未配置处理,缺省零回归)
500
+ */
501
+ export function extractMetricsRoutes(yamlText) {
502
+ if (!yamlText) return null
503
+ const lines = normalizeLineEndings(yamlText).split('\n')
504
+ let startIdx = -1
505
+ for (let i = 0; i < lines.length; i++) {
506
+ if (/^metrics_routes:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
507
+ }
508
+ if (startIdx === -1) return null
509
+
510
+ const routes = {}
511
+ for (let i = startIdx + 1; i < lines.length; i++) {
512
+ const line = lines[i]
513
+ const entry = line.match(/^([ \t]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
514
+ if (!entry) {
515
+ // 新顶层 key(行首非空格非注释非空)→ 块结束
516
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
517
+ continue
518
+ }
519
+ const name = entry[2]
520
+ const rest = (entry[3] || '').trim()
521
+ if (rest === '' || rest.startsWith('#')) {
522
+ // 子块展开式:paths / command / timeout_sec 子行(缩进须深于条目行)
523
+ let paths = null
524
+ let command = null
525
+ let timeoutSec = null
526
+ for (let j = i + 1; j < lines.length; j++) {
527
+ const sub = lines[j]
528
+ if (sub.trim() === '' || sub.trim().startsWith('#')) continue
529
+ const subM = sub.match(/^([ \t]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
530
+ if (!subM || subM[1].length <= entry[1].length) break
531
+ const key = subM[2]
532
+ const raw = (subM[3] || '').trim()
533
+ if (key === 'paths') paths = parseMetricsPathsValue(raw)
534
+ if (key === 'command') command = raw.replace(/^["']|["']$/g, '') || null
535
+ if (key === 'timeout_sec') { const n = Number(raw); if (Number.isFinite(n) && n > 0) timeoutSec = n }
536
+ }
537
+ if (paths && command) routes[name] = { paths, command, timeoutSec }
538
+ continue
539
+ }
540
+ // inline flow mapping: { paths: [...], command: "...", timeout_sec: N }
541
+ const pathsRaw = parseFlowValue(rest, 'paths')
542
+ const command = parseFlowValue(rest, 'command')
543
+ const tRaw = parseFlowValue(rest, 'timeout_sec')
544
+ if (command) {
545
+ const paths = parseMetricsPathsValue(pathsRaw)
546
+ if (paths) {
547
+ const n = Number(tRaw)
548
+ routes[name] = { paths, command, timeoutSec: tRaw && Number.isFinite(n) && n > 0 ? n : null }
549
+ }
550
+ }
551
+ }
552
+ return Object.keys(routes).length > 0 ? routes : null
553
+ }
554
+
555
+ /**
556
+ * 路由指标门实测(2026-10-29-metrics-routes task-01——多语言/monorepo 的路径集合路由)。
557
+ * 变更文件面 ∩ gate.paths 段全等前缀 → 仅执行命中 gate(未触域不跑——与测试面动态子集同
558
+ * 哲学);每 gate 子进程注入 SILLYSPEC_CHANGE_FILES=该 gate 命中子集;串行执行(放弃并行:
559
+ * 秒级 gate 串行总时长可控,并行放大资源峰值且输出交错难归因——design 风险节声明)。
560
+ * 语义逐条复用单命令门(runVerifyMetricsCheck):非零/超时=failed、CNF 环境缺件降档、
561
+ * 输出尾部。gate 级超时链(特异者胜):gate.timeout_sec > 显式调用参 > metrics_timeout_sec
562
+ * > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒)> 缺省 180s——per-gate 显式值不被调用侧快照
563
+ * 通用帽(5min)反向压低。
564
+ * 聚合形态:任一 gate failed → failed;全部命中 gate CNF 降档 → skipped(CNF 不掩盖亦不被
565
+ * 掩盖);其余 → passed。顶层字段与单命令结果同构(printQuickTestLintGate 通用循环直读),
566
+ * gates 数组逐 gate 带 name/status/exitCode/outputTail/matchedFiles。
567
+ */
568
+ export function runVerifyMetricsRoutesCheck({ cwd, specBase, timeoutMs, changeFiles } = {}) {
569
+ const localYamlPath = join(specBase, 'local.yaml')
570
+ const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
571
+ const routes = extractMetricsRoutes(yamlText)
572
+
573
+ if (!routes) {
574
+ return {
575
+ status: 'skipped', kind: 'routes', gates: [],
576
+ command: null, exitCode: null, durationMs: null, outputTail: null,
577
+ reason: yamlText
578
+ ? 'local.yaml 未配置 metrics_routes 段——多语言/monorepo 仓可按路径集合路由多个指标门(与 commands.metrics 单命令可共存,执行只跑变更命中的 gate;见 config schema desc)'
579
+ : `local.yaml 不存在(${localYamlPath})`,
580
+ }
581
+ }
582
+
583
+ const files = (Array.isArray(changeFiles) ? changeFiles : []).map((f) => String(f).replace(/\\/g, '/')).filter(Boolean)
584
+ const hitGates = []
585
+ for (const [name, gate] of Object.entries(routes)) {
586
+ const matched = files.filter((f) => gate.paths.some((p) => matchesPathPrefix(f, p)))
587
+ if (matched.length > 0) hitGates.push({ name, gate, matched })
588
+ }
589
+ if (hitGates.length === 0) {
590
+ return {
591
+ status: 'skipped', kind: 'routes', gates: [],
592
+ command: null, exitCode: null, durationMs: null, outputTail: null,
593
+ reason: `metrics_routes 零命中(变更文件面与 ${Object.keys(routes).length} 个 gate 的 paths 均无交集——未触域不跑,增量语义)gates:${Object.keys(routes).join('、')}`,
594
+ }
595
+ }
596
+
597
+ const yamlGlobalTimeoutMs = (() => {
598
+ const m = String(yamlText || '').match(/^\s*metrics_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
599
+ return m && Number(m[1]) > 0 ? Number(m[1]) * 1000 : 0
600
+ })()
601
+ const startedAt = Date.now()
602
+ const gates = []
603
+ for (const { name, gate, matched } of hitGates) {
604
+ const capMs = (gate.timeoutSec ? gate.timeoutSec * 1000 : 0)
605
+ || timeoutMs
606
+ || yamlGlobalTimeoutMs
607
+ || Number(process.env.SILLYSPEC_METRICS_TIMEOUT_MS)
608
+ || 3 * 60 * 1000
609
+ const gateStartedAt = Date.now()
610
+ let exitCode = 0
611
+ let output = ''
612
+ let gateReason = null
613
+ try {
614
+ output = decodeShellOutput(execSync(gate.command, {
615
+ cwd,
616
+ encoding: 'buffer',
617
+ timeout: capMs,
618
+ maxBuffer: 32 * 1024 * 1024,
619
+ stdio: ['ignore', 'pipe', 'pipe'],
620
+ windowsHide: true,
621
+ env: { ...process.env, SILLYSPEC_CHANGE_FILES: matched.join('\n') },
622
+ }))
623
+ } catch (e) {
624
+ exitCode = typeof e.status === 'number' ? e.status : 1
625
+ output = [decodeShellOutput(e.stdout), decodeShellOutput(e.stderr)].filter(Boolean).join('\n') || e.message
626
+ gateReason = e.signal === 'SIGTERM' && Date.now() - gateStartedAt >= capMs
627
+ ? `gate「${name}」超时(>${capMs / 1000}s)`
628
+ : `gate「${name}」退出码 ${exitCode}`
629
+ }
630
+ const durationMs = Date.now() - gateStartedAt
631
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
632
+ let status = exitCode === 0 ? 'passed' : 'failed'
633
+ let finalReason = exitCode === 0 ? null : gateReason
634
+ if (status === 'failed') {
635
+ const cnf = detectCommandMissingFailure(output)
636
+ if (cnf.matched) {
637
+ status = 'skipped'
638
+ finalReason = `gate「${name}」环境缺件:${cnf.binary ? `二进制「${cnf.binary}」` : '命令二进制'}不存在(${cnf.evidence})——非代码失败不拦门;安装对应工具后恢复实测`
639
+ console.warn(`⚠️ metrics gate「${name}」环境缺件降档为跳过:${cnf.evidence}——修复环境(安装工具)而非改代码`)
640
+ }
641
+ }
642
+ gates.push({ name, status, command: gate.command, exitCode, durationMs, outputTail, reason: finalReason, matchedFiles: matched })
643
+ }
644
+
645
+ const failedGates = gates.filter((g) => g.status === 'failed')
646
+ const skippedGates = gates.filter((g) => g.status === 'skipped')
647
+ const status = failedGates.length > 0 ? 'failed' : (skippedGates.length === gates.length ? 'skipped' : 'passed')
648
+ const reason = failedGates.length > 0
649
+ ? `路由门 ${failedGates.length}/${gates.length} 个 gate 失败:${failedGates.map((g) => g.name).join('、')}`
650
+ : (status === 'skipped'
651
+ ? `路由门全部命中 gate 环境缺件降档(${skippedGates.map((g) => g.name).join('、')})——安装工具后恢复实测`
652
+ : `路由门 ${gates.length} 个 gate 全过(${gates.map((g) => g.name).join('、')})`)
653
+ // 聚合 outputTail:失败 gate 的 name+尾部拼接(打印/定位面);无失败为 null
654
+ const outputTail = failedGates.length > 0
655
+ ? failedGates.map((g) => `── gate「${g.name}」──\n${g.outputTail || g.reason || ''}`).join('\n')
656
+ : null
657
+ return {
658
+ status,
659
+ kind: 'routes',
660
+ gates,
661
+ command: null,
662
+ exitCode: failedGates.length > 0 ? failedGates[0].exitCode : 0,
663
+ durationMs: Date.now() - startedAt,
664
+ outputTail,
665
+ reason,
666
+ }
667
+ }
668
+
347
669
  /**
348
670
  * lint 硬门决策(2026-09-09 升硬,外部数据驱动):观察期 tally 14 次 5 败全真阳性零误伤,
349
671
  * 且 pre-push 钩子本就硬拦——verify 侧硬门只是同一标准 fail-fast 前移。
@@ -3669,8 +3991,10 @@ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures
3669
3991
  * 优先并入 test 的 test-result.json(modules 并列一节 `lint` 字段,读改写);test 无
3670
3992
  * 结果文件(skipped/未跑)而 lint 实跑时,用 writeRunResult 形状独立落一份(kind: 'lint'
3671
3993
  * 标识)。返回落盘路径,失败返回 null(best-effort,不阻断门禁)。
3994
+ * section 参数(2026-10-29-open-metrics-gate task-02):并入字段名与独立件 kind 的泛化
3995
+ * ——metrics 结果走同款落盘语义(section: 'metrics'),缺省 'lint' 零变化。
3672
3996
  */
3673
- export function persistLintResult({ specBase, changeName, testResultPath, lint }) {
3997
+ export function persistLintResult({ specBase, changeName, testResultPath, lint, section = 'lint' }) {
3674
3998
  if (!lint || (lint.status !== 'passed' && lint.status !== 'failed')) return null
3675
3999
  if (testResultPath && existsSync(testResultPath)) {
3676
4000
  try {
@@ -3678,7 +4002,7 @@ export function persistLintResult({ specBase, changeName, testResultPath, lint }
3678
4002
  // 并入节自带 resultPath(收编评审 P3:调用方在 persistLintResult 返回后才给 lint.resultPath
3679
4003
  // 赋值——只变异对象不重写盘,并入节会永远缺 resultPath。这里预先补齐,调用方赋同值幂等)
3680
4004
  lint.resultPath = testResultPath
3681
- j.lint = lint
4005
+ j[section] = lint
3682
4006
  writeFileSync(testResultPath, JSON.stringify(j, null, 2) + '\n')
3683
4007
  return testResultPath
3684
4008
  } catch { /* 结果文件损坏 → 退独立落盘 */ }
@@ -3691,13 +4015,16 @@ export function persistLintResult({ specBase, changeName, testResultPath, lint }
3691
4015
  holder.resultPath = join(runDir, 'test-result.json')
3692
4016
  writeFileSync(holder.resultPath, JSON.stringify({
3693
4017
  change: changeName || null,
3694
- kind: 'lint',
4018
+ kind: section,
3695
4019
  command: lint.command,
3696
4020
  exit_code: lint.exitCode,
3697
4021
  status: lint.status,
3698
4022
  duration_ms: lint.durationMs,
3699
4023
  output_tail: lint.outputTail,
3700
4024
  reason: lint.reason || null,
4025
+ // gates 透传(2026-10-29-metrics-routes task-01):路由门独立件带逐 gate 明细;
4026
+ // 单命令/lint 无该数组零影响(additive)。
4027
+ ...(Array.isArray(lint.gates) ? { gates: lint.gates } : {}),
3701
4028
  ran_at: new Date().toISOString(),
3702
4029
  }, null, 2) + '\n')
3703
4030
  return holder.resultPath
@@ -32,7 +32,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
32
32
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
33
33
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
34
34
  3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档(轻量道即:FR 绑定+design 四节+用户批准先于动码,读代码探究不算动码)。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
35
- 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`(未配置/标 `unavailable` → 收口跳过并留痕理由,归 pre-push/CI 门——pre-push 已跑同款 lint 的仓可标 unavailable 免重复)。以落盘文件与测试结果为准,不信口头"已完成"。自验口径=变更测试面(新增测试+受影响既有面)即收口证据(lint 已配置时一并实测;标 `unavailable` 的仓归 git 钩子/CI 门)——全量套件归 pre-push 门,勿每变更重复手跑(共享仓全量红先做归属判别,非本变更引入如实留痕不阻断);跑批输出落 `.sillyspec/.runtime/logs/` 日志回看尾部,勿整段吞掉。
35
+ 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`(未配置/标 `unavailable` → 收口跳过并留痕理由,归 pre-push/CI 门——pre-push 已跑同款 lint 的仓可标 unavailable 免重复);指标门同款可选:`commands.metrics` 配置后收口实测该命令(非零退出阻断;未配置/`unavailable` 跳过留痕)——适合复杂度/重复率/死代码/覆盖率阈值类秒级检查(命令开放不绑定工具,`npx` 工具或 mvn/gradle 检查目标均可;执行时注入 `SILLYSPEC_CHANGE_FILES` 变更文件清单,大仓可只扫变更面),变异测试等慢工具建议 pre-push/CI;多语言/monorepo 仓可用 `metrics_routes` 路由门按路径集合配多个 gate(仅跑变更命中的 gate,gate 子进程注入 `SILLYSPEC_CHANGE_FILES`=该 gate 命中子集)。以落盘文件与测试结果为准,不信口头"已完成"。自验口径=变更测试面(新增测试+受影响既有面)即收口证据(lint 已配置时一并实测;标 `unavailable` 的仓归 git 钩子/CI 门)——全量套件归 pre-push 门,勿每变更重复手跑(共享仓全量红先做归属判别,非本变更引入如实留痕不阻断);跑批输出落 `.sillyspec/.runtime/logs/` 日志回看尾部,勿整段吞掉。
36
36
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
37
37
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
38
38
  7. **代码必须兼容 Windows / Linux / macOS**(路径 / 换行 / 并发都要顾)。