sillyspec 3.29.5 → 3.31.0
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-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-bindings.js — 验收×测试绑定 写侧基座(2026-09-24-fr-test-bindings,fr-test-binding 方案 §3.1/§3.2)
|
|
3
|
+
*
|
|
4
|
+
* 单点解析纪律(D-002@v1):两处真源仅本模块解析读写——
|
|
5
|
+
* 真源① FR:knowledge/fr/<域>.md 条目内「测试绑定:」机器子块;
|
|
6
|
+
* 真源② ql:.sillyspec/quicklog/test-bindings.json(quicklog 侧机器面)。
|
|
7
|
+
* 变更期载体(D-001@v1):changes/<名>/test-trace.json(局部锚 FR-NN;归档提升铸全局)。
|
|
8
|
+
*
|
|
9
|
+
* 行模型(方案 §3.1):anchor∈{FR-<域>-NNN | ql-<id> | null}(禁 CAP 第四空间);
|
|
10
|
+
* reason∈{spec|capability|regression};state∈{candidate|active};discovery∈{machine|agent};
|
|
11
|
+
* confirmed_by∈{agent|null}——预填/机械产物≠确认(未确认行恒 candidate+confirmed_by:null)。
|
|
12
|
+
* row_id 锚无关恒填:<change>:<task-id>:<accRef>;orphan accRef=acc-<index>-<sha256(原文)前8>
|
|
13
|
+
* (D-005@v1:禁易漂移纯序号作长期主键,插行后按指纹对齐)。
|
|
14
|
+
*
|
|
15
|
+
* 字段级所有权四硬约束(方案 §3.2):机器晋升按 (source_change, row_id) upsert、内容全等
|
|
16
|
+
* no-op、不删 agent 行;--bind/--unbind 原子写(writeAtomicSync);FR supersede 同步置
|
|
17
|
+
* status=superseded(markFrBindingsSuperseded,fr-index 承接翻链调用)。
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'crypto'
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
23
|
+
import { gitQuiet } from './git-helper.js'
|
|
24
|
+
import { quicklogSidecarPath } from './quicklog.js'
|
|
25
|
+
|
|
26
|
+
const sha256 = (s) => createHash('sha256').update(String(s || '')).digest('hex')
|
|
27
|
+
export const ANCHOR_RE = /^(FR-[A-Za-z0-9-]+-\d+|FR-\d+|ql-[0-9]{8}-\d{3}-[0-9a-f]{4}|null)$/
|
|
28
|
+
const REASONS = ['spec', 'capability', 'regression']
|
|
29
|
+
const STATES = ['candidate', 'active']
|
|
30
|
+
const BINDING_BLOCK_HEAD = '测试绑定:'
|
|
31
|
+
const BINDING_BLOCK_NOTE = '<!-- test-bindings: 机器字段(sillyspec tests 管理),勿手改 -->'
|
|
32
|
+
|
|
33
|
+
/** 行校验/归一(违例抛错拒绝写入——枚举红线在入口收口)。entryMode:条目内行不带 anchor(锚=条目 id),跳过锚枚举 */
|
|
34
|
+
// ── 测试路径仓根相对化(2026-09-26-dynamic-test-inference)──
|
|
35
|
+
// 存量绑定 tests 路径三形态混杂(仓根相对 / 丢子项目前缀的 cwd 相对 / 裸文件名——平台仓
|
|
36
|
+
// multi-agent-platform 实证)——写入侧归一 + 读侧解析统一走这里。子项目根探测 bounded:
|
|
37
|
+
// 深度 ≤2、node_modules/.git/dist 等排除;裸文件名兜底 glob 有扫描预算(防大仓漫游)。
|
|
38
|
+
|
|
39
|
+
const SUBPROJECT_SKIP_DIRS = new Set([
|
|
40
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.venv', 'venv', '__pycache__',
|
|
41
|
+
'.sillyspec', '.runtime', 'coverage', '.next', '.worktrees', '.turbo', 'target',
|
|
42
|
+
'public', 'assets', 'static', 'locales', 'migrations', '__snapshots__', '.storybook',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/** 子项目根探测:根 + 深度 ≤2 目录中带 package.json/pyproject.toml 者(posix 相对,''=根) */
|
|
46
|
+
export function detectSubprojectRoots(projectRoot) {
|
|
47
|
+
const out = []
|
|
48
|
+
const hasManifest = (p) => existsSync(join(p, 'package.json')) || existsSync(join(p, 'pyproject.toml'))
|
|
49
|
+
try {
|
|
50
|
+
if (hasManifest(projectRoot)) out.push('')
|
|
51
|
+
for (const d1 of readdirSync(projectRoot, { withFileTypes: true })) {
|
|
52
|
+
if (!d1.isDirectory() || SUBPROJECT_SKIP_DIRS.has(d1.name) || d1.name.startsWith('.')) continue
|
|
53
|
+
const p1 = join(projectRoot, d1.name)
|
|
54
|
+
if (hasManifest(p1)) { out.push(d1.name); continue }
|
|
55
|
+
for (const d2 of readdirSync(p1, { withFileTypes: true })) {
|
|
56
|
+
if (!d2.isDirectory() || SUBPROJECT_SKIP_DIRS.has(d2.name) || d2.name.startsWith('.')) continue
|
|
57
|
+
if (hasManifest(join(p1, d2.name))) out.push(`${d1.name}/${d2.name}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* 探测失败 → 只剩根(hasManifest 已计入) */ }
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 受限同名文件搜索:跳过依赖/产物目录,扫描预算耗尽放弃(返回空=未找到,不猜)。
|
|
65
|
+
* 预算 20000(平台仓实证:frontend/src+backend/app 全树 >6000,裸名深路径会预算内够不着) */
|
|
66
|
+
function globBasenameMatches(projectRoot, basename, budget = 20000) {
|
|
67
|
+
const hits = []
|
|
68
|
+
let scanned = 0
|
|
69
|
+
const stack = ['']
|
|
70
|
+
while (stack.length && scanned < budget) {
|
|
71
|
+
const dir = stack.pop()
|
|
72
|
+
let entries
|
|
73
|
+
try { entries = readdirSync(join(projectRoot, dir || '.'), { withFileTypes: true }) } catch { continue }
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
scanned++
|
|
76
|
+
if (scanned > budget) break
|
|
77
|
+
if (e.isDirectory()) {
|
|
78
|
+
if (!SUBPROJECT_SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) stack.push(dir ? `${dir}/${e.name}` : e.name)
|
|
79
|
+
} else if (e.name === basename) {
|
|
80
|
+
hits.push(dir ? `${dir}/${e.name}` : e.name)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return hits
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 测试路径解析为仓根相对:根相对直取 → 子项目根前缀补全(丢 frontend/ 前缀类)→
|
|
89
|
+
* 裸文件名受限 glob 兜底(多命中取最短路径)。解析失败返回 null(调用方决定保留原值或披露)。
|
|
90
|
+
*/
|
|
91
|
+
export function resolveTestFileRel(p, { projectRoot, subprojectRoots = null, basenameFallback = true } = {}) {
|
|
92
|
+
const norm = String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
93
|
+
if (!norm || !projectRoot) return null
|
|
94
|
+
const sprs = subprojectRoots || detectSubprojectRoots(projectRoot)
|
|
95
|
+
// 直接命中(含扩展名漂移兜底:绑定记 .ts 实为 .tsx——平台仓 34 处实证;只向 .tsx 漂,
|
|
96
|
+
// 反向会把 JSX 文件喂给 node --test 恒败)
|
|
97
|
+
const variants = /\.ts$/.test(norm) ? [norm, norm.replace(/\.ts$/, '.tsx')] : [norm]
|
|
98
|
+
for (const v of variants) {
|
|
99
|
+
if (existsSync(join(projectRoot, v))) return v
|
|
100
|
+
for (const spr of sprs) {
|
|
101
|
+
if (!spr) continue
|
|
102
|
+
if (existsSync(join(projectRoot, spr, v))) return `${spr}/${v}`
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!basenameFallback) return null
|
|
106
|
+
const base = norm.split('/').pop()
|
|
107
|
+
// 裸文件名兜底仅对测试形态文件开(*.test.* / *.spec.* / test_*.py / *_test.*),防误命中同名源文件;
|
|
108
|
+
// 变体含 .ts→.tsx(绑定记 .ts 实为 .tsx 的扩展名漂移——平台仓实证)
|
|
109
|
+
const isTestShaped = /\.(test|spec)\.[cm]?[jt]sx?$/.test(base) || /^test_[\w.]+\.py$/.test(base) || /_test\.(go|py)$/.test(base)
|
|
110
|
+
if (!isTestShaped) return null
|
|
111
|
+
const baseVariants = /\.ts$/.test(base) ? [base, base.replace(/\.ts$/, '.tsx')] : [base]
|
|
112
|
+
const hits = []
|
|
113
|
+
for (const bv of baseVariants) hits.push(...globBasenameMatches(projectRoot, bv))
|
|
114
|
+
if (hits.length === 0) return null
|
|
115
|
+
hits.sort((a, b) => a.length - b.length || a.localeCompare(b))
|
|
116
|
+
return hits[0]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 绑定 tests 路径归一(保留用例锚后缀):文件部分走 resolveTestFileRel,锚原样回接 */
|
|
120
|
+
function normalizeTestsRootRel(tests, { projectRoot } = {}) {
|
|
121
|
+
if (!projectRoot || !Array.isArray(tests)) return tests
|
|
122
|
+
return tests.map((t) => {
|
|
123
|
+
const filePart = testAnchorFile(String(t))
|
|
124
|
+
const anchorPart = String(t).slice(filePart.length)
|
|
125
|
+
const rel = resolveTestFileRel(filePart, { projectRoot })
|
|
126
|
+
return rel ? rel + anchorPart : String(t)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function normalizeRow(row, opts = {}) {
|
|
131
|
+
const entryMode = !!opts.entryMode
|
|
132
|
+
const anchor = row.anchor === null || row.anchor === undefined ? null : String(row.anchor)
|
|
133
|
+
if (!entryMode) {
|
|
134
|
+
if (!(anchor === null || ANCHOR_RE.test(anchor))) {
|
|
135
|
+
throw new Error(`绑定行 anchor 非法「${row.anchor}」——合法枚举 {FR-<域>-NNN | ql-<id> | null}(禁 CAP 第四空间)`)
|
|
136
|
+
}
|
|
137
|
+
if (anchor === null && !String(row.row_id || '').includes(':')) {
|
|
138
|
+
throw new Error('orphan 行(anchor:null)row_id 必填且含任务段(<change>:<task-id>:<accRef>)')
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!REASONS.includes(row.reason)) throw new Error(`绑定行 reason 非法「${row.reason}」(${REASONS.join('|')})`)
|
|
142
|
+
if (!STATES.includes(row.state)) throw new Error(`绑定行 state 非法「${row.state}」(${STATES.join('|')})`)
|
|
143
|
+
if (!row.row_id) throw new Error('绑定行 row_id 必填(<change>:<task-id>:<accRef>)')
|
|
144
|
+
if (row.state === 'active' && row.confirmed_by !== 'agent') {
|
|
145
|
+
throw new Error('active 行必须 confirmed_by=agent(预填/机械产物≠确认)')
|
|
146
|
+
}
|
|
147
|
+
const tests = [...new Set((row.tests || []).map(t => String(t).replace(/\\/g, '/')))].filter(Boolean).sort()
|
|
148
|
+
if (tests.length === 0) throw new Error('绑定行 tests 非空(空绑定=伪行)')
|
|
149
|
+
return {
|
|
150
|
+
anchor: anchor || null,
|
|
151
|
+
row_id: String(row.row_id),
|
|
152
|
+
tests,
|
|
153
|
+
reason: row.reason,
|
|
154
|
+
state: row.state,
|
|
155
|
+
discovery: row.discovery === 'agent' ? 'agent' : 'machine',
|
|
156
|
+
confirmed_by: row.confirmed_by === 'agent' ? 'agent' : null,
|
|
157
|
+
confirmed_at: row.confirmed_at && row.confirmed_at !== 'null' ? row.confirmed_at : null,
|
|
158
|
+
reconfirm: ['unchanged', 'rebound'].includes(row.reconfirm) ? row.reconfirm : null,
|
|
159
|
+
status: row.status === 'superseded' ? 'superseded' : 'active',
|
|
160
|
+
source_change: row.source_change && row.source_change !== 'null' ? row.source_change : null,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 剥用例锚取文件路径(2026-09-26-binding-anchor-fidelity):tests 条目自本变更起可携带
|
|
166
|
+
* 用例锚(书写原形:path「X」组 / path#x / path::x / path > x——提取侧只摘不译)。需要把
|
|
167
|
+
* 条目当文件路径消费的面(残差实测/归属匹配/覆盖判定/unbind 匹配)统一走这里;展示面
|
|
168
|
+
* (test-trace/FR 机器子块/tests 视图)保持完整锚点不剥。无锚条目幂等原样返回。
|
|
169
|
+
*/
|
|
170
|
+
export function testAnchorFile(entry) {
|
|
171
|
+
const s = String(entry || '').replace(/\\/g, '/').trim()
|
|
172
|
+
const idxs = ['#', '::', '>', '「'].map((sep) => s.indexOf(sep)).filter((i) => i > 0)
|
|
173
|
+
return idxs.length ? s.slice(0, Math.min(...idxs)).trim() : s
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** orphan accRef(D-005@v1):index 只作快照,身份=原文指纹 */
|
|
177
|
+
export function orphanAccRef(index, acceptanceText) {
|
|
178
|
+
return `acc-${index}-${sha256(acceptanceText).slice(0, 8)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 变更期载体(D-001@v1):changes/<名>/test-trace.json ──
|
|
182
|
+
|
|
183
|
+
export function changeTracePath(changeDir) { return join(changeDir, 'test-trace.json') }
|
|
184
|
+
|
|
185
|
+
export function readChangeTrace(changeDir) {
|
|
186
|
+
try {
|
|
187
|
+
const p = changeTracePath(changeDir)
|
|
188
|
+
if (!existsSync(p)) return []
|
|
189
|
+
const j = JSON.parse(readFileSync(p, 'utf8'))
|
|
190
|
+
return Array.isArray(j.rows) ? j.rows : []
|
|
191
|
+
} catch { return [] }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 写 trace(幂等:序列化字节不变跳写;原子:writeAtomicSync) */
|
|
195
|
+
export function writeChangeTrace(changeDir, changeName, rows) {
|
|
196
|
+
const normalized = rows.map(normalizeRow)
|
|
197
|
+
const payload = JSON.stringify({
|
|
198
|
+
schemaVersion: 1, change: changeName, rows: normalized,
|
|
199
|
+
}, null, 2) + '\n'
|
|
200
|
+
const p = changeTracePath(changeDir)
|
|
201
|
+
try { if (existsSync(p) && readFileSync(p, 'utf8') === payload) return { changed: false, path: p } } catch { /* 读失败照写 */ }
|
|
202
|
+
writeAtomicSync(p, payload)
|
|
203
|
+
return { changed: true, path: p }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** verify --done 晋升(D-003@v1):判定列驱动 candidate→active;uncovered/non-testable 删行。
|
|
207
|
+
* matrixRows = extractAcceptanceMatrixSlots().rows({task, verdict} 按文档序)——与 trace 行
|
|
208
|
+
* 按 task 分组后顺序 zip(矩阵行由 acceptance 列表渲染,1:1 同源;计数不齐的 task 跳过不晋升)。 */
|
|
209
|
+
export function promoteTraceFromMatrix({ changeDir, changeName, matrixRows }) {
|
|
210
|
+
const head = (() => { try { return gitQuiet(process.cwd(), ['rev-parse', 'HEAD'])?.trim() || null } catch { return null } })()
|
|
211
|
+
const byTask = new Map()
|
|
212
|
+
for (const m of (matrixRows || [])) {
|
|
213
|
+
if (!m || !m.task) continue
|
|
214
|
+
if (!byTask.has(m.task)) byTask.set(m.task, [])
|
|
215
|
+
byTask.get(m.task).push(m.verdict)
|
|
216
|
+
}
|
|
217
|
+
const rows = readChangeTrace(changeDir)
|
|
218
|
+
const traceByTask = new Map()
|
|
219
|
+
for (const r of rows) {
|
|
220
|
+
const t = String(r.row_id).split(':')[1] || ''
|
|
221
|
+
if (!traceByTask.has(t)) traceByTask.set(t, [])
|
|
222
|
+
traceByTask.get(t).push(r)
|
|
223
|
+
}
|
|
224
|
+
const out = []
|
|
225
|
+
let promoted = 0, kept = 0, dropped = 0
|
|
226
|
+
for (const r of rows) {
|
|
227
|
+
const t = String(r.row_id).split(':')[1] || ''
|
|
228
|
+
const verdicts = byTask.get(t) || []
|
|
229
|
+
const idx = traceByTask.get(t).indexOf(r)
|
|
230
|
+
const verdict = verdicts.length === traceByTask.get(t).length ? verdicts[idx] : undefined
|
|
231
|
+
if (verdict === 'uncovered' || verdict === 'non-testable') { dropped++; continue }
|
|
232
|
+
if ((verdict === 'covered' || verdict === 'covered-service') && r.state !== 'active') {
|
|
233
|
+
out.push(normalizeRow({ ...r, state: 'active', confirmed_by: 'agent', confirmed_at: head || r.confirmed_at })); promoted++
|
|
234
|
+
} else { out.push(normalizeRow(r)); kept++ }
|
|
235
|
+
}
|
|
236
|
+
const res = writeChangeTrace(changeDir, changeName, out)
|
|
237
|
+
return { promoted, kept, dropped, ...res }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── 真源① FR:knowledge/fr/<域>.md 条目「测试绑定:」子块 ──
|
|
241
|
+
|
|
242
|
+
/** 解析条目文本内的绑定子块 → rows(条目内行不带 anchor——锚=条目 id 本身) */
|
|
243
|
+
export function parseEntryBindings(entryText) {
|
|
244
|
+
const lines = String(entryText || '').replace(/\r\n/g, '\n').split('\n')
|
|
245
|
+
const rows = []
|
|
246
|
+
let inBlock = false
|
|
247
|
+
let cur = null
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
if (line.trim() === BINDING_BLOCK_HEAD) { inBlock = true; continue }
|
|
250
|
+
if (inBlock && /^##\s/.test(line)) break
|
|
251
|
+
if (!inBlock) continue
|
|
252
|
+
const rowMatch = line.match(/^-\s+row:\s*(.+)$/)
|
|
253
|
+
if (rowMatch) {
|
|
254
|
+
if (cur) rows.push(cur)
|
|
255
|
+
cur = { row_id: rowMatch[1].trim(), anchor: 'ENTRY' }
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
if (!cur) continue
|
|
259
|
+
const kv = line.match(/^\s{2}(\w+):\s*(.*)$/)
|
|
260
|
+
if (!kv) continue
|
|
261
|
+
const [, k, v] = kv
|
|
262
|
+
if (k === 'tests') cur.tests = v.split('|').map(s => s.trim()).filter(Boolean)
|
|
263
|
+
else cur[k] = v.trim() || null
|
|
264
|
+
}
|
|
265
|
+
if (cur) rows.push(cur)
|
|
266
|
+
return rows.map(r => normalizeRow(r, { entryMode: true }))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderBindingBlock(rows) {
|
|
270
|
+
if (!rows || rows.length === 0) return []
|
|
271
|
+
const L = [BINDING_BLOCK_HEAD, BINDING_BLOCK_NOTE]
|
|
272
|
+
for (const r of rows) {
|
|
273
|
+
L.push(`- row: ${r.row_id}`)
|
|
274
|
+
L.push(` tests: ${r.tests.join(' | ')}`)
|
|
275
|
+
L.push(` reason: ${r.reason}`)
|
|
276
|
+
L.push(` state: ${r.state}`)
|
|
277
|
+
L.push(` discovery: ${r.discovery}`)
|
|
278
|
+
L.push(` confirmed_by: ${r.confirmed_by || 'null'}`)
|
|
279
|
+
L.push(` confirmed_at: ${r.confirmed_at || 'null'}`)
|
|
280
|
+
L.push(` source_change: ${r.source_change || 'null'}`)
|
|
281
|
+
L.push(` status: ${r.status}`)
|
|
282
|
+
}
|
|
283
|
+
return L
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 在域文件内定位条目并 upsert 绑定行(surgical:只动条目段内的绑定子块)。
|
|
287
|
+
* projectRoot(可选,2026-09-26-dynamic-test-inference):在场时 tests 路径写入前归一为
|
|
288
|
+
* 仓根相对(治三形态混杂:cwd 相对丢子项目前缀 / 裸文件名——机器行自命令串解析、agent 行
|
|
289
|
+
* 自子项目肌肉记忆都会产生);解析失败的路径保留原值(repair-paths/门禁读侧另有披露)。 */
|
|
290
|
+
export function upsertFrBindings({ knowledgeRoot, frId, rows, projectRoot = null }) {
|
|
291
|
+
const normalized = rows.map(r => {
|
|
292
|
+
const row = normalizeRow({ ...r, anchor: frId })
|
|
293
|
+
if (projectRoot) row.tests = [...new Set(normalizeTestsRootRel(row.tests, { projectRoot }))].sort()
|
|
294
|
+
return row
|
|
295
|
+
})
|
|
296
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
297
|
+
if (!existsSync(dir)) return { ok: false, error: `knowledge/fr 不存在(${dir})` }
|
|
298
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.md')).sort()) {
|
|
299
|
+
const p = join(dir, f)
|
|
300
|
+
const text = readFileSync(p, 'utf8')
|
|
301
|
+
const lines = text.split(/\r?\n/)
|
|
302
|
+
const start = lines.findIndex(l => l.startsWith(`## ${frId} `) || l === `## ${frId}`)
|
|
303
|
+
if (start === -1) continue
|
|
304
|
+
let end = lines.length
|
|
305
|
+
for (let i = start + 1; i < lines.length; i++) { if (/^##\s/.test(lines[i])) { end = i; break } }
|
|
306
|
+
const entryLines = lines.slice(start, end)
|
|
307
|
+
// 剥旧绑定子块(含头注行),保留其余行原样——蒸馏字段与绑定字段互不越权
|
|
308
|
+
const kept = []
|
|
309
|
+
let skipping = false
|
|
310
|
+
for (const l of entryLines) {
|
|
311
|
+
if (l.trim() === BINDING_BLOCK_HEAD) { skipping = true; continue }
|
|
312
|
+
if (skipping && (/^-\s+row:/.test(l) || /^\s{2}\w+:/.test(l) || l.trim() === BINDING_BLOCK_NOTE)) continue
|
|
313
|
+
skipping = false
|
|
314
|
+
kept.push(l)
|
|
315
|
+
}
|
|
316
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop()
|
|
317
|
+
// 合并:既有行(含 agent 修复行)按 (source_change,row_id) 与新行合并——机器不删 agent 行
|
|
318
|
+
const existing = parseEntryBindings(entryLines.join('\n'))
|
|
319
|
+
const merged = new Map()
|
|
320
|
+
for (const e of existing) merged.set(`${e.source_change}::${e.row_id}`, e)
|
|
321
|
+
for (const n of normalized) {
|
|
322
|
+
const key = `${n.source_change}::${n.row_id}`
|
|
323
|
+
const cur = merged.get(key)
|
|
324
|
+
// agent 行优先保留(confirmed_by=agent 且新行是 machine 来源的,不覆盖)
|
|
325
|
+
if (cur && cur.confirmed_by === 'agent' && n.discovery === 'machine') continue
|
|
326
|
+
merged.set(key, n)
|
|
327
|
+
}
|
|
328
|
+
const block = renderBindingBlock([...merged.values()].sort((a, b) => (a.source_change + a.row_id).localeCompare(b.source_change + b.row_id)))
|
|
329
|
+
const newEntry = [...kept, ...(block.length ? ['', ...block] : []), '']
|
|
330
|
+
const next = [...lines.slice(0, start), ...newEntry, ...lines.slice(end)]
|
|
331
|
+
const nextText = next.join('\n')
|
|
332
|
+
if (nextText === text) return { ok: true, changed: false, file: p }
|
|
333
|
+
writeAtomicSync(p, nextText)
|
|
334
|
+
return { ok: true, changed: true, file: p }
|
|
335
|
+
}
|
|
336
|
+
return { ok: false, error: `条目 ${frId} 未在 knowledge/fr/*.md 中找到` }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** FR supersede 同步(四硬约束④):条目全部绑定行 status→superseded(禁死锚) */
|
|
340
|
+
export function markFrBindingsSuperseded({ knowledgeRoot, frId }) {
|
|
341
|
+
const rows = readFrBindings({ knowledgeRoot, frId })
|
|
342
|
+
if (rows.length === 0) return { ok: true, changed: false, rows: [] }
|
|
343
|
+
return upsertFrBindingsRaw({ knowledgeRoot, frId, rows: rows.map(r => ({ ...r, status: 'superseded' })) })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 条目行级 supersede 翻转(fr-index 承接翻链在内存态调用——文件尚未落盘,不能走文件级 API) */
|
|
347
|
+
export function applySupersededToEntryLines(lines) {
|
|
348
|
+
const text = lines.join('\n')
|
|
349
|
+
const rows = parseEntryBindings(text)
|
|
350
|
+
if (rows.length === 0) return lines
|
|
351
|
+
const flipped = rows.map(r => ({ ...r, status: 'superseded' }))
|
|
352
|
+
const block = renderBindingBlock(flipped)
|
|
353
|
+
const out = []
|
|
354
|
+
let skipping = false
|
|
355
|
+
for (const l of lines) {
|
|
356
|
+
if (l.trim() === BINDING_BLOCK_HEAD) { skipping = true; continue }
|
|
357
|
+
if (skipping && (/^-\s+row:/.test(l) || /^\s{2}\w+:/.test(l) || l.trim() === BINDING_BLOCK_NOTE)) continue
|
|
358
|
+
skipping = false
|
|
359
|
+
out.push(l)
|
|
360
|
+
}
|
|
361
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop()
|
|
362
|
+
return [...out, '', ...block, '']
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 删 FR 条目绑定行(--unbind 通道;修理工专用) */
|
|
366
|
+
export function unbindFrRows({ knowledgeRoot, frId, rowIds }) {
|
|
367
|
+
const rows = readFrBindings({ knowledgeRoot, frId }).filter(r => !rowIds.includes(r.row_id))
|
|
368
|
+
return upsertFrBindingsRaw({ knowledgeRoot, frId, rows })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function upsertFrBindingsRaw({ knowledgeRoot, frId, rows }) {
|
|
372
|
+
// 与 upsertFrBindings 同定位/重写逻辑,但行已是权威态(supersede 翻链用,不做 agent 保护)
|
|
373
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
374
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.md')).sort()) {
|
|
375
|
+
const p = join(dir, f)
|
|
376
|
+
const text = readFileSync(p, 'utf8')
|
|
377
|
+
const lines = text.split(/\r?\n/)
|
|
378
|
+
const start = lines.findIndex(l => l.startsWith(`## ${frId} `) || l === `## ${frId}`)
|
|
379
|
+
if (start === -1) continue
|
|
380
|
+
let end = lines.length
|
|
381
|
+
for (let i = start + 1; i < lines.length; i++) { if (/^##\s/.test(lines[i])) { end = i; break } }
|
|
382
|
+
const entryLines = lines.slice(start, end)
|
|
383
|
+
const kept = []
|
|
384
|
+
let skipping = false
|
|
385
|
+
for (const l of entryLines) {
|
|
386
|
+
if (l.trim() === BINDING_BLOCK_HEAD) { skipping = true; continue }
|
|
387
|
+
if (skipping && (/^-\s+row:/.test(l) || /^\s{2}\w+:/.test(l) || l.trim() === BINDING_BLOCK_NOTE)) continue
|
|
388
|
+
skipping = false
|
|
389
|
+
kept.push(l)
|
|
390
|
+
}
|
|
391
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop()
|
|
392
|
+
const block = renderBindingBlock(rows.map(r => normalizeRow({ ...r, anchor: frId })))
|
|
393
|
+
const next = [...lines.slice(0, start), ...kept, ...(block.length ? ['', ...block] : []), '', ...lines.slice(end)]
|
|
394
|
+
const nextText = next.join('\n')
|
|
395
|
+
if (nextText === text) return { ok: true, changed: false, file: p }
|
|
396
|
+
writeAtomicSync(p, nextText)
|
|
397
|
+
return { ok: true, changed: true, file: p }
|
|
398
|
+
}
|
|
399
|
+
return { ok: false, error: `条目 ${frId} 未找到` }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 读单条目绑定行(全局锚回填) */
|
|
403
|
+
export function readFrBindings({ knowledgeRoot, frId }) {
|
|
404
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
405
|
+
try {
|
|
406
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.md')).sort()) {
|
|
407
|
+
const text = readFileSync(join(dir, f), 'utf8')
|
|
408
|
+
const lines = text.split(/\r?\n/)
|
|
409
|
+
const start = lines.findIndex(l => l.startsWith(`## ${frId} `) || l === `## ${frId}`)
|
|
410
|
+
if (start === -1) continue
|
|
411
|
+
let end = lines.length
|
|
412
|
+
for (let i = start + 1; i < lines.length; i++) { if (/^##\s/.test(lines[i])) { end = i; break } }
|
|
413
|
+
return parseEntryBindings(lines.slice(start, end).join('\n')).map(r => ({ ...r, anchor: frId }))
|
|
414
|
+
}
|
|
415
|
+
} catch { /* 读失败 → 空 */ }
|
|
416
|
+
return []
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── 真源② ql:.sillyspec/quicklog/test-bindings.json ──
|
|
420
|
+
|
|
421
|
+
function qlBindingsPath(specBase) { return join(specBase, 'quicklog', 'test-bindings.json') }
|
|
422
|
+
|
|
423
|
+
export function readQlBindings(specBase) {
|
|
424
|
+
try {
|
|
425
|
+
const p = qlBindingsPath(specBase)
|
|
426
|
+
if (!existsSync(p)) return {}
|
|
427
|
+
const j = JSON.parse(readFileSync(p, 'utf8'))
|
|
428
|
+
return j && j.rows && typeof j.rows === 'object' && !Array.isArray(j.rows) ? j.rows : {}
|
|
429
|
+
} catch { return {} }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** ql 行 upsert(键=ql-id+row_id;原子+幂等) */
|
|
433
|
+
export function upsertQlBindings({ specBase, qlId, rows }) {
|
|
434
|
+
const all = readQlBindings(specBase)
|
|
435
|
+
const cur = new Map((all[qlId] || []).map(r => [r.row_id, r]))
|
|
436
|
+
for (const r0 of rows) {
|
|
437
|
+
const r = normalizeRow({ ...r0, anchor: qlId })
|
|
438
|
+
if (cur.has(r.row_id) && cur.get(r.row_id).confirmed_by === 'agent' && r.discovery === 'machine') continue
|
|
439
|
+
cur.set(r.row_id, r)
|
|
440
|
+
}
|
|
441
|
+
all[qlId] = [...cur.values()].sort((a, b) => a.row_id.localeCompare(b.row_id))
|
|
442
|
+
const payload = JSON.stringify({ schemaVersion: 1, rows: all }, null, 2) + '\n'
|
|
443
|
+
const p = qlBindingsPath(specBase)
|
|
444
|
+
try { if (existsSync(p) && readFileSync(p, 'utf8') === payload) return { changed: false, path: p } } catch { /* 照写 */ }
|
|
445
|
+
writeAtomicSync(p, payload)
|
|
446
|
+
return { changed: true, path: p }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 删 ql 行(--unbind 通道;修理工专用) */
|
|
450
|
+
export function unbindQlRows({ specBase, qlId, rowIds }) {
|
|
451
|
+
const all = readQlBindings(specBase)
|
|
452
|
+
const cur = (all[qlId] || []).filter(r => !rowIds.includes(r.row_id))
|
|
453
|
+
const removed = (all[qlId] || []).length - cur.length
|
|
454
|
+
all[qlId] = cur
|
|
455
|
+
writeAtomicSync(qlBindingsPath(specBase), JSON.stringify({ schemaVersion: 1, rows: all }, null, 2) + '\n')
|
|
456
|
+
return { removed }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── 视图查询(两真源合并)──
|
|
460
|
+
|
|
461
|
+
export function queryByAnchor({ specBase, knowledgeRoot, anchor }) {
|
|
462
|
+
const out = []
|
|
463
|
+
if (/^FR-/.test(anchor)) {
|
|
464
|
+
// 坑 tests-fr-view-empty(2026-09-24 平台合并后实锤):此处曾把 anchor 传成 frId
|
|
465
|
+
// 键——readFrBindings 取 frId=undefined → 全域查找落空 → FR 视图恒空(B6 只测 ql
|
|
466
|
+
// 分支放行)。键名对齐 readFrBindings 契约。
|
|
467
|
+
for (const r of readFrBindings({ knowledgeRoot, frId: anchor })) out.push(r)
|
|
468
|
+
} else if (/^ql-/.test(anchor)) {
|
|
469
|
+
for (const r of readQlBindings(specBase)[anchor] || []) out.push(r)
|
|
470
|
+
}
|
|
471
|
+
return out
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 测试文件归属解析(R2 定向化消费面,watcher 调用):files → 归属锚行(FR 条目+ql 面,
|
|
475
|
+
* 仅 active·非 superseded 行)。返回 Map<normPath, Array<{anchor, row_id, source_change}>>。 */
|
|
476
|
+
export function resolveTestFileOwners({ specBase, files }) {
|
|
477
|
+
const map = new Map()
|
|
478
|
+
const norm = (p) => String(p).replace(/\\/g, '/')
|
|
479
|
+
const want = new Set((files || []).map(norm))
|
|
480
|
+
if (want.size === 0) return map
|
|
481
|
+
const push = (file, entry) => {
|
|
482
|
+
const k = norm(file)
|
|
483
|
+
if (!map.has(k)) map.set(k, [])
|
|
484
|
+
map.get(k).push(entry)
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const knowledgeRoot = join(specBase, 'knowledge')
|
|
488
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
489
|
+
if (existsSync(dir)) {
|
|
490
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.md')).sort()) {
|
|
491
|
+
const text = readFileSync(join(dir, f), 'utf8')
|
|
492
|
+
for (const m of text.matchAll(/^## (FR-[A-Za-z0-9-]+-\d+) /gm)) {
|
|
493
|
+
for (const r of readFrBindings({ knowledgeRoot, frId: m[1] })) {
|
|
494
|
+
if (r.state !== 'active' || r.status === 'superseded') continue
|
|
495
|
+
// 剥锚后按文件面匹配(tests 条目可携带用例锚——2026-09-26-binding-anchor-fidelity)
|
|
496
|
+
for (const t of r.tests) { const f = testAnchorFile(t); if (want.has(norm(f))) push(f, { anchor: m[1], row_id: r.row_id, source_change: r.source_change }) }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} catch { /* FR 面读失败 → 只回 ql 面 */ }
|
|
502
|
+
try {
|
|
503
|
+
for (const [qlId, rows] of Object.entries(readQlBindings(specBase))) {
|
|
504
|
+
for (const r of rows || []) {
|
|
505
|
+
if (r.state !== 'active' || r.status === 'superseded') continue
|
|
506
|
+
for (const t of r.tests) { const f = testAnchorFile(t); if (want.has(norm(f))) push(f, { anchor: qlId, row_id: r.row_id, source_change: r.source_change }) }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch { /* ql 面读失败 → 只回 FR 面 */ }
|
|
510
|
+
return map
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function queryByChange({ specBase, knowledgeRoot, sourceChange }) {
|
|
514
|
+
const out = []
|
|
515
|
+
try {
|
|
516
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
517
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.md')).sort()) {
|
|
518
|
+
const text = readFileSync(join(dir, f), 'utf8')
|
|
519
|
+
for (const m of text.matchAll(/^## (FR-[A-Za-z0-9-]+-\d+) /gm)) {
|
|
520
|
+
for (const r of readFrBindings({ knowledgeRoot, frId: m[1] })) {
|
|
521
|
+
if (r.source_change === sourceChange) out.push(r)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
} catch { /* fr 面读失败 → 只回 ql 面 */ }
|
|
526
|
+
for (const rows of Object.values(readQlBindings(specBase))) {
|
|
527
|
+
for (const r of rows) if (r.source_change === sourceChange) out.push(r)
|
|
528
|
+
}
|
|
529
|
+
return out
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** 锚可解析校验(修理工入口硬校验——修理工不得制造悬空锚) */
|
|
533
|
+
export function anchorResolvable({ specBase, knowledgeRoot, anchor }) {
|
|
534
|
+
if (/^FR-/.test(anchor)) return frEntryExists(knowledgeRoot, anchor)
|
|
535
|
+
if (/^ql-/.test(anchor)) {
|
|
536
|
+
try { return existsSync(quicklogSidecarPath(specBase, anchor)) } catch { return false }
|
|
537
|
+
}
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function frEntryExists(knowledgeRoot, frId) {
|
|
542
|
+
try {
|
|
543
|
+
const dir = join(knowledgeRoot, 'fr')
|
|
544
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
|
|
545
|
+
if (readFileSync(join(dir, f), 'utf8').split(/\r?\n/).some(l => l.startsWith(`## ${frId} `) || l === `## ${frId}`)) return true
|
|
546
|
+
}
|
|
547
|
+
} catch { /* 读失败 → 不存在 */ }
|
|
548
|
+
return false
|
|
549
|
+
}
|