sillyspec 3.20.3 → 3.20.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.
Files changed (225) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -17
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -77
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -17
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -105
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -44
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -22
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -17
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -96
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +270 -0
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -52
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -17
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -17
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -111
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -17
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -17
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -17
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -149
  19. package/.husky/pre-push +13 -0
  20. package/CLAUDE.md +18 -0
  21. package/README.md +198 -186
  22. package/SKILL.md +90 -87
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -0
  25. package/docs/plan-execute-contract.md +123 -0
  26. package/docs/platform-scan-protocol.md +298 -0
  27. package/docs/revision-mode.md +115 -0
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -0
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -0
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -0
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -0
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -0
  33. package/docs/sillyspec/file-lifecycle.md +131 -0
  34. package/docs/workflow-contract-regression.md +106 -0
  35. package/docs/worktree-isolation.md +252 -0
  36. package/package.json +40 -34
  37. package/packages/dashboard/dist/assets/{index-D1EVTLmc.js → index-Bq_Z2hne.js} +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -0
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/dist/prototype-dashboard.html +836 -0
  41. package/packages/dashboard/dist/prototype-overview.html +256 -0
  42. package/packages/dashboard/index.html +15 -15
  43. package/packages/dashboard/package-lock.json +2384 -2384
  44. package/packages/dashboard/package.json +25 -25
  45. package/packages/dashboard/public/prototype-dashboard.html +836 -0
  46. package/packages/dashboard/public/prototype-overview.html +256 -0
  47. package/packages/dashboard/server/executor.js +86 -86
  48. package/packages/dashboard/server/index.js +588 -508
  49. package/packages/dashboard/server/parser.js +526 -458
  50. package/packages/dashboard/server/watcher.js +350 -342
  51. package/packages/dashboard/src/App.vue +558 -325
  52. package/packages/dashboard/src/components/ActionBar.vue +93 -84
  53. package/packages/dashboard/src/components/CommandPalette.vue +96 -92
  54. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  55. package/packages/dashboard/src/components/DocPreview.vue +105 -8
  56. package/packages/dashboard/src/components/DocTree.vue +75 -19
  57. package/packages/dashboard/src/components/HResizeHandle.vue +48 -0
  58. package/packages/dashboard/src/components/LogStream.vue +65 -65
  59. package/packages/dashboard/src/components/PipelineStage.vue +95 -75
  60. package/packages/dashboard/src/components/PipelineView.vue +156 -129
  61. package/packages/dashboard/src/components/ProjectCard.vue +187 -0
  62. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  63. package/packages/dashboard/src/components/ProjectOverview.vue +113 -139
  64. package/packages/dashboard/src/components/StageBadge.vue +67 -53
  65. package/packages/dashboard/src/components/StepCard.vue +94 -89
  66. package/packages/dashboard/src/components/VResizeHandle.vue +61 -0
  67. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  68. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  69. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  70. package/packages/dashboard/src/composables/useDashboard.js +192 -164
  71. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  72. package/packages/dashboard/src/composables/useLayout.js +131 -0
  73. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  74. package/packages/dashboard/src/main.js +8 -8
  75. package/packages/dashboard/src/style.css +132 -132
  76. package/packages/dashboard/vite.config.js +18 -18
  77. package/src/brainstorm-postcheck.js +158 -0
  78. package/src/change-list.js +52 -0
  79. package/src/change-risk-profile.js +352 -0
  80. package/src/classify-change.js +73 -0
  81. package/src/constants.js +70 -0
  82. package/src/contract-matrix.js +278 -0
  83. package/src/db.js +201 -0
  84. package/src/endpoint-extractor.js +315 -0
  85. package/src/hooks/claude-pre-tool-use.cjs +125 -0
  86. package/src/hooks/worktree-guard.js +761 -0
  87. package/src/index.js +922 -252
  88. package/src/init.js +470 -375
  89. package/src/knowledge-match.js +130 -0
  90. package/src/migrate.js +117 -117
  91. package/src/modules.js +482 -0
  92. package/src/progress.js +1734 -508
  93. package/src/run.js +3465 -652
  94. package/src/scan-postcheck.js +387 -0
  95. package/src/setup.js +398 -460
  96. package/src/stage-contract.js +700 -0
  97. package/src/stages/archive.js +160 -54
  98. package/src/stages/brainstorm-auto.js +229 -0
  99. package/src/stages/brainstorm.js +645 -239
  100. package/src/stages/doctor.js +365 -312
  101. package/src/stages/execute.js +634 -264
  102. package/src/stages/explore.js +34 -0
  103. package/src/stages/index.js +29 -35
  104. package/src/stages/knowledge.js +498 -0
  105. package/src/stages/plan-postcheck.js +518 -0
  106. package/src/stages/plan.js +582 -279
  107. package/src/stages/propose.js +174 -115
  108. package/src/stages/quick.js +102 -63
  109. package/src/stages/scan.js +558 -141
  110. package/src/stages/status.js +65 -65
  111. package/src/stages/verify.js +322 -135
  112. package/src/sync.js +497 -0
  113. package/src/task-review.js +346 -0
  114. package/src/workflow.js +785 -0
  115. package/src/worktree-apply.js +549 -0
  116. package/src/worktree-deps.js +185 -0
  117. package/src/worktree.js +982 -0
  118. package/templates/workflows/archive-impact.yaml +79 -0
  119. package/templates/workflows/scan-docs.yaml +132 -0
  120. package/test/brainstorm-plan-contract.test.mjs +273 -0
  121. package/test/check-syntax.mjs +26 -0
  122. package/test/cli-top-level-aliases.test.mjs +174 -0
  123. package/test/contract-artifacts.test.mjs +323 -0
  124. package/test/decision-supersede.test.mjs +277 -0
  125. package/test/knowledge-match.test.mjs +231 -0
  126. package/test/plan-execute-contract.test.mjs +357 -0
  127. package/test/plan-optimization.test.mjs +572 -0
  128. package/test/platform-artifacts.test.mjs +190 -0
  129. package/test/platform-failure-samples.test.mjs +199 -0
  130. package/test/platform-recovery-chain.test.mjs +179 -0
  131. package/test/platform-recovery.test.mjs +167 -0
  132. package/test/platform-scan-p0.test.mjs +175 -0
  133. package/test/revision-v1.test.mjs +1145 -0
  134. package/test/run-sanitize-project-name.test.mjs +51 -0
  135. package/test/run-scan-postcheck-fail.test.mjs +64 -0
  136. package/test/run-scan-project-parse.test.mjs +200 -0
  137. package/test/run-tests.mjs +48 -0
  138. package/test/runtime-cleanup-keeps-worktree.test.mjs +107 -0
  139. package/test/scan-docs-yaml-placeholders.test.mjs +84 -0
  140. package/test/scan-knowledge.test.mjs +175 -0
  141. package/test/scan-paths.test.mjs +68 -0
  142. package/test/scan-postcheck-project-priority.test.mjs +85 -0
  143. package/test/scan-postcheck.test.mjs +197 -0
  144. package/test/scan-workflow-anyfailed-block.test.mjs +52 -0
  145. package/test/spec-dir.test.mjs +206 -0
  146. package/test/stage-contract-failed-post-check.test.mjs +102 -0
  147. package/test/stage-contract.test.mjs +299 -0
  148. package/test/stage-definitions.test.mjs +39 -0
  149. package/test/wait-gates.test.mjs +501 -0
  150. package/test/workflow-spec-base.test.mjs +142 -0
  151. package/test/worktree-deps-provision.test.mjs +148 -0
  152. package/test/worktree-guard.test.mjs +136 -0
  153. package/test/worktree-native-overlay.test.mjs +188 -0
  154. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +0 -97
  155. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +0 -51
  156. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +0 -29
  157. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +0 -34
  158. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +0 -13
  159. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +0 -43
  160. package/.sillyspec/changes/auto-mode/design.md +0 -50
  161. package/.sillyspec/changes/auto-mode/proposal.md +0 -19
  162. package/.sillyspec/changes/auto-mode/requirements.md +0 -21
  163. package/.sillyspec/changes/auto-mode/tasks.md +0 -7
  164. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +0 -206
  165. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +0 -199
  166. package/.sillyspec/changes/dashboard/design.md +0 -219
  167. package/.sillyspec/changes/dashboard/design.md.braindraft +0 -206
  168. package/.sillyspec/changes/run-command-design/design.md +0 -1230
  169. package/.sillyspec/changes/unified-docs-design/design.md +0 -199
  170. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  171. package/.sillyspec/knowledge/INDEX.md +0 -8
  172. package/.sillyspec/knowledge/uncategorized.md +0 -3
  173. package/.sillyspec/plans/2026-04-05-dashboard.md +0 -737
  174. package/.sillyspec/projects/sillyspec.yaml +0 -3
  175. package/dist/steps/brainstorm/01-load-context.md +0 -30
  176. package/dist/steps/brainstorm/02-reuse-check.md +0 -6
  177. package/dist/steps/brainstorm/03-prototype-analysis.md +0 -11
  178. package/dist/steps/brainstorm/04-module-split.md +0 -23
  179. package/dist/steps/brainstorm/05-dialog-explore.md +0 -8
  180. package/dist/steps/brainstorm/06-propose-approaches.md +0 -3
  181. package/dist/steps/brainstorm/07-present-design.md +0 -3
  182. package/dist/steps/brainstorm/08-write-design.md +0 -21
  183. package/dist/steps/brainstorm/09-self-review.md +0 -15
  184. package/dist/steps/brainstorm/10-user-confirm.md +0 -3
  185. package/dist/steps/brainstorm/11-output-spec.md +0 -7
  186. package/dist/steps/brainstorm/manifest.yaml +0 -26
  187. package/dist/steps/execute/01-load-context.md +0 -41
  188. package/dist/steps/execute/02-scan-conventions.md +0 -47
  189. package/dist/steps/execute/03-skill-mcp.md +0 -19
  190. package/dist/steps/execute/04-assign-task.md +0 -22
  191. package/dist/steps/execute/04b-prompt-template.md +0 -54
  192. package/dist/steps/execute/05-write-test.md +0 -7
  193. package/dist/steps/execute/06-write-code.md +0 -8
  194. package/dist/steps/execute/07-run-test.md +0 -26
  195. package/dist/steps/execute/08-fix-issues.md +0 -28
  196. package/dist/steps/execute/09-next-task.md +0 -33
  197. package/dist/steps/execute/manifest.yaml +0 -28
  198. package/dist/steps/plan/01-load-context.md +0 -22
  199. package/dist/steps/plan/02-anchor-confirm.md +0 -1
  200. package/dist/steps/plan/03-expand-tasks.md +0 -33
  201. package/dist/steps/plan/04-mark-order.md +0 -15
  202. package/dist/steps/plan/05-e2e-planning.md +0 -17
  203. package/dist/steps/plan/06-self-check.md +0 -16
  204. package/dist/steps/plan/07-save.md +0 -1
  205. package/dist/steps/plan/manifest.yaml +0 -18
  206. package/dist/steps/scan/01-env-detect.md +0 -51
  207. package/dist/steps/scan/02-tech-stack.md +0 -16
  208. package/dist/steps/scan/03-conventions.md +0 -16
  209. package/dist/steps/scan/04-structure.md +0 -19
  210. package/dist/steps/scan/05-quality.md +0 -18
  211. package/dist/steps/scan/06-complete.md +0 -49
  212. package/dist/steps/scan/manifest.yaml +0 -16
  213. package/dist/steps/verify/01-load-specs.md +0 -28
  214. package/dist/steps/verify/02-check-tasks.md +0 -1
  215. package/dist/steps/verify/03-check-design.md +0 -6
  216. package/dist/steps/verify/04-run-tests.md +0 -7
  217. package/dist/steps/verify/05-e2e-tests.md +0 -27
  218. package/dist/steps/verify/05b-e2e-fix.md +0 -33
  219. package/dist/steps/verify/06-code-quality.md +0 -25
  220. package/dist/steps/verify/07-lint-check.md +0 -27
  221. package/dist/steps/verify/08-output-report.md +0 -14
  222. package/dist/steps/verify/manifest.yaml +0 -22
  223. package/packages/dashboard/dist/assets/index-DGe8CqeP.css +0 -1
  224. package/src/derive.js +0 -147
  225. package/src/step.js +0 -543
