sillyspec 3.18.3 → 3.18.5

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.
@@ -68,7 +68,7 @@ $ARGUMENTS
68
68
 
69
69
  ### 关键规则
70
70
  - 不要跳过任何步骤
71
- - 不要手动修改 progress.json
71
+ - 不要手动修改进度数据(SQLite 数据库)
72
72
  - 不要自动 commit,只 git add
73
73
  - 不要使用 npx
74
74
  - 不要编造不存在的 CLI 子命令
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sillyspec:doctor
3
- description: 用于 SillySpec 自检和状态修复。适合用户说"检查下状态、修复 progress、doctor、状态不对"。全量扫描进度一致性,修复 progress.json 与实际产出不匹配的问题。
3
+ description: 用于 SillySpec 自检和状态修复。适合用户说"检查下状态、修复 progress、doctor、状态不对"。全量扫描进度一致性,修复进度数据与实际产出不匹配的问题。
4
4
  ---
5
5
 
6
6
  ## 前置检查
@@ -23,6 +23,8 @@ description: 用于按 plan 执行代码实现。适合用户说"开始写代码
23
23
  - Worktree 路径在 Step 3(确认 worktree 路径)中输出,后续子代理的 cwd 必须设为该路径
24
24
  - **禁止跳过 worktree 或在主仓库直接写代码**
25
25
  - 如果 worktree 创建失败,CLI 会报错并退出,需要排查后再重试
26
+ - **未提交的文件、dirty 状态等不影响 worktree 创建和进入,直接按 CLI 输出的 worktree 路径操作即可**
27
+ - 不要自行检查 git 状态来判断是否可以进入 worktree,CLI 会自动处理
26
28
 
27
29
  ## 用户指令
28
30
  $ARGUMENTS
@@ -22,15 +22,15 @@ description: 恢复工作 — 从中断处继续
22
22
  sillyspec progress show
23
23
  ```
24
24
 
25
- ### 2. 如果有 progress.json
25
+ ### 2. 如果有活跃变更
26
26
 
27
- progress.json 中提取并展示当前状态,使用 `sillyspec progress show` 查看。
27
+ 从 `sillyspec progress show` 输出中提取并展示当前状态。
28
28
 
29
29
  然后问用户:
30
30
  1. 直接继续执行下一步
31
31
  2. 查看更多细节
32
32
 
33
- ### 3. 如果没有 progress.json 或变更目录
33
+ ### 3. 如果没有活跃变更
34
34
 
35
35
  自动探测项目状态:
36
36
 
@@ -64,5 +64,5 @@ cat .sillyspec/ROADMAP.md 2>/dev/null
64
64
 
65
65
  ### 4. 关键原则
66
66
 
67
- - progress.json 是唯一的恢复数据源(存储在 `.sillyspec/changes/<name>/progress.json` 或旧版 `.sillyspec/.runtime/progress.json`)
68
- - progress.json `sillyspec run <stage> --done` 自动更新,不需要手动保存
67
+ - 进度数据存储在 SQLite 数据库中(`.sillyspec/.runtime/sillyspec.db`),通过 `sillyspec progress show` 命令查看
68
+ - 进度随 `sillyspec run <stage> --done` 自动更新,不需要手动保存
@@ -1,19 +1,19 @@
1
1
  ---
2
2
  name: sillyspec:state
3
- description: 查看当前工作状态 — 显示 progress.json 内容
3
+ description: 查看当前工作状态 — 显示 SillySpec 进度
4
4
  ---
5
5
 
6
6
  你现在是 SillySpec 的状态查看器。
7
7
 
8
8
  ## 流程
9
9
 
10
- ### 1. 读取 progress.json
10
+ ### 1. 读取进度
11
11
 
12
12
  ```bash
