sillyspec 3.20.7 → 3.22.0
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/package.json +1 -1
- package/src/change-list.js +163 -52
- package/src/contract-matrix.js +67 -0
- package/src/quick-recommend.js +5 -5
- package/src/stages/execute.js +37 -18
- package/src/stages/plan-postcheck.js +214 -0
- package/src/stages/plan.js +13 -0
- package/docs/brainstorm-plan-contract.md +0 -64
- package/docs/plan-execute-contract.md +0 -123
- package/docs/platform-scan-protocol.md +0 -298
- package/docs/revision-mode.md +0 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -223
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -211
- package/docs/sillyspec/file-lifecycle.md +0 -143
- package/docs/workflow-contract-regression.md +0 -106
- package/docs/worktree-isolation.md +0 -252
- package/test/brainstorm-plan-contract.test.mjs +0 -273
- package/test/check-syntax.mjs +0 -26
- package/test/cli-top-level-aliases.test.mjs +0 -174
- package/test/contract-artifacts.test.mjs +0 -323
- package/test/decision-ref-version.mjs +0 -85
- package/test/decision-supersede.test.mjs +0 -277
- package/test/knowledge-match.test.mjs +0 -231
- package/test/plan-execute-contract.test.mjs +0 -357
- package/test/plan-optimization.test.mjs +0 -572
- package/test/platform-artifacts.test.mjs +0 -190
- package/test/platform-failure-samples.test.mjs +0 -199
- package/test/platform-recovery-chain.test.mjs +0 -179
- package/test/platform-recovery.test.mjs +0 -167
- package/test/platform-scan-p0.test.mjs +0 -186
- package/test/quick-recommend.test.mjs +0 -146
- package/test/revision-v1.test.mjs +0 -1145
- package/test/run-sanitize-project-name.test.mjs +0 -51
- package/test/run-scan-postcheck-fail.test.mjs +0 -64
- package/test/run-scan-project-parse.test.mjs +0 -200
- package/test/run-tests.mjs +0 -48
- package/test/runtime-cleanup-keeps-worktree.test.mjs +0 -107
- package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
- package/test/scan-knowledge.test.mjs +0 -175
- package/test/scan-paths.test.mjs +0 -68
- package/test/scan-postcheck-project-priority.test.mjs +0 -85
- package/test/scan-postcheck.test.mjs +0 -197
- package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
- package/test/spec-dir.test.mjs +0 -206
- package/test/stage-contract-failed-post-check.test.mjs +0 -102
- package/test/stage-contract.test.mjs +0 -301
- package/test/stage-definitions.test.mjs +0 -39
- package/test/wait-gates.test.mjs +0 -501
- package/test/workflow-spec-base.test.mjs +0 -142
- package/test/worktree-deps-provision.test.mjs +0 -148
- package/test/worktree-guard.test.mjs +0 -136
- package/test/worktree-native-overlay.test.mjs +0 -188
package/package.json
CHANGED
package/src/change-list.js
CHANGED
|
@@ -1,52 +1,163 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
import { readFileSync, existsSync } from 'fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 路径项归一化:去反引号、去行内括号注释(`src/foo.js (新增)` / `src/foo.js(说明)`)、
|
|
5
|
+
* 统一为正斜杠、去首尾空白与尾部斜杠。
|
|
6
|
+
* design 清单 cell 与 task allowed_paths 共用此归一化(两处写法都可能带注释/反引号)。
|
|
7
|
+
* @param {string} raw
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizePath(raw) {
|
|
11
|
+
if (!raw) return ''
|
|
12
|
+
return raw
|
|
13
|
+
.replace(/`/g, '')
|
|
14
|
+
.replace(/\s*([^)]*)\s*$/, '')
|
|
15
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
16
|
+
.replace(/\\/g, '/')
|
|
17
|
+
.replace(/\/+$/, '')
|
|
18
|
+
.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* glob → 正则:双星(globstar)跨零或多段目录(含零段,bash 语义)、
|
|
23
|
+
* 单星 → 单段([^/]*)、问号 → 单字符([^/]),其余转义。
|
|
24
|
+
* pattern 不含通配符时返回 false(调用方先用完全相等判断)。
|
|
25
|
+
* 不支持字符类(design/allowed_paths 实际不写,遇方括号按字面转义)。
|
|
26
|
+
* @param {string} str
|
|
27
|
+
* @param {string} pattern
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function globMatch(str, pattern) {
|
|
31
|
+
if (!/[*?]/.test(pattern)) return false
|
|
32
|
+
let re = '^'
|
|
33
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
34
|
+
const ch = pattern[i]
|
|
35
|
+
if (ch === '*') {
|
|
36
|
+
if (pattern[i + 1] === '*') {
|
|
37
|
+
// ** 跨零或多段:吞掉紧随的 /,输出 (?:.*/)? 让 src/**/a.js 也能匹配 src/a.js
|
|
38
|
+
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 2 }
|
|
39
|
+
else { re += '.*'; i++ }
|
|
40
|
+
} else {
|
|
41
|
+
re += '[^/]*'
|
|
42
|
+
}
|
|
43
|
+
} else if (ch === '?') {
|
|
44
|
+
re += '[^/]'
|
|
45
|
+
} else {
|
|
46
|
+
re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
re += '$'
|
|
50
|
+
try { return new RegExp(re).test(str) } catch { return false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 双向容差匹配:design 清单文件 vs task allowed_paths(或脏文件 vs 清单)。
|
|
55
|
+
* 命中条件(任一):完全相等 / 目录前缀包含(双向)/ glob 通配(双向)。
|
|
56
|
+
* @param {string} a
|
|
57
|
+
* @param {string} b
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function pathMatches(a, b) {
|
|
61
|
+
a = normalizePath(a)
|
|
62
|
+
b = normalizePath(b)
|
|
63
|
+
if (!a || !b) return false
|
|
64
|
+
if (a === b) return true
|
|
65
|
+
if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
|
|
66
|
+
if (globMatch(a, b) || globMatch(b, a)) return true
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 「文件变更清单」章节标题同义词(与 src/stage-contract.js 的识别集对齐,
|
|
72
|
+
* 避免两个校验器对「有没有清单」给出矛盾结论)。
|
|
73
|
+
*/
|
|
74
|
+
const FILE_LIST_SECTION_RE = /^#{2,3}\s*(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/im
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* exclude 子标题词集:「不修改/暂缓/保留」类 —— 其下路径从清单移除,不强制 task 覆盖。
|
|
78
|
+
* 用排除集而非白名单:容忍「改动/变更/涉及」等近义 include 写法。
|
|
79
|
+
*/
|
|
80
|
+
const EXCLUDE_SUBSECTION_RE = /不修改|不变|保留|无变更|未变更|不改动|暂不|暂缓|暂定|待定|后续|未改|无需|不涉及/
|
|
81
|
+
|
|
82
|
+
function isPlaceholder(p) {
|
|
83
|
+
return !p || p === '—' || p === '-' || /^(n\/?a|无|none|-+)$/i.test(p)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 表头列定位:cell 整体匹配路径类词 */
|
|
87
|
+
function isPathHeaderCell(c) {
|
|
88
|
+
return /^(文件路径|文件名|文件|路径|filepath|filename|file\s*path|path)$/i.test(c)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 从 design.md 解析文件变更清单。兼容两种真实写法:
|
|
93
|
+
* ① 表格:`| 操作 | 文件路径 | 说明 |`(brainstorm 模板默认)
|
|
94
|
+
* ② 分类列表:`### 新增文件` / `### 修改文件` / `### 不修改文件` 下的 `- path`
|
|
95
|
+
* 表头列顺序自适应(定位「文件/路径/file/path」列,列顺序写反时不会把操作名当路径);
|
|
96
|
+
* 忽略 `.sillyspec/` 与占位符(`—`/`-`/`N/A`/`无`);「不修改/暂缓」子段下的路径会被排除;
|
|
97
|
+
* CRLF 容错。
|
|
98
|
+
* @param {string} designMdPath - design.md 文件路径
|
|
99
|
+
* @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js")
|
|
100
|
+
*/
|
|
101
|
+
export function parseFileChangeList(designMdPath) {
|
|
102
|
+
const result = new Set()
|
|
103
|
+
if (!designMdPath || !existsSync(designMdPath)) return result
|
|
104
|
+
|
|
105
|
+
const content = readFileSync(designMdPath, 'utf8').replace(/\r\n/g, '\n')
|
|
106
|
+
|
|
107
|
+
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
108
|
+
if (!sectionMatch) return result
|
|
109
|
+
|
|
110
|
+
// 从标题后开始,截取到下一个 ## 标题或文件末尾
|
|
111
|
+
const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length)
|
|
112
|
+
const nextSectionMatch = afterSection.match(/^##\s/m)
|
|
113
|
+
const relevantContent = nextSectionMatch
|
|
114
|
+
? afterSection.slice(0, nextSectionMatch.index)
|
|
115
|
+
: afterSection
|
|
116
|
+
|
|
117
|
+
const lines = relevantContent.split('\n')
|
|
118
|
+
let headerSkipped = false
|
|
119
|
+
let pathColIdx = 1 // 默认第 2 列;解析表头后可重定位
|
|
120
|
+
let listMode = 'include' // include | exclude(分类列表子段)
|
|
121
|
+
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
// 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件
|
|
124
|
+
const subHeader = line.match(/^###\s+(.+?)\s*$/)
|
|
125
|
+
if (subHeader) {
|
|
126
|
+
listMode = EXCLUDE_SUBSECTION_RE.test(subHeader[1]) ? 'exclude' : 'include'
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 表格行
|
|
131
|
+
if (line.startsWith('|')) {
|
|
132
|
+
if (/^\|[-:\s|]+\|$/.test(line)) continue // 分隔行
|
|
133
|
+
const cells = line.split('|').slice(1, -1).map(c => c.trim())
|
|
134
|
+
if (cells.length < 2) continue
|
|
135
|
+
|
|
136
|
+
if (!headerSkipped) {
|
|
137
|
+
headerSkipped = true
|
|
138
|
+
// 表头列定位:找「文件/路径/file/path」列,找不到保持默认第 2 列
|
|
139
|
+
const idx = cells.findIndex(isPathHeaderCell)
|
|
140
|
+
if (idx >= 0) pathColIdx = idx
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath = normalizePath(cells[pathColIdx] || '')
|
|
145
|
+
// 列定位兜底:取到纯操作词(表头未命中且列顺序异常)→ 跳过,避免把「修改」当路径
|
|
146
|
+
if (/^(新增|修改|删除|重命名|new|modify|update|delete|create|rename)$/i.test(filePath)) continue
|
|
147
|
+
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
148
|
+
result.add(filePath)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 分类列表项:`- path` / `- \`path\``
|
|
153
|
+
const listItem = line.match(/^\s*-\s+(.+)/)
|
|
154
|
+
if (listItem) {
|
|
155
|
+
const filePath = normalizePath(listItem[1])
|
|
156
|
+
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
157
|
+
if (listMode === 'exclude') result.delete(filePath)
|
|
158
|
+
else result.add(filePath)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
}
|
package/src/contract-matrix.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
scanFrontendApiCalls,
|
|
16
16
|
normalizePath,
|
|
17
17
|
} from './endpoint-extractor.js'
|
|
18
|
+
import { parseTaskContracts } from './stages/plan-postcheck.js'
|
|
18
19
|
|
|
19
20
|
// ─── 关键词检测 ─────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -218,6 +219,72 @@ export function buildConsumerInjection(changeDir, specBase, taskName, contracts)
|
|
|
218
219
|
return parts.join('\n')
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 为 consumer task 构建字段级契约注入:对比 expects_from.needs vs provider.provides.fields
|
|
224
|
+
*
|
|
225
|
+
* 让 consumer 子代理带着明确的字段清单核验上游产出:
|
|
226
|
+
* - provider 已承诺 → 编码时只使用 provides.fields,运行时缺字段上报
|
|
227
|
+
* - provider 未承诺某 needs 字段 → 标 CONTRACT_GAP,要求 stop and report(禁止 fallback 编造)
|
|
228
|
+
*
|
|
229
|
+
* 命中场景:provider task 漏实现某字段(如 DaemonRuntimeRead 缺 daemon_instance_id),
|
|
230
|
+
* consumer 若 fallback 编造 → 运行时 403/500。此处把"缺字段"暴露在子代理启动前。
|
|
231
|
+
*
|
|
232
|
+
* 注:plan-postcheck 已对账 expects_from↔provides,此处是 execute 时的二次保险,
|
|
233
|
+
* 拦截 plan-postcheck 之后 task 文件被手改、或 provider 实际实现漏字段的情况。
|
|
234
|
+
*
|
|
235
|
+
* @param {string} changeDir - changes/<name>/ 目录
|
|
236
|
+
* @param {string} taskName - consumer task(如 task-11)
|
|
237
|
+
* @returns {string|null} 注入文本,无 expects_from 时返回 null
|
|
238
|
+
*/
|
|
239
|
+
export function buildContractFieldInjection(changeDir, taskName) {
|
|
240
|
+
const consumerFile = join(changeDir, 'tasks', `${taskName}.md`)
|
|
241
|
+
if (!existsSync(consumerFile)) return null
|
|
242
|
+
const { expectsFrom } = parseTaskContracts(readFileSync(consumerFile, 'utf8'))
|
|
243
|
+
const providers = Object.keys(expectsFrom)
|
|
244
|
+
if (providers.length === 0) return null
|
|
245
|
+
|
|
246
|
+
const lines = []
|
|
247
|
+
lines.push('## Upstream Contract Fields(字段级核验)')
|
|
248
|
+
lines.push('')
|
|
249
|
+
|
|
250
|
+
for (const providerTask of providers) {
|
|
251
|
+
const providerFile = join(changeDir, 'tasks', `${providerTask}.md`)
|
|
252
|
+
let providerProvides = []
|
|
253
|
+
if (existsSync(providerFile)) {
|
|
254
|
+
providerProvides = parseTaskContracts(readFileSync(providerFile, 'utf8')).provides
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const c of expectsFrom[providerTask]) {
|
|
258
|
+
const providerEntry = providerProvides.find(p => p.contract === c.contract)
|
|
259
|
+
if (!providerEntry) {
|
|
260
|
+
lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
|
|
261
|
+
lines.push(`你需要字段 [${c.needs.join(', ')}],但 ${providerTask} 的 provides 未声明此契约。`)
|
|
262
|
+
lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先确认 ${providerTask} 是否应产出此契约。`)
|
|
263
|
+
} else {
|
|
264
|
+
const providerFields = new Set(providerEntry.fields)
|
|
265
|
+
const missing = c.needs.filter(f => !providerFields.has(f))
|
|
266
|
+
if (missing.length > 0) {
|
|
267
|
+
lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
|
|
268
|
+
lines.push(`你需要字段 [${missing.join(', ')}],但 ${providerTask}.provides 仅承诺 [${providerEntry.fields.join(', ')}]。`)
|
|
269
|
+
lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先要求 ${providerTask} 在 provides.fields 补上 [${missing.join(', ')}]。`)
|
|
270
|
+
} else {
|
|
271
|
+
lines.push(`### ✅ ${providerTask} → ${c.contract}`)
|
|
272
|
+
lines.push(`你需要的字段 [${c.needs.join(', ')}] 均在 ${providerTask}.provides 承诺内:[${providerEntry.fields.join(', ')}]。`)
|
|
273
|
+
lines.push(`编码时**只使用上述字段**;若运行时实际返回缺字段,说明 provider 实现漏了 → 上报 CONTRACT_GAP,不要 fallback。`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
lines.push('')
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push('### 字段级铁律')
|
|
281
|
+
lines.push('1. 禁止 fallback 编造:若上游返回缺字段,停止并上报 CONTRACT_GAP,不要用 `x || defaultValue` 之类的防御性回退掩盖契约破裂。')
|
|
282
|
+
lines.push('2. 只消费 provides 承诺的字段;需要新字段必须先让 provider 更新 provides。')
|
|
283
|
+
lines.push('3. 启动子代理前,先读 provider task 的 review.json / acceptance,确认其已声明完成上述契约字段。')
|
|
284
|
+
|
|
285
|
+
return lines.join('\n')
|
|
286
|
+
}
|
|
287
|
+
|
|
221
288
|
// ─── Verify 阶段:parity check ──────────────────────────────────────────
|
|
222
289
|
|
|
223
290
|
/**
|
package/src/quick-recommend.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { existsSync, readFileSync } from 'fs'
|
|
11
|
-
import { parseFileChangeList } from './change-list.js'
|
|
11
|
+
import { parseFileChangeList, pathMatches } from './change-list.js'
|
|
12
12
|
|
|
13
13
|
function escapeRegex(s) {
|
|
14
14
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
@@ -60,13 +60,13 @@ function readProposalContext(specDir, changeName) {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* 判断单个脏文件是否命中 design.md 声明的文件清单。
|
|
63
|
-
*
|
|
63
|
+
* 复用 change-list.js 的 pathMatches:完全相等 / 目录前缀(双向)/ glob 通配(双向),
|
|
64
|
+
* 与 plan-postcheck 的文件覆盖对账共用同一套匹配语义;并修复了 design 清单含 glob
|
|
65
|
+
* (如 `src/stages/scan/*.md`)时纯前缀匹配漏召回的问题。
|
|
64
66
|
*/
|
|
65
67
|
function fileHitsList(dirtyFile, fileList) {
|
|
66
|
-
if (fileList.has(dirtyFile)) return true
|
|
67
68
|
for (const declared of fileList) {
|
|
68
|
-
if (declared
|
|
69
|
-
if (dirtyFile.startsWith(declared + '/') || declared.startsWith(dirtyFile + '/')) return true
|
|
69
|
+
if (pathMatches(dirtyFile, declared)) return true
|
|
70
70
|
}
|
|
71
71
|
return false
|
|
72
72
|
}
|
package/src/stages/execute.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
|
+
import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 校验 plan.md 是否满足 execute 执行契约
|
|
@@ -430,42 +431,60 @@ function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
|
430
431
|
let contractInjection = ''
|
|
431
432
|
if (changeDir) {
|
|
432
433
|
try {
|
|
433
|
-
const { buildContractMatrix, buildConsumerInjection } = require('../contract-matrix.js')
|
|
434
434
|
const planFile = path.join(changeDir, 'plan.md')
|
|
435
435
|
if (existsSync(planFile)) {
|
|
436
436
|
const planContent = readFileSync(planFile, 'utf8')
|
|
437
|
+
// 收集本 wave 所有 task(端点注入与字段注入共用)
|
|
438
|
+
const waveTasks = wave.tasks.map((t, ti) => {
|
|
439
|
+
const num = String(t.index || (ti + 1)).padStart(2, '0')
|
|
440
|
+
return `task-${num}`
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// 1) 端点级契约(provider/consumer via buildContractMatrix)
|
|
437
444
|
const contracts = buildContractMatrix(planContent, changeDir)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const num = String(t.index || (ti + 1)).padStart(2, '0')
|
|
442
|
-
return `task-${num}`
|
|
443
|
-
})
|
|
444
|
-
const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
|
|
445
|
-
if (relevantContracts.length > 0) {
|
|
446
|
-
contractInjection = `
|
|
445
|
+
const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
|
|
446
|
+
if (relevantContracts.length > 0) {
|
|
447
|
+
contractInjection = `
|
|
447
448
|
### API Contract Matrix
|
|
448
449
|
本 Wave 存在前端/后端跨 task 契约:
|
|
449
450
|
${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
|
|
450
451
|
`
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
### 子代理 ${taskName} 的契约注入
|
|
452
|
+
for (const taskName of waveTasks) {
|
|
453
|
+
const injection = buildConsumerInjection(changeDir, path.join(changeDir, '..', '..'), taskName, contracts)
|
|
454
|
+
if (injection) {
|
|
455
|
+
contractInjection += `
|
|
456
|
+
### 子代理 ${taskName} 的端点契约注入
|
|
457
457
|
为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
|
|
458
458
|
|
|
459
459
|
<contract-injection>
|
|
460
460
|
${injection}
|
|
461
461
|
</contract-injection>
|
|
462
462
|
`
|
|
463
|
-
}
|
|
464
463
|
}
|
|
465
464
|
}
|
|
466
465
|
}
|
|
466
|
+
|
|
467
|
+
// 2) 字段级契约(expects_from ↔ provides)
|
|
468
|
+
// 命中「provider 漏字段、consumer fallback 编造 → 运行时 403/500」这类 bug:
|
|
469
|
+
// 把 consumer 期望字段 vs provider 承诺字段显式注入子代理 prompt
|
|
470
|
+
for (const taskName of waveTasks) {
|
|
471
|
+
const fi = buildContractFieldInjection(changeDir, taskName)
|
|
472
|
+
if (fi) {
|
|
473
|
+
contractInjection += `
|
|
474
|
+
### 子代理 ${taskName} 的字段契约注入
|
|
475
|
+
为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
|
|
476
|
+
|
|
477
|
+
<contract-field-injection>
|
|
478
|
+
${fi}
|
|
479
|
+
</contract-field-injection>
|
|
480
|
+
`
|
|
481
|
+
}
|
|
482
|
+
}
|
|
467
483
|
}
|
|
468
|
-
} catch {
|
|
484
|
+
} catch (e) {
|
|
485
|
+
// 契约注入是 best-effort:失败不阻断 execute,只记录
|
|
486
|
+
console.warn(` ⚠️ 契约注入跳过: ${e?.message || e}`)
|
|
487
|
+
}
|
|
469
488
|
}
|
|
470
489
|
|
|
471
490
|
// 构建任务摘要(不再内联完整蓝图,减少上下文污染)
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
13
13
|
import { join as pJoin } from 'path'
|
|
14
|
+
import jsYaml from 'js-yaml'
|
|
15
|
+
import { parseFileChangeList, pathMatches } from '../change-list.js'
|
|
14
16
|
|
|
15
17
|
// ═══════════════════════════════════════════════════════════════
|
|
16
18
|
// 解析工具(从 plan.js 迁移)
|
|
@@ -90,6 +92,47 @@ function hasTddOrVerify(content) {
|
|
|
90
92
|
return /##\s*TDD/.test(content) || /##\s*验证/.test(content) || /##\s*Verify/.test(content)
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 解析 task-NN.md 的跨任务契约字段 provides / expects_from
|
|
97
|
+
* 用 js-yaml 解析 frontmatter(嵌套结构,正则不可靠)。
|
|
98
|
+
* provides/expects_from 为可选字段;缺失或解析失败时返回空(不阻断)。
|
|
99
|
+
* @param {string} content - task 文件内容
|
|
100
|
+
* @returns {{ provides: Array<{contract, fields}>, expectsFrom: Record<string, Array<{contract, needs}>> }}
|
|
101
|
+
*/
|
|
102
|
+
export function parseTaskContracts(content) {
|
|
103
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
104
|
+
if (!fmMatch) return { provides: [], expectsFrom: {} }
|
|
105
|
+
let fm
|
|
106
|
+
try {
|
|
107
|
+
fm = jsYaml.load(fmMatch[1]) || {}
|
|
108
|
+
} catch {
|
|
109
|
+
// frontmatter 非合法 YAML(老格式/手写不规范)—— 当作无契约字段,不阻断
|
|
110
|
+
return { provides: [], expectsFrom: {} }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const provides = Array.isArray(fm.provides)
|
|
114
|
+
? fm.provides.map(p => ({
|
|
115
|
+
contract: String(p?.contract || ''),
|
|
116
|
+
fields: Array.isArray(p?.fields) ? p.fields.map(String) : [],
|
|
117
|
+
})).filter(p => p.contract)
|
|
118
|
+
: []
|
|
119
|
+
|
|
120
|
+
const expectsFrom = {}
|
|
121
|
+
if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
|
|
122
|
+
for (const [providerTask, contracts] of Object.entries(fm.expects_from)) {
|
|
123
|
+
if (!Array.isArray(contracts)) continue
|
|
124
|
+
expectsFrom[providerTask] = contracts
|
|
125
|
+
.map(c => ({
|
|
126
|
+
contract: String(c?.contract || ''),
|
|
127
|
+
needs: Array.isArray(c?.needs) ? c.needs.map(String) : [],
|
|
128
|
+
}))
|
|
129
|
+
.filter(c => c.contract)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { provides, expectsFrom }
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
// ═══════════════════════════════════════════════════════════════
|
|
94
137
|
// 核心逻辑
|
|
95
138
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -222,6 +265,151 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
222
265
|
return { ok: errors.length === 0, errors, warnings }
|
|
223
266
|
}
|
|
224
267
|
|
|
268
|
+
/**
|
|
269
|
+
* 跨任务契约校验器:对账 consumer.expects_from ↔ provider.provides
|
|
270
|
+
*
|
|
271
|
+
* 修复场景:consumer task 声明期望 provider 产出某 DTO 的某字段,
|
|
272
|
+
* 但 provider 的 provides 未承诺(或字段缺失)→ plan 阶段阻断,
|
|
273
|
+
* 避免到 execute/verify 才暴露(典型表现:前端 fallback 错误字段 → 403/500)。
|
|
274
|
+
*
|
|
275
|
+
* provides / expects_from 均为可选字段:未声明时不校验(向后兼容)。
|
|
276
|
+
* @param {string} changeDir - 变更目录
|
|
277
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[] }}
|
|
278
|
+
*/
|
|
279
|
+
export function validateCrossTaskContracts(changeDir) {
|
|
280
|
+
const errors = []
|
|
281
|
+
const warnings = []
|
|
282
|
+
|
|
283
|
+
const tasksDir = pJoin(changeDir, 'tasks')
|
|
284
|
+
if (!existsSync(tasksDir)) {
|
|
285
|
+
return { ok: true, errors, warnings }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
289
|
+
if (taskFiles.length === 0) {
|
|
290
|
+
return { ok: true, errors, warnings }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 第一遍:收集每个 task 的 provides(taskId → Map(contract → Set(fields)))
|
|
294
|
+
const providesByTask = new Map()
|
|
295
|
+
for (const file of taskFiles) {
|
|
296
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
297
|
+
const taskId = parseTaskId(content, file)
|
|
298
|
+
if (!taskId) continue
|
|
299
|
+
const { provides } = parseTaskContracts(content)
|
|
300
|
+
const contractMap = new Map()
|
|
301
|
+
for (const p of provides) {
|
|
302
|
+
contractMap.set(p.contract, new Set(p.fields))
|
|
303
|
+
}
|
|
304
|
+
providesByTask.set(taskId, contractMap)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖
|
|
308
|
+
for (const file of taskFiles) {
|
|
309
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
310
|
+
const consumerId = parseTaskId(content, file)
|
|
311
|
+
if (!consumerId) continue
|
|
312
|
+
const { expectsFrom } = parseTaskContracts(content)
|
|
313
|
+
|
|
314
|
+
for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
|
|
315
|
+
if (!providesByTask.has(providerTask)) {
|
|
316
|
+
for (const c of contracts) {
|
|
317
|
+
errors.push(`${consumerId}: expects_from 引用了不存在的 ${providerTask}(contract "${c.contract}", needs [${c.needs.join(', ')}])`)
|
|
318
|
+
}
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const providerContracts = providesByTask.get(providerTask)
|
|
323
|
+
for (const c of contracts) {
|
|
324
|
+
const providerFields = providerContracts.get(c.contract)
|
|
325
|
+
if (providerFields === undefined) {
|
|
326
|
+
errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${c.needs.join(', ')}] — ${providerTask} 的 provides 未声明此契约`)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const missing = c.needs.filter(f => !providerFields.has(f))
|
|
330
|
+
if (missing.length > 0) {
|
|
331
|
+
errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${missing.join(', ')}] — ${providerTask}.provides 仅含 [${[...providerFields].join(', ')}]`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
|
|
341
|
+
// design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* design.md 文件变更清单 → tasks allowed_paths 覆盖对账
|
|
345
|
+
*
|
|
346
|
+
* 修复场景:design.md 声明要改某源码文件(如 access-guide UI),但没有任何 task 的
|
|
347
|
+
* allowed_paths 覆盖它 → execute 子代理被 allowed_paths 锁死不能碰它 → UI 没改
|
|
348
|
+
* (「页面还是错的」),而 verify 一路对照 task 卡片循环验证照样 PASS。
|
|
349
|
+
*
|
|
350
|
+
* 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
|
|
351
|
+
* 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
|
|
352
|
+
*
|
|
353
|
+
* fail-open 边界(不阻断):design.md 不存在、无 task 卡片(none 级别)、
|
|
354
|
+
* task 卡片均无 allowed_paths(由 validatePlanFeasibility 把关)。
|
|
355
|
+
* 阻断边界:有 task 卡片但 design 缺清单章节(light/full 已生成 task → design 必有清单)、
|
|
356
|
+
* 清单文件未被任何 task 的 allowed_paths 覆盖。
|
|
357
|
+
*
|
|
358
|
+
* @param {string} changeDir - 变更目录
|
|
359
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], designFiles: string[], uncovered: string[] }}
|
|
360
|
+
*/
|
|
361
|
+
export function validateDesignFileCoverage(changeDir) {
|
|
362
|
+
const errors = []
|
|
363
|
+
const warnings = []
|
|
364
|
+
|
|
365
|
+
const designPath = pJoin(changeDir, 'design.md')
|
|
366
|
+
if (!existsSync(designPath)) {
|
|
367
|
+
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const tasksDir = pJoin(changeDir, 'tasks')
|
|
371
|
+
const taskFiles = existsSync(tasksDir)
|
|
372
|
+
? readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
373
|
+
: []
|
|
374
|
+
|
|
375
|
+
// 无 task 卡片(plan_level=none 或老变更)→ 无对账对象,fail-open
|
|
376
|
+
if (taskFiles.length === 0) {
|
|
377
|
+
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const designFiles = [...parseFileChangeList(designPath)]
|
|
381
|
+
if (designFiles.length === 0) {
|
|
382
|
+
// 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
|
|
383
|
+
// 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
|
|
384
|
+
errors.push(
|
|
385
|
+
'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。' +
|
|
386
|
+
'该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。'
|
|
387
|
+
)
|
|
388
|
+
return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
|
|
389
|
+
}
|
|
390
|
+
const allAllowed = []
|
|
391
|
+
for (const file of taskFiles) {
|
|
392
|
+
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
393
|
+
allAllowed.push(...parseAllowedPaths(content))
|
|
394
|
+
}
|
|
395
|
+
if (allAllowed.length === 0) {
|
|
396
|
+
return { ok: true, errors, warnings, designFiles, uncovered: [] }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
|
|
400
|
+
if (uncovered.length > 0) {
|
|
401
|
+
errors.push(
|
|
402
|
+
`design.md 文件变更清单中 ${uncovered.length} 个文件未被任何 task 的 allowed_paths 覆盖:\n` +
|
|
403
|
+
uncovered.map(f => ` • ${f}`).join('\n') +
|
|
404
|
+
`\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。` +
|
|
405
|
+
`\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,` +
|
|
406
|
+
`或在 design.md「不修改文件」章节说明不改原因。`
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
|
|
411
|
+
}
|
|
412
|
+
|
|
225
413
|
/**
|
|
226
414
|
* Plan 可行性校验器(本地代码证明 execute 前置条件)
|
|
227
415
|
* 检查 TaskCard 的完整性和可行性
|
|
@@ -426,6 +614,32 @@ export async function executePlanPostcheck(context) {
|
|
|
426
614
|
for (const w of feasibility.warnings) console.warn(` - ${w}`)
|
|
427
615
|
}
|
|
428
616
|
|
|
617
|
+
// ── 1c. 跨任务契约校验 ──
|
|
618
|
+
// 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
|
|
619
|
+
// 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
|
|
620
|
+
const crossTask = validateCrossTaskContracts(changeDir)
|
|
621
|
+
if (crossTask.errors.length > 0) {
|
|
622
|
+
console.error('\n❌ 跨任务契约校验失败(consumer 期望的字段未被 provider 承诺):')
|
|
623
|
+
for (const err of crossTask.errors) console.error(` - ${err}`)
|
|
624
|
+
console.error('\n 修复方式:要么在 provider task 的 provides.fields 补上缺失字段,')
|
|
625
|
+
console.error(' 要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。')
|
|
626
|
+
throw new Error('planPostcheck: cross-task contract check failed')
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── 1d. design 文件覆盖对账 ──
|
|
630
|
+
// design.md 清单中的每个源码文件必须被某 task 的 allowed_paths 覆盖,
|
|
631
|
+
// 否则 execute 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。
|
|
632
|
+
const coverage = validateDesignFileCoverage(changeDir)
|
|
633
|
+
for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
|
|
634
|
+
if (coverage.errors.length > 0) {
|
|
635
|
+
console.error('\n❌ design.md 文件覆盖对账失败(清单中的文件未被任何 task 覆盖):')
|
|
636
|
+
for (const err of coverage.errors) console.error(` - ${err}`)
|
|
637
|
+
throw new Error('planPostcheck: design file coverage check failed')
|
|
638
|
+
}
|
|
639
|
+
if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
|
|
640
|
+
console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
|
|
641
|
+
}
|
|
642
|
+
|
|
429
643
|
// ── 2. Wave 重排 ──
|
|
430
644
|
const tasksDir = pJoin(changeDir, 'tasks')
|
|
431
645
|
if (existsSync(tasksDir)) {
|