sillyspec 3.26.10 → 3.26.13

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.
Files changed (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,379 +1,388 @@
1
- /**
2
- * contract-matrix.js — API Contract Matrix 生成与注入
3
- *
4
- * plan 阶段:识别 task 之间的 provider/consumer 关系,生成契约矩阵
5
- * execute 阶段:
6
- * - 后端 task 完成后自动提取 endpoint artifact
7
- * - 前端 task 开始时注入上游契约
8
- * verify 阶段:读取 artifact 做 parity check
9
- */
10
-
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
- import { join, resolve, basename, relative } from 'path'
13
- import {
14
- scanBackendEndpoints,
15
- scanFrontendApiCalls,
16
- normalizePath,
17
- diffApiParity,
18
- } from './endpoint-extractor.js'
19
- import { parseTaskContracts } from './stages/plan-postcheck.js'
20
-
21
- // ─── 关键词检测 ─────────────────────────────────────────────────────────
22
-
23
- const PROVIDER_KEYWORDS = /router|routes|endpoint|api|backend|controller|fastapi|flask|express|koa|spring/i
24
- const CONSUMER_KEYWORDS = /frontend|client|service|apiFetch|request|fetch|axios|http/i
25
-
26
- /**
27
- * 判断一个 task 文档是 provider(产出 API)还是 consumer(消费 API)
28
- * @param {string} taskContent - task markdown 内容
29
- * @returns {{ isProvider: boolean, isConsumer: boolean, confidence: number }}
30
- */
31
- export function classifyTask(taskContent) {
32
- const isProvider = PROVIDER_KEYWORDS.test(taskContent)
33
- const isConsumer = CONSUMER_KEYWORDS.test(taskContent)
34
- // 避免所有 task 都被标记(因为几乎所有 task 都含 "api")
35
- // 加强判定:provider 要命中 router/endpoint/backend/controller + api
36
- // consumer 要命中 frontend/client/apiFetch + api
37
- const providerStrong = /router|endpoint|backend|controller|fastapi|flask/i.test(taskContent)
38
- const consumerStrong = /frontend|apiFetch|axios|api.*client/i.test(taskContent)
39
- const providerConfidence = providerStrong ? 0.8 : (isProvider ? 0.4 : 0)
40
- const consumerConfidence = consumerStrong ? 0.8 : (isConsumer ? 0.4 : 0)
41
- return {
42
- isProvider: providerConfidence >= 0.4,
43
- isConsumer: consumerConfidence >= 0.4,
44
- confidence: Math.max(providerConfidence, consumerConfidence),
45
- }
46
- }
47
-
48
- /**
49
- * 从 plan.md 解析 task 依赖关系,识别 provider → consumer 对
50
- * @param {string} planContent - plan.md 内容
51
- * @param {string} changeDir - changes/<name>/ 目录
52
- * @returns {Array<{ provider: string, consumer: string, type: string }>}
53
- */
54
- export function buildContractMatrix(planContent, changeDir) {
55
- const contracts = []
56
-
57
- // 解析 task 依赖关系
58
- // 格式: - [ ] task-04: ... (depends_on: [task-01]) 或
59
- // | task-04 | ... | 01 |
60
- const taskDeps = parseTaskDependencies(planContent)
61
-
62
- // 读取各 task 文档,分类 provider/consumer
63
- // classify consumers(taskDeps keys)+ providers(deps 值)。
64
- // 旧实现只 classify consumers → provider task 不在 keys 里 → providerClass 恒 undefined
65
- // → contracts 恒空 → 端点契约管线(buildConsumerInjection / extractArtifactsForChange)全失效。
66
- const taskClasses = {}
67
- const allTaskNames = new Set([...Object.keys(taskDeps), ...Object.values(taskDeps).flat()])
68
- for (const taskName of allTaskNames) {
69
- const taskFile = join(changeDir, 'tasks', `${taskName}.md`)
70
- if (existsSync(taskFile)) {
71
- taskClasses[taskName] = classifyTask(readFileSync(taskFile, 'utf8'))
72
- }
73
- }
74
-
75
- // 识别契约对:A depends_on B,且 A 是 consumer,B 是 provider
76
- for (const [consumer, deps] of Object.entries(taskDeps)) {
77
- const consumerClass = taskClasses[consumer]
78
- if (!consumerClass?.isConsumer) continue
79
-
80
- for (const provider of deps) {
81
- const providerClass = taskClasses[provider]
82
- if (!providerClass?.isProvider) continue
83
-
84
- // 避免自引用和重复
85
- if (consumer === provider) continue
86
- const alreadyExists = contracts.some(
87
- c => c.provider === provider && c.consumer === consumer
88
- )
89
- if (alreadyExists) continue
90
-
91
- contracts.push({
92
- provider,
93
- consumer,
94
- type: 'api',
95
- })
96
- }
97
- }
98
-
99
- return contracts
100
- }
101
-
102
- /**
103
- * 从 plan.md 解析 task 依赖关系
104
- * @param {string} planContent
105
- * @returns {Record<string, string[]>} task → depends_on list
106
- */
107
- function parseTaskDependencies(planContent) {
108
- const deps = {}
109
-
110
- // 方式 1: 表格形式 | task-04 | ... | 01,02 |
111
- // 列前缀用 [^|\d]*(不吃数字):旧 [^|]* 贪婪会吃掉 deps 列前导 0(" 01" → g2=1 → task-1),
112
- // 导致 provider task-1.md 不存在 → contracts 恒空,端点契约管线失效。
113
- const tableRows = planContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|\d]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
114
- for (const match of tableRows) {
115
- const task = `task-${match[1]}`
116
- const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
117
- deps[task] = depList
118
- }
119
-
120
- // 方式 2: depends_on 关键字
121
- const dependsPattern = planContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
122
- for (const match of dependsPattern) {
123
- const task = `task-${match[1]}`
124
- const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
125
- if (!deps[task]) deps[task] = []
126
- for (const d of depList) {
127
- if (!deps[task].includes(d)) deps[task].push(d)
128
- }
129
- }
130
-
131
- return deps
132
- }
133
-
134
- // ─── Execute 阶段:后端 task 完成后提取 artifact ───────────────────────
135
-
136
- /**
137
- * 后端 task 完成后,扫描变更文件提取 endpoint artifact
138
- * @param {string} changeDir - changes/<name>/ 目录
139
- * @param {string} worktreePath - worktree 路径(扫描源码用)
140
- * @param {string} specBase - .sillyspec 目录
141
- * @param {string} taskName - task-04
142
- * @returns {{ ok: boolean, endpoints: Array, artifactPath: string|null }}
143
- */
144
- export function extractProviderArtifact(changeDir, worktreePath, specBase, taskName, runtimeRoot) {
145
- // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
146
- const artifactRoot = runtimeRoot || join(specBase, '.runtime')
147
- // 跨变更隔离:contract-artifacts/<changeName>/<taskName>/,避免不同变更同名 task 互相覆盖污染对账
148
- const changeName = changeDir ? basename(changeDir) : '_unknown'
149
- const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, taskName)
150
- const artifactPath = join(artifactDir, 'endpoints.json')
151
-
152
- if (!worktreePath || !existsSync(worktreePath)) {
153
- return { ok: false, endpoints: [], artifactPath: null, error: 'worktree not found' }
154
- }
155
-
156
- try {
157
- const endpoints = scanBackendEndpoints(worktreePath)
158
-
159
- if (endpoints.length > 0) {
160
- mkdirSync(artifactDir, { recursive: true })
161
- const artifact = {
162
- task: taskName,
163
- type: 'backend_endpoints',
164
- extractedAt: new Date().toISOString(),
165
- endpoints: endpoints.map(e => ({
166
- method: e.method,
167
- path: normalizePath(e.path),
168
- source: relative(worktreePath, e.source),
169
- line: e.line,
170
- })),
171
- }
172
- writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + '\n')
173
- return { ok: true, endpoints: artifact.endpoints, artifactPath }
174
- }
175
-
176
- // 无端点提取到 — 不算错误(可能不是 router task)
177
- return { ok: true, endpoints: [], artifactPath: null }
178
- } catch (e) {
179
- return { ok: false, endpoints: [], artifactPath: null, error: e.message }
180
- }
181
- }
182
-
183
- /**
184
- * 汇总提取一个变更内所有 provider task 的 endpoint artifact。
185
- * run.js completeStep 在 execute Wave 完成时调用:扫 worktree → 按 buildContractMatrix 识别的
186
- * provider task,各自落 contract-artifacts/<changeName>/<taskName>/endpoints.json。
187
- * @param {{ changeDir: string, specBase: string, changeName: string, worktreePath: string|null }} args
188
- * @returns {string|null} 日志摘要;无 provider/无 plan/worktree 缺失时返回 null(不打扰)
189
- */
190
- export function extractArtifactsForChange({ changeDir, specBase, changeName, worktreePath }) {
191
- if (!worktreePath || !changeDir || !existsSync(changeDir)) return null
192
- const planFile = join(changeDir, 'plan.md')
193
- if (!existsSync(planFile)) return null
194
- const contracts = buildContractMatrix(readFileSync(planFile, 'utf8'), changeDir)
195
- const providers = [...new Set(contracts.map(c => c.provider))]
196
- if (providers.length === 0) return null
197
- let withEndpoints = 0
198
- for (const taskName of providers) {
199
- const r = extractProviderArtifact(changeDir, worktreePath, specBase, taskName)
200
- if (r.ok && r.endpoints.length > 0) withEndpoints++
201
- }
202
- return `📦 契约 artifact 提取: providers=${providers.join(',')}(${withEndpoints}/${providers.length} 含端点)`
203
- }
204
-
205
- // ─── Execute 阶段:前端 task 开始时注入契约 ─────────────────────────────
206
-
207
- /**
208
- * 为 consumer task 构建上游契约注入文本
209
- * @param {string} changeDir - changes/<name>/ 目录
210
- * @param {string} specBase - .sillyspec 目录
211
- * @param {string} taskName - 当前 task(consumer)
212
- * @param {Array<{ provider: string, consumer: string, type: string }>} contracts
213
- * @returns {string|null} 注入到 prompt 的契约文本,无契约时返回 null
214
- */
215
- export function buildConsumerInjection(changeDir, specBase, taskName, contracts, runtimeRoot) {
216
- // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
217
- const artifactRoot = runtimeRoot || join(specBase, '.runtime')
218
- const changeName = changeDir ? basename(changeDir) : '_unknown'
219
- const myContracts = contracts.filter(c => c.consumer === taskName)
220
- if (myContracts.length === 0) return null
221
-
222
- const parts = []
223
- for (const contract of myContracts) {
224
- const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, contract.provider)
225
- const artifactFile = join(artifactDir, 'endpoints.json')
226
-
227
- let endpoints = []
228
- if (existsSync(artifactFile)) {
229
- try {
230
- const artifact = JSON.parse(readFileSync(artifactFile, 'utf8'))
231
- endpoints = artifact.endpoints || []
232
- } catch {}
233
- }
234
-
235
- parts.push(`### Upstream Contract: ${contract.provider}`)
236
- if (endpoints.length > 0) {
237
- parts.push(`\nAvailable endpoints from **${contract.provider}**:`)
238
- for (const ep of endpoints) {
239
- parts.push(`- **${ep.method}** \`${ep.path}\``)
240
- }
241
- } else {
242
- parts.push(`\n⚠️ No endpoint artifact found for ${contract.provider}. This may indicate a contract gap.`)
243
- }
244
- }
245
-
246
- if (parts.length === 0) return null
247
-
248
- parts.unshift('## Upstream API Contracts')
249
- parts.push('')
250
- parts.push('### Rules')
251
- parts.push('1. Do not invent API paths. Use only endpoints listed above.')
252
- parts.push('2. If a required endpoint is missing, **stop and report the contract gap** instead of coding around it.')
253
- parts.push('3. If you need to add new endpoints, you must also update the backend provider task.')
254
-
255
- return parts.join('\n')
256
- }
257
-
258
- /**
259
- * 为 consumer task 构建字段级契约注入:对比 expects_from.needs vs provider.provides.fields
260
- *
261
- * 让 consumer 子代理带着明确的字段清单核验上游产出:
262
- * - provider 已承诺 → 编码时只使用 provides.fields,运行时缺字段上报
263
- * - provider 未承诺某 needs 字段 → 标 CONTRACT_GAP,要求 stop and report(禁止 fallback 编造)
264
- *
265
- * 命中场景:provider task 漏实现某字段(如 DaemonRuntimeRead 缺 daemon_instance_id),
266
- * consumer 若 fallback 编造 → 运行时 403/500。此处把"缺字段"暴露在子代理启动前。
267
- *
268
- * 注:plan-postcheck 已对账 expects_from↔provides,此处是 execute 时的二次保险,
269
- * 拦截 plan-postcheck 之后 task 文件被手改、或 provider 实际实现漏字段的情况。
270
- *
271
- * @param {string} changeDir - changes/<name>/ 目录
272
- * @param {string} taskName - consumer task(如 task-11)
273
- * @returns {string|null} 注入文本,无 expects_from 时返回 null
274
- */
275
- export function buildContractFieldInjection(changeDir, taskName) {
276
- const consumerFile = join(changeDir, 'tasks', `${taskName}.md`)
277
- if (!existsSync(consumerFile)) return null
278
- const { expectsFrom } = parseTaskContracts(readFileSync(consumerFile, 'utf8'))
279
- const providers = Object.keys(expectsFrom)
280
- if (providers.length === 0) return null
281
-
282
- const lines = []
283
- lines.push('## Upstream Contract Fields(字段级核验)')
284
- lines.push('')
285
-
286
- for (const providerTask of providers) {
287
- const providerFile = join(changeDir, 'tasks', `${providerTask}.md`)
288
- let providerProvides = []
289
- if (existsSync(providerFile)) {
290
- providerProvides = parseTaskContracts(readFileSync(providerFile, 'utf8')).provides
291
- }
292
-
293
- for (const c of expectsFrom[providerTask]) {
294
- const providerEntry = providerProvides.find(p => p.contract === c.contract)
295
- if (!providerEntry) {
296
- lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
297
- lines.push(`你需要字段 [${c.needs.join(', ')}],但 ${providerTask} 的 provides 未声明此契约。`)
298
- lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先确认 ${providerTask} 是否应产出此契约。`)
299
- } else {
300
- const providerFields = new Set(providerEntry.fields)
301
- const missing = c.needs.filter(f => !providerFields.has(f))
302
- if (missing.length > 0) {
303
- lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
304
- lines.push(`你需要字段 [${missing.join(', ')}],但 ${providerTask}.provides 仅承诺 [${providerEntry.fields.join(', ')}]。`)
305
- lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先要求 ${providerTask} 在 provides.fields 补上 [${missing.join(', ')}]。`)
306
- } else {
307
- lines.push(`### ✅ ${providerTask} → ${c.contract}`)
308
- lines.push(`你需要的字段 [${c.needs.join(', ')}] 均在 ${providerTask}.provides 承诺内:[${providerEntry.fields.join(', ')}]。`)
309
- lines.push(`编码时**只使用上述字段**;若运行时实际返回缺字段,说明 provider 实现漏了 → 上报 CONTRACT_GAP,不要 fallback。`)
310
- }
311
- }
312
- lines.push('')
313
- }
314
- }
315
-
316
- lines.push('### 字段级铁律')
317
- lines.push('1. 禁止 fallback 编造:若上游返回缺字段,停止并上报 CONTRACT_GAP,不要用 `x || defaultValue` 之类的防御性回退掩盖契约破裂。')
318
- lines.push('2. 只消费 provides 承诺的字段;需要新字段必须先让 provider 更新 provides。')
319
- lines.push('3. 启动子代理前,先读 provider task 的 review.json / acceptance,确认其已声明完成上述契约字段。')
320
-
321
- return lines.join('\n')
322
- }
323
-
324
- // ─── Verify 阶段:parity check ──────────────────────────────────────────
325
-
326
- /**
327
- * verify 阶段执行 API parity check
328
- * @param {string} specBase - .sillyspec 目录
329
- * @param {string} worktreePath - worktree 路径
330
- * @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string, backendCount: number, frontendCount: number }}
331
- */
332
- export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = null) {
333
- // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
334
- const artifactRoot = runtimeRoot || join(specBase, '.runtime')
335
- // 读取该变更的所有 provider artifacts(contract-artifacts/<changeName>/*/endpoints.json)。
336
- // changeName 缺失时回退扫顶层 contract-artifacts(CLI contractScan 跨变更场景兼容)。
337
- const artifactBase = changeName
338
- ? join(artifactRoot, 'contract-artifacts', changeName)
339
- : join(artifactRoot, 'contract-artifacts')
340
- const allProviderEndpoints = []
341
-
342
- if (existsSync(artifactBase)) {
343
- for (const entry of readdirSync(artifactBase, { withFileTypes: true })) {
344
- if (!entry.isDirectory()) continue
345
- const epFile = join(artifactBase, entry.name, 'endpoints.json')
346
- if (existsSync(epFile)) {
347
- try {
348
- const artifact = JSON.parse(readFileSync(epFile, 'utf8'))
349
- for (const ep of (artifact.endpoints || [])) {
350
- allProviderEndpoints.push({
351
- method: ep.method,
352
- path: ep.path,
353
- source: `${entry.name}/${ep.source}`,
354
- })
355
- }
356
- } catch {}
357
- }
358
- }
359
- }
360
-
361
- // 扫描前端调用(scanRoot 通常 = 主工作区 cwd:verify 时代码已 apply 到主工作区)
362
- if (!scanRoot || !existsSync(scanRoot)) {
363
- return { ok: true, missingBackend: [], unusedBackend: [], summary: 'No scan root for parity check', backendCount: allProviderEndpoints.length, frontendCount: 0 }
364
- }
365
-
366
- const frontendCalls = scanFrontendApiCalls(scanRoot)
367
- const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
368
-
369
- const ok = missingBackend.length === 0
370
- let summary = ok
371
- ? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
372
- : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
373
-
374
- if (unusedBackend.length > 0) {
375
- summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
376
- }
377
-
378
- return { ok, missingBackend, unusedBackend, summary, backendCount: allProviderEndpoints.length, frontendCount: frontendCalls.length }
379
- }
1
+ /**
2
+ * contract-matrix.js — API Contract Matrix 生成与注入
3
+ *
4
+ * plan 阶段:识别 task 之间的 provider/consumer 关系,生成契约矩阵
5
+ * execute 阶段:
6
+ * - 后端 task 完成后自动提取 endpoint artifact
7
+ * - 前端 task 开始时注入上游契约
8
+ * verify 阶段:读取 artifact 做 parity check
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
+ import { join, resolve, basename, relative } from 'path'
13
+ import {
14
+ scanBackendEndpoints,
15
+ scanFrontendApiCalls,
16
+ normalizePath,
17
+ diffApiParity,
18
+ } from './endpoint-extractor.js'
19
+ import { parseTaskContracts } from './stages/plan-postcheck.js'
20
+
21
+ // ─── 关键词检测 ─────────────────────────────────────────────────────────
22
+
23
+ const PROVIDER_KEYWORDS = /router|routes|endpoint|api|backend|controller|fastapi|flask|express|koa|spring/i
24
+ const CONSUMER_KEYWORDS = /frontend|client|service|apiFetch|request|fetch|axios|http/i
25
+
26
+ /**
27
+ * 判断一个 task 文档是 provider(产出 API)还是 consumer(消费 API)
28
+ * @param {string} taskContent - task markdown 内容
29
+ * @returns {{ isProvider: boolean, isConsumer: boolean, confidence: number }}
30
+ */
31
+ export function classifyTask(taskContent) {
32
+ const isProvider = PROVIDER_KEYWORDS.test(taskContent)
33
+ const isConsumer = CONSUMER_KEYWORDS.test(taskContent)
34
+ // 避免所有 task 都被标记(因为几乎所有 task 都含 "api")
35
+ // 加强判定:provider 要命中 router/endpoint/backend/controller + api
36
+ // consumer 要命中 frontend/client/apiFetch + api
37
+ const providerStrong = /router|endpoint|backend|controller|fastapi|flask/i.test(taskContent)
38
+ const consumerStrong = /frontend|apiFetch|axios|api.*client/i.test(taskContent)
39
+ const providerConfidence = providerStrong ? 0.8 : (isProvider ? 0.4 : 0)
40
+ const consumerConfidence = consumerStrong ? 0.8 : (isConsumer ? 0.4 : 0)
41
+ return {
42
+ isProvider: providerConfidence >= 0.4,
43
+ isConsumer: consumerConfidence >= 0.4,
44
+ confidence: Math.max(providerConfidence, consumerConfidence),
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 解析 task 依赖关系,识别 provider → consumer 对(2026-08-20-task-truth-unify D-003@v1:
50
+ * 行内 depends_on 标注源迁 tasks.md,函数内 tasks.md 优先、plan.md 回退拼接)
51
+ * @param {string} planContent - plan.md 内容(任务总表依赖列兜底源)
52
+ * @param {string} changeDir - changes/<name>/ 目录
53
+ * @returns {Array<{ provider: string, consumer: string, type: string }>}
54
+ */
55
+ export function buildContractMatrix(planContent, changeDir) {
56
+ const contracts = []
57
+
58
+ // 解析 task 依赖关系(tasks.md 注册表行内标注优先;plan.md 总表列兜底——两源拼合去重)
59
+ // tasks.md 格式: - [ ] task-04: 名称 (depends_on: task-01,02);
60
+ // plan.md 总表: | task-04 | ... | 01 |
61
+ const tasksMdPath = changeDir ? join(changeDir, 'tasks.md') : null
62
+ let registryContent = ''
63
+ if (tasksMdPath && existsSync(tasksMdPath)) {
64
+ try { registryContent = readFileSync(tasksMdPath, 'utf8') } catch { registryContent = '' }
65
+ }
66
+ const taskDeps = parseTaskDependencies(registryContent + '\n' + String(planContent || ''))
67
+
68
+ // 读取各 task 文档,分类 provider/consumer
69
+ // classify consumers(taskDeps keys)+ providers(deps 值)。
70
+ // 旧实现只 classify consumers → provider task 不在 keys 里 → providerClass 恒 undefined
71
+ // → contracts 恒空 → 端点契约管线(buildConsumerInjection / extractArtifactsForChange)全失效。
72
+ const taskClasses = {}
73
+ const allTaskNames = new Set([...Object.keys(taskDeps), ...Object.values(taskDeps).flat()])
74
+ for (const taskName of allTaskNames) {
75
+ const taskFile = join(changeDir, 'tasks', `${taskName}.md`)
76
+ if (existsSync(taskFile)) {
77
+ taskClasses[taskName] = classifyTask(readFileSync(taskFile, 'utf8'))
78
+ }
79
+ }
80
+
81
+ // 识别契约对:A depends_on B,且 A 是 consumer,B 是 provider
82
+ for (const [consumer, deps] of Object.entries(taskDeps)) {
83
+ const consumerClass = taskClasses[consumer]
84
+ if (!consumerClass?.isConsumer) continue
85
+
86
+ for (const provider of deps) {
87
+ const providerClass = taskClasses[provider]
88
+ if (!providerClass?.isProvider) continue
89
+
90
+ // 避免自引用和重复
91
+ if (consumer === provider) continue
92
+ const alreadyExists = contracts.some(
93
+ c => c.provider === provider && c.consumer === consumer
94
+ )
95
+ if (alreadyExists) continue
96
+
97
+ contracts.push({
98
+ provider,
99
+ consumer,
100
+ type: 'api',
101
+ })
102
+ }
103
+ }
104
+
105
+ return contracts
106
+ }
107
+
108
+ /**
109
+ * 解析 task 依赖关系(2026-08-20-task-truth-unify D-003@v1)。
110
+ * 方式 2(depends_on 行内标注)的机器解析源随任务行迁 tasks.md——调用方 buildContractMatrix
111
+ * 读 tasks.md 传入(缺失回退 plan.md 内容,旧变更兼容);方式 1(任务总表依赖列)仍来自 plan.md。
112
+ * @param {string} registryContent - tasks.md 内容(优先)或 plan.md 内容(回退)
113
+ * @returns {Record<string, string[]>} task → depends_on list
114
+ */
115
+ function parseTaskDependencies(registryContent) {
116
+ const deps = {}
117
+
118
+ // 方式 1: 任务总表表格形式 | task-04 | ... | 01,02 |
119
+ // 列前缀用 [^|\d]*(不吃数字):旧 [^|]* 贪婪会吃掉 deps 列前导 0(" 01" → g2=1 → task-1),
120
+ // 导致 provider task-1.md 不存在 → contracts 恒空,端点契约管线失效。
121
+ const tableRows = registryContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|\d]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
122
+ for (const match of tableRows) {
123
+ const task = `task-${match[1]}`
124
+ const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
125
+ deps[task] = depList
126
+ }
127
+
128
+ // 方式 2: tasks.md 行内标注 "- [ ] task-04: 名称 (depends_on: task-01,02)"(新家,D-003@v1;
129
+ // 旧 plan.md checkbox 行内标注同格式兼容)
130
+ const dependsPattern = registryContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
131
+ for (const match of dependsPattern) {
132
+ const task = `task-${match[1]}`
133
+ const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
134
+ if (!deps[task]) deps[task] = []
135
+ for (const d of depList) {
136
+ if (!deps[task].includes(d)) deps[task].push(d)
137
+ }
138
+ }
139
+
140
+ return deps
141
+ }
142
+
143
+ // ─── Execute 阶段:后端 task 完成后提取 artifact ───────────────────────
144
+
145
+ /**
146
+ * 后端 task 完成后,扫描变更文件提取 endpoint artifact
147
+ * @param {string} changeDir - changes/<name>/ 目录
148
+ * @param {string} worktreePath - worktree 路径(扫描源码用)
149
+ * @param {string} specBase - .sillyspec 目录
150
+ * @param {string} taskName - task-04
151
+ * @returns {{ ok: boolean, endpoints: Array, artifactPath: string|null }}
152
+ */
153
+ export function extractProviderArtifact(changeDir, worktreePath, specBase, taskName, runtimeRoot) {
154
+ // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
155
+ const artifactRoot = runtimeRoot || join(specBase, '.runtime')
156
+ // 跨变更隔离:contract-artifacts/<changeName>/<taskName>/,避免不同变更同名 task 互相覆盖污染对账
157
+ const changeName = changeDir ? basename(changeDir) : '_unknown'
158
+ const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, taskName)
159
+ const artifactPath = join(artifactDir, 'endpoints.json')
160
+
161
+ if (!worktreePath || !existsSync(worktreePath)) {
162
+ return { ok: false, endpoints: [], artifactPath: null, error: 'worktree not found' }
163
+ }
164
+
165
+ try {
166
+ const endpoints = scanBackendEndpoints(worktreePath)
167
+
168
+ if (endpoints.length > 0) {
169
+ mkdirSync(artifactDir, { recursive: true })
170
+ const artifact = {
171
+ task: taskName,
172
+ type: 'backend_endpoints',
173
+ extractedAt: new Date().toISOString(),
174
+ endpoints: endpoints.map(e => ({
175
+ method: e.method,
176
+ path: normalizePath(e.path),
177
+ source: relative(worktreePath, e.source),
178
+ line: e.line,
179
+ })),
180
+ }
181
+ writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + '\n')
182
+ return { ok: true, endpoints: artifact.endpoints, artifactPath }
183
+ }
184
+
185
+ // 无端点提取到 — 不算错误(可能不是 router task)
186
+ return { ok: true, endpoints: [], artifactPath: null }
187
+ } catch (e) {
188
+ return { ok: false, endpoints: [], artifactPath: null, error: e.message }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 汇总提取一个变更内所有 provider task 的 endpoint artifact。
194
+ * run.js completeStep 在 execute Wave 完成时调用:扫 worktree → 按 buildContractMatrix 识别的
195
+ * provider task,各自落 contract-artifacts/<changeName>/<taskName>/endpoints.json。
196
+ * @param {{ changeDir: string, specBase: string, changeName: string, worktreePath: string|null }} args
197
+ * @returns {string|null} 日志摘要;无 provider/无 plan/worktree 缺失时返回 null(不打扰)
198
+ */
199
+ export function extractArtifactsForChange({ changeDir, specBase, changeName, worktreePath }) {
200
+ if (!worktreePath || !changeDir || !existsSync(changeDir)) return null
201
+ const planFile = join(changeDir, 'plan.md')
202
+ if (!existsSync(planFile)) return null
203
+ const contracts = buildContractMatrix(readFileSync(planFile, 'utf8'), changeDir)
204
+ const providers = [...new Set(contracts.map(c => c.provider))]
205
+ if (providers.length === 0) return null
206
+ let withEndpoints = 0
207
+ for (const taskName of providers) {
208
+ const r = extractProviderArtifact(changeDir, worktreePath, specBase, taskName)
209
+ if (r.ok && r.endpoints.length > 0) withEndpoints++
210
+ }
211
+ return `📦 契约 artifact 提取: providers=${providers.join(',')}(${withEndpoints}/${providers.length} 含端点)`
212
+ }
213
+
214
+ // ─── Execute 阶段:前端 task 开始时注入契约 ─────────────────────────────
215
+
216
+ /**
217
+ * 为 consumer task 构建上游契约注入文本
218
+ * @param {string} changeDir - changes/<name>/ 目录
219
+ * @param {string} specBase - .sillyspec 目录
220
+ * @param {string} taskName - 当前 task(consumer)
221
+ * @param {Array<{ provider: string, consumer: string, type: string }>} contracts
222
+ * @returns {string|null} 注入到 prompt 的契约文本,无契约时返回 null
223
+ */
224
+ export function buildConsumerInjection(changeDir, specBase, taskName, contracts, runtimeRoot) {
225
+ // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
226
+ const artifactRoot = runtimeRoot || join(specBase, '.runtime')
227
+ const changeName = changeDir ? basename(changeDir) : '_unknown'
228
+ const myContracts = contracts.filter(c => c.consumer === taskName)
229
+ if (myContracts.length === 0) return null
230
+
231
+ const parts = []
232
+ for (const contract of myContracts) {
233
+ const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, contract.provider)
234
+ const artifactFile = join(artifactDir, 'endpoints.json')
235
+
236
+ let endpoints = []
237
+ if (existsSync(artifactFile)) {
238
+ try {
239
+ const artifact = JSON.parse(readFileSync(artifactFile, 'utf8'))
240
+ endpoints = artifact.endpoints || []
241
+ } catch {}
242
+ }
243
+
244
+ parts.push(`### Upstream Contract: ${contract.provider}`)
245
+ if (endpoints.length > 0) {
246
+ parts.push(`\nAvailable endpoints from **${contract.provider}**:`)
247
+ for (const ep of endpoints) {
248
+ parts.push(`- **${ep.method}** \`${ep.path}\``)
249
+ }
250
+ } else {
251
+ parts.push(`\n⚠️ No endpoint artifact found for ${contract.provider}. This may indicate a contract gap.`)
252
+ }
253
+ }
254
+
255
+ if (parts.length === 0) return null
256
+
257
+ parts.unshift('## Upstream API Contracts')
258
+ parts.push('')
259
+ parts.push('### Rules')
260
+ parts.push('1. Do not invent API paths. Use only endpoints listed above.')
261
+ parts.push('2. If a required endpoint is missing, **stop and report the contract gap** instead of coding around it.')
262
+ parts.push('3. If you need to add new endpoints, you must also update the backend provider task.')
263
+
264
+ return parts.join('\n')
265
+ }
266
+
267
+ /**
268
+ * 为 consumer task 构建字段级契约注入:对比 expects_from.needs vs provider.provides.fields
269
+ *
270
+ * 让 consumer 子代理带着明确的字段清单核验上游产出:
271
+ * - provider 已承诺 → 编码时只使用 provides.fields,运行时缺字段上报
272
+ * - provider 未承诺某 needs 字段 → 标 CONTRACT_GAP,要求 stop and report(禁止 fallback 编造)
273
+ *
274
+ * 命中场景:provider task 漏实现某字段(如 DaemonRuntimeRead 缺 daemon_instance_id),
275
+ * consumer 若 fallback 编造 → 运行时 403/500。此处把"缺字段"暴露在子代理启动前。
276
+ *
277
+ * 注:plan-postcheck 已对账 expects_from↔provides,此处是 execute 时的二次保险,
278
+ * 拦截 plan-postcheck 之后 task 文件被手改、或 provider 实际实现漏字段的情况。
279
+ *
280
+ * @param {string} changeDir - changes/<name>/ 目录
281
+ * @param {string} taskName - consumer task(如 task-11)
282
+ * @returns {string|null} 注入文本,无 expects_from 时返回 null
283
+ */
284
+ export function buildContractFieldInjection(changeDir, taskName) {
285
+ const consumerFile = join(changeDir, 'tasks', `${taskName}.md`)
286
+ if (!existsSync(consumerFile)) return null
287
+ const { expectsFrom } = parseTaskContracts(readFileSync(consumerFile, 'utf8'))
288
+ const providers = Object.keys(expectsFrom)
289
+ if (providers.length === 0) return null
290
+
291
+ const lines = []
292
+ lines.push('## Upstream Contract Fields(字段级核验)')
293
+ lines.push('')
294
+
295
+ for (const providerTask of providers) {
296
+ const providerFile = join(changeDir, 'tasks', `${providerTask}.md`)
297
+ let providerProvides = []
298
+ if (existsSync(providerFile)) {
299
+ providerProvides = parseTaskContracts(readFileSync(providerFile, 'utf8')).provides
300
+ }
301
+
302
+ for (const c of expectsFrom[providerTask]) {
303
+ const providerEntry = providerProvides.find(p => p.contract === c.contract)
304
+ if (!providerEntry) {
305
+ lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
306
+ lines.push(`你需要字段 [${c.needs.join(', ')}],但 ${providerTask} 的 provides 未声明此契约。`)
307
+ lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先确认 ${providerTask} 是否应产出此契约。`)
308
+ } else {
309
+ const providerFields = new Set(providerEntry.fields)
310
+ const missing = c.needs.filter(f => !providerFields.has(f))
311
+ if (missing.length > 0) {
312
+ lines.push(`### ⚠️ CONTRACT_GAP: ${providerTask} → ${c.contract}`)
313
+ lines.push(`你需要字段 [${missing.join(', ')}],但 ${providerTask}.provides 仅承诺 [${providerEntry.fields.join(', ')}]。`)
314
+ lines.push(`**立即停止编码并上报**:不要 fallback、不要编造字段,先要求 ${providerTask} 在 provides.fields 补上 [${missing.join(', ')}]。`)
315
+ } else {
316
+ lines.push(`### ✅ ${providerTask} → ${c.contract}`)
317
+ lines.push(`你需要的字段 [${c.needs.join(', ')}] 均在 ${providerTask}.provides 承诺内:[${providerEntry.fields.join(', ')}]。`)
318
+ lines.push(`编码时**只使用上述字段**;若运行时实际返回缺字段,说明 provider 实现漏了 → 上报 CONTRACT_GAP,不要 fallback。`)
319
+ }
320
+ }
321
+ lines.push('')
322
+ }
323
+ }
324
+
325
+ lines.push('### 字段级铁律')
326
+ lines.push('1. 禁止 fallback 编造:若上游返回缺字段,停止并上报 CONTRACT_GAP,不要用 `x || defaultValue` 之类的防御性回退掩盖契约破裂。')
327
+ lines.push('2. 只消费 provides 承诺的字段;需要新字段必须先让 provider 更新 provides。')
328
+ lines.push('3. 启动子代理前,先读 provider task 的 review.json / acceptance,确认其已声明完成上述契约字段。')
329
+
330
+ return lines.join('\n')
331
+ }
332
+
333
+ // ─── Verify 阶段:parity check ──────────────────────────────────────────
334
+
335
+ /**
336
+ * verify 阶段执行 API parity check
337
+ * @param {string} specBase - .sillyspec 目录
338
+ * @param {string} worktreePath - worktree 路径
339
+ * @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string, backendCount: number, frontendCount: number }}
340
+ */
341
+ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = null) {
342
+ // 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
343
+ const artifactRoot = runtimeRoot || join(specBase, '.runtime')
344
+ // 读取该变更的所有 provider artifacts(contract-artifacts/<changeName>/*/endpoints.json)。
345
+ // changeName 缺失时回退扫顶层 contract-artifacts(CLI contractScan 跨变更场景兼容)。
346
+ const artifactBase = changeName
347
+ ? join(artifactRoot, 'contract-artifacts', changeName)
348
+ : join(artifactRoot, 'contract-artifacts')
349
+ const allProviderEndpoints = []
350
+
351
+ if (existsSync(artifactBase)) {
352
+ for (const entry of readdirSync(artifactBase, { withFileTypes: true })) {
353
+ if (!entry.isDirectory()) continue
354
+ const epFile = join(artifactBase, entry.name, 'endpoints.json')
355
+ if (existsSync(epFile)) {
356
+ try {
357
+ const artifact = JSON.parse(readFileSync(epFile, 'utf8'))
358
+ for (const ep of (artifact.endpoints || [])) {
359
+ allProviderEndpoints.push({
360
+ method: ep.method,
361
+ path: ep.path,
362
+ source: `${entry.name}/${ep.source}`,
363
+ })
364
+ }
365
+ } catch {}
366
+ }
367
+ }
368
+ }
369
+
370
+ // 扫描前端调用(scanRoot 通常 = 主工作区 cwd:verify 时代码已 apply 到主工作区)
371
+ if (!scanRoot || !existsSync(scanRoot)) {
372
+ return { ok: true, missingBackend: [], unusedBackend: [], summary: 'No scan root for parity check', backendCount: allProviderEndpoints.length, frontendCount: 0 }
373
+ }
374
+
375
+ const frontendCalls = scanFrontendApiCalls(scanRoot)
376
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
377
+
378
+ const ok = missingBackend.length === 0
379
+ let summary = ok
380
+ ? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
381
+ : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
382
+
383
+ if (unusedBackend.length > 0) {
384
+ summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
385
+ }
386
+
387
+ return { ok, missingBackend, unusedBackend, summary, backendCount: allProviderEndpoints.length, frontendCount: frontendCalls.length }
388
+ }