13
- sillyspec run state --status 2>/dev/null
13
+ sillyspec progress show
14
14
  ```
15
15
 
16
- ### 2. 如果有 progress.json
16
+ ### 2. 如果有活跃变更
17
17
 
18
18
  格式化展示当前状态:
19
19
 
@@ -33,9 +33,9 @@ sillyspec run state --status 2>/dev/null
33
33
  > **阻塞项**:
34
34
  > - xxx(如无则省略)
35
35
 
36
- ### 3. 如果没有 progress.json
36
+ ### 3. 如果没有活跃变更
37
37
 
38
- 提示用户项目还没有开始,或 progress.json 尚未生成:
38
+ 提示用户项目还没有开始:
39
39
 
40
40
  > 📊 还没有工作记录。
41
41
  >
@@ -44,11 +44,11 @@ sillyspec run state --status 2>/dev/null
44
44
  > - 已有项目:`/sillyspec:scan`
45
45
  > - 恢复中断的工作:`/sillyspec:resume`
46
46
  >
47
- > progress.json 会在 `sillyspec init` 时自动创建。
47
+ > 进度数据会在 `sillyspec init` 时自动创建到 SQLite 数据库中。
48
48
 
49
49
  ### 注意
50
50
 
51
51
  - 这是只读命令,**不修改任何文件**
52
52
  - `/sillyspec:status` 查看项目整体进度(change 文件级别)
53
- - `/sillyspec:state` 查看当前工作状态(progress.json 级别)
53
+ - `/sillyspec:state` 查看当前工作状态(阶段/步骤级别)
54
54
  - 两者互补:status 看"有什么",state 看"在做什么"
@@ -43,7 +43,7 @@ created_at: 2026-06-04 16:25:42
43
43
  | `batch_progress` | 批量任务统计 |
44
44
  | `approvals` | 平台审批状态 |
45
45
 
46
- `progress.js` 通过 SQL 读写这些表,并组装成兼容旧 progress JSON JS 对象。当前没有看到 `progress.json` 被作为权威状态写入。
46
+ `progress.js` 通过 SQL 读写这些表,并组装成兼容旧 progress 格式的 JS 对象。进度数据仅存储在 SQLite 数据库中,不再使用 progress.json 文件。
47
47
 
48
48
  注意:`db.js` 的 `project.schema_version` DDL 默认值是 `4`,但 `progress.js` 的 `CURRENT_VERSION` 是 `3`,并在初始化/写入时使用 `3`。文档不要把这里写成稳定的 v4 schema 事实。
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.18.3",
3
+ "version": "3.18.5",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -48,12 +48,13 @@ function startProgressWatch(projectPath) {
48
48
  progressWatchers.get(projectPath).refCount++
49
49
  return
50
50
  }
51
- const progressFile = join(projectPath, '.sillyspec', '.runtime', 'progress.json')
52
- if (!existsSync(progressFile)) return
51
+ // Watch the SQLite database file for changes (replaces old progress.json watch)
52
+ const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
53
+ if (!existsSync(dbFile)) return
53
54
 
54
55
  let timer = null
55
56
  try {
56
- const watcher = watch(progressFile, (eventType) => {
57
+ const watcher = watch(dbFile, (eventType) => {
57
58
  if (timer) clearTimeout(timer)
58
59
  timer = setTimeout(() => {
59
60
  timer = null
@@ -77,18 +77,13 @@ export function parseProjectOverview(projectPath) {
77
77
 
78
78
  // --- Last active ---
79
79
  const sillyspecDir = join(projectPath, '.sillyspec')
80
- const progressPath = join(sillyspecDir, '.runtime', 'progress.json')
81
- if (existsSync(progressPath)) {
80
+ // Progress is stored in SQLite (.sillyspec/.runtime/sillyspec.db), not progress.json
81
+ // Use mtime of the DB file as a fallback for lastActive
82
+ const dbPath = join(sillyspecDir, '.runtime', 'sillyspec.db')
83
+ if (existsSync(dbPath)) {
82
84
  try {
83
- const progress = JSON.parse(readFileSync(progressPath, 'utf-8'))
84
- if (progress.stages) {
85
- for (const stageData of Object.values(progress.stages)) {
86
- if (stageData.lastActive && (!result.lastActive || new Date(stageData.lastActive) > new Date(result.lastActive))) {
87
- result.lastActive = stageData.lastActive
88
- }
89
- }
90
- }
91
- if (progress.lastActive) result.lastActive = progress.lastActive
85
+ const s = statSync(dbPath)
86
+ result.lastActive = s.mtime.toISOString()
92
87
  } catch {}
93
88
  }
94
89
  if (!result.lastActive) {
@@ -432,81 +427,46 @@ export function parseSillyspecDocsTree(projectPath) {
432
427
  }
433
428
 
434
429
  /**
435
- * Parse project state from .sillyspec directory
430
+ * Parse project state from .sillyspec SQLite database via CLI.
431
+ * Progress data is stored in SQLite (.sillyspec/.runtime/sillyspec.db),
432
+ * accessed through `sillyspec progress show`.
436
433
  * @param {string} projectPath - Path to the project directory
437
- * @returns {object} Project state with currentStage, nextStep, progress, stages, specs, lastActive
434
+ * @returns {object|null} Project state with currentStage, stages, lastActive
438
435
  */
439
436
  export function parseProjectState(projectPath) {
440
437
  const sillyspecDir = join(projectPath, '.sillyspec')
438
+ const dbPath = join(sillyspecDir, '.runtime', 'sillyspec.db')
441
439
 
442
- if (!existsSync(sillyspecDir)) {
440
+ if (!existsSync(sillyspecDir) || !existsSync(dbPath)) {
443
441
  return null
444
442
  }
445
443
 
446
444
  let currentStage = ''
447
- let nextStep = null
448
- let progress = { stages: {} }
449
- let stages = []
450
- let specs = []
451
445
  let lastActive = null
452
446
 
453
- // Read progress.json for current stage
454
- const progressPath = join(sillyspecDir, '.runtime', 'progress.json')
455
- if (existsSync(progressPath)) {
456
- try {
457
- const progressData = JSON.parse(readFileSync(progressPath, 'utf-8'))
458
- progress = progressData
459
- currentStage = progressData.currentStage || ''
460
- stages = Object.keys(progressData.stages || {})
461
-
462
- // Find last active
463
- if (progressData.lastActive) lastActive = progressData.lastActive
464
- if (progressData.stages) {
465
- for (const [stageName, stageData] of Object.entries(progressData.stages)) {
466
- if (stageData.lastActive || stageData.startedAt) {
467
- const t = stageData.lastActive || stageData.startedAt
468
- if (!lastActive || new Date(t) > new Date(lastActive)) lastActive = t
469
- }
470
- }
471
- }
472
- } catch (err) {
473
- // Progress file exists but couldn't be parsed
474
- }
475
- }
447
+ // Use DB file mtime as lastActive indicator
448
+ try {
449
+ const s = statSync(dbPath)
450
+ lastActive = s.mtime.toISOString()
451
+ } catch {}
476
452
 
477
- // List all spec files
478
- const specsDir = join(sillyspecDir, 'specs')
479
- if (existsSync(specsDir)) {
480
- try {
481
- const specFiles = readdirSync(specsDir)
482
- .filter(f => f.endsWith('.md'))
483
- .sort()
484
-
485
- specs = specFiles.map(f => {
486
- const specPath = join(specsDir, f)
487
- try {
488
- const content = readFileSync(specPath, 'utf-8')
489
- const titleMatch = content.match(/^#\s+(.+)$/m)
490
- return {
491
- name: f,
492
- title: titleMatch ? titleMatch[1] : f,
493
- path: specPath
494
- }
495
- } catch {
496
- return { name: f, title: f, path: specPath }
497
- }
498
- })
499
- } catch (err) {
500
- // Specs directory couldn't be read
501
- }
453
+ // Use CLI to read current stage from SQLite
454
+ try {
455
+ const output = execSync('sillyspec progress show 2>/dev/null', {
456
+ cwd: projectPath, encoding: 'utf-8', timeout: 5000
457
+ })
458
+ const stageMatch = output.match(/当前阶段:\s*(\S+)/)
459
+ if (stageMatch) currentStage = stageMatch[1]
460
+ } catch {
461
+ // CLI unavailable or no active change
502
462
  }
503
463
 
504
464
  return {
505
465
  currentStage,
506
- nextStep,
507
- progress,
508
- stages,
509
- specs,
466
+ nextStep: null,
467
+ progress: { stages: {} },
468
+ stages: [],
469
+ specs: [],
510
470
  lastActive
511
471
  }
512
472
  }
@@ -0,0 +1,278 @@
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
+ } from './endpoint-extractor.js'
18
+
19
+ // ─── 关键词检测 ─────────────────────────────────────────────────────────
20
+
21
+ const PROVIDER_KEYWORDS = /router|routes|endpoint|api|backend|controller|fastapi|flask|express|koa|spring/i
22
+ const CONSUMER_KEYWORDS = /frontend|client|service|apiFetch|request|fetch|axios|http/i
23
+
24
+ /**
25
+ * 判断一个 task 文档是 provider(产出 API)还是 consumer(消费 API)
26
+ * @param {string} taskContent - task markdown 内容
27
+ * @returns {{ isProvider: boolean, isConsumer: boolean, confidence: number }}
28
+ */
29
+ export function classifyTask(taskContent) {
30
+ const isProvider = PROVIDER_KEYWORDS.test(taskContent)
31
+ const isConsumer = CONSUMER_KEYWORDS.test(taskContent)
32
+ // 避免所有 task 都被标记(因为几乎所有 task 都含 "api")
33
+ // 加强判定:provider 要命中 router/endpoint/backend/controller + api
34
+ // consumer 要命中 frontend/client/apiFetch + api
35
+ const providerStrong = /router|endpoint|backend|controller|fastapi|flask/i.test(taskContent)
36
+ const consumerStrong = /frontend|apiFetch|axios|api.*client/i.test(taskContent)
37
+ const providerConfidence = providerStrong ? 0.8 : (isProvider ? 0.4 : 0)
38
+ const consumerConfidence = consumerStrong ? 0.8 : (isConsumer ? 0.4 : 0)
39
+ return {
40
+ isProvider: providerConfidence >= 0.4,
41
+ isConsumer: consumerConfidence >= 0.4,
42
+ confidence: Math.max(providerConfidence, consumerConfidence),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 从 plan.md 解析 task 依赖关系,识别 provider → consumer 对
48
+ * @param {string} planContent - plan.md 内容
49
+ * @param {string} changeDir - changes/<name>/ 目录
50
+ * @returns {Array<{ provider: string, consumer: string, type: string }>}
51
+ */
52
+ export function buildContractMatrix(planContent, changeDir) {
53
+ const contracts = []
54
+
55
+ // 解析 task 依赖关系
56
+ // 格式: - [ ] task-04: ... (depends_on: [task-01]) 或
57
+ // | task-04 | ... | 01 |
58
+ const taskDeps = parseTaskDependencies(planContent)
59
+
60
+ // 读取各 task 文档,分类 provider/consumer
61
+ const taskClasses = {}
62
+ for (const taskName of Object.keys(taskDeps)) {
63
+ const taskFile = join(changeDir, 'tasks', `${taskName}.md`)
64
+ if (existsSync(taskFile)) {
65
+ taskClasses[taskName] = classifyTask(readFileSync(taskFile, 'utf8'))
66
+ }
67
+ }
68
+
69
+ // 识别契约对:A depends_on B,且 A 是 consumer,B 是 provider
70
+ for (const [consumer, deps] of Object.entries(taskDeps)) {
71
+ const consumerClass = taskClasses[consumer]
72
+ if (!consumerClass?.isConsumer) continue
73
+
74
+ for (const provider of deps) {
75
+ const providerClass = taskClasses[provider]
76
+ if (!providerClass?.isProvider) continue
77
+
78
+ // 避免自引用和重复
79
+ if (consumer === provider) continue
80
+ const alreadyExists = contracts.some(
81
+ c => c.provider === provider && c.consumer === consumer
82
+ )
83
+ if (alreadyExists) continue
84
+
85
+ contracts.push({
86
+ provider,
87
+ consumer,
88
+ type: 'api',
89
+ })
90
+ }
91
+ }
92
+
93
+ return contracts
94
+ }
95
+
96
+ /**
97
+ * 从 plan.md 解析 task 依赖关系
98
+ * @param {string} planContent
99
+ * @returns {Record<string, string[]>} task → depends_on list
100
+ */
101
+ function parseTaskDependencies(planContent) {
102
+ const deps = {}
103
+
104
+ // 方式 1: 表格形式 | task-04 | ... | 01,02 |
105
+ const tableRows = planContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
106
+ for (const match of tableRows) {
107
+ const task = `task-${match[1]}`
108
+ const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
109
+ deps[task] = depList
110
+ }
111
+
112
+ // 方式 2: depends_on 关键字
113
+ const dependsPattern = planContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
114
+ for (const match of dependsPattern) {
115
+ const task = `task-${match[1]}`
116
+ const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
117
+ if (!deps[task]) deps[task] = []
118
+ for (const d of depList) {
119
+ if (!deps[task].includes(d)) deps[task].push(d)
120
+ }
121
+ }
122
+
123
+ return deps
124
+ }
125
+
126
+ // ─── Execute 阶段:后端 task 完成后提取 artifact ───────────────────────
127
+
128
+ /**
129
+ * 后端 task 完成后,扫描变更文件提取 endpoint artifact
130
+ * @param {string} changeDir - changes/<name>/ 目录
131
+ * @param {string} worktreePath - worktree 路径(扫描源码用)
132
+ * @param {string} specBase - .sillyspec 目录
133
+ * @param {string} taskName - task-04
134
+ * @returns {{ ok: boolean, endpoints: Array, artifactPath: string|null }}
135
+ */
136
+ export function extractProviderArtifact(changeDir, worktreePath, specBase, taskName) {
137
+ const artifactDir = join(specBase, '.runtime', 'contract-artifacts', taskName)
138
+ const artifactPath = join(artifactDir, 'endpoints.json')
139
+
140
+ if (!worktreePath || !existsSync(worktreePath)) {
141
+ return { ok: false, endpoints: [], artifactPath: null, error: 'worktree not found' }
142
+ }
143
+
144
+ try {
145
+ const endpoints = scanBackendEndpoints(worktreePath)
146
+
147
+ if (endpoints.length > 0) {
148
+ mkdirSync(artifactDir, { recursive: true })
149
+ const artifact = {
150
+ task: taskName,
151
+ type: 'backend_endpoints',
152
+ extractedAt: new Date().toISOString(),
153
+ endpoints: endpoints.map(e => ({
154
+ method: e.method,
155
+ path: normalizePath(e.path),
156
+ source: relative(worktreePath, e.source),
157
+ line: e.line,
158
+ })),
159
+ }
160
+ writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + '\n')
161
+ return { ok: true, endpoints: artifact.endpoints, artifactPath }
162
+ }
163
+
164
+ // 无端点提取到 — 不算错误(可能不是 router task)
165
+ return { ok: true, endpoints: [], artifactPath: null }
166
+ } catch (e) {
167
+ return { ok: false, endpoints: [], artifactPath: null, error: e.message }
168
+ }
169
+ }
170
+
171
+ // ─── Execute 阶段:前端 task 开始时注入契约 ─────────────────────────────
172
+
173
+ /**
174
+ * 为 consumer task 构建上游契约注入文本
175
+ * @param {string} changeDir - changes/<name>/ 目录
176
+ * @param {string} specBase - .sillyspec 目录
177
+ * @param {string} taskName - 当前 task(consumer)
178
+ * @param {Array<{ provider: string, consumer: string, type: string }>} contracts
179
+ * @returns {string|null} 注入到 prompt 的契约文本,无契约时返回 null
180
+ */
181
+ export function buildConsumerInjection(changeDir, specBase, taskName, contracts) {
182
+ const myContracts = contracts.filter(c => c.consumer === taskName)
183
+ if (myContracts.length === 0) return null
184
+
185
+ const parts = []
186
+ for (const contract of myContracts) {
187
+ const artifactDir = join(specBase, '.runtime', 'contract-artifacts', contract.provider)
188
+ const artifactFile = join(artifactDir, 'endpoints.json')
189
+
190
+ let endpoints = []
191
+ if (existsSync(artifactFile)) {
192
+ try {
193
+ const artifact = JSON.parse(readFileSync(artifactFile, 'utf8'))
194
+ endpoints = artifact.endpoints || []
195
+ } catch {}
196
+ }
197
+
198
+ parts.push(`### Upstream Contract: ${contract.provider}`)
199
+ if (endpoints.length > 0) {
200
+ parts.push(`\nAvailable endpoints from **${contract.provider}**:`)
201
+ for (const ep of endpoints) {
202
+ parts.push(`- **${ep.method}** \`${ep.path}\``)
203
+ }
204
+ } else {
205
+ parts.push(`\n⚠️ No endpoint artifact found for ${contract.provider}. This may indicate a contract gap.`)
206
+ }
207
+ }
208
+
209
+ if (parts.length === 0) return null
210
+
211
+ parts.unshift('## Upstream API Contracts')
212
+ parts.push('')
213
+ parts.push('### Rules')
214
+ parts.push('1. Do not invent API paths. Use only endpoints listed above.')
215
+ parts.push('2. If a required endpoint is missing, **stop and report the contract gap** instead of coding around it.')
216
+ parts.push('3. If you need to add new endpoints, you must also update the backend provider task.')
217
+
218
+ return parts.join('\n')
219
+ }
220
+
221
+ // ─── Verify 阶段:parity check ──────────────────────────────────────────
222
+
223
+ /**
224
+ * verify 阶段执行 API parity check
225
+ * @param {string} specBase - .sillyspec 目录
226
+ * @param {string} worktreePath - worktree 路径
227
+ * @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string }}
228
+ */
229
+ export function verifyApiParity(specBase, worktreePath) {
230
+ const { diffApiParity } = require('./endpoint-extractor.js')
231
+
232
+ // 读取所有 provider artifacts
233
+ const artifactBase = join(specBase, '.runtime', 'contract-artifacts')
234
+ const allProviderEndpoints = []
235
+
236
+ if (existsSync(artifactBase)) {
237
+ for (const entry of readdirSync(artifactBase, { withFileTypes: true })) {
238
+ if (!entry.isDirectory()) continue
239
+ const epFile = join(artifactBase, entry.name, 'endpoints.json')
240
+ if (existsSync(epFile)) {
241
+ try {
242
+ const artifact = JSON.parse(readFileSync(epFile, 'utf8'))
243
+ for (const ep of (artifact.endpoints || [])) {
244
+ allProviderEndpoints.push({
245
+ method: ep.method,
246
+ path: ep.path,
247
+ source: `${entry.name}/${ep.source}`,
248
+ })
249
+ }
250
+ } catch {}
251
+ }
252
+ }
253
+ }
254
+
255
+ // 扫描前端调用
256
+ if (!worktreePath || !existsSync(worktreePath)) {
257
+ return {
258
+ ok: true,
259
+ missingBackend: [],
260
+ unusedBackend: [],
261
+ summary: 'No worktree to scan for parity check',
262
+ }
263
+ }
264
+
265
+ const frontendCalls = scanFrontendApiCalls(worktreePath)
266
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
267
+
268
+ const ok = missingBackend.length === 0
269
+ let summary = ok
270
+ ? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
271
+ : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
272
+
273
+ if (unusedBackend.length > 0) {
274
+ summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
275
+ }
276
+
277
+ return { ok, missingBackend, unusedBackend, summary }
278
+ }