sillyspec 3.25.0 → 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/db.js +28 -8
- package/src/hooks/worktree-guard.js +9 -2
- package/src/index.js +140 -11
- package/src/machine-interface.js +13 -7
- 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 +75 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +1088 -0
- package/src/run/complete-handlers.js +874 -0
- package/src/run/complete.js +725 -0
- package/src/run/gates.js +326 -0
- package/src/run/prompt.js +619 -0
- package/src/run/quick-audit.js +107 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +418 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4379
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +87 -150
- 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 +23 -11
- package/src/brainstorm-postcheck.js +0 -158
|
@@ -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
|
+
}
|