sillyspec 3.28.2 → 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 +45 -2
- package/src/constants.js +6 -0
- package/src/design-facts.js +64 -0
- package/src/dispatch/backends/sillyhub-mcp.js +17 -5
- package/src/docs-check.js +119 -27
- package/src/docs-migrate.js +124 -0
- package/src/index.js +172 -22
- package/src/progress/change-registry.js +19 -0
- package/src/progress.js +2 -0
- package/src/run/command.js +129 -4
- package/src/run/complete-handlers.js +10 -3
- package/src/run/complete.js +59 -5
- package/src/run/gates.js +22 -2
- package/src/run/progress-confirm.js +66 -0
- package/src/run/prompt.js +139 -31
- package/src/run/quick-audit.js +12 -5
- package/src/run/scan-profile.js +20 -2
- package/src/run/shared.js +10 -5
- package/src/run/stage.js +7 -0
- package/src/scan-postcheck.js +5 -1
- package/src/stages/brainstorm-auto.js +2 -1
- package/src/stages/brainstorm.js +9 -17
- package/src/stages/execute.js +10 -14
- package/src/stages/plan.js +2 -1
- package/src/stages/quick.js +4 -2
- package/src/stages/verify.js +13 -15
- package/src/task-review.js +200 -0
- package/src/verify-postcheck.js +164 -30
- package/templates/agents-instruction.md +1 -1
- package/.claude/skills/verify-per-user/SKILL.md +0 -6
|
@@ -234,11 +234,23 @@ dispatch 后在当前 step 内主动轮询,不阻塞等待:
|
|
|
234
234
|
|
|
235
235
|
1. **轮询 \`list_workers\`**:间隔默认 15s,可配 local.yaml \`dispatch.poll_interval_ms\`
|
|
236
236
|
- 入参 \`mission_id\`;关注每个 worker 的 status(pending → running → completed/failed)
|
|
237
|
-
2. **
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
2. **worker 活性知情决策(2026-09-07-agent-liveness-states task-13)**:\`list_workers\`
|
|
238
|
+
返回的 worker 在 **running 期间** 附 \`liveness\` 字段(\`{ state, evidence, derived_at }\`,
|
|
239
|
+
无则为 null)——\`state\` 取值 \`working\` / \`blocked\` / \`idle\` / \`ended\` / \`unknown\`
|
|
240
|
+
(daemon liveness 推导;\`blocked\` 主源为第一方权限事件,PERMISSION_REQUEST 等人)。
|
|
241
|
+
轮询时按活性分流,替代无脑超时 kill:
|
|
242
|
+
- a. **\`liveness.state=blocked\` 且持续超阈值**(对齐平台 \`BLOCKED_ALERT_MS\`=120s 口径,
|
|
243
|
+
连续两轮轮询仍 blocked 即视为持续)→ **升级给人**:告知用户该 worker 在等权限确认
|
|
244
|
+
(harness + evidence),由人处理(平台会同步发 agent_blocked 通知);**绝不 kill、
|
|
245
|
+
绝不自动批准**(自动批准权限提示=代人做安全决策,安全敏感)。
|
|
246
|
+
- b. **\`liveness.state=working\` 且久无终态** → 属长任务场景(日志持续增长、跑测试等),
|
|
247
|
+
按既有 \`dispatch.worker_timeout_ms\` 继续等待,**不抢跑 kill**——区分「在干活的长任务」
|
|
248
|
+
与「死了」正是活性字段的价值。
|
|
249
|
+
- c. **仅当超时且无 liveness / \`liveness.state=unknown\`** → 走既有三步兜底(超时兜底路径):
|
|
250
|
+
- ① **标记**:调 \`report_progress\` tool(worker_id + 超时说明)记录该 worker 超时
|
|
251
|
+
- ② **kill lease 防双写**:调 \`report_progress\` tool(worker_id + \`kill: true\` + marker)终止 lease
|
|
252
|
+
(等价 client.js \`killLease(workerId)\` 路径;SillyHub 当前无专用 kill tool,路径A 落地后升级为专用 kill)
|
|
253
|
+
- ③ **fallback Local 重派**:该 task 改用本机 Agent tool 重派(调 \`sillyspec dispatch hint\` 走 Local 模板)
|
|
242
254
|
3. **worker 终态(completed / failed / killed)** → 进入下方回收约定
|
|
243
255
|
|
|
244
256
|
${recycleRule}`
|
package/src/docs-check.js
CHANGED
|
@@ -28,11 +28,16 @@ import { parseModuleMapSimple } from './modules.js'
|
|
|
28
28
|
* 不同设备兄弟仓库位置不同,未配映射时默认跳过本地校验(防跨设备误报);
|
|
29
29
|
* 在本机 .sillyspec/local.yaml 的 docs-check.cross_repo_roots 配
|
|
30
30
|
* `<仓库名>: <本机绝对路径>` 后,走与本地引用完全相同的层1(行号边界)+ 层2(关键词窗口)校验。
|
|
31
|
-
* 正则组:1=仓库名(可选),2=文件,3=start,4=end。
|
|
32
|
-
|
|
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
|
|
33
38
|
|
|
34
39
|
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
|
|
35
|
-
const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
|
|
40
|
+
export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
43
|
* 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
|
|
@@ -99,6 +104,44 @@ export function collectDocRefs(md) {
|
|
|
99
104
|
return refs
|
|
100
105
|
}
|
|
101
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
|
+
|
|
102
145
|
/**
|
|
103
146
|
* 纯函数:判定 token 是否「像代码符号」:首字符字母/_/$,且含大写字母/下划线/点/$ 之一。
|
|
104
147
|
* 纯小写英文单词(local/platform/abort)→ false(自然语言,跳过断言防误报)。
|
|
@@ -391,7 +434,7 @@ export function runDocsCheck(opts) {
|
|
|
391
434
|
// 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
|
|
392
435
|
const {
|
|
393
436
|
projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [],
|
|
394
|
-
keywordAssert = true, crossRepoRoots = {},
|
|
437
|
+
keywordAssert = true, crossRepoRoots = {}, exempt = true,
|
|
395
438
|
} = opts || {}
|
|
396
439
|
const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : DEFAULT_DOC_PATHS
|
|
397
440
|
const warnings = []
|
|
@@ -405,6 +448,10 @@ export function runDocsCheck(opts) {
|
|
|
405
448
|
let kwChecked = 0
|
|
406
449
|
// repo:// 跨仓引用:未配映射被跳过的数量(不计入 total/invalid,跨设备零误报)
|
|
407
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
|
|
408
455
|
// per-call 缓存:裸名引用的 src/ 全树扫描结果 + 候选文件行数组——100 文档 × 10 裸名引用
|
|
409
456
|
// 此前 = 1000 次全树 walk + 同文件 N 次重读(2026-08-21 性能审查 PERF-5)。仅本次调用
|
|
410
457
|
// 生命周期内有效,不跨调用(防测试流程中途改树后读到陈旧结果)
|
|
@@ -420,11 +467,17 @@ export function runDocsCheck(opts) {
|
|
|
420
467
|
})
|
|
421
468
|
continue
|
|
422
469
|
}
|
|
470
|
+
// 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
|
|
471
|
+
// frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
|
|
472
|
+
if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
|
|
423
473
|
const md = readFileSync(docAbs, 'utf8')
|
|
474
|
+
if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
|
|
424
475
|
const refs = collectDocRefs(md)
|
|
425
476
|
const mdLines = md.split(/\r?\n/)
|
|
426
477
|
|
|
427
478
|
for (const r of refs) {
|
|
479
|
+
// 模糊路径(含 `...` 省略号):跳过校验(FR-1.2,宁漏不误报——文档作者明示意图非精确路径)
|
|
480
|
+
if (r.file.includes('...')) { skippedFuzzy++; continue }
|
|
428
481
|
// repo:// 跨仓引用:未配映射 → 跳过(不同设备仓库位置不同,不按本仓解析误报);
|
|
429
482
|
// 配了映射 → 以映射根为唯一候选,走与本地引用相同的层1+层2 校验。
|
|
430
483
|
let candidates
|
|
@@ -452,10 +505,20 @@ export function runDocsCheck(opts) {
|
|
|
452
505
|
}
|
|
453
506
|
total++
|
|
454
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
|
+
}
|
|
455
518
|
invalid.push({
|
|
456
519
|
doc: docRel, docLine: r.docLine, ref: r.ref,
|
|
457
520
|
reason: '文件不存在(含 / 按仓库根解析;裸文件名在 src/ 递归)',
|
|
458
|
-
fix:
|
|
521
|
+
fix: fixEntry,
|
|
459
522
|
})
|
|
460
523
|
continue
|
|
461
524
|
}
|
|
@@ -489,13 +552,13 @@ export function runDocsCheck(opts) {
|
|
|
489
552
|
// 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
|
|
490
553
|
suggest: suggestLines(candidates, tokens, linesCache),
|
|
491
554
|
// 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
|
|
492
|
-
fix: classifyFix(candidates, tokens, r.start, linesCache),
|
|
555
|
+
fix: classifyFix(candidates, tokens, r.start, linesCache, projectRoot),
|
|
493
556
|
})
|
|
494
557
|
}
|
|
495
558
|
}
|
|
496
559
|
}
|
|
497
560
|
|
|
498
|
-
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped }
|
|
561
|
+
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy }
|
|
499
562
|
}
|
|
500
563
|
|
|
501
564
|
/**
|
|
@@ -532,13 +595,14 @@ function suggestLines(candidates, tokens, linesCache = null) {
|
|
|
532
595
|
* @param {string[]} candidates resolveCandidates 返回的全量候选绝对路径
|
|
533
596
|
* @param {string[]} tokens 层2 提取的期望 token(keywordAssert=false 时为空数组)
|
|
534
597
|
* @param {number|null} [refStart] 文档引用的旧行号(打分用距离信号;范围引用取 start)
|
|
535
|
-
* @
|
|
598
|
+
* @param {string} [projectRootHint] candidates 相对路径显示锚(tie candidates 的 file 字段用)
|
|
599
|
+
* @returns {{ fixable: boolean, newLine?: number, candidates?: Array<{file?: string, line: number}>, reason: string }}
|
|
536
600
|
*/
|
|
537
601
|
function escapeReLocal(s) {
|
|
538
602
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
539
603
|
}
|
|
540
604
|
|
|
541
|
-
function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
|
|
605
|
+
function classifyFix(candidates, tokens, refStart = null, linesCache = null, projectRootHint = '') {
|
|
542
606
|
if (tokens.length === 0) {
|
|
543
607
|
return { fixable: false, reason: '无 token 符号线索(纯位置引用或关键词断言关闭),无法自动定位' }
|
|
544
608
|
}
|
|
@@ -578,7 +642,16 @@ function classifyFix(candidates, tokens, refStart = null, linesCache = null) {
|
|
|
578
642
|
}
|
|
579
643
|
}
|
|
580
644
|
const shown = ranked.slice(0, 8).map(([l]) => l).join('、') + (ranked.length > 8 ? '…' : '')
|
|
581
|
-
|
|
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
|
+
}
|
|
582
655
|
}
|
|
583
656
|
|
|
584
657
|
/**
|
|
@@ -701,23 +774,35 @@ function extractKnownFailureKeys(yamlText) {
|
|
|
701
774
|
if (inline) {
|
|
702
775
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
703
776
|
}
|
|
704
|
-
// 坑 verify-known-failures-comment-line-truncation
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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])
|
|
719
795
|
}
|
|
720
|
-
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)
|
|
721
806
|
}
|
|
722
807
|
|
|
723
808
|
/**
|
|
@@ -733,8 +818,15 @@ export function readDecisionRulesConfig(projectRoot) {
|
|
|
733
818
|
const p = join(projectRoot, '.sillyspec', 'local.yaml')
|
|
734
819
|
if (!existsSync(p)) return fallback
|
|
735
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,单独兜底;豁免键在任何形态下存活。
|
|
736
825
|
const knownFailures = extractKnownFailureKeys(raw)
|
|
737
|
-
|
|
826
|
+
let doc = null
|
|
827
|
+
try {
|
|
828
|
+
doc = jsYaml.load(raw)
|
|
829
|
+
} catch { doc = null }
|
|
738
830
|
if (!doc || typeof doc !== 'object') return { ...fallback, knownFailures }
|
|
739
831
|
const d = doc['decisions']
|
|
740
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
|
+
}
|