sillyspec 3.28.0 → 3.28.3
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 +54 -93
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-continue/SKILL.md +17 -47
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -214
- package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -270
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-propose/SKILL.md +16 -87
- package/.claude/skills/sillyspec-resume/SKILL.md +21 -70
- package/.claude/skills/sillyspec-state/SKILL.md +25 -64
- package/.claude/skills/sillyspec-workspace/SKILL.md +33 -175
- package/package.json +1 -1
- package/src/archive-delta.js +462 -0
- package/src/constants.js +6 -0
- package/src/design-facts.js +418 -0
- package/src/dispatch/backends/sillyhub-mcp.js +17 -5
- package/src/docs-check.js +197 -34
- package/src/docs-migrate.js +124 -0
- 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 +381 -22
- package/src/modules.js +41 -12
- package/src/progress/change-registry.js +39 -0
- package/src/progress/consistency-doctor.js +124 -5
- package/src/progress/stage-machine.js +13 -2
- package/src/progress.js +5 -0
- package/src/quicklog.js +51 -0
- package/src/run/command.js +199 -5
- package/src/run/complete-handlers.js +60 -4
- package/src/run/complete.js +105 -6
- package/src/run/gates.js +353 -2
- package/src/run/progress-confirm.js +66 -0
- package/src/run/prompt.js +193 -15
- package/src/run/quick-audit.js +12 -5
- package/src/run/scan-profile.js +117 -7
- package/src/run/shared.js +22 -6
- package/src/run/stage.js +14 -1
- package/src/scan-diff.js +54 -1
- package/src/scan-facts.js +173 -0
- package/src/scan-postcheck.js +114 -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-auto.js +2 -1
- package/src/stages/brainstorm.js +12 -20
- package/src/stages/execute.js +19 -21
- package/src/stages/plan-postcheck.js +194 -1
- package/src/stages/plan.js +8 -3
- package/src/stages/quick.js +4 -2
- package/src/stages/scan.js +44 -102
- package/src/stages/verify.js +18 -18
- package/src/task-review.js +200 -0
- package/src/taskcard.js +8 -1
- package/src/verify-postcheck.js +771 -29
- 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
- package/.claude/skills/verify-per-user/SKILL.md +0 -6
package/src/docs-check.js
CHANGED
|
@@ -18,16 +18,26 @@ 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
|
-
* 正则组:1=仓库名(可选),2=文件,3=start,4=end。
|
|
27
|
-
|
|
31
|
+
* 正则组:1=仓库名(可选),2=文件,3=start,4=end。
|
|
32
|
+
* 文件段展开循环形(D-006,2026-09-08-docs-fix-capability):普通段可选 + 零或多个「闭合括号段+普通段」
|
|
33
|
+
* 迭代——支持 Next.js 路由组 (dashboard) 等括号路径;markdown 链接 [t](foo.js:12) 在 `(` 处括号段
|
|
34
|
+
* 要求 `)` 先于 `:` 闭合而失败,回落 foo.js:12 零回归;嵌套 ((x)) 部分提取(与旧行为一致)。
|
|
35
|
+
* ⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
|
|
36
|
+
* 展开循环每次迭代必含括号段 → 划分唯一 → 线性。 */
|
|
37
|
+
const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?/g
|
|
28
38
|
|
|
29
39
|
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
|
|
30
|
-
const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
|
|
40
|
+
export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
|
|
@@ -94,6 +104,44 @@ export function collectDocRefs(md) {
|
|
|
94
104
|
return refs
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 豁免判定双通道(FR-3 / D-003,2026-09-08-docs-fix-capability):
|
|
109
|
+
* ①路径段:POSIX 归一后任一路径段 === archive 或 finished(历史快照/归档文档天然过时);
|
|
110
|
+
* ②frontmatter:文件头 --- 块内行 `doc_type: snapshot`(机械精确匹配——容忍行内注释与首尾
|
|
111
|
+
* 空白,带引号 doc_type: "snapshot" 不识别,不做 YAML 解析)。
|
|
112
|
+
* 拆两函数:路径段判免 IO(readFileSync 前);frontmatter 判需文件内容。
|
|
113
|
+
* @param {string} relPath 文档相对路径(POSIX 或 Windows 反斜杠均可)
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function isExemptDocPath(relPath) {
|
|
117
|
+
if (typeof relPath !== 'string' || relPath === '') return false
|
|
118
|
+
const segs = relPath.replace(/\\/g, '/').split('/')
|
|
119
|
+
return segs.some(s => s === 'archive' || s === 'finished')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* frontmatter 通道:文件头 --- 块内机械扫描 `doc_type: snapshot` 行。
|
|
124
|
+
* @param {string} mdText 文档全文(已读取)
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function isExemptDocFrontmatter(mdText) {
|
|
128
|
+
if (typeof mdText !== 'string' || !mdText.startsWith('---')) return false
|
|
129
|
+
const end = mdText.indexOf('\n---', 3)
|
|
130
|
+
if (end === -1) return false
|
|
131
|
+
const block = mdText.slice(3, end)
|
|
132
|
+
return /^[ \t]*doc_type:[ \t]*snapshot[ \t]*(?:#.*)?$/m.test(block)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 双通道合并(调用方便利包装)。
|
|
137
|
+
* @param {string} relPath 文档相对路径
|
|
138
|
+
* @param {string} [mdText] 文档全文(路径段命中时可省——调用方先判路径段免 IO)
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isExemptDoc(relPath, mdText) {
|
|
142
|
+
return isExemptDocPath(relPath) || (mdText !== undefined && isExemptDocFrontmatter(mdText))
|
|
143
|
+
}
|
|
144
|
+
|
|
97
145
|
/**
|
|
98
146
|
* 纯函数:判定 token 是否「像代码符号」:首字符字母/_/$,且含大写字母/下划线/点/$ 之一。
|
|
99
147
|
* 纯小写英文单词(local/platform/abort)→ false(自然语言,跳过断言防误报)。
|
|
@@ -183,7 +231,72 @@ export function resolveCandidates(projectRoot, refFile, treeCache = null) {
|
|
|
183
231
|
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache).map((rel) => join(projectRoot, 'src', rel))
|
|
184
232
|
}
|
|
185
233
|
|
|
234
|
+
/**
|
|
235
|
+
* 联合核验一组文档的全部 file:line 引用(archify 借鉴,2026-09-04):
|
|
236
|
+
* 层1(resolveCandidates 可解析 + validateRefLines 行号边界)+ 层2(引用行反引号 token
|
|
237
|
+
* 在源文件 [start-2, end+5] 窗口内命中,窗口与 runDocsCheck 共用 keywordWindowText 单一源)。
|
|
238
|
+
* 任一候选通过即有效。scan-postcheck(scan_doc_ref_invalid)与 workflow ref_exists 检查共用。
|
|
239
|
+
* 与 runDocsCheck 的已知差异:repo:// 跨仓引用一律跳过(不读 cross_repo_roots 映射);
|
|
240
|
+
* 层2 无条件执行(不读 keywordAssert 配置)——需要降噪时在调用方处理。
|
|
241
|
+
* 单文档不可读(如路径是目录)降级跳过并计入 readFailures,不废整批。
|
|
242
|
+
* @param {string} projectRoot 引用解析锚点(源码项目根)
|
|
243
|
+
* @param {Array<{name: string, absPath: string}>} docEntries 文档标识与绝对路径
|
|
244
|
+
* @returns {{ invalid: Array<{doc: string, ref: string, reason: string}>, totalRefs: number, skippedCrossRepo: number, readFailures: number }}
|
|
245
|
+
*/
|
|
246
|
+
export function collectInvalidDocRefs(projectRoot, docEntries) {
|
|
247
|
+
const invalid = []
|
|
248
|
+
let totalRefs = 0
|
|
249
|
+
let skippedCrossRepo = 0
|
|
250
|
+
let readFailures = 0
|
|
251
|
+
const treeCache = new Map()
|
|
252
|
+
const linesCache = new Map()
|
|
253
|
+
for (const { name, absPath } of docEntries) {
|
|
254
|
+
let content
|
|
255
|
+
try {
|
|
256
|
+
content = readFileSync(absPath, 'utf8')
|
|
257
|
+
} catch {
|
|
258
|
+
readFailures++
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const docLines = content.split(/\r?\n/)
|
|
262
|
+
for (const ref of collectDocRefs(content)) {
|
|
263
|
+
if (ref.repo !== null) { skippedCrossRepo++; continue }
|
|
264
|
+
totalRefs++
|
|
265
|
+
const candidates = resolveCandidates(projectRoot, ref.file, treeCache)
|
|
266
|
+
if (candidates.length === 0) {
|
|
267
|
+
invalid.push({ doc: name, ref: ref.ref, reason: '文件不存在' })
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const tokens = extractExpectedTokensFromLine(docLines[ref.docLine - 1] || '')
|
|
271
|
+
const infos = candidates.map(abs => ({ lines: readLines(abs, linesCache) }))
|
|
272
|
+
const ok = infos.some(({ lines }) => {
|
|
273
|
+
if (!lines || lines.length === 0) return false
|
|
274
|
+
if (!validateRefLines(lines.length, ref.start, ref.end).ok) return false
|
|
275
|
+
if (tokens.length === 0) return true // 纯位置引用(行内无可断言符号),只做层1
|
|
276
|
+
return tokens.some(t => keywordWindowText(lines, ref.start, ref.end).includes(t))
|
|
277
|
+
})
|
|
278
|
+
if (!ok) {
|
|
279
|
+
const readable = infos.some(({ lines }) => lines && lines.length > 0)
|
|
280
|
+
const inBounds = readable && infos.some(({ lines }) => lines && validateRefLines(lines.length, ref.start, ref.end).ok)
|
|
281
|
+
const reason = !readable
|
|
282
|
+
? (infos.some(({ lines }) => lines && lines.length === 0) ? '空文件(无可引用内容)' : '文件不可读')
|
|
283
|
+
: !inBounds ? '行号超界' : '关键词未命中(引用行附近不含所述符号)'
|
|
284
|
+
invalid.push({ doc: name, ref: ref.ref, reason })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { invalid, totalRefs, skippedCrossRepo, readFailures }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 层2 关键词窗口文本(1-based 闭区间 [start-2, end+5],含端点)。runDocsCheck 与
|
|
292
|
+
* collectInvalidDocRefs 共用——此前两处手写 slice 相差一行(off-by-one),此函数即窗口单一语义源。 */
|
|
293
|
+
function keywordWindowText(lines, start, end) {
|
|
294
|
+
return lines.slice(Math.max(0, start - 3), Math.min(lines.length, end + 5)).join('\n')
|
|
295
|
+
}
|
|
296
|
+
|
|
186
297
|
/** 读文件行数组(CRLF/LF 归一:split(/\r?\n/),层2 只做子串查找不污染原文)。
|
|
298
|
+
* 尾换行产生的「幻影 N+1 行」剥除(编辑器口径 N 行 = split 后去掉一个尾部空元素);
|
|
299
|
+
* 空文件返回 [](length 0)——:1 引用按超界判失效,不再因 [''] 假长度通过。
|
|
187
300
|
* linesCache 给定时按绝对路径复用(同文件被 N 条引用消费免 N 次读盘+split) */
|
|
188
301
|
function readLines(absPath, linesCache = null) {
|
|
189
302
|
if (linesCache) {
|
|
@@ -192,7 +305,11 @@ function readLines(absPath, linesCache = null) {
|
|
|
192
305
|
linesCache.set(absPath, lines)
|
|
193
306
|
return lines
|
|
194
307
|
}
|
|
195
|
-
try {
|
|
308
|
+
try {
|
|
309
|
+
const lines = readFileSync(absPath, 'utf8').split(/\r?\n/)
|
|
310
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
311
|
+
return lines
|
|
312
|
+
} catch { return null }
|
|
196
313
|
}
|
|
197
314
|
|
|
198
315
|
/** 简单通配匹配(skip 排除用,仅 * 单段) */
|
|
@@ -317,7 +434,7 @@ export function runDocsCheck(opts) {
|
|
|
317
434
|
// 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
|
|
318
435
|
const {
|
|
319
436
|
projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [],
|
|
320
|
-
keywordAssert = true, crossRepoRoots = {},
|
|
437
|
+
keywordAssert = true, crossRepoRoots = {}, exempt = true,
|
|
321
438
|
} = opts || {}
|
|
322
439
|
const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : DEFAULT_DOC_PATHS
|
|
323
440
|
const warnings = []
|
|
@@ -331,6 +448,10 @@ export function runDocsCheck(opts) {
|
|
|
331
448
|
let kwChecked = 0
|
|
332
449
|
// repo:// 跨仓引用:未配映射被跳过的数量(不计入 total/invalid,跨设备零误报)
|
|
333
450
|
let crossRepoSkipped = 0
|
|
451
|
+
// 豁免文档数(archive/finished 路径段或 frontmatter doc_type: snapshot,不计 invalid——FR-3)
|
|
452
|
+
let skippedExempt = 0
|
|
453
|
+
// 模糊路径引用数(含 `...` 省略号,跳过校验——FR-1.2)
|
|
454
|
+
let skippedFuzzy = 0
|
|
334
455
|
// per-call 缓存:裸名引用的 src/ 全树扫描结果 + 候选文件行数组——100 文档 × 10 裸名引用
|
|
335
456
|
// 此前 = 1000 次全树 walk + 同文件 N 次重读(2026-08-21 性能审查 PERF-5)。仅本次调用
|
|
336
457
|
// 生命周期内有效,不跨调用(防测试流程中途改树后读到陈旧结果)
|
|
@@ -346,11 +467,17 @@ export function runDocsCheck(opts) {
|
|
|
346
467
|
})
|
|
347
468
|
continue
|
|
348
469
|
}
|
|
470
|
+
// 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
|
|
471
|
+
// frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
|
|
472
|
+
if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
|
|
349
473
|
const md = readFileSync(docAbs, 'utf8')
|
|
474
|
+
if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
|
|
350
475
|
const refs = collectDocRefs(md)
|
|
351
476
|
const mdLines = md.split(/\r?\n/)
|
|
352
477
|
|
|
353
478
|
for (const r of refs) {
|
|
479
|
+
// 模糊路径(含 `...` 省略号):跳过校验(FR-1.2,宁漏不误报——文档作者明示意图非精确路径)
|
|
480
|
+
if (r.file.includes('...')) { skippedFuzzy++; continue }
|
|
354
481
|
// repo:// 跨仓引用:未配映射 → 跳过(不同设备仓库位置不同,不按本仓解析误报);
|
|
355
482
|
// 配了映射 → 以映射根为唯一候选,走与本地引用相同的层1+层2 校验。
|
|
356
483
|
let candidates
|
|
@@ -378,10 +505,20 @@ export function runDocsCheck(opts) {
|
|
|
378
505
|
}
|
|
379
506
|
total++
|
|
380
507
|
if (candidates.length === 0) {
|
|
508
|
+
// FR-4(candidates JSON):带 / 路径的文件不存在引用,basename 树扫非空 → 附同名文件候选
|
|
509
|
+
// (机械数据供脚本消费;裸文件名引用 resolveCandidates 已全树扫返回空时重扫恒空,无意义——Grill 修正)
|
|
510
|
+
const fixEntry = { fixable: false, reason: '文件不存在,无法自动定位' }
|
|
511
|
+
if (r.file.includes('/')) {
|
|
512
|
+
const baseName = r.file.slice(r.file.lastIndexOf('/') + 1)
|
|
513
|
+
const sameName = findInTree(join(projectRoot, 'src'), baseName, '', treeCache)
|
|
514
|
+
if (sameName.length > 0) {
|
|
515
|
+
fixEntry.candidates = sameName.map(rel => ({ file: 'src/' + rel }))
|
|
516
|
+
}
|
|
517
|
+
}
|
|
381
518
|
invalid.push({
|
|
382
519
|
doc: docRel, docLine: r.docLine, ref: r.ref,
|
|
383
520
|
reason: '文件不存在(含 / 按仓库根解析;裸文件名在 src/ 递归)',
|
|
384
|
-
fix:
|
|
521
|
+
fix: fixEntry,
|
|
385
522
|
})
|
|
386
523
|
continue
|
|
387
524
|
}
|
|
@@ -395,13 +532,10 @@ export function runDocsCheck(opts) {
|
|
|
395
532
|
const lines = readLines(candAbs, linesCache)
|
|
396
533
|
if (lines === null) { candidateFails.push(`${relDisplay(candAbs, projectRoot)}: 读取失败`); continue }
|
|
397
534
|
const v = validateRefLines(lines.length, r.start, r.end)
|
|
398
|
-
// 层2:token 任一在 [start-2, end+5]
|
|
535
|
+
// 层2:token 任一在 [start-2, end+5] 窗口命中即过(窗口与 collectInvalidDocRefs 共用 keywordWindowText)
|
|
399
536
|
let kwOk = true
|
|
400
537
|
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))
|
|
538
|
+
kwOk = tokens.some((t) => keywordWindowText(lines, r.start, r.end).includes(t))
|
|
405
539
|
}
|
|
406
540
|
if (v.ok && kwOk) { passedAny = true; break }
|
|
407
541
|
const reasons = [...v.reasons]
|
|
@@ -418,13 +552,13 @@ export function runDocsCheck(opts) {
|
|
|
418
552
|
// 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
|
|
419
553
|
suggest: suggestLines(candidates, tokens, linesCache),
|
|
420
554
|
// 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
|
|
421
|
-
fix: classifyFix(candidates, tokens, r.start, linesCache),
|
|
555
|
+
fix: classifyFix(candidates, tokens, r.start, linesCache, projectRoot),
|
|
422
556
|
})
|
|
423
557
|
}
|
|
424
558
|
}
|
|
425
559
|
}
|
|
426
560
|
|
|
427
|
-
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped }
|
|
561
|
+
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy }
|
|
428
562
|
}
|
|
429
563
|
|
|
430
564
|
/**
|
|
@@ -461,13 +595,14 @@ function suggestLines(candidates, tokens, linesCache = null) {
|
|
|
461
595
|
* @param {string[]} candidates resolveCandidates 返回的全量候选绝对路径
|
|
462
596
|
* @param {string[]} tokens 层2 提取的期望 token(keywordAssert=false 时为空数组)
|
|
463
597
|
* @param {number|null} [refStart] 文档引用的旧行号(打分用距离信号;范围引用取 start)
|
|
464
|
-
* @
|
|
598
|
+
* @param {string} [projectRootHint] candidates 相对路径显示锚(tie candidates 的 file 字段用)
|
|
599
|
+
* @returns {{ fixable: boolean, newLine?: number, candidates?: Array<{file?: string, line: number}>, reason: string }}
|
|
465
600
|
*/
|
|
466
601
|
function escapeReLocal(s) {
|
|
467
602
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
468
603
|
}
|
|
469
604
|
|
|
470
|
-
function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
|
|
605
|
+
function classifyFix(candidates, tokens, refStart = null, linesCache = null, projectRootHint = '') {
|
|
471
606
|
if (tokens.length === 0) {
|
|
472
607
|
return { fixable: false, reason: '无 token 符号线索(纯位置引用或关键词断言关闭),无法自动定位' }
|
|
473
608
|
}
|
|
@@ -507,7 +642,16 @@ function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
|
|
|
507
642
|
}
|
|
508
643
|
}
|
|
509
644
|
const shown = ranked.slice(0, 8).map(([l]) => l).join('、') + (ranked.length > 8 ? '…' : '')
|
|
510
|
-
|
|
645
|
+
// FR-4(candidates JSON):tie 歧义时附机械候选行号数组(供 --json 消费,无置信度——D-001 边界)。
|
|
646
|
+
// file 字段仅在单候选文件时有意义(多候选同分行号已合并,行号不区分文件归属)。
|
|
647
|
+
const tieCandidates = ranked.slice(0, 8).map(([l]) =>
|
|
648
|
+
candidates.length === 1 ? { file: relDisplay(candidates[0], projectRootHint), line: l } : { line: l }
|
|
649
|
+
)
|
|
650
|
+
return {
|
|
651
|
+
fixable: false,
|
|
652
|
+
candidates: tieCandidates,
|
|
653
|
+
reason: `token 多处命中且最优两行同分(候选行号:${shown}),歧义需人工确认(D-006 保守默认)`,
|
|
654
|
+
}
|
|
511
655
|
}
|
|
512
656
|
|
|
513
657
|
/**
|
|
@@ -630,23 +774,35 @@ function extractKnownFailureKeys(yamlText) {
|
|
|
630
774
|
if (inline) {
|
|
631
775
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
632
776
|
}
|
|
633
|
-
// 坑 verify-known-failures-comment-line-truncation
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
777
|
+
// 坑 verify-known-failures-comment-line-truncation → verify-known-failures-block-fragile-chain
|
|
778
|
+
// (与 verify-postcheck.extractKnownFailures 口径互指对齐,逐字同步):块式捕获曾要求
|
|
779
|
+
// 「- 项行 / 注释行 / 空行」连续成链,手改残留的缩进杂行/空项断链 → 其后键静默丢失。
|
|
780
|
+
// 改为逐行扫描:块 = 头之后连续的「缩进行 / 注释行(任意缩进含列首)/ 空行」,列首正文行
|
|
781
|
+
// 收块;块内杂行容忍不提取但不断链,`- ` 项行无论隔着什么都提取。
|
|
782
|
+
// 行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
|
|
783
|
+
const lines = yaml.split('\n')
|
|
784
|
+
const headIdx = lines.findIndex(l => /^known_failures:\s*(?:#.*)?$/.test(l))
|
|
785
|
+
if (headIdx === -1) return []
|
|
786
|
+
const itemRe = /^[ \t]+-[ \t]+(.+)$/
|
|
787
|
+
const items = []
|
|
788
|
+
for (let i = headIdx + 1; i < lines.length; i++) {
|
|
789
|
+
const l = lines[i]
|
|
790
|
+
if (l.trim() === '') continue // 空行:块内分隔,放行
|
|
791
|
+
if (/^[ \t]*#/.test(l)) continue // 注释行(任意缩进含列首):段头/归因注记,放行
|
|
792
|
+
if (!/^[ \t]/.test(l)) break // 列首正文行:下一个顶层键,收块
|
|
793
|
+
const m = l.match(itemRe) // 其余缩进行一律留在块内;项行才提取(杂行不提取但不断链)
|
|
794
|
+
if (m) items.push(m[1])
|
|
648
795
|
}
|
|
649
|
-
return
|
|
796
|
+
return items
|
|
797
|
+
.map(s => {
|
|
798
|
+
const item = s.trim()
|
|
799
|
+
const full = item.match(/^(['"])(.*)\1$/)
|
|
800
|
+
if (full) return full[2]
|
|
801
|
+
const stripped = item.replace(/[ \t]+#.*$/, '').trim()
|
|
802
|
+
const q2 = stripped.match(/^(['"])(.*)\1$/)
|
|
803
|
+
return q2 ? q2[2] : stripped
|
|
804
|
+
})
|
|
805
|
+
.filter(Boolean)
|
|
650
806
|
}
|
|
651
807
|
|
|
652
808
|
/**
|
|
@@ -662,8 +818,15 @@ export function readDecisionRulesConfig(projectRoot) {
|
|
|
662
818
|
const p = join(projectRoot, '.sillyspec', 'local.yaml')
|
|
663
819
|
if (!existsSync(p)) return fallback
|
|
664
820
|
const raw = readFileSync(p, 'utf8')
|
|
821
|
+
// knownFailures 用容错行扫描提取(与 verify-postcheck.extractKnownFailures 同款),且先于
|
|
822
|
+
// jsYaml——YAML 整体不合法(如 known_failures 块内手改残迹行)时 jsYaml 抛错不能连带丢掉
|
|
823
|
+
// 豁免键(坑 verify-known-failures-block-fragile-chain:曾整段 catch 回 fallback、键静默丢)。
|
|
824
|
+
// jsYaml 只供 behind_threshold,单独兜底;豁免键在任何形态下存活。
|
|
665
825
|
const knownFailures = extractKnownFailureKeys(raw)
|
|
666
|
-
|
|
826
|
+
let doc = null
|
|
827
|
+
try {
|
|
828
|
+
doc = jsYaml.load(raw)
|
|
829
|
+
} catch { doc = null }
|
|
667
830
|
if (!doc || typeof doc !== 'object') return { ...fallback, knownFailures }
|
|
668
831
|
const d = doc['decisions']
|
|
669
832
|
if (!d || typeof d !== 'object' || Array.isArray(d)) return { ...fallback, knownFailures }
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs migrate:确定性批量路径前缀迁移(2026-09-08-docs-fix-capability / D-002+D-004)。
|
|
3
|
+
*
|
|
4
|
+
* 动机:文档引用批量路径迁移(如 modules/ → backend/app/modules/)是已知改名规则的确定性
|
|
5
|
+
* 替换,不该靠手写临时脚本。薄模块——import 复用 docs-check 的提取/校验/写回,不复制解析正则。
|
|
6
|
+
*
|
|
7
|
+
* 语义(D-002):只做用户显式给定的 --from/--to 前缀替换(零推断);改动面=docs check 所辖
|
|
8
|
+
* .md 文件中的 file:line 引用文本;默认 dry-run 列计划,--apply 才写盘;写盘后自动 docs check
|
|
9
|
+
* 复核(防 from/to 写反)。不碰 git 历史、不改非引用文本。
|
|
10
|
+
*
|
|
11
|
+
* exit code(CLI 层):0=dry-run 零计划或 --apply 后 postCheck 全绿;1=--apply 后仍失效或
|
|
12
|
+
* unverified>0;2=用法/配置错误;dry-run 有计划恒 0(预览不判成败)。
|
|
13
|
+
*
|
|
14
|
+
* 纯 Node 内置模块零依赖(与 docs-check.js 同款)。
|
|
15
|
+
*/
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import {
|
|
19
|
+
collectDocRefs,
|
|
20
|
+
resolveCandidates,
|
|
21
|
+
readDocsCheckConfig,
|
|
22
|
+
applyFixes,
|
|
23
|
+
runDocsCheck,
|
|
24
|
+
DEFAULT_DOC_PATHS,
|
|
25
|
+
walkGlob,
|
|
26
|
+
} from './docs-check.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 生成迁移计划(纯函数,不写盘)。
|
|
30
|
+
* 文档集与 docs check 同源(readDocsCheckConfig paths/skip——校验面即改写面)。
|
|
31
|
+
* @param {{ projectRoot: string, from: string, to: string, docs?: string[], paths?: string[], skip?: string[] }} opts
|
|
32
|
+
* from/to 为用户显式前缀(如 'modules/' → 'backend/app/modules/')
|
|
33
|
+
* @returns {{ plans: Array<{doc, docLine, ref, newRef, verified}>, scanned: number }}
|
|
34
|
+
* plans 按 doc+docLine 排序;verified=false 表示新路径经 resolveCandidates 验证 0 候选(unverified)
|
|
35
|
+
*/
|
|
36
|
+
export function planDocsMigrate(opts) {
|
|
37
|
+
const { projectRoot, from, to } = opts || {}
|
|
38
|
+
if (!projectRoot || typeof from !== 'string' || from === '' || typeof to !== 'string' || to === '') {
|
|
39
|
+
throw new Error('planDocsMigrate: from/to 必填且非空')
|
|
40
|
+
}
|
|
41
|
+
const cfg = readDocsCheckConfig(projectRoot)
|
|
42
|
+
const paths = (opts.paths && opts.paths.length > 0 ? opts.paths : null)
|
|
43
|
+
|| cfg.paths
|
|
44
|
+
|| DEFAULT_DOC_PATHS
|
|
45
|
+
const skip = opts.skip || cfg.skip
|
|
46
|
+
const docFiles = (opts.docs && opts.docs.length > 0)
|
|
47
|
+
? opts.docs
|
|
48
|
+
: [...new Set(paths.flatMap(p => walkGlob(projectRoot, p, skip)))].sort()
|
|
49
|
+
|
|
50
|
+
const plans = []
|
|
51
|
+
const treeCache = new Map()
|
|
52
|
+
for (const docRel of docFiles) {
|
|
53
|
+
const docAbs = join(projectRoot, docRel)
|
|
54
|
+
if (!existsSync(docAbs)) continue
|
|
55
|
+
const md = readFileSync(docAbs, 'utf8')
|
|
56
|
+
const refs = collectDocRefs(md)
|
|
57
|
+
for (const r of refs) {
|
|
58
|
+
if (r.repo) continue // 跨仓引用不迁移(repo:// 前缀)
|
|
59
|
+
if (!r.file.startsWith(from)) continue
|
|
60
|
+
if (from === to) continue // from=to 时 newRef === ref,无迁移意义(零计划)
|
|
61
|
+
const newFile = to + r.file.slice(from.length)
|
|
62
|
+
const linePart = r.end !== r.start ? `:${r.start}-${r.end}` : `:${r.start}`
|
|
63
|
+
const newRef = newFile + linePart
|
|
64
|
+
const newCandidates = resolveCandidates(projectRoot, newFile, treeCache)
|
|
65
|
+
plans.push({
|
|
66
|
+
doc: docRel,
|
|
67
|
+
docLine: r.docLine,
|
|
68
|
+
ref: r.ref,
|
|
69
|
+
newRef,
|
|
70
|
+
verified: newCandidates.length > 0,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
plans.sort((a, b) => a.doc.localeCompare(b.doc) || a.docLine - b.docLine)
|
|
75
|
+
return { plans, scanned: docFiles.length }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 执行迁移:默认 dry-run 输出计划表;--apply 写盘(applyFixes)+ 自动 docs check 复核。
|
|
80
|
+
* @param {{ projectRoot: string, from: string, to: string, apply?: boolean, docs?: string[], paths?: string[], skip?: string[] }} opts
|
|
81
|
+
* @returns {{ plans, applied, skipped, unverified, postCheck?: {invalid, total} }}
|
|
82
|
+
*/
|
|
83
|
+
export function runDocsMigrate(opts) {
|
|
84
|
+
const { apply = false } = opts || {}
|
|
85
|
+
const { plans, scanned } = planDocsMigrate(opts)
|
|
86
|
+
const unverified = plans.filter(p => !p.verified)
|
|
87
|
+
|
|
88
|
+
if (!apply) {
|
|
89
|
+
// dry-run:打印计划表,零写盘
|
|
90
|
+
if (plans.length === 0) {
|
|
91
|
+
console.log(`✅ docs migrate: 扫描 ${scanned} 个文档,无可迁移引用(from=${opts.from} 无匹配)`)
|
|
92
|
+
} else {
|
|
93
|
+
console.log(`📋 docs migrate 计划(dry-run 未写盘):扫描 ${scanned} 个文档,${plans.length} 处引用可迁移${unverified.length > 0 ? `(${unverified.length} 处 unverified)` : ''}:`)
|
|
94
|
+
for (const p of plans) {
|
|
95
|
+
const mark = p.verified ? '✅' : '⚠️'
|
|
96
|
+
console.log(` ${mark} [${p.doc}:L${p.docLine}] ${p.ref} → ${p.newRef}${p.verified ? '' : '(目标不存在)'}`)
|
|
97
|
+
}
|
|
98
|
+
console.log(`\n确认后加 --apply 写盘;unverified 项请核对 from/to 是否写反。`)
|
|
99
|
+
}
|
|
100
|
+
return { plans, applied: 0, skipped: [], unverified: unverified.length }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --apply:applyFixes 写盘(shape 一致,含 R-04 行内偏移防护)
|
|
104
|
+
const { applied, skipped } = applyFixes(opts.projectRoot, plans)
|
|
105
|
+
// 写盘后自动 docs check 复核(防 from/to 写反)
|
|
106
|
+
const postCheck = runDocsCheck({
|
|
107
|
+
projectRoot: opts.projectRoot,
|
|
108
|
+
paths: opts.paths || null,
|
|
109
|
+
skip: opts.skip || [],
|
|
110
|
+
})
|
|
111
|
+
console.log(`✅ docs migrate: ${applied} 处已写回${skipped.length > 0 ? `、${skipped.length} 处写回跳过` : ''}(${unverified.length} 处 unverified)。`)
|
|
112
|
+
console.log(`📊 迁移后 docs check:${postCheck.invalid.length}/${postCheck.total} 处失效。`)
|
|
113
|
+
if (unverified.length > 0) {
|
|
114
|
+
console.warn(`⚠️ ${unverified.length} 处 unverified(目标不存在),请核对 from/to 是否写反:`)
|
|
115
|
+
for (const p of unverified.slice(0, 10)) console.warn(` - [${p.doc}:L${p.docLine}] ${p.ref} → ${p.newRef}`)
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
plans,
|
|
119
|
+
applied,
|
|
120
|
+
skipped,
|
|
121
|
+
unverified: unverified.length,
|
|
122
|
+
postCheck: { invalid: postCheck.invalid.length, total: postCheck.total },
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|