terramend 0.2.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.
Files changed (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ToolContext } from "#app/mcp/server";
3
+ import {
4
+ assessStaleFix,
5
+ ClosePullRequestTool,
6
+ groupIdFromBranch,
7
+ isBotActor,
8
+ isRemediationBranch,
9
+ ListRemediationPrsTool,
10
+ } from "#app/mcp/staleFix";
11
+
12
+ describe("isRemediationBranch / groupIdFromBranch", () => {
13
+ it("recognises remediate and generate branches, rejects others", () => {
14
+ expect(isRemediationBranch("remediate/abc123")).toBe(true);
15
+ expect(isRemediationBranch("remediate/batch-deadbeef")).toBe(true);
16
+ expect(isRemediationBranch("terramend/generate-s3-site")).toBe(true);
17
+ expect(isRemediationBranch("feature/foo")).toBe(false);
18
+ expect(isRemediationBranch("main")).toBe(false);
19
+ });
20
+
21
+ it("extracts the group id from a remediate branch only", () => {
22
+ expect(groupIdFromBranch("remediate/abc123")).toBe("abc123");
23
+ expect(groupIdFromBranch("remediate/batch-deadbeef")).toBe("batch-deadbeef");
24
+ expect(groupIdFromBranch("terramend/generate-s3")).toBeNull();
25
+ expect(groupIdFromBranch("main")).toBeNull();
26
+ });
27
+ });
28
+
29
+ describe("isBotActor", () => {
30
+ it("treats terramend logins and any [bot] as bot; a null login as non-human", () => {
31
+ expect(isBotActor("terramend[bot]")).toBe(true);
32
+ expect(isBotActor("terramenddev")).toBe(true);
33
+ expect(isBotActor("dependabot[bot]")).toBe(true);
34
+ expect(isBotActor(null)).toBe(true); // unmapped author → not provably human
35
+ expect(isBotActor(undefined)).toBe(true);
36
+ });
37
+
38
+ it("treats a real user login as non-bot", () => {
39
+ expect(isBotActor("alice")).toBe(false);
40
+ expect(isBotActor("octocat")).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("assessStaleFix", () => {
45
+ it("escalates a human-touched branch regardless of base movement", () => {
46
+ const a = assessStaleFix({ baseBehindBy: 5, hasNonBotCommits: true });
47
+ expect(a.status).toBe("human_touched");
48
+ expect(a.action).toBe("escalate");
49
+ });
50
+
51
+ it("skips a PR whose base has not advanced", () => {
52
+ const a = assessStaleFix({ baseBehindBy: 0, hasNonBotCommits: false });
53
+ expect(a.status).toBe("current");
54
+ expect(a.action).toBe("skip");
55
+ });
56
+
57
+ it("flags a stale PR for refresh when the base advanced", () => {
58
+ const a = assessStaleFix({ baseBehindBy: 3, hasNonBotCommits: false });
59
+ expect(a.status).toBe("stale");
60
+ expect(a.action).toBe("refresh");
61
+ expect(a.reason).toContain("3 commit");
62
+ });
63
+ });
64
+
65
+ // --- tool tests ------------------------------------------------------------
66
+
67
+ function execText(t: ReturnType<typeof ListRemediationPrsTool>) {
68
+ return t.execute as (
69
+ p: unknown,
70
+ c: unknown,
71
+ ) => Promise<{ content: [{ type: "text"; text: string }]; isError?: boolean }>;
72
+ }
73
+
74
+ describe("ListRemediationPrsTool", () => {
75
+ function makeCtx(
76
+ openPrs: unknown[],
77
+ compares: Record<string, { ahead_by: number; behind_by: number; commits: unknown[] }>,
78
+ ): ToolContext {
79
+ return {
80
+ repo: { owner: "o", name: "r" },
81
+ payload: {},
82
+ octokit: {
83
+ // paginate is mocked to return the list directly; the first arg (the
84
+ // `pulls.list` route) just needs to exist to be referenced.
85
+ paginate: vi.fn(async () => openPrs),
86
+ rest: {
87
+ pulls: { list: vi.fn() },
88
+ repos: {
89
+ compareCommits: vi.fn(async ({ head }: { head: string }) => ({
90
+ data: compares[head] ?? { ahead_by: 1, behind_by: 0, commits: [] },
91
+ })),
92
+ },
93
+ },
94
+ },
95
+ } as unknown as ToolContext;
96
+ }
97
+
98
+ it("filters to remediation branches and assesses each", async () => {
99
+ const openPrs = [
100
+ {
101
+ number: 1,
102
+ html_url: "u1",
103
+ title: "fix a",
104
+ head: { ref: "remediate/aaa" },
105
+ base: { ref: "main" },
106
+ labels: [{ name: "terramend" }],
107
+ },
108
+ {
109
+ number: 2,
110
+ html_url: "u2",
111
+ title: "human PR",
112
+ head: { ref: "feature/x" },
113
+ base: { ref: "main" },
114
+ labels: [],
115
+ },
116
+ {
117
+ number: 3,
118
+ html_url: "u3",
119
+ title: "fix b (stale)",
120
+ head: { ref: "remediate/bbb" },
121
+ base: { ref: "main" },
122
+ labels: [],
123
+ },
124
+ ];
125
+ const compares = {
126
+ "remediate/aaa": {
127
+ ahead_by: 1,
128
+ behind_by: 0,
129
+ commits: [{ author: { login: "terramend[bot]" } }],
130
+ },
131
+ "remediate/bbb": {
132
+ ahead_by: 1,
133
+ behind_by: 4,
134
+ commits: [{ author: { login: "terramend[bot]" } }],
135
+ },
136
+ };
137
+ const ctx = makeCtx(openPrs, compares);
138
+ const text = await execText(ListRemediationPrsTool(ctx))({}, {});
139
+ const out = text.content[0].text;
140
+ expect(out).toContain("ok: true");
141
+ // only the two remediation PRs appear (feature/x filtered out)
142
+ expect(out).toContain("remediate/aaa");
143
+ expect(out).toContain("remediate/bbb");
144
+ expect(out).not.toContain("feature/x");
145
+ // #1 current (behind 0) → skip; #3 stale (behind 4) → refresh
146
+ expect(out).toContain("skip");
147
+ expect(out).toContain("refresh");
148
+ });
149
+
150
+ it("marks a branch with a human commit as escalate", async () => {
151
+ const openPrs = [
152
+ {
153
+ number: 5,
154
+ html_url: "u5",
155
+ title: "fix c",
156
+ head: { ref: "remediate/ccc" },
157
+ base: { ref: "main" },
158
+ labels: [],
159
+ },
160
+ ];
161
+ const compares = {
162
+ "remediate/ccc": {
163
+ ahead_by: 2,
164
+ behind_by: 3,
165
+ commits: [{ author: { login: "terramend[bot]" } }, { author: { login: "alice" } }],
166
+ },
167
+ };
168
+ const ctx = makeCtx(openPrs, compares);
169
+ const text = await execText(ListRemediationPrsTool(ctx))({}, {});
170
+ const out = text.content[0].text;
171
+ expect(out).toContain("escalate");
172
+ expect(out).toContain("human_touched");
173
+ expect(out).toContain("alice");
174
+ });
175
+ });
176
+
177
+ describe("ClosePullRequestTool", () => {
178
+ function makeCtx(opts: {
179
+ push?: "disabled" | "restricted" | "enabled";
180
+ issueNumber?: number;
181
+ }): ToolContext & {
182
+ _update: ReturnType<typeof vi.fn>;
183
+ _comment: ReturnType<typeof vi.fn>;
184
+ } {
185
+ const update = vi.fn(async ({ pull_number }: { pull_number: number }) => ({
186
+ data: { number: pull_number, state: "closed", html_url: `u${pull_number}` },
187
+ }));
188
+ const comment = vi.fn(async () => ({ data: { id: 1 } }));
189
+ const ctx = {
190
+ repo: { owner: "o", name: "r" },
191
+ payload: {
192
+ push: opts.push ?? "restricted",
193
+ event:
194
+ opts.issueNumber !== undefined
195
+ ? { trigger: "issue", issue_number: opts.issueNumber, is_pr: true }
196
+ : { trigger: "unknown" },
197
+ },
198
+ toolState: { model: undefined, createdTargets: new Set<number>() },
199
+ octokit: {
200
+ rest: {
201
+ issues: { createComment: comment },
202
+ pulls: { update },
203
+ },
204
+ },
205
+ } as unknown as ToolContext;
206
+ return Object.assign(ctx, { _update: update, _comment: comment });
207
+ }
208
+
209
+ it("closes a PR (standalone run = in scope) and posts the comment first", async () => {
210
+ const ctx = makeCtx({});
211
+ const text = await execText(ClosePullRequestTool(ctx) as never)(
212
+ { pull_number: 7, comment: "already resolved on base" },
213
+ {},
214
+ );
215
+ expect(text.content[0].text).toContain("state: closed");
216
+ expect(ctx._comment).toHaveBeenCalledOnce();
217
+ expect(ctx._update).toHaveBeenCalledWith(
218
+ expect.objectContaining({ pull_number: 7, state: "closed" }),
219
+ );
220
+ });
221
+
222
+ it("is blocked under push: disabled", async () => {
223
+ const ctx = makeCtx({ push: "disabled" });
224
+ const text = await execText(ClosePullRequestTool(ctx) as never)({ pull_number: 7 }, {});
225
+ expect(text.isError).toBe(true);
226
+ expect(text.content[0].text).toContain("read-only");
227
+ expect(ctx._update).not.toHaveBeenCalled();
228
+ });
229
+
230
+ it("refuses to close a PR outside the run's scope", async () => {
231
+ const ctx = makeCtx({ issueNumber: 42 });
232
+ const text = await execText(ClosePullRequestTool(ctx) as never)({ pull_number: 7 }, {});
233
+ expect(text.isError).toBe(true);
234
+ expect(text.content[0].text).toContain("scoped to #42");
235
+ expect(ctx._update).not.toHaveBeenCalled();
236
+ });
237
+ });
@@ -0,0 +1,277 @@
1
+ import { type } from "arktype";
2
+ import { addFooter } from "#app/mcp/comment";
3
+ import { assertTargetInScope } from "#app/mcp/scope";
4
+ import type { ToolContext } from "#app/mcp/server";
5
+ import { execute, tool, toolOk } from "#app/mcp/shared";
6
+ import { log } from "#app/utils/cli";
7
+
8
+ /**
9
+ * §27 Stale-fix self-healing. A Terramend remediation PR is "base + a minimal,
10
+ * proven fix". When the base branch advances after the PR is opened, the PR goes
11
+ * stale: its diff is computed against an old base, the concern may already be
12
+ * resolved upstream (a human fixed it, or the base changed the file), or the
13
+ * branch simply needs re-deriving on the new base. A scheduled `RefreshRemediation`
14
+ * run sweeps the open remediation PRs and, per PR, either re-derives the fix on
15
+ * the current base and force-updates it, closes it as already-resolved, or leaves
16
+ * it for a human when someone has added their own commits.
17
+ *
18
+ * This file is the GitHub-orchestration seam: a pure classifier (`assessStaleFix`)
19
+ * that decides what to do from git facts, the read tool that surfaces the open
20
+ * remediation PRs with their staleness, and the focused write tool that closes a
21
+ * now-redundant PR. The actual re-derive/validate/verify loop is driven by the
22
+ * mode prompt using the existing scan/validate/verify/push tools — no git merge
23
+ * is performed (re-deriving on the fresh base avoids conflict resolution entirely
24
+ * and keeps the PR diff to exactly the fix).
25
+ */
26
+
27
+ // the branch-name conventions Terramend opens PRs under: `remediate/<group-id>`
28
+ // (incl. `remediate/batch-<hash>`) for a fix and `terramend/generate-<slug>` for
29
+ // a generation. The naming is the primary "this is Terramend's PR" signal.
30
+ const REMEDIATION_BRANCH = /^(?:remediate\/|terramend\/generate-)/;
31
+
32
+ /** true when `branch` is a Terramend remediation/generation branch. */
33
+ export function isRemediationBranch(branch: string): boolean {
34
+ return REMEDIATION_BRANCH.test(branch);
35
+ }
36
+
37
+ /** the `<group-id>` of a `remediate/<group-id>` branch (the scan group id that
38
+ * keys the fix), or null for a generation branch / non-remediation branch. */
39
+ export function groupIdFromBranch(branch: string): string | null {
40
+ return branch.match(/^remediate\/(.+)$/)?.[1] ?? null;
41
+ }
42
+
43
+ /** true when a commit-author login is Terramend's bot (so a commit by it is NOT
44
+ * a human edit). A null/absent login is treated as non-human: Terramend pushes
45
+ * with a git identity that often doesn't map to a GitHub user, so requiring a
46
+ * positive bot match would make every PR look human-touched and never refresh. */
47
+ export function isBotActor(login: string | null | undefined): boolean {
48
+ if (!login) return true;
49
+ const normalized = login.replace(/\[bot\]$/i, "").toLowerCase();
50
+ return normalized === "terramend" || normalized === "terramenddev" || /\[bot\]$/i.test(login);
51
+ }
52
+
53
+ export type StaleFixStatus = "current" | "stale" | "human_touched";
54
+ export type StaleFixAction = "skip" | "refresh" | "escalate";
55
+
56
+ export interface StaleFixAssessment {
57
+ status: StaleFixStatus;
58
+ action: StaleFixAction;
59
+ reason: string;
60
+ }
61
+
62
+ /**
63
+ * Decide what a refresh sweep should do with one open remediation PR, from git
64
+ * facts alone. Pure.
65
+ * - a branch carrying NON-bot commits is `human_touched` → `escalate` (never
66
+ * force-overwrite a human's work; leave it for review).
67
+ * - a branch whose base has NOT advanced is `current` → `skip` (the fix is still
68
+ * derived against the live base).
69
+ * - otherwise the base moved → `stale` → `refresh`: re-derive the fix on the
70
+ * current base, then close it if the concern is already resolved or update it.
71
+ */
72
+ export function assessStaleFix(input: {
73
+ baseBehindBy: number;
74
+ hasNonBotCommits: boolean;
75
+ }): StaleFixAssessment {
76
+ if (input.hasNonBotCommits) {
77
+ return {
78
+ status: "human_touched",
79
+ action: "escalate",
80
+ reason:
81
+ "the PR branch has commit(s) not authored by terramend — auto-refresh would overwrite a human's work; label it needs-human and leave it for review",
82
+ };
83
+ }
84
+ if (input.baseBehindBy <= 0) {
85
+ return {
86
+ status: "current",
87
+ action: "skip",
88
+ reason: "the base has not advanced since the PR was opened — the fix is still current",
89
+ };
90
+ }
91
+ return {
92
+ status: "stale",
93
+ action: "refresh",
94
+ reason:
95
+ `the base advanced ${input.baseBehindBy} commit(s) since the PR was opened — re-scan on the current base, ` +
96
+ "then close the PR if the concern is already resolved or re-derive + force-update the fix otherwise",
97
+ };
98
+ }
99
+
100
+ interface AssessedRemediationPr {
101
+ number: number;
102
+ url: string;
103
+ title: string;
104
+ branch: string;
105
+ base: string;
106
+ group_id: string | null;
107
+ base_behind_by: number;
108
+ head_ahead_by: number;
109
+ has_non_bot_commits: boolean;
110
+ commit_authors: string[];
111
+ labels: string[];
112
+ status: StaleFixStatus;
113
+ recommended_action: StaleFixAction;
114
+ reason: string;
115
+ }
116
+
117
+ export const ListRemediationPrsParams = type({
118
+ "limit?": type.number.describe(
119
+ "max open remediation PRs to assess (default 30). Each is one compare-commits API call.",
120
+ ),
121
+ });
122
+
123
+ export function ListRemediationPrsTool(ctx: ToolContext) {
124
+ return tool({
125
+ name: "list_remediation_prs",
126
+ description:
127
+ "§27 — list the repo's OPEN Terramend remediation/generation PRs (branches `remediate/<id>` or " +
128
+ "`terramend/generate-<slug>`) with their staleness, so a refresh sweep knows which to act on. For each " +
129
+ "PR it compares the head branch against its base and returns `base_behind_by` (how many commits the " +
130
+ "base advanced since the PR was opened), `head_ahead_by`, `has_non_bot_commits` (a human pushed to the " +
131
+ "branch), the `group_id`, labels, and a `recommended_action`: `skip` (still current), `refresh` (base " +
132
+ "moved — re-derive the fix on the current base, then close-if-resolved or force-update), or `escalate` " +
133
+ "(human-touched — label needs-human, don't overwrite). Read-only. Use it as the first step of " +
134
+ "RefreshRemediation.",
135
+ parameters: ListRemediationPrsParams,
136
+ execute: execute(async ({ limit }) => {
137
+ const cap = limit ?? 30;
138
+ const owner = ctx.repo.owner;
139
+ const repo = ctx.repo.name;
140
+
141
+ const open = await ctx.octokit.paginate(ctx.octokit.rest.pulls.list, {
142
+ owner,
143
+ repo,
144
+ state: "open",
145
+ per_page: 100,
146
+ });
147
+ const remediation = open.filter((pr) => isRemediationBranch(pr.head.ref)).slice(0, cap);
148
+
149
+ const prs: AssessedRemediationPr[] = [];
150
+ const errors: { number: number; error: string }[] = [];
151
+ for (const pr of remediation) {
152
+ try {
153
+ // base..head: ahead_by = the PR's own commits, behind_by = commits on
154
+ // the base the PR lacks (how far the base moved since the fork point).
155
+ const cmp = await ctx.octokit.rest.repos.compareCommits({
156
+ owner,
157
+ repo,
158
+ base: pr.base.ref,
159
+ head: pr.head.ref,
160
+ });
161
+ const commitAuthors = [
162
+ ...new Set(
163
+ (cmp.data.commits ?? [])
164
+ .map((c) => c.author?.login ?? c.commit.author?.name ?? null)
165
+ .filter((a): a is string => a !== null),
166
+ ),
167
+ ];
168
+ const hasNonBotCommits = (cmp.data.commits ?? []).some(
169
+ (c) => !isBotActor(c.author?.login),
170
+ );
171
+ const assessment = assessStaleFix({
172
+ baseBehindBy: cmp.data.behind_by,
173
+ hasNonBotCommits,
174
+ });
175
+ prs.push({
176
+ number: pr.number,
177
+ url: pr.html_url,
178
+ title: pr.title,
179
+ branch: pr.head.ref,
180
+ base: pr.base.ref,
181
+ group_id: groupIdFromBranch(pr.head.ref),
182
+ base_behind_by: cmp.data.behind_by,
183
+ head_ahead_by: cmp.data.ahead_by,
184
+ has_non_bot_commits: hasNonBotCommits,
185
+ commit_authors: commitAuthors,
186
+ labels: pr.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean),
187
+ status: assessment.status,
188
+ recommended_action: assessment.action,
189
+ reason: assessment.reason,
190
+ });
191
+ } catch (e) {
192
+ errors.push({ number: pr.number, error: e instanceof Error ? e.message : String(e) });
193
+ }
194
+ }
195
+
196
+ const counts = {
197
+ refresh: prs.filter((p) => p.recommended_action === "refresh").length,
198
+ skip: prs.filter((p) => p.recommended_action === "skip").length,
199
+ escalate: prs.filter((p) => p.recommended_action === "escalate").length,
200
+ };
201
+ log.info(
202
+ `» list_remediation_prs: ${prs.length} open remediation PR(s) — ` +
203
+ `${counts.refresh} refresh, ${counts.skip} skip, ${counts.escalate} escalate` +
204
+ (errors.length ? ` (${errors.length} errored)` : ""),
205
+ );
206
+ return toolOk({
207
+ count: prs.length,
208
+ action_counts: counts,
209
+ pull_requests: prs,
210
+ ...(errors.length ? { errors } : {}),
211
+ note:
212
+ prs.length === 0
213
+ ? "No open Terramend remediation PRs to refresh."
214
+ : "Act on the `refresh` PRs (re-derive on the current base, then close-if-resolved or force-update); `escalate` PRs get a needs-human label and are left for a human; `skip` PRs are already current.",
215
+ });
216
+ }),
217
+ });
218
+ }
219
+
220
+ export const ClosePullRequest = type({
221
+ pull_number: type.number.describe("the pull request number to close (not merge)."),
222
+ "comment?": type.string.describe(
223
+ "an optional comment explaining why it's being closed — posted before closing (e.g. 'concern already resolved on the base; this fix is now redundant').",
224
+ ),
225
+ });
226
+
227
+ export function ClosePullRequestTool(ctx: ToolContext) {
228
+ return tool({
229
+ name: "close_pull_request",
230
+ description:
231
+ "Close (NOT merge) a pull request. §27 use: a Terramend remediation PR whose concern is already " +
232
+ "resolved on the current base — the fix is redundant, so close it (optionally with an explanatory " +
233
+ "comment) instead of leaving a stale PR open. Blocked under `push: disabled`, and bound to the run's " +
234
+ "scope (a comment-triggered run can only close the PR it was triggered on or one it opened; a " +
235
+ "standalone scheduled sweep may close any). Never merges — closing is reversible.",
236
+ parameters: ClosePullRequest,
237
+ execute: execute(async ({ pull_number, comment }) => {
238
+ // permission gate: closing a PR is a repo write — block it under read-only.
239
+ if (ctx.payload.push === "disabled") {
240
+ throw new Error(
241
+ "Closing a pull request is disabled. This repository is configured for read-only access (push: disabled).",
242
+ );
243
+ }
244
+ // scope gate: same binding as comment/label/PR-body writes (mcp/scope.ts).
245
+ assertTargetInScope(ctx, pull_number, "close");
246
+
247
+ const owner = ctx.repo.owner;
248
+ const repo = ctx.repo.name;
249
+
250
+ if (comment) {
251
+ await ctx.octokit.rest.issues.createComment({
252
+ owner,
253
+ repo,
254
+ issue_number: pull_number,
255
+ body: addFooter(ctx, comment),
256
+ });
257
+ log.info(`» commented before closing PR #${pull_number}`);
258
+ }
259
+
260
+ const result = await ctx.octokit.rest.pulls.update({
261
+ owner,
262
+ repo,
263
+ pull_number,
264
+ state: "closed",
265
+ });
266
+ ctx.toolState.wasUpdated = true;
267
+ log.info(`» closed pull request #${result.data.number}`);
268
+
269
+ return {
270
+ success: true,
271
+ number: result.data.number,
272
+ state: result.data.state,
273
+ url: result.data.html_url,
274
+ };
275
+ }),
276
+ });
277
+ }
@@ -0,0 +1,163 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type RunResult, resolveBaseRef, run } from "#app/mcp/terraform/types";
4
+
5
+ // --- infracost (cost lens) ------------------------------------------------
6
+
7
+ export interface CostBreakdown {
8
+ /** total estimated monthly cost, or null when no resources are priced. */
9
+ totalMonthlyCost: number | null;
10
+ currency: string;
11
+ }
12
+
13
+ /**
14
+ * Parse `infracost breakdown --format json`. The top-level `totalMonthlyCost`
15
+ * is a decimal string (absent / null when a project has no priced resources);
16
+ * `currency` defaults to USD. A missing/unparseable cost becomes null so the
17
+ * caller reports "unpriced" rather than a misleading $0.00.
18
+ */
19
+ export function parseInfracostBreakdown(stdout: string): CostBreakdown {
20
+ const parsed = JSON.parse(stdout || "{}") as {
21
+ totalMonthlyCost?: string | number | null;
22
+ currency?: string;
23
+ };
24
+ const raw = parsed.totalMonthlyCost;
25
+ const num = typeof raw === "number" ? raw : raw != null ? Number.parseFloat(raw) : Number.NaN;
26
+ return {
27
+ totalMonthlyCost: Number.isFinite(num) ? num : null,
28
+ currency: parsed.currency || "USD",
29
+ };
30
+ }
31
+
32
+ export interface ResourceCost {
33
+ name: string;
34
+ monthlyCost: number;
35
+ }
36
+
37
+ /**
38
+ * Parse the per-resource monthly costs from `infracost breakdown --format json`
39
+ * (`projects[].breakdown.resources[]`), so a cost increase can be attributed to
40
+ * the specific resources that drove it instead of just a total. Skips unpriced
41
+ * (null/zero) resources; returns them sorted most-expensive first. Pure.
42
+ */
43
+ export function parseInfracostResources(stdout: string): ResourceCost[] {
44
+ let parsed: {
45
+ projects?: {
46
+ breakdown?: { resources?: { name?: string; monthlyCost?: string | number | null }[] };
47
+ }[];
48
+ };
49
+ try {
50
+ parsed = JSON.parse(stdout || "{}");
51
+ } catch {
52
+ return [];
53
+ }
54
+ const out: ResourceCost[] = [];
55
+ for (const project of parsed.projects ?? []) {
56
+ for (const r of project.breakdown?.resources ?? []) {
57
+ const raw = r.monthlyCost;
58
+ const cost =
59
+ typeof raw === "number" ? raw : raw != null ? Number.parseFloat(raw) : Number.NaN;
60
+ if (Number.isFinite(cost) && cost > 0 && r.name) {
61
+ out.push({ name: r.name, monthlyCost: Math.round(cost * 100) / 100 });
62
+ }
63
+ }
64
+ }
65
+ return out.sort((a, b) => b.monthlyCost - a.monthlyCost);
66
+ }
67
+
68
+ export interface CostDelta {
69
+ currency: string;
70
+ baselineMonthly: number | null;
71
+ currentMonthly: number | null;
72
+ /** current − baseline, rounded to cents; null when either side is unknown. */
73
+ deltaMonthly: number | null;
74
+ direction: "increase" | "decrease" | "no-change" | "unknown";
75
+ }
76
+
77
+ /** Pure cost-delta computation: current (post-fix) vs the base-branch baseline. */
78
+ export function computeCostDelta(
79
+ baseline: CostBreakdown | null,
80
+ current: CostBreakdown,
81
+ ): CostDelta {
82
+ const currency = current.currency || baseline?.currency || "USD";
83
+ const baselineMonthly = baseline?.totalMonthlyCost ?? null;
84
+ const currentMonthly = current.totalMonthlyCost;
85
+ if (baselineMonthly === null || currentMonthly === null) {
86
+ return { currency, baselineMonthly, currentMonthly, deltaMonthly: null, direction: "unknown" };
87
+ }
88
+ const deltaMonthly = Math.round((currentMonthly - baselineMonthly) * 100) / 100;
89
+ const direction = deltaMonthly > 0 ? "increase" : deltaMonthly < 0 ? "decrease" : "no-change";
90
+ return { currency, baselineMonthly, currentMonthly, deltaMonthly, direction };
91
+ }
92
+
93
+ export interface CostEscalation {
94
+ /** true when the monthly increase meets/exceeds the operator's threshold. */
95
+ escalate: boolean;
96
+ reason?: string;
97
+ }
98
+
99
+ /**
100
+ * §4.16-next — decide whether a cost increase is large enough to escalate the PR
101
+ * to human review (`needs-human`). Compares the monthly delta against the
102
+ * operator's `cost_increase_block_usd` threshold. No threshold set, an unknown
103
+ * delta, or a decrease/no-change ⇒ no escalation. Pure + deterministic so the
104
+ * decision is auditable, not a model judgement.
105
+ */
106
+ export function classifyCostEscalation(
107
+ deltaMonthly: number | null,
108
+ thresholdUsd: number | undefined,
109
+ ): CostEscalation {
110
+ if (thresholdUsd === undefined || deltaMonthly === null || deltaMonthly <= 0) {
111
+ return { escalate: false };
112
+ }
113
+ if (deltaMonthly >= thresholdUsd) {
114
+ return {
115
+ escalate: true,
116
+ reason: `the fix raises monthly cost by ${deltaMonthly}, at or above the ${thresholdUsd} escalation threshold`,
117
+ };
118
+ }
119
+ return { escalate: false };
120
+ }
121
+
122
+ export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
123
+ return run("infracost", ["breakdown", "--path", ".", "--format", "json", "--no-color"], scanCwd, {
124
+ INFRACOST_API_KEY: key,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Cost of the base-branch version of the same Terraform, computed in a detached
130
+ * git worktree so the current (fixed) checkout is never disturbed. Best-effort:
131
+ * any failure (no base ref, worktree add fails, infracost errors) returns null
132
+ * and the caller falls back to reporting current cost only.
133
+ */
134
+ export function infracostBaseline(cwd: string, key: string, tmpdir: string): CostBreakdown | null {
135
+ const baseRef = resolveBaseRef(cwd);
136
+ if (!baseRef) return null;
137
+ const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
138
+ const prefix = prefixResult.status === 0 ? prefixResult.stdout.trim() : "";
139
+ // unique, unpredictable worktree path. the old `infracost-base-<pid>` name was
140
+ // predictable (a local actor could pre-create/symlink it) and collided when
141
+ // two baselines ran in the same process. mkdtemp gives a fresh PARENT dir;
142
+ // the worktree itself goes in a not-yet-existing child (git worktree add
143
+ // refuses a path that already exists). the finally removes both the git
144
+ // worktree registration and the parent dir.
145
+ const baseDir = mkdtempSync(join(tmpdir, "infracost-base-"));
146
+ const worktree = join(baseDir, "wt");
147
+ const add = run("git", ["worktree", "add", "--detach", worktree, baseRef], cwd);
148
+ if (add.status !== 0) {
149
+ rmSync(baseDir, { recursive: true, force: true });
150
+ return null;
151
+ }
152
+ try {
153
+ const scanCwd = prefix ? join(worktree, prefix) : worktree;
154
+ const r = runInfracostBreakdown(scanCwd, key);
155
+ if (r.missing || r.status !== 0) return null;
156
+ return parseInfracostBreakdown(r.stdout);
157
+ } catch {
158
+ return null;
159
+ } finally {
160
+ run("git", ["worktree", "remove", "--force", worktree], cwd);
161
+ rmSync(baseDir, { recursive: true, force: true });
162
+ }
163
+ }