@@ -0,0 +1,315 @@
1
+ /**
2
+ * endpoint-extractor.js — 从代码中提取 HTTP 端点定义和调用
3
+ *
4
+ * provider 端:扫描 router 文件,提取注册的 API 路径
5
+ * consumer 端:扫描前端文件,提取 apiFetch/request 调用路径
6
+ *
7
+ * 契约对账时:provider 产出 ≠ consumer 消费 → gap
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
11
+ import { join, extname, basename, dirname, resolve } from 'path'
12
+ import { execSync } from 'child_process'
13
+
14
+ // ─── Provider: 扫描后端 router 注册的端点 ───────────────────────────────
15
+
16
+ /**
17
+ * 从单个文件提取 FastAPI router 端点
18
+ * 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
19
+ *
20
+ * @param {string} filePath - 文件绝对路径
21
+ * @returns {Array<{ method: string, path: string, source: string, line: number }>}
22
+ */
23
+ export function extractFastApiEndpoints(filePath) {
24
+ const content = readFileSync(filePath, 'utf8')
25
+ const lines = content.split('\n')
26
+ const endpoints = []
27
+
28
+ // 1. 提取 router prefix
29
+ let routerPrefix = ''
30
+ for (const line of lines) {
31
+ const prefixMatch = line.match(/(?:APIRouter|router)\s*\(\s*(?:prefix\s*=\s*)?["'`]([^"'`]+)["'`]/)
32
+ || line.match(/\.include_router\s*\([^)]*prefix\s*=\s*["'`]([^"'`]+)["'`]/)
33
+ if (prefixMatch) {
34
+ routerPrefix = prefixMatch[1]
35
+ }
36
+ }
37
+
38
+ // 2. 提取 @router.method("/path") 或分散式定义
39
+ // FastAPI 支持两种写法:
40
+ // a) @router.get("/path", ...) 下一行 def func():
41
+ // b) @router.get 下一行 ("/path", ...)
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i]
44
+ const decoratorMatch = line.match(
45
+ /@(?:router|api_router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/
46
+ )
47
+ if (decoratorMatch) {
48
+ const method = decoratorMatch[1].toUpperCase()
49
+ const rawPath = decoratorMatch[2]
50
+ endpoints.push({
51
+ method,
52
+ path: routerPrefix + rawPath,
53
+ source: filePath,
54
+ line: i + 1,
55
+ })
56
+ continue
57
+ }
58
+ // 分散式: @router.get\n ("/path",
59
+ const splitMatch = line.match(/@(?:router|api_router)\.(get|post|put|delete|patch)\s*$/)
60
+ if (splitMatch && i + 1 < lines.length) {
61
+ const nextLine = lines[i + 1]
62
+ const pathMatch = nextLine.match(/\(\s*["'`]([^"'`]+)["'`]/)
63
+ if (pathMatch) {
64
+ endpoints.push({
65
+ method: splitMatch[1].toUpperCase(),
66
+ path: routerPrefix + pathMatch[1],
67
+ source: filePath,
68
+ line: i + 1,
69
+ })
70
+ }
71
+ }
72
+ }
73
+
74
+ return endpoints
75
+ }
76
+
77
+ /**
78
+ * 从目录递归扫描所有 Python router 文件的端点
79
+ * @param {string} dir
80
+ * @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
81
+ * @returns {Array<{ method: string, path: string, source: string, line: number }>}
82
+ */
83
+ export function scanBackendEndpoints(dir, opts = {}) {
84
+ const filePattern = opts.filePattern || /(?:router|routes|api|endpoint|controller)\.py$/i
85
+ const excludePatterns = opts.excludePatterns || [/__pycache__/, /node_modules/, /\.venv/, /test/i]
86
+
87
+ const results = []
88
+ if (!existsSync(dir)) return results
89
+
90
+ function walk(d) {
91
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
92
+ const full = join(d, entry.name)
93
+ if (entry.isDirectory()) {
94
+ if (excludePatterns.some(p => p.test(entry.name))) continue
95
+ walk(full)
96
+ } else if (entry.isFile() && filePattern.test(entry.name)) {
97
+ try {
98
+ results.push(...extractFastApiEndpoints(full))
99
+ } catch {}
100
+ }
101
+ }
102
+ }
103
+ walk(dir)
104
+ return results
105
+ }
106
+
107
+ // ─── Consumer: 扫描前端 API 调用路径 ─────────────────────────────────────
108
+
109
+ /**
110
+ * 从前端文件提取 API 调用路径
111
+ * 支持:
112
+ * apiFetch("/api/xxx")
113
+ * request("/api/xxx")
114
+ * axios.get("/api/xxx")
115
+ * axios.post("/api/xxx")
116
+ * fetch("/api/xxx")
117
+ *
118
+ * @param {string} filePath
119
+ * @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
120
+ */
121
+ export function extractFrontendApiCalls(filePath) {
122
+ const content = readFileSync(filePath, 'utf8')
123
+ const lines = content.split('\n')
124
+ const results = []
125
+
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const line = lines[i]
128
+
129
+ // Pattern 1: apiFetch<T>("/path", { method: "POST" }) — default GET
130
+ const apiFetchMatch = line.match(/apiFetch\s*(?:<[^>]*>)?\s*\(\s*["'`]([^"'`]+)["'`]/)
131
+ if (apiFetchMatch) {
132
+ // 检查是否有 method 字段(在后续行或同一行)
133
+ let method = 'GET'
134
+ const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
135
+ const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
136
+ if (methodMatch) method = methodMatch[1].toUpperCase()
137
+
138
+ results.push({
139
+ method,
140
+ path: normalizePath(apiFetchMatch[1]),
141
+ source: filePath,
142
+ line: i + 1,
143
+ raw: apiFetchMatch[1],
144
+ })
145
+ continue
146
+ }
147
+
148
+ // Pattern 2: axios.get/post/put/delete("/path")
149
+ const axiosMatch = line.match(/axios\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/i)
150
+ if (axiosMatch) {
151
+ results.push({
152
+ method: axiosMatch[1].toUpperCase(),
153
+ path: normalizePath(axiosMatch[2]),
154
+ source: filePath,
155
+ line: i + 1,
156
+ raw: axiosMatch[2],
157
+ })
158
+ continue
159
+ }
160
+
161
+ // Pattern 3: fetch("/api/xxx", { method: "POST" })
162
+ const fetchMatch = line.match(/(?:api_?)?fetch\s*\(\s*["'`]([^"'`]+)["'`]/)
163
+ if (fetchMatch && !apiFetchMatch) {
164
+ let method = 'GET'
165
+ const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
166
+ const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
167
+ if (methodMatch) method = methodMatch[1].toUpperCase()
168
+
169
+ results.push({
170
+ method,
171
+ path: normalizePath(fetchMatch[1]),
172
+ source: filePath,
173
+ line: i + 1,
174
+ raw: fetchMatch[1],
175
+ })
176
+ continue
177
+ }
178
+ }
179
+
180
+ return results
181
+ }
182
+
183
+ /**
184
+ * 递归扫描前端目录的 API 调用
185
+ * @param {string} dir
186
+ * @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
187
+ * @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
188
+ */
189
+ export function scanFrontendApiCalls(dir, opts = {}) {
190
+ const filePattern = opts.filePattern || /\.(ts|tsx|js|jsx)$/
191
+ const excludePatterns = opts.excludePatterns || [/node_modules/, /\.next/, /dist/, /__tests__/, /\.d\.ts$/]
192
+
193
+ const results = []
194
+ if (!existsSync(dir)) return results
195
+
196
+ function walk(d) {
197
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
198
+ const full = join(d, entry.name)
199
+ if (entry.isDirectory()) {
200
+ if (excludePatterns.some(p => p.test(entry.name))) continue
201
+ walk(full)
202
+ } else if (entry.isFile() && filePattern.test(entry.name)) {
203
+ try {
204
+ results.push(...extractFrontendApiCalls(full))
205
+ } catch {}
206
+ }
207
+ }
208
+ }
209
+ walk(dir)
210
+ return results
211
+ }
212
+
213
+ // ─── 路径归一化 ─────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * 将动态路径归一化为参数占位符
217
+ * /api/ppm/project-plan/${id}/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
218
+ * /api/ppm/project-plan/:planId/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
219
+ * @param {string} rawPath
220
+ * @returns {string}
221
+ */
222
+ export function normalizePath(rawPath) {
223
+ return rawPath
224
+ .replace(/\$\{[^}]+\}/g, '{param}')
225
+ .replace(/:\w+/g, '{param}')
226
+ .replace(/\$\w+/g, '{param}')
227
+ }
228
+
229
+ // ─── 对账 ───────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * 比较前端调用的路径和后端注册的端点,返回差异
233
+ *
234
+ * @param {Array<{ path: string, method: string, source: string }>} frontendCalls
235
+ * @param {Array<{ path: string, method: string, source: string }>} backendEndpoints
236
+ * @returns {{
237
+ * missingBackend: Array<{ path: string, method: string, consumerFile: string, consumerLine: number }>,
238
+ * unusedBackend: Array<{ path: string, method: string, providerFile: string }>
239
+ * }}
240
+ */
241
+ export function diffApiParity(frontendCalls, backendEndpoints) {
242
+ // 构建 backend 注册表:归一化 path + method → endpoint
243
+ const backendMap = new Map()
244
+ for (const ep of backendEndpoints) {
245
+ const key = `${ep.method}:${normalizePath(ep.path)}`
246
+ if (!backendMap.has(key)) backendMap.set(key, ep)
247
+ }
248
+
249
+ const missingBackend = []
250
+ for (const call of frontendCalls) {
251
+ const key = `${call.method}:${normalizePath(call.path)}`
252
+ if (!backendMap.has(key)) {
253
+ missingBackend.push({
254
+ path: normalizePath(call.path),
255
+ method: call.method,
256
+ consumerFile: call.source,
257
+ consumerLine: call.line,
258
+ })
259
+ }
260
+ }
261
+
262
+ // 构建 frontend 调用表
263
+ const frontendSet = new Set(
264
+ frontendCalls.map(c => `${c.method}:${normalizePath(c.path)}`)
265
+ )
266
+
267
+ const unusedBackend = []
268
+ for (const ep of backendEndpoints) {
269
+ const key = `${ep.method}:${normalizePath(ep.path)}`
270
+ if (!frontendSet.has(key)) {
271
+ unusedBackend.push({
272
+ path: normalizePath(ep.path),
273
+ method: ep.method,
274
+ providerFile: ep.source,
275
+ })
276
+ }
277
+ }
278
+
279
+ return { missingBackend, unusedBackend, ok: missingBackend.length === 0 }
280
+ }
281
+
282
+ // ─── CLI 入口 ────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * CLI 子命令入口:sillyspec contract scan [--backend dir] [--frontend dir]
286
+ * 输出 JSON 格式的端点清单和对账结果
287
+ */
288
+ export async function contractScan(args, cwd) {
289
+ const backendIdx = args.indexOf('--backend')
290
+ const frontendIdx = args.indexOf('--frontend')
291
+ const backendDir = backendIdx !== -1 && args[backendIdx + 1]
292
+ ? resolve(cwd, args[backendIdx + 1])
293
+ : resolve(cwd, 'backend')
294
+ const frontendDir = frontendIdx !== -1 && args[frontendIdx + 1]
295
+ ? resolve(cwd, args[frontendIdx + 1])
296
+ : resolve(cwd, 'frontend')
297
+
298
+ const backendEndpoints = scanBackendEndpoints(backendDir)
299
+ const frontendCalls = scanFrontendApiCalls(frontendDir)
300
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, backendEndpoints)
301
+
302
+ return {
303
+ backend: backendEndpoints.map(e => ({ method: e.method, path: normalizePath(e.path), file: e.source })),
304
+ frontend: frontendCalls.map(c => ({ method: c.method, path: normalizePath(c.path), file: c.source })),
305
+ missingBackend,
306
+ unusedBackend,
307
+ summary: {
308
+ backendEndpointCount: backendEndpoints.length,
309
+ frontendCallCount: frontendCalls.length,
310
+ missingBackendCount: missingBackend.length,
311
+ unusedBackendCount: unusedBackend.length,
312
+ ok: missingBackend.length === 0,
313
+ },
314
+ }
315
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PreToolUse hook 入口
4
+ *
5
+ * 从 stdin 读取 JSON,调用 worktree-guard 判断是否拦截。
6
+ *
7
+ * Claude Code hooks.json 配置:
8
+ * {
9
+ * "hooks": {
10
+ * "PreToolUse": [
11
+ * {
12
+ * "matcher": "Edit|Write|MultiEdit|Bash",
13
+ * "hooks": [
14
+ * {
15
+ * "type": "command",
16
+ * "command": "node /path/to/sillyspec/src/hooks/claude-pre-tool-use.cjs"
17
+ * }
18
+ * ]
19
+ * }
20
+ * ]
21
+ * }
22
+ * }
23
+ */
24
+
25
+ 'use strict'
26
+
27
+ const path = require('path')
28
+ const fs = require('fs')
29
+
30
+ // 动态 import ESM 模块
31
+ async function main() {
32
+ // Claude Code hook 通过 stdin 传入 JSON
33
+ let input = ''
34
+ try {
35
+ input = await readStdin()
36
+ } catch {
37
+ // 无法读取 stdin → 放行(安全优先于阻断)
38
+ process.exit(0)
39
+ }
40
+
41
+ let parsed
42
+ try {
43
+ parsed = JSON.parse(input)
44
+ } catch {
45
+ // 非法 JSON → 放行
46
+ process.exit(0)
47
+ }
48
+
49
+ // 解析 Claude Code hook 输入格式
50
+ // 格式:{ tool_name: string, tool_input: { ... } }
51
+ const toolName = parsed.tool_name || parsed.tool || ''
52
+ const toolInput = parsed.tool_input || {}
53
+
54
+ // 转换为 shouldBlock 的输入格式
55
+ const toolMap = {
56
+ 'Write': 'Write',
57
+ 'Edit': 'Edit',
58
+ 'MultiEdit': 'MultiEdit',
59
+ 'Bash': 'Bash',
60
+ }
61
+
62
+ const mappedTool = toolMap[toolName]
63
+ if (!mappedTool) {
64
+ // 不在拦截范围内的工具 → 放行
65
+ process.exit(0)
66
+ }
67
+
68
+ // 构造 opts
69
+ const opts = { tool: mappedTool }
70
+ if (mappedTool === 'Bash') {
71
+ opts.command = toolInput.command || ''
72
+ } else {
73
+ // Write/Edit/MultiEdit
74
+ const fp = toolInput.file_path || toolInput.filePath
75
+ if (fp) opts.filePath = fp
76
+ // MultiEdit 可能有多个文件
77
+ if (mappedTool === 'MultiEdit') {
78
+ const fps = toolInput.edits
79
+ ? toolInput.edits.map(e => e.file_path || e.filePath).filter(Boolean)
80
+ : []
81
+ if (fps.length > 0) opts.filePaths = fps
82
+ }
83
+ }
84
+
85
+ // 加载 worktree-guard(ESM)
86
+ let shouldBlock
87
+ try {
88
+ const mod = await import('./worktree-guard.js')
89
+ shouldBlock = mod.shouldBlock
90
+ } catch (e) {
91
+ // 模块加载失败 → 放行(不因为 hook 出错阻断工作流)
92
+ // eslint-disable-next-line no-console
93
+ console.error(`[sillyspec-hook] 模块加载失败: ${e.message}`)
94
+ process.exit(0)
95
+ }
96
+
97
+ const result = shouldBlock(opts)
98
+
99
+ if (result.blocked) {
100
+ // 输出错误信息(Claude Code 会显示给 agent)
101
+ // eslint-disable-next-line no-console
102
+ console.error(`[sillyspec] ❌ ${result.reason || 'blocked by worktree guard'}`)
103
+ process.exit(2) // exit code 2 = 阻止工具执行
104
+ }
105
+
106
+ // 放行
107
+ process.exit(0)
108
+ }
109
+
110
+ function readStdin() {
111
+ return new Promise((resolve, reject) => {
112
+ const chunks = []
113
+ process.stdin.setEncoding('utf8')
114
+ process.stdin.on('data', chunk => chunks.push(chunk))
115
+ process.stdin.on('end', () => resolve(chunks.join('')))
116
+ process.stdin.on('error', reject)
117
+ // 超时保护(3秒)
118
+ setTimeout(() => {
119
+ process.stdin.destroy()
120
+ resolve('')
121
+ }, 3000)
122
+ })
123
+ }
124
+
125
+ main().catch(() => process.exit(0))