sillyspec 3.25.1 → 3.25.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-doctor/SKILL.md +9 -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 +14 -4
- package/src/run/command.js +127 -50
- package/src/run/complete-handlers.js +13 -3
- package/src/run/complete.js +725 -695
- package/src/run/gates.js +326 -321
- package/src/run/prompt.js +633 -604
- package/src/run/quick-audit.js +107 -102
- package/src/run/shared.js +78 -23
- package/src/run.js +2 -2
- 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 +97 -2
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +99 -30
- 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 +10 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +11 -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'
|
|
@@ -145,6 +188,38 @@ export function parsePorcelainPath(line) {
|
|
|
145
188
|
return path.replace(/\\/g, '/')
|
|
146
189
|
}
|
|
147
190
|
|
|
191
|
+
/**
|
|
192
|
+
* quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计 与 QUICKLOG「文件:」行回填
|
|
193
|
+
* 单源)。回填文件行时复用:review.changedFiles 含这些元数据时过滤掉,只留真实业务文件。
|
|
194
|
+
*
|
|
195
|
+
* 归类口径:
|
|
196
|
+
* - quicklog/.runtime/modules/_module-map/knowledge-uncategorized 等 quick 自身产物 → 元数据
|
|
197
|
+
* - .sillyspec/changes/:quick 自己没有该目录,其下文件要么是关联变更(reverse-sync),
|
|
198
|
+
* 要么是并发他者会话的工作。非关联变更目录整体视为元数据放行;关联变更文件不算(属本 quick 真实改动)
|
|
199
|
+
*
|
|
200
|
+
* 「并发工作 vs 偷建变更」的意图软判定留给 sillyhub,确定性校验只做路径归类。
|
|
201
|
+
*
|
|
202
|
+
* @param {string} p 文件路径(容错 \\ / 混用)
|
|
203
|
+
* @param {string[]} [linkedChanges] 关联变更名列表(影响 changes/ 归类)
|
|
204
|
+
* @returns {boolean} true=quick 元数据,应从业务文件列表过滤掉
|
|
205
|
+
*/
|
|
206
|
+
export function isQuickMetadata(p, linkedChanges = []) {
|
|
207
|
+
const normalizeGitPath = (x) => String(x).replace(/\\/g, '/')
|
|
208
|
+
const file = normalizeGitPath(p)
|
|
209
|
+
if (file.startsWith('.sillyspec/quicklog/')
|
|
210
|
+
|| file.startsWith('.sillyspec/.runtime/')
|
|
211
|
+
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
212
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
213
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
|
|
214
|
+
if (file.startsWith('.sillyspec/changes/')) {
|
|
215
|
+
const linkedChangeNames = new Set((Array.isArray(linkedChanges) ? linkedChanges : []).map(c => normalizeGitPath(c)))
|
|
216
|
+
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
217
|
+
// bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)或具体但非关联的变更 → 放行
|
|
218
|
+
if (!m || !linkedChangeNames.has(m[1])) return true
|
|
219
|
+
}
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
|
|
148
223
|
/**
|
|
149
224
|
* quick 完成审计:对比 baseline 与实际变更。
|
|
150
225
|
* @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[] }}
|
|
@@ -172,26 +247,6 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
172
247
|
const f = normalizeGitPath(p)
|
|
173
248
|
return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
|
|
174
249
|
}
|
|
175
|
-
// quick 自己没有 .sillyspec/changes/ 目录——该路径下任何文件要么是关联变更(reverse-sync),
|
|
176
|
-
// 要么是并发其他会话的变更工作。多会话并行时别人的 changes/ 不应被本 quick 审计拦截
|
|
177
|
-
// (确定性校验无法区分「并发工作」与「本 quick 偷建变更」,后者这类意图软判定留给 sillyhub)。
|
|
178
|
-
// 故:非关联变更目录整体放行;关联变更的文件仍走正常审计(reverse-sync 可见,需 --force-baseline)。
|
|
179
|
-
const linkedChangeNames = new Set((Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []).map(c => normalizeGitPath(c)))
|
|
180
|
-
const isQuickMetadata = (p) => {
|
|
181
|
-
const file = normalizeGitPath(p)
|
|
182
|
-
if (file.startsWith('.sillyspec/quicklog/')
|
|
183
|
-
|| file.startsWith('.sillyspec/.runtime/')
|
|
184
|
-
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
185
|
-
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
186
|
-
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
|
|
187
|
-
// .sillyspec/changes/ 下:bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)
|
|
188
|
-
// 或具体但【非关联】的变更 → 都属并发其他会话工作,放行;关联变更文件仍走审计。
|
|
189
|
-
if (file.startsWith('.sillyspec/changes/')) {
|
|
190
|
-
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
191
|
-
if (!m || !linkedChangeNames.has(m[1])) return true
|
|
192
|
-
}
|
|
193
|
-
return false
|
|
194
|
-
}
|
|
195
250
|
const DANGEROUS_PATTERNS = [
|
|
196
251
|
'package.json',
|
|
197
252
|
'package-lock.json',
|
|
@@ -226,7 +281,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
226
281
|
}
|
|
227
282
|
|
|
228
283
|
// 检查危险文件(除非 force-baseline)
|
|
229
|
-
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
|
|
284
|
+
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file, guard.linkedChanges) && !forceBaseline) {
|
|
230
285
|
result.reasons.push(`危险文件变更: ${file}`)
|
|
231
286
|
}
|
|
232
287
|
|
|
@@ -250,7 +305,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
250
305
|
// 检查 new files(除非 allow-new)
|
|
251
306
|
if (!allowNew) {
|
|
252
307
|
for (const f of result.newFiles) {
|
|
253
|
-
if (!isQuickMetadata(f)) {
|
|
308
|
+
if (!isQuickMetadata(f, guard.linkedChanges)) {
|
|
254
309
|
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
255
310
|
}
|
|
256
311
|
}
|
|
@@ -259,7 +314,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
259
314
|
// 检查 allowedFiles 范围
|
|
260
315
|
if (allowedFiles.length > 0) {
|
|
261
316
|
for (const f of result.changedFiles) {
|
|
262
|
-
if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
|
|
317
|
+
if (!allowedFiles.includes(f) && !isQuickMetadata(f, guard.linkedChanges)) {
|
|
263
318
|
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
264
319
|
}
|
|
265
320
|
}
|
package/src/run.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
// W6 Step8c: 主命令分发(runCommand)+ auto 模式 + ensureStageSteps 搬至 ./run/command.js
|
|
14
14
|
export { runCommand, ensureStageSteps } from './run/command.js'
|
|
15
|
-
// barrel re-export: parsePorcelainPath + auditQuickCompletion 被 test 直接 import(契约保留)
|
|
16
|
-
export { parsePorcelainPath, auditQuickCompletion } from './run/shared.js'
|
|
15
|
+
// barrel re-export: parsePorcelainPath + auditQuickCompletion + isQuickMetadata 被 test 直接 import(契约保留)
|
|
16
|
+
export { parsePorcelainPath, auditQuickCompletion, isQuickMetadata } from './run/shared.js'
|
|
17
17
|
// barrel re-export: applyRootPlaceholders 被 test/prompt-placeholders.test.mjs 直接 import(契约保留)
|
|
18
18
|
export { applyRootPlaceholders } from './run/prompt.js'
|
|
19
19
|
// barrel re-export: sanitizeProjectName + validateParsedProjects 被 test 直接 import(随 handleScan 搬走,契约保留)
|
|
@@ -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
|
+
}
|