sillyspec 3.28.0 → 3.28.2
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/package.json +1 -1
- package/src/archive-delta.js +419 -0
- package/src/design-facts.js +354 -0
- package/src/docs-check.js +79 -8
- package/src/doctor-diagnostics.js +11 -2
- package/src/endpoint-baseline.js +109 -0
- package/src/git-helper.js +31 -0
- package/src/index.js +213 -4
- package/src/modules.js +41 -12
- package/src/progress/change-registry.js +20 -0
- package/src/progress/consistency-doctor.js +124 -5
- package/src/progress/stage-machine.js +13 -2
- package/src/progress.js +3 -0
- package/src/quicklog.js +51 -0
- package/src/run/command.js +70 -1
- package/src/run/complete-handlers.js +53 -4
- package/src/run/complete.js +46 -1
- package/src/run/gates.js +333 -2
- package/src/run/prompt.js +70 -0
- package/src/run/scan-profile.js +98 -6
- package/src/run/shared.js +12 -1
- package/src/run/stage.js +7 -1
- package/src/scan-diff.js +54 -1
- package/src/scan-facts.js +173 -0
- package/src/scan-postcheck.js +110 -3
- package/src/spec-sync.js +19 -2
- package/src/stage-review.js +62 -3
- package/src/stages/archive.js +5 -5
- package/src/stages/brainstorm.js +10 -10
- package/src/stages/execute.js +15 -13
- package/src/stages/plan-postcheck.js +194 -1
- package/src/stages/plan.js +6 -2
- package/src/stages/scan.js +44 -102
- package/src/stages/verify.js +9 -7
- package/src/taskcard.js +8 -1
- package/src/verify-postcheck.js +610 -2
- package/src/verify-probes.js +89 -10
- package/src/workflow.js +30 -0
- package/templates/agents-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +5 -0
- package/templates/workflows/scan-docs.yaml +6 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-facts — 设计事实核验 + design 骨架渲染纯函数集
|
|
3
|
+
* (change: 2026-09-07-ir-stage-p3c,task-01,FR-01/FR-02,D-001@v1/D-002@v1/D-004@v1)
|
|
4
|
+
*
|
|
5
|
+
* 职责(设计文档 Wave 1 + Wave 2 的纯函数本体,接线在 task-02/03):
|
|
6
|
+
* 1. parseDecisionDomains(decisionsText):decisions.md 文本 → 当前版本 D 条目的模块域投影
|
|
7
|
+
* [{ id, domains }](NEW: 前缀项原样保留,豁免存在性核验)。
|
|
8
|
+
* 2. loadModuleMap(specRoot, project):_module-map.yaml → { ids, prefixPairs } | null。
|
|
9
|
+
* 3. validateDecisionModuleRefs({ changeDir, specRoot, project }):模块域分级核验
|
|
10
|
+
* (ERROR=幻觉模块/书写错误,WARNING=声明域×实改面差异,skipped=无索引/无决策)。
|
|
11
|
+
* 4. generateDesignSkeleton({ changeName, decisionsText, author, now }):design.md 十三章节
|
|
12
|
+
* 骨架字符串(章节标题逐字取自 brainstorm Step 6 模板,R-03——禁止自创措辞)。
|
|
13
|
+
*
|
|
14
|
+
* 依赖方向(R-01 单向防环):design-facts → decision-distill / change-list / modules,
|
|
15
|
+
* 三者均不反向 import 本模块。条目解析双源说明:distill 的 parseDecisions 只吃 changeDir
|
|
16
|
+
* (内部读盘),故文本入口 parseDecisionDomains 持有其行循环的逐字镜像(正则/字段映射/
|
|
17
|
+
* 列表切分全部同源复制),validateDecisionModuleRefs 则直接复用 parseDecisions 导出——
|
|
18
|
+
* 双解析一致性由 task-04 的同一 fixture 双源测试锁定。
|
|
19
|
+
*
|
|
20
|
+
* 信封 code(task-02 接线层使用,本模块已内嵌在消息前缀):
|
|
21
|
+
* decision_module_ref_invalid(ERROR)/ decision_module_domain_gap(WARNING)/
|
|
22
|
+
* decision_module_check_skipped(skipped 原因字符串,接线层包 code)。
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { parseDecisions } from './decision-distill.js'
|
|
27
|
+
import { parseFileChangeListDetailed } from './change-list.js'
|
|
28
|
+
import { parseModuleMapSimple } from './modules.js'
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 条目解析(distill parseDecisions 的同源镜像,R-01)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** 模块域值解析(decision-distill.js:47 parseListValue 逐字镜像):`[a, b]` / `a、b` / `a b` → ['a','b'] */
|
|
35
|
+
function parseListValue(v) {
|
|
36
|
+
return v.replace(/^\[/, '').replace(/\]$/, '')
|
|
37
|
+
.split(/[,,、\s]+/)
|
|
38
|
+
.map(s => s.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 模块域字段标签集(decision-distill.js:77 applyField 的 domains 分支同款,中英标签+中英冒号兼容) */
|
|
43
|
+
const DOMAIN_LABELS = new Set(['模块域', '模块', 'domains', 'domain'])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* decisions.md 文本 → 裸条目数组(decision-distill.js:98 parseDecisions 行循环的模块域子集镜像:
|
|
47
|
+
* 头部正则 :114 / 字段行正则 :123 逐字同源;仅解析本模块消费的 id/number/version/domains)。
|
|
48
|
+
* @param {string} content decisions.md 全文
|
|
49
|
+
* @returns {Array<{ id: string, number: string, version: number, domains: string[] }>}
|
|
50
|
+
*/
|
|
51
|
+
function extractDomainEntries(content) {
|
|
52
|
+
const entries = []
|
|
53
|
+
let cur = null
|
|
54
|
+
const flush = () => {
|
|
55
|
+
if (!cur) return
|
|
56
|
+
entries.push(cur)
|
|
57
|
+
cur = null
|
|
58
|
+
}
|
|
59
|
+
for (const line of String(content ?? '').replace(/\r\n/g, '\n').split('\n')) {
|
|
60
|
+
const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
|
|
61
|
+
if (h) {
|
|
62
|
+
flush()
|
|
63
|
+
const version = h[2] ? parseInt(h[2], 10) : 1
|
|
64
|
+
cur = { id: `${h[1]}@v${version}`, number: h[1], version, domains: [] }
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (!cur) continue
|
|
68
|
+
const f = line.match(/^-\s+([^\s::]+)\s*[::]\s*(.*)$/)
|
|
69
|
+
if (f && DOMAIN_LABELS.has(f[1].toLowerCase())) cur.domains = parseListValue(f[2].trim())
|
|
70
|
+
}
|
|
71
|
+
flush()
|
|
72
|
+
return entries
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 当前版本过滤(decision-distill.js:462 highestByNumber 同款):同号只留最高版本
|
|
77
|
+
* (D-xxx@v1 与 @v2 并存时 v1 视为 superseded 旧版剔除)。
|
|
78
|
+
* @template T
|
|
79
|
+
* @param {Array<T & { number: string, version: number }>} entries
|
|
80
|
+
* @returns {T[]}
|
|
81
|
+
*/
|
|
82
|
+
function currentVersionEntries(entries) {
|
|
83
|
+
const highestByNumber = new Map()
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
const prev = highestByNumber.get(e.number)
|
|
86
|
+
if (!prev || e.version > prev.version) highestByNumber.set(e.number, e)
|
|
87
|
+
}
|
|
88
|
+
return [...highestByNumber.values()]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 纯函数:解析 decisions.md 文本的当前版本决策条目模块域(FR-01)。
|
|
93
|
+
* NEW: 前缀项原样保留在 domains 项内(`NEW:foo` 豁免存在性核验;`NEW: foo` 带空格会被
|
|
94
|
+
* parseListValue 切成裸项 'NEW:'/'foo',由 validateDecisionModuleRefs 报书写错误)。
|
|
95
|
+
* @param {string} decisionsText decisions.md 全文(纯文本入参,不读盘)
|
|
96
|
+
* @returns {Array<{ id: string, domains: string[] }>} 当前版本条目(同号取最高 @vN,首现顺序)
|
|
97
|
+
*/
|
|
98
|
+
export function parseDecisionDomains(decisionsText) {
|
|
99
|
+
return currentVersionEntries(extractDomainEntries(decisionsText))
|
|
100
|
+
.map(e => ({ id: e.id, domains: [...e.domains] }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// 模块索引(_module-map.yaml 只读,复用 modules.js canonical 解析避免三写)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 读 docs/<project>/modules/_module-map.yaml → 模块 id 集合 + paths 前缀对(FR-01)。
|
|
109
|
+
* 解析复用 modules.js parseModuleMapSimple(canonical 单一真相源);prefixPairs 含
|
|
110
|
+
* paths+core_files(与 decision-distill.js:203 matchImpactsToModules 的取并集口径一致),
|
|
111
|
+
* 目录条目去尾斜杠后按「全等或目录前缀」匹配。
|
|
112
|
+
* @param {string} specRoot 规范根目录(如 <repo>/.sillyspec——与 run/prompt.js 的 specBase 同义)
|
|
113
|
+
* @param {string} project 子项目名(docs/ 下第一段目录名)
|
|
114
|
+
* @returns {{ ids: Set<string>, prefixPairs: Array<{ id: string, path: string }> } | null}
|
|
115
|
+
* 无 map / 不可读 / 解析不出任何模块 → null(无索引不误拦,调用方走 skipped)
|
|
116
|
+
*/
|
|
117
|
+
export function loadModuleMap(specRoot, project) {
|
|
118
|
+
if (!specRoot || !project) return null
|
|
119
|
+
const mapPath = join(specRoot, 'docs', project, 'modules', '_module-map.yaml')
|
|
120
|
+
if (!existsSync(mapPath)) return null
|
|
121
|
+
let content
|
|
122
|
+
try { content = readFileSync(mapPath, 'utf8') } catch { return null }
|
|
123
|
+
let parsed = {}
|
|
124
|
+
try { parsed = parseModuleMapSimple(content) || {} } catch { return null }
|
|
125
|
+
const ids = new Set(Object.keys(parsed))
|
|
126
|
+
if (ids.size === 0) return null // 空/失配 map 视同无索引(fail-open,D-002)
|
|
127
|
+
const prefixPairs = []
|
|
128
|
+
for (const [id, m] of Object.entries(parsed)) {
|
|
129
|
+
for (const p of [...((m && m.paths) || []), ...((m && m.core_files) || [])]) {
|
|
130
|
+
const ps = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
131
|
+
if (ps) prefixPairs.push({ id, path: ps })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { ids, prefixPairs }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 核验(FR-02 分级:ERROR / WARNING / skipped)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* design 文件清单路径 × module-map paths 前缀 → 实改模块集(decision-distill.js:212 同款
|
|
143
|
+
* 匹配语义:全等或目录前缀命中)。未命中任何模块的文件不产生推导(R-02:模块 paths 缺口
|
|
144
|
+
* 不误报,只对命中的做差异)。
|
|
145
|
+
* @param {string[]} filePaths design.md 文件变更清单路径(仓根相对、正斜杠)
|
|
146
|
+
* @param {Array<{ id: string, path: string }>} prefixPairs loadModuleMap 的前缀对
|
|
147
|
+
* @returns {Set<string>} 实改模块 id 集
|
|
148
|
+
*/
|
|
149
|
+
export function deriveActualModules(filePaths, prefixPairs) {
|
|
150
|
+
const actual = new Set()
|
|
151
|
+
for (const f of filePaths) {
|
|
152
|
+
const t = String(f || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
153
|
+
if (!t) continue
|
|
154
|
+
for (const { id, path: p } of prefixPairs) {
|
|
155
|
+
if (t === p || t.startsWith(p + '/')) { actual.add(id); break }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return actual
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 核验变更决策的模块域引用(FR-02/D-002/D-004;brainstorm「生成规范文件」步 --done gate 本体,
|
|
163
|
+
* 接线在 task-02)。分级:
|
|
164
|
+
* - ERROR(decision_module_ref_invalid):
|
|
165
|
+
* · 模块域项 ∉ _module-map.yaml 且无 NEW: 前缀(模块幻觉)——出路提示二选一;
|
|
166
|
+
* · 裸项 'NEW:'(`NEW: <名>` 冒号后带空格被列表切分)——书写错误,附正确写法。
|
|
167
|
+
* - WARNING(decision_module_domain_gap):
|
|
168
|
+
* · design.md 文件清单推导的实改模块集 × 声明域并集差异(双向,措辞留人工裁量);
|
|
169
|
+
* · 全部条目均未填模块域 → 汇总一条(存量兼容)。
|
|
170
|
+
* - skipped:decisions.md 不存在 / module-map 缺失(无索引不误拦)→ ok=true + skipped 原因。
|
|
171
|
+
* @param {{ changeDir: string, specRoot: string, project: string }} opts
|
|
172
|
+
* changeDir=变更目录(含 decisions.md 与 design.md);specRoot=规范根;project=子项目名
|
|
173
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], skipped?: string }}
|
|
174
|
+
* ok = errors.length === 0(WARNING 不拦);errors/warnings 每条已内嵌信封 code 前缀,
|
|
175
|
+
* skipped 为原因字符串(接线层包 decision_module_check_skipped)。
|
|
176
|
+
*/
|
|
177
|
+
export function validateDecisionModuleRefs({ changeDir, specRoot, project } = {}) {
|
|
178
|
+
const errors = []
|
|
179
|
+
const warnings = []
|
|
180
|
+
|
|
181
|
+
if (!changeDir) {
|
|
182
|
+
return { ok: true, errors, warnings, skipped: 'changeDir 未提供——核验跳过' }
|
|
183
|
+
}
|
|
184
|
+
const parsed = parseDecisions(changeDir) // distill 权威条目解析(:98 导出,单向依赖)
|
|
185
|
+
if (parsed.missing) {
|
|
186
|
+
return { ok: true, errors, warnings, skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')})——无决策条目可核验,跳过` }
|
|
187
|
+
}
|
|
188
|
+
const moduleMap = loadModuleMap(specRoot, project)
|
|
189
|
+
if (!moduleMap) {
|
|
190
|
+
return { ok: true, errors, warnings, skipped: `_module-map.yaml 不存在或不可解析(${join(specRoot || '?', 'docs', project || '?', 'modules', '_module-map.yaml')})——无模块索引不误拦,跳过` }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ① 存在性核验(ERROR):NEW: 前缀豁免;∈ ids 通过;否则幻觉 ERROR(附出路)
|
|
194
|
+
const entries = currentVersionEntries(parsed.entries)
|
|
195
|
+
.map(e => ({ id: e.id, domains: e.domains ? [...e.domains] : [] }))
|
|
196
|
+
let anyDeclared = false
|
|
197
|
+
for (const { id, domains } of entries) {
|
|
198
|
+
if (domains.length > 0) anyDeclared = true
|
|
199
|
+
for (const item of domains) {
|
|
200
|
+
if (item === 'NEW:') {
|
|
201
|
+
errors.push(`[decision_module_ref_invalid] ${id} 模块域书写错误:出现裸项 "NEW:"("NEW: <名>" 冒号后带空格会被列表切分成 "NEW:" 与 "<名>" 两项)——正确写法 NEW:<名>(冒号后不加空格,如 NEW:design-facts)`)
|
|
202
|
+
} else if (item.startsWith('NEW:')) {
|
|
203
|
+
// 新模块声明:豁免存在性核验(D-002/D-004),原样保留
|
|
204
|
+
} else if (!moduleMap.ids.has(item)) {
|
|
205
|
+
errors.push(`[decision_module_ref_invalid] ${id} 模块域含未注册模块 "${item}"——补录 _module-map.yaml 或改用 NEW:${item} 前缀声明新模块`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ② 全部条目无模块域 → 汇总一条 WARNING(存量兼容,零红门禁)
|
|
211
|
+
if (entries.length > 0 && !anyDeclared) {
|
|
212
|
+
warnings.push(`[decision_module_domain_gap] decisions.md 解析出 ${entries.length} 条当前版本决策,均未填写「模块域」可选字段——模块引用核验无对象(存量兼容放行;建议补填:模块 ID 取自 docs/${project}/modules/_module-map.yaml,新模块用 NEW:<名> 前缀声明)`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ③ 实改面 × 声明域差异(WARNING 双向;design.md 缺失/无清单时静默跳过——R-02 不产噪)。
|
|
216
|
+
// 声明侧只计已注册的非 NEW: 域:NEW: 域按定义不在 map 内、未注册域已由 ① 报 ERROR,
|
|
217
|
+
// 纳入只会产生重复噪音。
|
|
218
|
+
if (anyDeclared) {
|
|
219
|
+
const fileList = parseFileChangeListDetailed(join(changeDir, 'design.md')).map(e => e.path)
|
|
220
|
+
if (fileList.length > 0) {
|
|
221
|
+
const actual = deriveActualModules(fileList, moduleMap.prefixPairs)
|
|
222
|
+
const declared = new Set()
|
|
223
|
+
for (const { domains } of entries) {
|
|
224
|
+
for (const d of domains) {
|
|
225
|
+
if (!d.startsWith('NEW:') && moduleMap.ids.has(d)) declared.add(d)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const m of actual) {
|
|
229
|
+
if (!declared.has(m)) warnings.push(`[decision_module_domain_gap] 实改模块 "${m}"(design.md 文件清单命中该模块路径)未出现在任何决策的模块域声明中——请确认是遗漏声明(补填模块域)还是文件清单多列`)
|
|
230
|
+
}
|
|
231
|
+
for (const d of declared) {
|
|
232
|
+
if (!actual.has(d)) warnings.push(`[decision_module_domain_gap] 声明模块域 "${d}" 未命中 design.md 文件清单中的任何文件——可能为纯设计影响/跨模块约束(人工裁量即可),也可能是文件清单遗漏`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// design.md 骨架渲染(Wave 2;R-03:章节标题逐字取自 brainstorm Step 6 模板,禁止自创)
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 生成 design.md 十三章节骨架字符串(纯函数不落盘,写盘/幂等保护由 design-init CLI 负责)。
|
|
246
|
+
* 章节标题与 brainstorm.js「design.md 必须包含的章节」模板逐字对齐(1 背景/2 设计目标/
|
|
247
|
+
* 3 非目标/4 拆分判断/5 总体方案/6 文件变更清单/7 接口定义/7.5 生命周期契约表/8 数据模型/
|
|
248
|
+
* 9 兼容策略/10 风险登记/11 决策追踪/12 自审),满足 stage-contract-spec 的字面命中检查
|
|
249
|
+
* (文件变更清单/风险登记/自审/生命周期豁免)与 plan.design-readiness 六章就绪检查。
|
|
250
|
+
* 决策追踪表从 decisionsText 当前版本 D 条目逐行预填;文件变更清单给表头+示例注释行
|
|
251
|
+
* (注释形态不会被 plan-postcheck 清单解析计入)。
|
|
252
|
+
* @param {{ changeName?: string, decisionsText?: string, author?: string, now?: string }} opts
|
|
253
|
+
* changeName=变更简述(入标题);decisionsText=decisions.md 全文;author/now=frontmatter 值
|
|
254
|
+
* @returns {string} design.md 骨架全文(LF 行尾,单一尾换行)
|
|
255
|
+
*/
|
|
256
|
+
export function generateDesignSkeleton({ changeName, decisionsText, author, now } = {}) {
|
|
257
|
+
const change = String(changeName || '').trim() || '<变更简述>'
|
|
258
|
+
const decisions = parseDecisionDomains(decisionsText || '')
|
|
259
|
+
|
|
260
|
+
const lines = []
|
|
261
|
+
lines.push('---')
|
|
262
|
+
lines.push(`author: ${String(author || '').trim() || 'TODO(git 用户名)'}`)
|
|
263
|
+
lines.push(`created_at: ${String(now || '').trim() || 'TODO(ISO 时间)'}`)
|
|
264
|
+
lines.push('scale: large')
|
|
265
|
+
lines.push('---')
|
|
266
|
+
lines.push('')
|
|
267
|
+
lines.push(`# 设计文档(Design)— ${change}`)
|
|
268
|
+
lines.push('')
|
|
269
|
+
lines.push(`<!-- 由 sillyspec design-init 生成的骨架(${change})——逐节填散文后删除本注释;存量手写路径不受影响 -->`)
|
|
270
|
+
lines.push('')
|
|
271
|
+
|
|
272
|
+
lines.push('## 背景')
|
|
273
|
+
lines.push('')
|
|
274
|
+
lines.push('<!-- TODO:为什么做、解决什么问题 -->')
|
|
275
|
+
lines.push('')
|
|
276
|
+
|
|
277
|
+
lines.push('## 设计目标')
|
|
278
|
+
lines.push('')
|
|
279
|
+
lines.push('<!-- TODO:要达成什么 -->')
|
|
280
|
+
lines.push('')
|
|
281
|
+
|
|
282
|
+
lines.push('## 非目标')
|
|
283
|
+
lines.push('')
|
|
284
|
+
lines.push('<!-- TODO:明确不做的事(防止 scope creep) -->')
|
|
285
|
+
lines.push('')
|
|
286
|
+
|
|
287
|
+
lines.push('## 拆分判断')
|
|
288
|
+
lines.push('')
|
|
289
|
+
lines.push('<!-- TODO(如适用):为什么这样组织变更、为什么不走批量模式;不适用可整节删除 -->')
|
|
290
|
+
lines.push('')
|
|
291
|
+
|
|
292
|
+
lines.push('## 总体方案')
|
|
293
|
+
lines.push('')
|
|
294
|
+
lines.push('<!-- TODO:技术方案(分 Phase/Wave) -->')
|
|
295
|
+
lines.push('')
|
|
296
|
+
|
|
297
|
+
lines.push('## 文件变更清单')
|
|
298
|
+
lines.push('')
|
|
299
|
+
lines.push('| 操作 | 文件路径 | 说明 |')
|
|
300
|
+
lines.push('|---|---|---|')
|
|
301
|
+
lines.push('<!-- 示例行(替换为实际清单后删除本注释):| 新增 | src/xxx/NewFile.java | 说明(含对外字段时交代 producer→consumer 数据流) | -->')
|
|
302
|
+
lines.push('')
|
|
303
|
+
|
|
304
|
+
lines.push('## 接口定义')
|
|
305
|
+
lines.push('')
|
|
306
|
+
lines.push('<!-- TODO(代码类任务必填):方法签名、数据结构 -->')
|
|
307
|
+
lines.push('')
|
|
308
|
+
|
|
309
|
+
lines.push('## 生命周期契约表')
|
|
310
|
+
lines.push('')
|
|
311
|
+
lines.push('<!-- TODO:涉及 session/lease/agent_run/daemon/lifecycle/state transition/claim/heartbeat 等关键词时本表必填(事件×发起方×接收方×必需字段×状态变化 矩阵);确实不涉及时在紧邻位置写豁免短语——否定词必须紧邻「生命周期契约/lifecycle contract」,宽写法不被识别(写法见 brainstorm 模板) -->')
|
|
312
|
+
lines.push('')
|
|
313
|
+
|
|
314
|
+
lines.push('## 数据模型')
|
|
315
|
+
lines.push('')
|
|
316
|
+
lines.push('<!-- TODO(如涉及):表结构/字段变更;不涉及可整节删除或写明无 schema 变更 -->')
|
|
317
|
+
lines.push('')
|
|
318
|
+
|
|
319
|
+
lines.push('## 兼容策略(brownfield 必填)')
|
|
320
|
+
lines.push('')
|
|
321
|
+
lines.push('<!-- TODO(brownfield 必填):未配置新功能时行为不变 / 新旧逻辑的回退路径 / 不改变的 API 与表结构 -->')
|
|
322
|
+
lines.push('')
|
|
323
|
+
|
|
324
|
+
lines.push('## 风险登记')
|
|
325
|
+
lines.push('')
|
|
326
|
+
lines.push('| 编号 | 风险 | 等级 | 应对策略 |')
|
|
327
|
+
lines.push('|---|---|---|---|')
|
|
328
|
+
lines.push('| R-01 | (待填风险) | P1 | (待填应对策略) |')
|
|
329
|
+
lines.push('')
|
|
330
|
+
|
|
331
|
+
lines.push('## 决策追踪')
|
|
332
|
+
lines.push('')
|
|
333
|
+
lines.push('| 决策 | 覆盖点 | 状态 |')
|
|
334
|
+
lines.push('|---|---|---|')
|
|
335
|
+
if (decisions.length > 0) {
|
|
336
|
+
for (const { id } of decisions) lines.push(`| ${id} | (待填覆盖点) | 待确认 |`)
|
|
337
|
+
} else {
|
|
338
|
+
lines.push('<!-- 未从 decisions.md 解析出当前版本决策条目——如确无决策可写「无决策记录」 -->')
|
|
339
|
+
}
|
|
340
|
+
lines.push('<!-- TODO:说明每个 D-xxx@vN 被哪些 FR-xxx / 设计章节覆盖;标注仍未解决的 D-xxx@vN 或剩余风险 -->')
|
|
341
|
+
lines.push('')
|
|
342
|
+
|
|
343
|
+
lines.push('## 自审')
|
|
344
|
+
lines.push('')
|
|
345
|
+
lines.push('- [ ] 章节齐全(背景/设计目标/非目标/总体方案/文件变更清单/接口定义/风险登记)')
|
|
346
|
+
lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
|
|
347
|
+
lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
|
|
348
|
+
lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
|
|
349
|
+
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html 或跳过原因记入风险登记)')
|
|
350
|
+
lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
|
|
351
|
+
lines.push('')
|
|
352
|
+
|
|
353
|
+
return lines.join('\n')
|
|
354
|
+
}
|
package/src/docs-check.js
CHANGED
|
@@ -18,13 +18,18 @@ import { join } from 'node:path'
|
|
|
18
18
|
import jsYaml from 'js-yaml'
|
|
19
19
|
import { parseModuleMapSimple } from './modules.js'
|
|
20
20
|
|
|
21
|
-
/** 提取 file.js:line / file.js:start-end
|
|
21
|
+
/** 提取 file.js:line / file.js:start-end 引用(反引号包裹与裸文本均命中,全文扫描 D-006)。
|
|
22
|
+
* 扩展名覆盖(archify 借鉴 P1b,2026-09-04):js/mjs/cjs/ts/tsx/jsx/py/java/go——
|
|
23
|
+
* scan 产物面向任意技术栈项目(如 SillyHub = Python + TypeScript),只认 .js 会让
|
|
24
|
+
* 非 JS 项目的文档引用全部漏检。注意:扩展会让存量文档中此前被忽略的引用新进入核验范围,
|
|
25
|
+
* 而 docs-gate ratchet 只拦增量不吸收增量(基线 0 时新失效必红 gate)——存量失效必须
|
|
26
|
+
* 在同一变更内消化(本仓 2026-09-04 已消化:跨仓 .py 引用改 repo:// 前缀、示例记法改写)。
|
|
22
27
|
* repo:// 前缀(2026-08-20):跨仓引用显式标记——`repo://<仓库名>/<路径>.js:行`。
|
|
23
28
|
* 不同设备兄弟仓库位置不同,未配映射时默认跳过本地校验(防跨设备误报);
|
|
24
29
|
* 在本机 .sillyspec/local.yaml 的 docs-check.cross_repo_roots 配
|
|
25
30
|
* `<仓库名>: <本机绝对路径>` 后,走与本地引用完全相同的层1(行号边界)+ 层2(关键词窗口)校验。
|
|
26
31
|
* 正则组:1=仓库名(可选),2=文件,3=start,4=end。 */
|
|
27
|
-
const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]+\.(?:js|mjs)):(\d+)(?:-(\d+))?/g
|
|
32
|
+
const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?/g
|
|
28
33
|
|
|
29
34
|
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
|
|
30
35
|
const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
|
|
@@ -183,7 +188,72 @@ export function resolveCandidates(projectRoot, refFile, treeCache = null) {
|
|
|
183
188
|
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache).map((rel) => join(projectRoot, 'src', rel))
|
|
184
189
|
}
|
|
185
190
|
|
|
191
|
+
/**
|
|
192
|
+
* 联合核验一组文档的全部 file:line 引用(archify 借鉴,2026-09-04):
|
|
193
|
+
* 层1(resolveCandidates 可解析 + validateRefLines 行号边界)+ 层2(引用行反引号 token
|
|
194
|
+
* 在源文件 [start-2, end+5] 窗口内命中,窗口与 runDocsCheck 共用 keywordWindowText 单一源)。
|
|
195
|
+
* 任一候选通过即有效。scan-postcheck(scan_doc_ref_invalid)与 workflow ref_exists 检查共用。
|
|
196
|
+
* 与 runDocsCheck 的已知差异:repo:// 跨仓引用一律跳过(不读 cross_repo_roots 映射);
|
|
197
|
+
* 层2 无条件执行(不读 keywordAssert 配置)——需要降噪时在调用方处理。
|
|
198
|
+
* 单文档不可读(如路径是目录)降级跳过并计入 readFailures,不废整批。
|
|
199
|
+
* @param {string} projectRoot 引用解析锚点(源码项目根)
|
|
200
|
+
* @param {Array<{name: string, absPath: string}>} docEntries 文档标识与绝对路径
|
|
201
|
+
* @returns {{ invalid: Array<{doc: string, ref: string, reason: string}>, totalRefs: number, skippedCrossRepo: number, readFailures: number }}
|
|
202
|
+
*/
|
|
203
|
+
export function collectInvalidDocRefs(projectRoot, docEntries) {
|
|
204
|
+
const invalid = []
|
|
205
|
+
let totalRefs = 0
|
|
206
|
+
let skippedCrossRepo = 0
|
|
207
|
+
let readFailures = 0
|
|
208
|
+
const treeCache = new Map()
|
|
209
|
+
const linesCache = new Map()
|
|
210
|
+
for (const { name, absPath } of docEntries) {
|
|
211
|
+
let content
|
|
212
|
+
try {
|
|
213
|
+
content = readFileSync(absPath, 'utf8')
|
|
214
|
+
} catch {
|
|
215
|
+
readFailures++
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const docLines = content.split(/\r?\n/)
|
|
219
|
+
for (const ref of collectDocRefs(content)) {
|
|
220
|
+
if (ref.repo !== null) { skippedCrossRepo++; continue }
|
|
221
|
+
totalRefs++
|
|
222
|
+
const candidates = resolveCandidates(projectRoot, ref.file, treeCache)
|
|
223
|
+
if (candidates.length === 0) {
|
|
224
|
+
invalid.push({ doc: name, ref: ref.ref, reason: '文件不存在' })
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
const tokens = extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
|
|
228
|
+
const infos = candidates.map(abs => ({ lines: readLines(abs, linesCache) }))
|
|
229
|
+
const ok = infos.some(({ lines }) => {
|
|
230
|
+
if (!lines || lines.length === 0) return false
|
|
231
|
+
if (!validateRefLines(lines.length, ref.start, ref.end).ok) return false
|
|
232
|
+
if (tokens.length === 0) return true // 纯位置引用(行内无可断言符号),只做层1
|
|
233
|
+
return tokens.some(t => keywordWindowText(lines, ref.start, ref.end).includes(t))
|
|
234
|
+
})
|
|
235
|
+
if (!ok) {
|
|
236
|
+
const readable = infos.some(({ lines }) => lines && lines.length > 0)
|
|
237
|
+
const inBounds = readable && infos.some(({ lines }) => lines && validateRefLines(lines.length, ref.start, ref.end).ok)
|
|
238
|
+
const reason = !readable
|
|
239
|
+
? (infos.some(({ lines }) => lines && lines.length === 0) ? '空文件(无可引用内容)' : '文件不可读')
|
|
240
|
+
: !inBounds ? '行号超界' : '关键词未命中(引用行附近不含所述符号)'
|
|
241
|
+
invalid.push({ doc: name, ref: ref.ref, reason })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { invalid, totalRefs, skippedCrossRepo, readFailures }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 层2 关键词窗口文本(1-based 闭区间 [start-2, end+5],含端点)。runDocsCheck 与
|
|
249
|
+
* collectInvalidDocRefs 共用——此前两处手写 slice 相差一行(off-by-one),此函数即窗口单一语义源。 */
|
|
250
|
+
function keywordWindowText(lines, start, end) {
|
|
251
|
+
return lines.slice(Math.max(0, start - 3), Math.min(lines.length, end + 5)).join('\n')
|
|
252
|
+
}
|
|
253
|
+
|
|
186
254
|
/** 读文件行数组(CRLF/LF 归一:split(/\r?\n/),层2 只做子串查找不污染原文)。
|
|
255
|
+
* 尾换行产生的「幻影 N+1 行」剥除(编辑器口径 N 行 = split 后去掉一个尾部空元素);
|
|
256
|
+
* 空文件返回 [](length 0)——:1 引用按超界判失效,不再因 [''] 假长度通过。
|
|
187
257
|
* linesCache 给定时按绝对路径复用(同文件被 N 条引用消费免 N 次读盘+split) */
|
|
188
258
|
function readLines(absPath, linesCache = null) {
|
|
189
259
|
if (linesCache) {
|
|
@@ -192,7 +262,11 @@ function readLines(absPath, linesCache = null) {
|
|
|
192
262
|
linesCache.set(absPath, lines)
|
|
193
263
|
return lines
|
|
194
264
|
}
|
|
195
|
-
try {
|
|
265
|
+
try {
|
|
266
|
+
const lines = readFileSync(absPath, 'utf8').split(/\r?\n/)
|
|
267
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
268
|
+
return lines
|
|
269
|
+
} catch { return null }
|
|
196
270
|
}
|
|
197
271
|
|
|
198
272
|
/** 简单通配匹配(skip 排除用,仅 * 单段) */
|
|
@@ -395,13 +469,10 @@ export function runDocsCheck(opts) {
|
|
|
395
469
|
const lines = readLines(candAbs, linesCache)
|
|
396
470
|
if (lines === null) { candidateFails.push(`${relDisplay(candAbs, projectRoot)}: 读取失败`); continue }
|
|
397
471
|
const v = validateRefLines(lines.length, r.start, r.end)
|
|
398
|
-
// 层2:token 任一在 [start-2, end+5]
|
|
472
|
+
// 层2:token 任一在 [start-2, end+5] 窗口命中即过(窗口与 collectInvalidDocRefs 共用 keywordWindowText)
|
|
399
473
|
let kwOk = true
|
|
400
474
|
if (tokens.length > 0) {
|
|
401
|
-
|
|
402
|
-
const to = Math.min(lines.length, r.end + 5)
|
|
403
|
-
const window = lines.slice(from, to).join('\n')
|
|
404
|
-
kwOk = tokens.some((t) => window.includes(t))
|
|
475
|
+
kwOk = tokens.some((t) => keywordWindowText(lines, r.start, r.end).includes(t))
|
|
405
476
|
}
|
|
406
477
|
if (v.ok && kwOk) { passedAny = true; break }
|
|
407
478
|
const reasons = [...v.reasons]
|
|
@@ -26,7 +26,7 @@ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSy
|
|
|
26
26
|
import { join } from 'path';
|
|
27
27
|
import jsYaml from 'js-yaml';
|
|
28
28
|
import { CHECK_SEVERITY } from './constants.js';
|
|
29
|
-
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME } from './run/shared.js';
|
|
29
|
+
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
30
30
|
|
|
31
31
|
// db 角色标签
|
|
32
32
|
const DB_ROLE = {
|
|
@@ -424,7 +424,12 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
|
|
|
424
424
|
: [];
|
|
425
425
|
const dirSet = new Set(dirChanges);
|
|
426
426
|
const activeSet = new Set(activeNames);
|
|
427
|
-
|
|
427
|
+
// ghost 判定排除 quick 会话行(quick-<8hex>):initChange 对 quick 特意不建 changes/ 目录
|
|
428
|
+
// (进度存 SQL 不需要实体目录),「active + 无目录」是它的设计形态而非残留——不排除则
|
|
429
|
+
// 进行中 quick 从写库起即误报(2026-09-07 quick-inflight-ghost-misjudge)。与
|
|
430
|
+
// stage-machine overview/show 同源(同正则);cleanupGhostChanges 不在此排除——
|
|
431
|
+
// 「QUICKLOG 已完成但 DB 行仍 active」的收尾中断行仍由它归档(兜底出口)。
|
|
432
|
+
const ghostRows = activeNames.filter((n) => !dirSet.has(n) && !QUICK_SID_RE.test(n));
|
|
428
433
|
const orphanDirs = dirChanges.filter((n) => !activeSet.has(n));
|
|
429
434
|
// SS-2b:空壳——db active + 目录存在但 0 文件 + last_active 超过 7 天。有目录所以
|
|
430
435
|
// 逃过 ghostRows 的「无目录」判定(2026-08-15 清理时 6 个空壳即此漏网形态)。
|
|
@@ -1059,6 +1064,10 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
|
|
|
1059
1064
|
db = openDatabase(dbPath, { readOnly: !confirm });
|
|
1060
1065
|
// last_active 与 name 一起取:空壳判定需要时间门槛(见 isStaleLastActive)。
|
|
1061
1066
|
const activeRows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
|
|
1067
|
+
// 有意不排除 quick 会话行(与 D4 ghost 判定不对称是设计,2026-09-07 quick-inflight-ghost
|
|
1068
|
+
// -misjudge 文档明示):quick 按设计无 changes/ 目录,D4/overview 豁免其 ghost 误报;
|
|
1069
|
+
// 而本函数是「QUICKLOG 已完成但 DB 行仍 active」收尾中断形态的兜底归档出口,保留归档
|
|
1070
|
+
// 能力。归档仅翻 status(可逆),进行中 quick 被误归档时其 --done 收尾链不受影响。
|
|
1062
1071
|
const ghosts = activeRows.filter((r) => !dirSet.has(r.name)).map((r) => r.name);
|
|
1063
1072
|
const emptyShells = activeRows
|
|
1064
1073
|
.filter((r) => dirSet.has(r.name) && isStaleLastActive(r.last_active) && dirHasNoFiles(join(changesDir, r.name)))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* endpoint-baseline.js — 变更级端点 before/after 基线(变更 2026-09-07-endpoint-baseline)
|
|
3
|
+
*
|
|
4
|
+
* 背景:contract-matrix 只有 provider 完成后的 endpoints.json 事后快照,无变更前基线——
|
|
5
|
+
* 端点增删(archify Delta 核心内容之一)无法计算。本模块补齐:
|
|
6
|
+
* - captureEndpointBaseline:变更起点拍端点集快照(幂等,已存在不覆盖)
|
|
7
|
+
* - diffEndpointSets:基线 × 现算的集合运算纯函数(method+path 粒度)
|
|
8
|
+
*
|
|
9
|
+
* 消费链:CLI `endpoints baseline`(task-02)→ endpoint-baselines/<change>.json →
|
|
10
|
+
* archive-delta 第五源(Wave 2)对比出 delta.md「端点增删」节。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { scanBackendEndpoints, normalizePath } from './endpoint-extractor.js'
|
|
16
|
+
import { gitQuiet } from './git-helper.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* diff 键归一:method 大写 + normalizePath 参数归一(endpoint-extractor.js 既有导出,
|
|
20
|
+
* diffApiParity 同款——`:id/{plan_id}` / `{plan_id}/{id}` 等参数形态统一为 `{param}`)
|
|
21
|
+
* + 去尾斜杠(`/api/x/` 与 `/api/x` 同键;全斜杠归一为 `/`)。
|
|
22
|
+
* @param {{ method?: string, path?: string }} ep
|
|
23
|
+
* @returns {string} 形如 `GET /api/ppm/project-plan/{param}`
|
|
24
|
+
*/
|
|
25
|
+
function endpointKey(ep) {
|
|
26
|
+
const normalized = normalizePath(ep.path || '').replace(/\/+$/, '') || '/'
|
|
27
|
+
return `${(ep.method || '').toUpperCase()} ${normalized}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 拍变更前端点基线:endpoint-extractor 现扫 cwd → 写 <runtimeRoot>/endpoint-baselines/<change>.json。
|
|
32
|
+
*
|
|
33
|
+
* - 幂等:目标文件已存在 → {written:false, reason:'exists'}(不读旧内容不覆盖——首次跑
|
|
34
|
+
* 即变更前状态,重跑保持原快照);存在性检查先于扫描(已拍过直接跳过,不做无效重扫)。
|
|
35
|
+
* - fail-soft:扫描异常返回错误对象不抛(调用方降级注记,不阻断流程)。
|
|
36
|
+
* - cwd 锚定主仓根由调用方保证(task-02 CLI 层职责),本函数不做锚定。
|
|
37
|
+
*
|
|
38
|
+
* @param {{ cwd: string, changeName: string, runtimeRoot?: string }} opts
|
|
39
|
+
* runtimeRoot 缺省 join(cwd, '.sillyspec', '.runtime')
|
|
40
|
+
* @returns {{ written: boolean, reason?: 'exists', count?: number, path?: string, error?: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function captureEndpointBaseline({ cwd, changeName, runtimeRoot } = {}) {
|
|
43
|
+
const root = runtimeRoot || join(cwd, '.sillyspec', '.runtime')
|
|
44
|
+
const baselineDir = join(root, 'endpoint-baselines')
|
|
45
|
+
const baselinePath = join(baselineDir, `${changeName}.json`)
|
|
46
|
+
|
|
47
|
+
// 幂等守卫在前:已存在直接跳过(不读旧内容不覆盖)
|
|
48
|
+
if (existsSync(baselinePath)) return { written: false, reason: 'exists' }
|
|
49
|
+
|
|
50
|
+
let endpoints
|
|
51
|
+
try {
|
|
52
|
+
endpoints = scanBackendEndpoints(cwd)
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { written: false, error: (e && e.message) || String(e) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// git rev-parse fail-soft:非仓库/git 缺失 → null(基线仍可拍,端点集是主体)
|
|
58
|
+
const baseCommit = gitQuiet(cwd, ['rev-parse', '--short', 'HEAD']) || null
|
|
59
|
+
|
|
60
|
+
const payload = {
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
change: changeName,
|
|
63
|
+
baseCommit,
|
|
64
|
+
generatedAt: new Date().toISOString(),
|
|
65
|
+
endpoints: endpoints.map(e => ({ method: e.method, path: e.path, source: e.source })),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mkdirSync(baselineDir, { recursive: true })
|
|
69
|
+
writeFileSync(baselinePath, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
|
70
|
+
return { written: true, count: payload.endpoints.length, path: baselinePath }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 端点集合 diff:baseline × current 按 method+归一 path 做 Map 集合运算。
|
|
75
|
+
*
|
|
76
|
+
* - 任一输入为 null/非数组 → null(调用方降级:无基线不比)。
|
|
77
|
+
* - added:current 有 baseline 无(带 current 的 source);removed:反向(带 baseline 的 source)。
|
|
78
|
+
* - changed(同 path 参数改名等)不配对——归一键相同则判同(`:id/{plan_id}` vs
|
|
79
|
+
* `{param}/{plan_id}` 同键不假报);键不同的变更天然呈 removed+added 两条独立行
|
|
80
|
+
* (design Gap-3 显式声明,防止实现期发明配对逻辑)。
|
|
81
|
+
*
|
|
82
|
+
* @param {Array<{method:string,path:string,source:string}>|null} baselineEndpoints
|
|
83
|
+
* @param {Array<{method:string,path:string,source:string}>|null} currentEndpoints
|
|
84
|
+
* @returns {{ added: Array<{method,path,source}>, removed: Array<{method,path,source}> } | null}
|
|
85
|
+
*/
|
|
86
|
+
export function diffEndpointSets(baselineEndpoints, currentEndpoints) {
|
|
87
|
+
if (!Array.isArray(baselineEndpoints) || !Array.isArray(currentEndpoints)) return null
|
|
88
|
+
|
|
89
|
+
const baselineMap = new Map()
|
|
90
|
+
for (const ep of baselineEndpoints) {
|
|
91
|
+
const key = endpointKey(ep)
|
|
92
|
+
if (!baselineMap.has(key)) baselineMap.set(key, ep) // 同键保留首个,防重复行
|
|
93
|
+
}
|
|
94
|
+
const currentMap = new Map()
|
|
95
|
+
for (const ep of currentEndpoints) {
|
|
96
|
+
const key = endpointKey(ep)
|
|
97
|
+
if (!currentMap.has(key)) currentMap.set(key, ep)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const added = []
|
|
101
|
+
for (const [key, ep] of currentMap) {
|
|
102
|
+
if (!baselineMap.has(key)) added.push({ method: ep.method, path: ep.path, source: ep.source })
|
|
103
|
+
}
|
|
104
|
+
const removed = []
|
|
105
|
+
for (const [key, ep] of baselineMap) {
|
|
106
|
+
if (!currentMap.has(key)) removed.push({ method: ep.method, path: ep.path, source: ep.source })
|
|
107
|
+
}
|
|
108
|
+
return { added, removed }
|
|
109
|
+
}
|
package/src/git-helper.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* safeGit 额外支持 opts.retryOnTimeout:ETIMEDOUT(机器忙瞬时抖动)时用 2× timeout 重试一次。
|
|
14
14
|
*/
|
|
15
15
|
import { execFileSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* git C 风格引号路径解码:`\\` `\"` `\n` `\t` 与 `\NNN` 八进制**原始字节**。
|
|
@@ -148,3 +150,32 @@ export function gitQuiet(cwd, args, opts = {}) {
|
|
|
148
150
|
return null
|
|
149
151
|
}
|
|
150
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 归档产物机械暂存(2026-09-05 全流程审计:原 archive 步骤 6 的 3 条 git add agent 指令 CLI 化)。
|
|
156
|
+
* 固定路径:changes/archive/(归档产物)、knowledge/decisions/(决策提炼)、docs/<project>/modules/
|
|
157
|
+
* (本次同步的模块文档——按已存在目录枚举,未变的目录 git add 是无害 no-op)。
|
|
158
|
+
* 只 add 不 commit(统一提交工具处理);平台模式由调用方跳过(specRoot 在源码仓外)。
|
|
159
|
+
* @param {string} cwd 源码项目根
|
|
160
|
+
* @returns {{ staged: string[], skipped: string[] }}
|
|
161
|
+
*/
|
|
162
|
+
export function stageArchiveArtifacts(cwd) {
|
|
163
|
+
const specBase = join(cwd, '.sillyspec')
|
|
164
|
+
const candidates = [join(specBase, 'changes', 'archive'), join(specBase, 'knowledge', 'decisions')]
|
|
165
|
+
const docsRoot = join(specBase, 'docs')
|
|
166
|
+
try {
|
|
167
|
+
for (const e of readdirSync(docsRoot, { withFileTypes: true })) {
|
|
168
|
+
if (e.isDirectory()) candidates.push(join(docsRoot, e.name, 'modules'))
|
|
169
|
+
}
|
|
170
|
+
} catch { /* 无 docs 目录 → 跳过 */ }
|
|
171
|
+
const staged = []
|
|
172
|
+
const skipped = []
|
|
173
|
+
const existing = candidates.filter(p => existsSync(p))
|
|
174
|
+
if (existing.length === 0) return { staged, skipped }
|
|
175
|
+
const r = safeGit(cwd, ['add', ...existing])
|
|
176
|
+
if (r && r.error) {
|
|
177
|
+
console.warn(` ⚠️ 归档产物 git add 失败(可手动处理):${String(r.error).slice(0, 120)}`)
|
|
178
|
+
return { staged, skipped: existing }
|
|
179
|
+
}
|
|
180
|
+
return { staged: existing, skipped }
|
|
181
|
+
}
|