sillyspec 3.28.8 → 3.28.10
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/full.json +1 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/change-risk-profile.js +20 -0
- package/src/config-schema.js +62 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +89 -20
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +206 -18
- package/src/knowledge-classify.js +382 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/probe7-anchor-check.js +69 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +164 -18
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +131 -13
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +123 -23
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gate-snapshot.js +110 -2
- package/src/run/gates.js +141 -16
- package/src/run/prompt.js +221 -11
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +147 -11
- package/src/run/stage.js +6 -0
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +456 -56
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +76 -6
- package/src/stages/plan-postcheck.js +32 -5
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +170 -6
- package/src/sync.js +141 -9
- package/src/task-review.js +366 -46
- package/src/verify-postcheck.js +212 -55
- package/src/verify-probes.js +727 -16
- package/src/worktree-apply.js +685 -48
- package/src/worktree-deps.js +124 -3
- package/src/worktree.js +391 -15
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- package/sa-rename-probe/src/new.js +0 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cross-repo-reconcile.js — 跨仓 task 卡 target_files 的 per-repo 对账(零环新模块)。
|
|
3
|
+
*
|
|
4
|
+
* 坑 cross-repo-reconcile-blindness(2026-09-15 复盘实证:三端变更的对账表只有主仓机器可见,
|
|
5
|
+
* 22 个跨仓文件在归档表全标「计划未动」、探针5 报「0 frontend calls」,跨仓全靠人工到对应仓
|
|
6
|
+
* 解释):reconcileTargetFiles 主链按 D-004 把跨仓卡从声明侧剔除——主仓 actual 永远对不上跨仓
|
|
7
|
+
* 声明,剔除本身正确;但「留待后续分期」一直没有兑现,跨仓侧零机器可见性。
|
|
8
|
+
*
|
|
9
|
+
* 本模块兑现该分期:对每张跨仓卡声明的 repo key,读 local.yaml repos 注册表解析仓根,在**该仓**
|
|
10
|
+
* 取 actual(diff HEAD~1..HEAD ∪ status porcelain untracked,与 resolveVerifyChangedFiles 跨仓
|
|
11
|
+
* 分支同源口径),按主仓同款三类差集对账(matched / missing / undeclared + 脚手架软桶)。
|
|
12
|
+
*
|
|
13
|
+
* 定位与边界(与主仓对账的差异,显式声明防误读):
|
|
14
|
+
* - **advisory 不阻断**:跨仓 diff 锚点是 HEAD~1..HEAD 最近提交窗口(多 task 同仓时只反映最近
|
|
15
|
+
* 一笔,精确范围要 task 卡 base/head 锡点——verify-postcheck:1119 先例同款限制),锚点脆弱期
|
|
16
|
+
* (已 commit 多笔 / 全在 working-tree)会产②类假信号,故只报告不翻转 verify 状态。
|
|
17
|
+
* - 仓未注册 / 路径不可达 / 非 git 仓 → 该 repo degraded 一行说明,不炸整体。
|
|
18
|
+
* - 不 import verify-postcheck(后者 import 本模块,反向成环);路径归一/大小写折叠与主仓
|
|
19
|
+
* normalizeReconcilePath/pathKey 同口径本地实现,注释锚定不漂移。
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'fs'
|
|
22
|
+
import { join, isAbsolute, resolve } from 'path'
|
|
23
|
+
import { gitQuiet } from './git-helper.js'
|
|
24
|
+
import { parseRepoRegistry } from './stages/plan-postcheck.js'
|
|
25
|
+
import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
|
|
26
|
+
|
|
27
|
+
// 与 verify-postcheck normalizeReconcilePath 同口径(本地实现防环):剥 ./ 前缀、反斜杠归一
|
|
28
|
+
function normalizeRepoPath(p) {
|
|
29
|
+
return String(p || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 与 verify-postcheck reconcileTargetFiles 的 foldCase 同口径:win32/darwin 文件系统大小写
|
|
33
|
+
// 不敏感,两形指同一文件;Linux 保持敏感
|
|
34
|
+
const foldCase = process.platform === 'win32' || process.platform === 'darwin'
|
|
35
|
+
const pathKey = (p) => {
|
|
36
|
+
const n = normalizeRepoPath(p)
|
|
37
|
+
return foldCase ? n.toLowerCase() : n
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 与 verify-postcheck parsePorcelainFilePaths 同口径(本地实现防环):剥 XY 状态列、rename 取
|
|
41
|
+
// -> 后新路径、剥包裹引号、反斜杠归一
|
|
42
|
+
function parsePorcelain(raw) {
|
|
43
|
+
return String(raw || '').split('\n')
|
|
44
|
+
.map(l => l.slice(3).trim())
|
|
45
|
+
.map(p => (p.includes(' -> ') ? p.slice(p.lastIndexOf(' -> ') + 4) : p).trim())
|
|
46
|
+
.map(p => p.replace(/^"|"$/g, ''))
|
|
47
|
+
.map(p => p.replace(/\\/g, '/'))
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 跨仓声明对账。declarationsByRepo 由声明侧收集(collectDeclaredTargetFiles 跨仓卡分支):
|
|
53
|
+
* `{ [repoKey]: Array<{task, path, isNew}> }`——path 相对该仓根(跨仓卡口径)。
|
|
54
|
+
*
|
|
55
|
+
* @param {{ specBase: string, cwd: string, declarationsByRepo: Record<string, Array<{task:string,path:string,isNew:boolean}>> }} args
|
|
56
|
+
* @returns {Array<{repo:string, repoPath:string|null, declaredCount:number, actualCount:number,
|
|
57
|
+
* matched:string[], missing:Array<{task:string,path:string,isNew?:boolean}>,
|
|
58
|
+
* undeclared:string[], scaffoldCount:number, degradedReason:string|null}>}
|
|
59
|
+
* 每 repo 一项;degradedReason 非空时 matched/missing/undeclared 为空数组(该仓无结论)。
|
|
60
|
+
*/
|
|
61
|
+
export function reconcileCrossRepoDeclarations({ specBase, cwd, declarationsByRepo }) {
|
|
62
|
+
const results = []
|
|
63
|
+
const repos = declarationsByRepo && typeof declarationsByRepo === 'object' ? Object.keys(declarationsByRepo) : []
|
|
64
|
+
if (repos.length === 0) return results
|
|
65
|
+
|
|
66
|
+
let registry = new Map()
|
|
67
|
+
try {
|
|
68
|
+
const yamlPath = join(specBase || '', 'local.yaml')
|
|
69
|
+
if (specBase && existsSync(yamlPath)) {
|
|
70
|
+
registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
|
|
71
|
+
}
|
|
72
|
+
} catch { /* local.yaml 不可读 → 空注册表,各 repo 落「未注册」degraded */ }
|
|
73
|
+
|
|
74
|
+
for (const repoKey of repos) {
|
|
75
|
+
const declarations = declarationsByRepo[repoKey] || []
|
|
76
|
+
const base = { repo: repoKey, repoPath: null, declaredCount: declarations.length, actualCount: 0,
|
|
77
|
+
matched: [], missing: [], undeclared: [], scaffoldCount: 0, degradedReason: null }
|
|
78
|
+
|
|
79
|
+
const rawPath = registry.get(repoKey)
|
|
80
|
+
if (!rawPath) {
|
|
81
|
+
results.push({ ...base, degradedReason: `repo key「${repoKey}」未在 local.yaml repos 注册——跨仓对账不可达,请人工到对应仓核对` })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const repoPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
|
|
85
|
+
base.repoPath = repoPath
|
|
86
|
+
if (!existsSync(repoPath)) {
|
|
87
|
+
results.push({ ...base, degradedReason: `注册路径不可达:${repoPath}` })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// actual 双源(resolveVerifyChangedFiles 跨仓分支同源):已提交窗口 + working-tree 未提交
|
|
92
|
+
const union = new Set()
|
|
93
|
+
let anySource = false
|
|
94
|
+
const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
|
|
95
|
+
if (diffOut !== null) {
|
|
96
|
+
anySource = true
|
|
97
|
+
for (const f of String(diffOut).split('\n')) {
|
|
98
|
+
const n = normalizeRepoPath(f)
|
|
99
|
+
if (n) union.add(n)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const statusOut = gitQuiet(repoPath, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
|
|
103
|
+
if (statusOut !== null) {
|
|
104
|
+
anySource = true
|
|
105
|
+
for (const f of parsePorcelain(statusOut)) union.add(f)
|
|
106
|
+
}
|
|
107
|
+
if (!anySource) {
|
|
108
|
+
results.push({ ...base, degradedReason: '该仓 git 不可用/非仓库(diff 与 status 双失败)' })
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const actualFiles = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
|
|
113
|
+
base.actualCount = actualFiles.length
|
|
114
|
+
const actualKeySet = new Set(actualFiles.map(pathKey))
|
|
115
|
+
const declaredPaths = [...new Set(declarations.map(d => d.path))].sort()
|
|
116
|
+
const declaredKeySet = new Set(declaredPaths.map(pathKey))
|
|
117
|
+
for (const path of declaredPaths) {
|
|
118
|
+
if (actualKeySet.has(pathKey(path))) base.matched.push(path)
|
|
119
|
+
}
|
|
120
|
+
for (const d of declarations) {
|
|
121
|
+
if (!actualKeySet.has(pathKey(d.path))) {
|
|
122
|
+
base.missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 脚手架软桶(主仓 reconcileTargetFiles 同口径):工具/平台设施文件不进③类逐条清单
|
|
126
|
+
for (const path of actualFiles) {
|
|
127
|
+
if (declaredKeySet.has(pathKey(path))) continue
|
|
128
|
+
if (classifyToolScaffold(path)) { base.scaffoldCount++; continue }
|
|
129
|
+
base.undeclared.push(path)
|
|
130
|
+
}
|
|
131
|
+
results.push(base)
|
|
132
|
+
}
|
|
133
|
+
return results
|
|
134
|
+
}
|
package/src/db.js
CHANGED
|
@@ -7,7 +7,7 @@ import { dirname } from 'path';
|
|
|
7
7
|
// 每次 new ProgressManager 都过 init,靠版本戳跳过建表省开销)。
|
|
8
8
|
// node:sqlite(DatabaseSync)是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
|
|
9
9
|
// _createSchema 内 DDL 直接落盘,无需额外 _save。
|
|
10
|
-
const DB_SCHEMA_VERSION =
|
|
10
|
+
const DB_SCHEMA_VERSION = 6;
|
|
11
11
|
|
|
12
12
|
// SQLITE_BUSY 应用层有限重试(R-08 / NFR-03):WAL 单写者模型,并发写第二者在
|
|
13
13
|
// busy_timeout=5000(init PRAGMA)后抛 SQLITE_BUSY。busy_timeout 已在引擎层处理大部分等待;
|
|
@@ -252,7 +252,7 @@ export class DB {
|
|
|
252
252
|
CREATE TABLE IF NOT EXISTS project (
|
|
253
253
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
254
254
|
name TEXT NOT NULL,
|
|
255
|
-
schema_version INTEGER DEFAULT
|
|
255
|
+
schema_version INTEGER DEFAULT 6,
|
|
256
256
|
created_at TEXT NOT NULL,
|
|
257
257
|
updated_at TEXT NOT NULL
|
|
258
258
|
)
|
|
@@ -271,7 +271,8 @@ export class DB {
|
|
|
271
271
|
platform_change_id INTEGER,
|
|
272
272
|
platform_workspace_id INTEGER,
|
|
273
273
|
platform_last_sync TEXT,
|
|
274
|
-
platform_sync_enabled INTEGER DEFAULT 0
|
|
274
|
+
platform_sync_enabled INTEGER DEFAULT 0,
|
|
275
|
+
owner_session TEXT
|
|
275
276
|
)
|
|
276
277
|
`);
|
|
277
278
|
|
|
@@ -370,6 +371,13 @@ export class DB {
|
|
|
370
371
|
// 已语义化,title/quicklog_id 留空。不纳入平台同步脏度(本地展示用元信息)。
|
|
371
372
|
this._migrateAddColumn('changes', 'title', 'TEXT');
|
|
372
373
|
this._migrateAddColumn('changes', 'quicklog_id', 'TEXT');
|
|
374
|
+
|
|
375
|
+
// v6(2026-09-14-change-ownership-guards task-01,D-005@v1):changes 表加 owner_session——
|
|
376
|
+
// change 级所有权载体(接管类操作对他人活跃 change 拒绝执行的心跳窗口判定数据源)。
|
|
377
|
+
// NULL=无主(存量行迁移后即此态,任何会话可接管——向后兼容零行为变化);写入方=
|
|
378
|
+
// change-registry 首建/claimChangeOwner(task-02 接线会话标识);消费方=assertChangeOwnership
|
|
379
|
+
// (task-02)+ serializeForSync 平台同步投影。幂等 ALTER(列存在跳过,v5 先例同款)。
|
|
380
|
+
this._migrateAddColumn('changes', 'owner_session', 'TEXT');
|
|
373
381
|
}
|
|
374
382
|
|
|
375
383
|
/**
|
package/src/decision-distill.js
CHANGED
|
@@ -80,6 +80,10 @@ function applyField(entry, label, value) {
|
|
|
80
80
|
case '模块域': case '模块': case 'domains': case 'domain': entry.domains = parseListValue(value); break
|
|
81
81
|
// 文件字段(FR-01/D-001@v1,2026-09-11-cross-change-decision-guard):列表语义同模块域 + 逐项反斜杠归一 POSIX
|
|
82
82
|
case '文件': case 'files': entry.files = parseListValue(value).map(s => s.replace(/\\/g, '/')); break
|
|
83
|
+
// 故障面/退役判据(FR-01/D-001@v1,2026-09-15-tax-governance):自维护税治理字段——本决策
|
|
84
|
+
// 引入的新失败模式 / 出现什么信号时简化或删除本机制;单值语义,仅中文标签(模板同源)
|
|
85
|
+
case '故障面': entry.failureMode = value; break
|
|
86
|
+
case '退役判据': entry.retireWhen = value; break
|
|
83
87
|
case '否决理由': case 'reject_reason': case 'rejectreason': entry.rejectReason = value; break
|
|
84
88
|
case '复潮条件': case 'revisit_when': case 'revisitwhen': entry.revisitWhen = value; break
|
|
85
89
|
case 'supersedes': entry.supersedes = value; break
|
|
@@ -95,7 +99,8 @@ function applyField(entry, label, value) {
|
|
|
95
99
|
* @returns {{ entries: Array<{
|
|
96
100
|
* id: string, number: string, version: number, title: string,
|
|
97
101
|
* type?: string, status?: string, question?: string, answer?: string,
|
|
98
|
-
* anchor?: string, domains?: string[], files?: string[],
|
|
102
|
+
* anchor?: string, domains?: string[], files?: string[], failureMode?: string, retireWhen?: string,
|
|
103
|
+
* rejectReason?: string, revisitWhen?: string,
|
|
99
104
|
* supersedes?: string, impacts?: string, normalizedRequirement?: string,
|
|
100
105
|
* selected: 'implemented'|'rejected'|null, raw: string }>, missing: boolean }}
|
|
101
106
|
* missing=true = decisions.md 不存在;selected = FR-02 入选裁决,null 表示解析保留但不入选。
|
|
@@ -166,8 +171,10 @@ export function parseDecisions(changeDir) {
|
|
|
166
171
|
return { entries, missing: false, zeroWithContent: hasDecisionLike && entries.length === 0 }
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
/** applyField 认识的字段标签白名单(行内 |
|
|
170
|
-
|
|
174
|
+
/** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零)。
|
|
175
|
+
* 故障面/退役判据(2026-09-15-tax-governance FR-01):白名单双触点之二——缺收录则两标签
|
|
176
|
+
* 永远留 raw 静默丢弃(applyField case 缺一不可)。 */
|
|
177
|
+
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|文件|files|normalized_requirement|状态|类型|故障面|退役判据)$/i
|
|
171
178
|
|
|
172
179
|
// ---------------------------------------------------------------------------
|
|
173
180
|
// 域三级兜底(FR-03)
|
|
@@ -300,6 +307,11 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
300
307
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
301
308
|
lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
|
|
302
309
|
if (supersedesNote) lines.push(`supersedes:${oneLine(supersedesNote)}`)
|
|
310
|
+
// 故障面/退役判据行(FR-01/D-001@v1,2026-09-15-tax-governance):仅非空渲染——存量条目零迁移、
|
|
311
|
+
// 幂等重归档不添空行(先例 :298-299 文件行);位置在「理由:/supersedes:」之后、rejected 专属行
|
|
312
|
+
// 之前——绝不插在「锚点:」与「文件:」行之间(decision-file-field.test 行序断言红线)
|
|
313
|
+
if (entry.failureMode) lines.push(`故障面:${oneLine(entry.failureMode)}`)
|
|
314
|
+
if (entry.retireWhen) lines.push(`退役判据:${oneLine(entry.retireWhen)}`)
|
|
303
315
|
if (entry.selected === 'rejected') {
|
|
304
316
|
lines.push(`否决理由:${oneLine(entry.rejectReason)}`)
|
|
305
317
|
lines.push(`复潮条件:${oneLine(entry.revisitWhen)}`)
|
package/src/design-facts.js
CHANGED
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
* decision_module_ref_invalid(ERROR)/ decision_module_domain_gap(WARNING)/
|
|
22
22
|
* decision_module_check_skipped(skipped 原因字符串,接线层包 code)。
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
import { parseDecisions } from './decision-distill.js'
|
|
27
27
|
import { parseFileChangeListDetailed } from './change-list.js'
|
|
28
|
+
import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
|
|
28
29
|
import { parseModuleMapSimple } from './modules.js'
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -134,6 +135,26 @@ export function loadModuleMap(specRoot, project) {
|
|
|
134
135
|
return { ids, prefixPairs }
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 在兄弟项目的 map 里定位模块 id 归属(多项目仓 docs/<其他项目>/modules/_module-map.yaml)。
|
|
140
|
+
* 幻觉 ERROR 的消歧源(2026-09-14 quick,用户反馈①):id 真实存在但属另一子项目时报
|
|
141
|
+
* 「归属 + 本次核验只认当前项目 map」,堵 agent 按占位提示误读子项目 map 的死胡同。
|
|
142
|
+
* @param {string} specRoot 规范根目录
|
|
143
|
+
* @param {string} currentProject 当前核验项目(跳过自身)
|
|
144
|
+
* @param {string} id 模块 id
|
|
145
|
+
* @returns {string | null} 命中的项目名(docs/ 下第一段目录名),无命中/不可读 → null
|
|
146
|
+
*/
|
|
147
|
+
function locateIdInSiblingProjects(specRoot, currentProject, id) {
|
|
148
|
+
let entries
|
|
149
|
+
try { entries = readdirSync(join(specRoot, 'docs'), { withFileTypes: true }) } catch { return null }
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === currentProject) continue
|
|
152
|
+
const m = loadModuleMap(specRoot, e.name)
|
|
153
|
+
if (m && m.ids.has(id)) return e.name
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
// ---------------------------------------------------------------------------
|
|
138
159
|
// 核验(FR-02 分级:ERROR / WARNING / skipped)
|
|
139
160
|
// ---------------------------------------------------------------------------
|
|
@@ -162,7 +183,8 @@ export function deriveActualModules(filePaths, prefixPairs) {
|
|
|
162
183
|
* 核验变更决策的模块域引用(FR-02/D-002/D-004;brainstorm「生成规范文件」步 --done gate 本体,
|
|
163
184
|
* 接线在 task-02)。分级:
|
|
164
185
|
* - ERROR(decision_module_ref_invalid):
|
|
165
|
-
* · 模块域项 ∉ _module-map.yaml 且无 NEW:
|
|
186
|
+
* · 模块域项 ∉ _module-map.yaml 且无 NEW: 前缀(模块幻觉)——出路提示二选一;消息内嵌
|
|
187
|
+
* 当前项目 map 路径 + 合法 id 示例,id 命中兄弟项目 map 时改报归属消歧(仍拦);
|
|
166
188
|
* · 裸项 'NEW:'(`NEW: <名>` 冒号后带空格被列表切分)——书写错误,附正确写法。
|
|
167
189
|
* - WARNING(decision_module_domain_gap):
|
|
168
190
|
* · design.md 文件清单推导的实改模块集 × 声明域并集差异(双向,措辞留人工裁量);
|
|
@@ -202,7 +224,16 @@ export function validateDecisionModuleRefs({ changeDir, specRoot, project } = {}
|
|
|
202
224
|
} else if (item.startsWith('NEW:')) {
|
|
203
225
|
// 新模块声明:豁免存在性核验(D-002/D-004),原样保留
|
|
204
226
|
} else if (!moduleMap.ids.has(item)) {
|
|
205
|
-
|
|
227
|
+
// 消息自足(2026-09-14 quick,用户反馈①):内嵌解析出的 map 路径 + 合法 id 示例,
|
|
228
|
+
// agent 不必再猜「合法 id 从哪张 map 取」;多项目仓先查兄弟项目 map 消歧——id 真实
|
|
229
|
+
// 存在但属其他子项目时报归属,而非笼统「模块幻觉」(语义不变:仍 ERROR 拦)。
|
|
230
|
+
const mapRel = `docs/${project}/modules/_module-map.yaml`
|
|
231
|
+
const ids = [...moduleMap.ids]
|
|
232
|
+
const sample = `${ids.slice(0, 5).join('、')}${ids.length > 5 ? ' 等' : ''}`
|
|
233
|
+
const sibling = locateIdInSiblingProjects(specRoot, project, item)
|
|
234
|
+
errors.push(sibling
|
|
235
|
+
? `[decision_module_ref_invalid] ${id} 模块域含未注册模块 "${item}"——该 id 属于子项目 ${sibling}(docs/${sibling}/modules/_module-map.yaml),本次核验只认当前项目 ${mapRel} 的 id(合法示例:${sample},共 ${ids.length} 个);跨项目引用请补录当前项目 map 或改用 NEW:${item} 前缀`
|
|
236
|
+
: `[decision_module_ref_invalid] ${id} 模块域含未注册模块 "${item}"——合法 id 来源:${mapRel}(示例:${sample},共 ${ids.length} 个);补录该 map 或改用 NEW:${item} 前缀声明新模块`)
|
|
206
237
|
}
|
|
207
238
|
}
|
|
208
239
|
}
|
|
@@ -393,25 +424,63 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
|
|
|
393
424
|
if (!existsSync(designPath)) {
|
|
394
425
|
return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 不存在'] }
|
|
395
426
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
427
|
+
|
|
428
|
+
// 单段存在性核验内核:NEW:/glob/占位符口径与原实现一致,仅把「根」参数化
|
|
429
|
+
const checkAgainstRoot = (root, rawPaths, repoLabel) => {
|
|
430
|
+
for (const raw of rawPaths) {
|
|
431
|
+
const rawPath = String(raw || '').trim()
|
|
432
|
+
if (!rawPath) continue
|
|
433
|
+
if (rawPath.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
|
|
434
|
+
if (rawPath.includes('*') || rawPath.includes('?')) {
|
|
435
|
+
warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${rawPath}`)
|
|
436
|
+
continue
|
|
437
|
+
}
|
|
438
|
+
const normalized = stripPathPlaceholders(rawPath)
|
|
439
|
+
if (!normalized || normalized === '.') continue
|
|
440
|
+
if (!existsSync(join(root, normalized))) {
|
|
441
|
+
errors.push({
|
|
442
|
+
path: rawPath,
|
|
443
|
+
message: `design_file_ref_invalid:文件变更清单条目「${rawPath}」在${repoLabel}(${root})下不存在且无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${rawPath} 前缀`,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 坑 design-file-ref-cross-repo-blind(2026-09-15 wp 会话实证,41 条误报):存在性核验
|
|
450
|
+
// 此前恒以主仓 cwd 为根——design §6 按仓分段(D-014)下的跨仓「修改」条目(如
|
|
451
|
+
// sub-grid-security 的 src/common/router.js)在其自己仓里明明存在,却被逼标 NEW: 前缀
|
|
452
|
+
// 过 gate(标「修改」的既有文件被迫声明为计划新建,语义撒谎)。对齐
|
|
453
|
+
// validateDesignFileCoverage 的分段口径:复用 parseDesignCoverageByRepo 按 `## <repo>
|
|
454
|
+
// 仓变更` 段头切分,main 段核主仓根,跨仓段核 local.yaml repos: 注册根;无段头时
|
|
455
|
+
// 整章归 main(原行为零回归)。
|
|
456
|
+
const { byRepo, hasSegmentHeader, malformedHeaders } = parseDesignCoverageByRepo(designPath)
|
|
457
|
+
for (const h of malformedHeaders) {
|
|
458
|
+
warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
|
|
399
459
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${raw}`)
|
|
406
|
-
continue
|
|
460
|
+
|
|
461
|
+
if (!hasSegmentHeader) {
|
|
462
|
+
const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
|
|
463
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
464
|
+
return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
|
|
407
465
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
466
|
+
checkAgainstRoot(cwd, entries.map(e => e.path), '主仓')
|
|
467
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 有段头:逐段按其仓根核验。跨仓注册表读侧口径 = <cwd>/.sillyspec/local.yaml
|
|
471
|
+
// (与 execute MultiRepoContext / register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)
|
|
472
|
+
let registry = null
|
|
473
|
+
const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
|
|
474
|
+
if (existsSync(localYamlPath)) {
|
|
475
|
+
try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
|
|
476
|
+
}
|
|
477
|
+
for (const [repo, paths] of byRepo) {
|
|
478
|
+
if (repo === 'main') {
|
|
479
|
+
checkAgainstRoot(cwd, paths, '主仓')
|
|
480
|
+
} else if (registry && registry.has(repo)) {
|
|
481
|
+
checkAgainstRoot(registry.get(repo), paths, `${repo} 仓`)
|
|
482
|
+
} else {
|
|
483
|
+
warnings.push(`design 清单「${repo} 仓变更」段的 repo 未在 local.yaml repos: 注册,跳过该段存在性核验(execute 启动会 fail-closed 拦截,先跑 sillyspec local register-repo ${repo} <仓根路径>)`)
|
|
415
484
|
}
|
|
416
485
|
}
|
|
417
486
|
return { ok: errors.length === 0, errors, warnings }
|
package/src/docs-gate.js
CHANGED
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* exit code:0 过(≤基线)/ 1 拦(>基线)/ 2 配置或 IO 错误(含无基线)。
|
|
13
13
|
* 纯判定逻辑(evaluateRatchet)与 IO 面(runDocsGate)分离,前者可单测。
|
|
14
|
+
*
|
|
15
|
+
* 坑 docs-gate-stale-baseline(ql-20260915-004,2026-09-15 用户实证):基线文件是静态快照,
|
|
16
|
+
* 远端 origin/main 已合入的失效消化/新增不会回流本地基线——出现「基线 371 < origin/main
|
|
17
|
+
* 实测 379、本地 current 379」时 ratchet 拦 379>371,但本次推送零增量(不劣于远端)——
|
|
18
|
+
* ratchet 本质=拦增量,被陈旧基线破坏成拦存量。修复:current > baseline 分支先实测
|
|
19
|
+
* origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行 + 提示
|
|
20
|
+
* 重锚;实测失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
|
|
14
21
|
*/
|
|
15
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
16
23
|
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
17
25
|
import { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError } from './docs-check.js';
|
|
26
|
+
import { safeGit } from './git-helper.js';
|
|
18
27
|
|
|
19
28
|
/** 基线文件名(相对 specBase/.sillyspec 根;specBase 由调用方传入完整路径锚) */
|
|
20
29
|
export const BASELINE_FILENAME = 'docs-check-baseline';
|
|
@@ -58,6 +67,73 @@ export function writeBaseline(specBase, value) {
|
|
|
58
67
|
writeFileSync(join(specBase, BASELINE_FILENAME), `${value}\n`, 'utf8');
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 实测远端基准树的 docs check 失效数(坑 docs-gate-stale-baseline,ql-20260915-004)。
|
|
72
|
+
*
|
|
73
|
+
* 为什么临时 worktree 而非 runDocsCheck 的 --against reader:against reader 的干净文件
|
|
74
|
+
* 走磁盘直读(磁盘 == HEAD 才成立,docs-check.js createHeadReader :88)——ref=origin/main
|
|
75
|
+
* 时本地领先 origin 的已提交内容会被误当 origin 版,实测失真。临时 detach worktree
|
|
76
|
+
* (`git worktree add --detach <tmp> origin/main`)检出的是真远端树,磁盘即实测基。
|
|
77
|
+
*
|
|
78
|
+
* 配置口径:读树内 local.yaml(gitignored → 通常缺省 → DEFAULT_DOC_PATHS,与远端真实
|
|
79
|
+
* CI 口径一致——设备本地 skip 定制不污染远端实测)。清理含 .git 锁容错:worktree remove
|
|
80
|
+
* 失败 → prune + rmSync 兜底;仍失败 stderr 一行不抛(临时目录残留 OS 清理,不阻断 gate)。
|
|
81
|
+
*
|
|
82
|
+
* @param {string} projectRoot 源码仓根
|
|
83
|
+
* @param {string[]} [remoteRefs] 候选远端 ref(origin/main 优先;master 缺省仓兜底)
|
|
84
|
+
* @returns {Promise<{ originCount: number|null, ref: string|null, error: string|null }>}
|
|
85
|
+
* originCount null = 未实测(无 ref / worktree 建/跑失败——调用方 fail-open 回原拦)
|
|
86
|
+
*/
|
|
87
|
+
export async function measureRemoteBaselineCount(projectRoot, remoteRefs = ['origin/main', 'origin/master']) {
|
|
88
|
+
let ref = null;
|
|
89
|
+
for (const r of remoteRefs) {
|
|
90
|
+
if ((safeGit(projectRoot, ['rev-parse', '--verify', '--quiet', r]) || {}).value) { ref = r; break; }
|
|
91
|
+
}
|
|
92
|
+
if (!ref) return { originCount: null, ref: null, error: 'no-remote-ref' };
|
|
93
|
+
|
|
94
|
+
const tmp = mkdtempSync(join(tmpdir(), 'sillyspec-docsgate-remote-'));
|
|
95
|
+
let originCount = null;
|
|
96
|
+
let error = null;
|
|
97
|
+
try {
|
|
98
|
+
// git worktree add 接受已存在的空目录(mkdtempSync 产物);失败 fail-open 由调用方回原拦
|
|
99
|
+
const added = safeGit(projectRoot, ['worktree', 'add', '--detach', tmp, ref], { timeout: 120000 });
|
|
100
|
+
if (added.error) {
|
|
101
|
+
error = added.error;
|
|
102
|
+
return { originCount: null, ref, error };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const cfg = readDocsCheckConfig(tmp);
|
|
106
|
+
const res = runDocsCheck({
|
|
107
|
+
projectRoot: tmp,
|
|
108
|
+
paths: cfg.paths,
|
|
109
|
+
skip: cfg.skip,
|
|
110
|
+
keywordAssert: cfg.keywordAssert,
|
|
111
|
+
crossRepoRoots: cfg.crossRepoRoots,
|
|
112
|
+
});
|
|
113
|
+
originCount = res.invalid.length;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof DocsCheckConfigError) {
|
|
116
|
+
error = e.message;
|
|
117
|
+
originCount = null;
|
|
118
|
+
} else {
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
const removed = safeGit(projectRoot, ['worktree', 'remove', '--force', tmp]);
|
|
125
|
+
if (removed.error) {
|
|
126
|
+
// .git 锁/并发竞争容错:prune 清注册残留 + 直删目录兜底
|
|
127
|
+
safeGit(projectRoot, ['worktree', 'prune']);
|
|
128
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* 目录已不在 */ }
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* 同上 */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { originCount, ref, error };
|
|
135
|
+
}
|
|
136
|
+
|
|
61
137
|
/**
|
|
62
138
|
* IO 入口:跑一次 gate。
|
|
63
139
|
* @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
|
|
@@ -83,7 +159,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
83
159
|
});
|
|
84
160
|
} catch (e) {
|
|
85
161
|
if (e instanceof DocsCheckConfigError) {
|
|
86
|
-
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, message: `docs gate 配置错误:${e.message}`, inited: false };
|
|
162
|
+
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false };
|
|
87
163
|
}
|
|
88
164
|
throw e;
|
|
89
165
|
}
|
|
@@ -91,20 +167,43 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
91
167
|
|
|
92
168
|
if (initBaseline) {
|
|
93
169
|
writeBaseline(specBase, current);
|
|
94
|
-
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
|
|
170
|
+
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
|
|
95
171
|
}
|
|
96
172
|
|
|
97
173
|
const baseline = readBaseline(specBase);
|
|
98
174
|
if (baseline === null) {
|
|
99
175
|
return {
|
|
100
|
-
exitCode: 2, ok: false, current, baseline: null, delta: null,
|
|
176
|
+
exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null,
|
|
101
177
|
message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
|
|
102
178
|
inited: false,
|
|
103
179
|
};
|
|
104
180
|
}
|
|
105
181
|
if (Number.isNaN(baseline)) {
|
|
106
|
-
return { exitCode: 2, ok: false, current, baseline: null, delta: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
|
|
182
|
+
return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
|
|
107
183
|
}
|
|
108
184
|
const v = evaluateRatchet({ current, baseline });
|
|
109
|
-
|
|
185
|
+
if (v.ok) {
|
|
186
|
+
// 快路径(current ≤ baseline):原路零变化——不触远端实测(零 git 成本零行为漂移)
|
|
187
|
+
return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false };
|
|
188
|
+
}
|
|
189
|
+
// 坑 docs-gate-stale-baseline(ql-20260915-004):current > baseline 时 origin 实测兜底——
|
|
190
|
+
// ratchet 本质=拦增量;基线是静态快照,远端已合入的失效增长不回流基线会造成「未劣于远端
|
|
191
|
+
// 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 提示
|
|
192
|
+
// 重锚;无远端 ref / 实测失败 fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
|
|
193
|
+
const measured = await measureRemoteBaselineCount(projectRoot);
|
|
194
|
+
if (measured.originCount !== null && current <= measured.originCount) {
|
|
195
|
+
return {
|
|
196
|
+
exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
197
|
+
message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
|
|
198
|
+
inited: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const remoteNote = measured.originCount !== null
|
|
202
|
+
? `,且劣于 ${measured.ref} 实测 ${measured.originCount} 处(真增量)`
|
|
203
|
+
: '';
|
|
204
|
+
return {
|
|
205
|
+
exitCode: 1, ok: false, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
206
|
+
message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${v.delta} 处)${remoteNote},拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
|
|
207
|
+
inited: false,
|
|
208
|
+
};
|
|
110
209
|
}
|