sillyspec 3.25.1 → 3.25.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/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
- package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
- package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
- package/.playwright-mcp/prompt-final.png +0 -0
- package/.playwright-mcp/prompt-view.png +0 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +60 -12
- package/src/check-primitives.js +65 -0
- package/src/index.js +135 -6
- package/src/progress/change-registry.js +217 -0
- package/src/progress/consistency-doctor.js +259 -0
- package/src/progress/shared.js +33 -0
- package/src/progress/stage-machine.js +535 -0
- package/src/progress/step-store.js +256 -0
- package/src/progress.js +59 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +127 -50
- package/src/run/complete-handlers.js +2 -1
- package/src/run/complete.js +725 -695
- package/src/run/gates.js +326 -321
- package/src/run/prompt.js +619 -604
- package/src/run/quick-audit.js +107 -102
- package/src/run/shared.js +43 -0
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +83 -148
- package/src/stage-review.js +63 -0
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +38 -27
- package/src/stages/plan.js +12 -49
- package/src/stages/propose.js +2 -1
- package/src/stages/scan.js +3 -1
- package/src/workflow.js +9 -17
- package/src/worktree-apply.js +5 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +6 -3
- package/src/brainstorm-postcheck.js +0 -158
package/src/run/quick-audit.js
CHANGED
|
@@ -1,102 +1,107 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* run/quick-audit.js(W6 Step2 从 run.js 抽出)。
|
|
3
|
-
*
|
|
4
|
-
* quick 审计结论打印 + quick 多变更关联选择(自洽,不依赖 run.js 闭包)。
|
|
5
|
-
* 安全锚:run.js 始终 barrel,本模块函数 run.js import 回来;无 test 直接 import,无需 re-export。
|
|
6
|
-
*
|
|
7
|
-
* 路径修正(相对 src/run/):
|
|
8
|
-
* - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
|
|
9
|
-
* - 原动态 import('child_process') 删除,改顶部静态 execSync
|
|
10
|
-
* - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
|
|
11
|
-
*/
|
|
12
|
-
import { execSync } from 'node:child_process'
|
|
13
|
-
import { checkbox } from '@inquirer/prompts'
|
|
14
|
-
import { parsePorcelainPath } from './shared.js'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
|
|
18
|
-
*/
|
|
19
|
-
export function printQuickAuditReview(review) {
|
|
20
|
-
if (review.status === 'blocked') {
|
|
21
|
-
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
22
|
-
for (const r of review.reasons) {
|
|
23
|
-
console.error(` - ${r}`)
|
|
24
|
-
}
|
|
25
|
-
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} else {
|
|
32
|
-
console.
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1
|
+
/**
|
|
2
|
+
* run/quick-audit.js(W6 Step2 从 run.js 抽出)。
|
|
3
|
+
*
|
|
4
|
+
* quick 审计结论打印 + quick 多变更关联选择(自洽,不依赖 run.js 闭包)。
|
|
5
|
+
* 安全锚:run.js 始终 barrel,本模块函数 run.js import 回来;无 test 直接 import,无需 re-export。
|
|
6
|
+
*
|
|
7
|
+
* 路径修正(相对 src/run/):
|
|
8
|
+
* - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
|
|
9
|
+
* - 原动态 import('child_process') 删除,改顶部静态 execSync
|
|
10
|
+
* - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from 'node:child_process'
|
|
13
|
+
import { checkbox } from '@inquirer/prompts'
|
|
14
|
+
import { parsePorcelainPath } from './shared.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
|
|
18
|
+
*/
|
|
19
|
+
export function printQuickAuditReview(review) {
|
|
20
|
+
if (review.status === 'blocked') {
|
|
21
|
+
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
22
|
+
for (const r of review.reasons) {
|
|
23
|
+
console.error(` - ${r}`)
|
|
24
|
+
}
|
|
25
|
+
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
26
|
+
// 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
|
|
27
|
+
// 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
|
|
28
|
+
console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
29
|
+
console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
30
|
+
console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
31
|
+
} else if (review.status === 'warning') {
|
|
32
|
+
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
33
|
+
for (const r of review.reasons) {
|
|
34
|
+
console.warn(` - ${r}`)
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* quick 阶段多变更交互式选择关联变更。
|
|
43
|
+
* - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
|
|
44
|
+
* - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
|
|
45
|
+
* - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
|
|
46
|
+
* - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
|
|
49
|
+
let activeChanges = []
|
|
50
|
+
try {
|
|
51
|
+
activeChanges = await pm.listChanges(cwd)
|
|
52
|
+
} catch {
|
|
53
|
+
activeChanges = []
|
|
54
|
+
}
|
|
55
|
+
if (activeChanges.length === 0) return []
|
|
56
|
+
if (activeChanges.length === 1) return [activeChanges[0]]
|
|
57
|
+
|
|
58
|
+
if (nonInteractive || !process.stdin.isTTY) {
|
|
59
|
+
console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 脏文件(推荐信号之一)
|
|
64
|
+
let baselineFiles = []
|
|
65
|
+
try {
|
|
66
|
+
const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
67
|
+
baselineFiles = gs.split('\n').filter(Boolean)
|
|
68
|
+
.map(l => parsePorcelainPath(l))
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.filter(f => !f.startsWith('.sillyspec/'))
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
// 推荐打分(脏文件 + 任务描述双信号)
|
|
74
|
+
let recommendations = []
|
|
75
|
+
try {
|
|
76
|
+
// quick-recommend.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
|
|
77
|
+
const { recommendChanges } = await import('../quick-recommend.js')
|
|
78
|
+
recommendations = recommendChanges({ activeChanges, specDir, baselineFiles, quickFiles, taskDescription })
|
|
79
|
+
} catch {
|
|
80
|
+
recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
|
|
81
|
+
}
|
|
82
|
+
const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
|
|
83
|
+
const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
|
|
84
|
+
const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
|
|
85
|
+
|
|
86
|
+
// 按推荐分降序展示
|
|
87
|
+
const ordered = [...activeChanges].sort((a, b) =>
|
|
88
|
+
(scoreMap.get(b) || 0) - (scoreMap.get(a) || 0) || a.localeCompare(b))
|
|
89
|
+
|
|
90
|
+
const choices = ordered.map(name => {
|
|
91
|
+
const reasons = reasonMap.get(name) || []
|
|
92
|
+
const isRec = recommendedSet.has(name)
|
|
93
|
+
return {
|
|
94
|
+
name: `${isRec ? '⭐ ' : ' '}${name}`,
|
|
95
|
+
value: name,
|
|
96
|
+
description: reasons.length > 0 ? reasons.join(';') : '无推荐信号',
|
|
97
|
+
checked: isRec,
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
console.log('🔗 检测到多个活跃变更,选择本次 quick 关联哪些(可多选;不勾选任何项 = 仅记 QUICKLOG,不关联变更)')
|
|
102
|
+
if (recommendedSet.size > 0) {
|
|
103
|
+
console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
|
|
104
|
+
}
|
|
105
|
+
const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
|
|
106
|
+
return selected
|
|
107
|
+
}
|
package/src/run/shared.js
CHANGED
|
@@ -19,6 +19,49 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
19
19
|
// ── Wait State Constants ──(W6 Step3 从 run.js 搬入:prompt.js outputStep 与 run.js wait-detection 共用)
|
|
20
20
|
// 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
|
|
21
21
|
export const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
|
|
22
|
+
|
|
23
|
+
// ── did-you-mean(命令级 + flag 级 typo 建议,零依赖)──
|
|
24
|
+
// 历史:agent 打错命令名/flag 名只报「未知」+整屏列表,要自己捞正确拼写。
|
|
25
|
+
export function levenshtein(a, b) {
|
|
26
|
+
const m = String(a).length, n = String(b).length
|
|
27
|
+
if (!m) return n; if (!n) return m
|
|
28
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i)
|
|
29
|
+
for (let i = 1; i <= m; i++) {
|
|
30
|
+
const cur = [i]
|
|
31
|
+
for (let j = 1; j <= n; j++) {
|
|
32
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
33
|
+
}
|
|
34
|
+
prev = cur
|
|
35
|
+
}
|
|
36
|
+
return prev[n]
|
|
37
|
+
}
|
|
38
|
+
export function didYouMean(input, candidates) {
|
|
39
|
+
if (!input) return null
|
|
40
|
+
let best = null, bestD = Infinity
|
|
41
|
+
for (const c of candidates) {
|
|
42
|
+
const d = levenshtein(String(input).toLowerCase(), String(c).toLowerCase())
|
|
43
|
+
if (d < bestD) { bestD = d; best = c }
|
|
44
|
+
}
|
|
45
|
+
return (best !== null && bestD <= Math.max(1, Math.floor(String(input).length / 2))) ? best : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 变更名路径穿越消毒(F6)──
|
|
49
|
+
// change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
|
|
50
|
+
// 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
|
|
51
|
+
// 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
|
|
52
|
+
export function assertSafeChangeName(name, label = '变更名') {
|
|
53
|
+
if (name == null) return
|
|
54
|
+
const s = String(name)
|
|
55
|
+
if (s === '') throw new Error(`${label}不能为空`)
|
|
56
|
+
// 含路径分隔符 或 父目录穿越段 → 拒绝(Windows 下 \\ 也算)
|
|
57
|
+
if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
|
|
58
|
+
throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
|
|
59
|
+
}
|
|
60
|
+
if (!/^[\w.\-]+$/.test(s)) {
|
|
61
|
+
throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
import { buildExecuteSteps } from '../stages/execute.js'
|
|
23
66
|
import { buildPlanSteps } from '../stages/plan.js'
|
|
24
67
|
import { stageRegistry } from '../stages/index.js'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stage-contract-engine.js — 产物字面校验通用引擎
|
|
3
|
+
*
|
|
4
|
+
* 消费 stage-contract-spec.js 的 manifest 规则,按 kind dispatch 判定,产出 {errors, warnings}。
|
|
5
|
+
* 哲学对齐 workflow.js 的 checkOutput(声明式 check + type 分发),但独立:支持 severity(error/warning)、
|
|
6
|
+
* 更丰富的纯 kind、custom kind(引擎 skip,留 validator 保留算法)。
|
|
7
|
+
*
|
|
8
|
+
* 设计原则:
|
|
9
|
+
* - 引擎不直接碰 fs:readFile/readDir 由调用方注入(默认原生)。CRLF 归一化在调用方收敛
|
|
10
|
+
* (plan-postcheck 注入 LF-normalizing reader),引擎拿到什么判什么,不在引擎层二次引入差异。
|
|
11
|
+
* - regex 每次 new RegExp:manifest 存 {pattern, flags} 字符串,规避 lastIndex 状态坑
|
|
12
|
+
* (旧代码多处 /g 正则跨调用复用需手写 lastIndex=0,是潜在 bug)。
|
|
13
|
+
* - 内容规则(literal-any、literal-all、regex、contains-section、field-present、header-field、list-non-empty、
|
|
14
|
+
* min-lines/no-placeholder-line)在 target 文件不存在时自动 skip(不报错)——存在性由独立的
|
|
15
|
+
* file-exists 规则保证,避免"文件缺失"被内容规则重复报。
|
|
16
|
+
* - custom kind 引擎 skip(不计 errors/warnings),由调用方 validator 保留判定算法,
|
|
17
|
+
* 但 import manifest 的 data + failMessage(同源)。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { getRulesFor, isCustomKind } from './stage-contract-spec.js'
|
|
22
|
+
import { hasAllSections, hasNoPlaceholderLine, meetsMinLines } from './check-primitives.js'
|
|
23
|
+
|
|
24
|
+
// ============ IO 注入(默认原生 fs,调用方可替换为 normalized reader)============
|
|
25
|
+
|
|
26
|
+
function defaultReadFile(absPath) {
|
|
27
|
+
if (!existsSync(absPath)) return { exists: false, content: '' }
|
|
28
|
+
return { exists: true, content: readFileSync(absPath, 'utf8') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultReadDir(absPath) {
|
|
32
|
+
if (!existsSync(absPath)) return { exists: false, files: [] }
|
|
33
|
+
try {
|
|
34
|
+
return { exists: true, files: readdirSync(absPath) }
|
|
35
|
+
} catch {
|
|
36
|
+
return { exists: false, files: [] }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_IO = { readFile: defaultReadFile, readDir: defaultReadDir }
|
|
41
|
+
|
|
42
|
+
// ============ target 解析 + scope 截取 ============
|
|
43
|
+
|
|
44
|
+
function resolveTargetPath(target, ctx) {
|
|
45
|
+
if (!target || !target.path) return null
|
|
46
|
+
switch (target.root) {
|
|
47
|
+
case 'change': return ctx.changeDir ? join(ctx.changeDir, target.path) : null
|
|
48
|
+
case 'docs': return ctx.docsRoot ? join(ctx.docsRoot, target.path) : null
|
|
49
|
+
case 'archive': return ctx.archiveDir ? join(ctx.archiveDir, target.path) : null
|
|
50
|
+
case 'specBase': return ctx.specBase ? join(ctx.specBase, target.path) : null
|
|
51
|
+
default: return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 按 scope 截取文本:full=全文 / head=前 512 字节(对齐 scan-postcheck header 切片)/ fm=frontmatter 块内容 */
|
|
56
|
+
function sliceScope(content, scope) {
|
|
57
|
+
if (!scope || scope === 'full') return content
|
|
58
|
+
if (scope === 'head') return content.slice(0, 512)
|
|
59
|
+
if (scope === 'fm') {
|
|
60
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
61
|
+
return m ? m[1] : ''
|
|
62
|
+
}
|
|
63
|
+
return content
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============ 纯 kind dispatch ============
|
|
67
|
+
|
|
68
|
+
function dispatchPure(rule, entry) {
|
|
69
|
+
const { exists, content } = entry
|
|
70
|
+
// 存在性 kind:不存在即 fail
|
|
71
|
+
if (rule.kind === 'file-exists' || rule.kind === 'dir-exists') return exists
|
|
72
|
+
// 其余为内容规则:文件不存在 → skip(通过,存在性由 file-exists 规则保证)
|
|
73
|
+
if (!exists) return true
|
|
74
|
+
const scoped = sliceScope(content, rule.target && rule.target.scope)
|
|
75
|
+
switch (rule.kind) {
|
|
76
|
+
case 'literal-any': return (rule.data.literals || []).some(s => scoped.includes(s))
|
|
77
|
+
case 'literal-all': return (rule.data.literals || []).every(s => scoped.includes(s))
|
|
78
|
+
case 'regex': return new RegExp(rule.data.pattern, rule.data.flags || '').test(scoped)
|
|
79
|
+
case 'contains-section': return hasAllSections(scoped, rule.data.sections || [])
|
|
80
|
+
case 'no-placeholder-line': return hasNoPlaceholderLine(scoped, rule.data.patterns)
|
|
81
|
+
case 'field-present':
|
|
82
|
+
// frontmatter 字段:行首 `<field>:`(与 plan-postcheck /^goal:/m 一致,字段名为固定标识符无正则元字符)
|
|
83
|
+
return (rule.data.fields || []).every(f => new RegExp(`^${f}:`, 'm').test(scoped))
|
|
84
|
+
case 'header-field':
|
|
85
|
+
// 头部 N 字节内含 `<field>:`(对齐 scan-postcheck/complete.js author:/created_at: 检查)
|
|
86
|
+
return (rule.data.fields || []).every(f => scoped.includes(`${f}:`))
|
|
87
|
+
case 'list-non-empty':
|
|
88
|
+
// 至少一行 trim 后以 - / * / 数字. 开头(逐字对齐 validateBrainstormOutputs tasks.md 检查)
|
|
89
|
+
return scoped.split(/\r?\n/).some(l => {
|
|
90
|
+
const t = l.trim()
|
|
91
|
+
return t.startsWith('-') || t.startsWith('*') || /^\d+\./.test(t)
|
|
92
|
+
})
|
|
93
|
+
case 'min-lines': return meetsMinLines(scoped, rule.data.min || 1)
|
|
94
|
+
default: return true // 未知纯 kind,放过(不阻断),避免新 kind 上线时误杀
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkDirNonEmpty(rule, absPath, readDir) {
|
|
99
|
+
const { exists, files } = readDir(absPath)
|
|
100
|
+
if (!exists) return false
|
|
101
|
+
const glob = rule.data.glob || '.md'
|
|
102
|
+
const min = rule.data.min || 1
|
|
103
|
+
return files.filter(f => f.endsWith(glob)).length >= min
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============ 报错文案格式化 ============
|
|
107
|
+
|
|
108
|
+
/** failMessage 里的 ${path} 占位 → 替换为解析后的绝对路径(与旧 validator join(changeDir, file) 文案一致)。 */
|
|
109
|
+
function formatFail(failMessage, absPath) {
|
|
110
|
+
return failMessage && failMessage.includes('${path}')
|
|
111
|
+
? failMessage.replace('${path}', absPath)
|
|
112
|
+
: failMessage
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============ 主入口 ============
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 对某 stage 的全部纯 kind 规则跑判定。custom kind / target 基缺失 / enabled=false 的规则计入 skipped。
|
|
119
|
+
*
|
|
120
|
+
* @param {string} stage
|
|
121
|
+
* @param {{ changeDir?: string, docsRoot?: string, archiveDir?: string, specBase?: string }} ctx
|
|
122
|
+
* @param {{ readFile?: Function, readDir?: Function }} [io] 默认原生 fs;plan-postcheck 可注入 CRLF-normalizing reader
|
|
123
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], applied: string[], skipped: string[] }}
|
|
124
|
+
*/
|
|
125
|
+
export function evaluateRules(stage, ctx, io, opts = {}) {
|
|
126
|
+
const readFile = (io || DEFAULT_IO).readFile || defaultReadFile
|
|
127
|
+
const readDir = (io || DEFAULT_IO).readDir || defaultReadDir
|
|
128
|
+
const errors = []
|
|
129
|
+
const warnings = []
|
|
130
|
+
const applied = []
|
|
131
|
+
const skipped = []
|
|
132
|
+
|
|
133
|
+
for (const rule of getRulesFor(stage, { source: opts.source })) {
|
|
134
|
+
if (rule.enabled === false) { skipped.push(rule.id); continue }
|
|
135
|
+
if (isCustomKind(rule.kind)) { skipped.push(rule.id); continue }
|
|
136
|
+
const absPath = resolveTargetPath(rule.target, ctx)
|
|
137
|
+
if (absPath == null) { skipped.push(rule.id); continue }
|
|
138
|
+
|
|
139
|
+
const ok = rule.kind === 'dir-non-empty'
|
|
140
|
+
? checkDirNonEmpty(rule, absPath, readDir)
|
|
141
|
+
: dispatchPure(rule, readFile(absPath))
|
|
142
|
+
|
|
143
|
+
applied.push(rule.id)
|
|
144
|
+
if (!ok) {
|
|
145
|
+
(rule.severity === 'error' ? errors : warnings).push(formatFail(rule.failMessage, absPath))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ok: errors.length === 0, errors, warnings, applied, skipped }
|
|
150
|
+
}
|