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,1071 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Mock the subprocess boundary: every scanner / terraform / git / infracost
7
+ // invocation funnels through `spawnSync` (via `run()` in terraform/types.ts and
8
+ // `loadProvidersSchema`), so one dispatcher fakes the whole toolchain.
9
+ const spawnSyncMock = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("node:child_process", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("node:child_process")>();
13
+ return { ...actual, spawnSync: spawnSyncMock };
14
+ });
15
+
16
+ // Unwrap the ToolResult envelope so tests assert on the raw object a tool
17
+ // returns instead of decoding the encoded MCP text content.
18
+ vi.mock("#app/mcp/shared", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("#app/mcp/shared")>();
20
+ return {
21
+ ...actual,
22
+ execute: <T, R>(fn: (params: T) => Promise<R>): ((params: T) => Promise<R>) => fn,
23
+ };
24
+ });
25
+
26
+ import { _clearProviderSchemaCache } from "#app/mcp/providerSchema";
27
+ import type { ToolContext } from "#app/mcp/server";
28
+ import { changedTerraformFiles } from "#app/mcp/terraform/scanners";
29
+ import {
30
+ InfracostDiffTool,
31
+ ReadFindingsTool,
32
+ TerraformEmitSarifTool,
33
+ TerraformPlanTool,
34
+ TerraformScanTool,
35
+ TerraformValidateTool,
36
+ TerraformVerifyRemediationTool,
37
+ } from "#app/mcp/terraform/tools";
38
+ import { concernId } from "#app/mcp/terraform/types";
39
+
40
+ // --- fake subprocess plumbing ----------------------------------------------
41
+
42
+ interface FakeSpawnResult {
43
+ status?: number;
44
+ stdout?: string;
45
+ stderr?: string;
46
+ /** simulate the binary being absent from PATH (ENOENT). */
47
+ missing?: boolean;
48
+ }
49
+
50
+ type SpawnDispatch = (cmd: string, args: string[], cwd: string) => FakeSpawnResult;
51
+
52
+ const MISSING: FakeSpawnResult = { missing: true };
53
+ let dispatch: SpawnDispatch = () => MISSING;
54
+
55
+ const tempDirs: string[] = [];
56
+
57
+ function makeDir(files: Record<string, string> = {}): string {
58
+ const dir = mkdtempSync(join(tmpdir(), "terramend-tools-"));
59
+ tempDirs.push(dir);
60
+ for (const [rel, content] of Object.entries(files)) {
61
+ const abs = join(dir, rel);
62
+ mkdirSync(dirname(abs), { recursive: true });
63
+ writeFileSync(abs, content);
64
+ }
65
+ return dir;
66
+ }
67
+
68
+ function makeCtx(
69
+ cwd: string,
70
+ over: { payload?: Record<string, unknown>; toolState?: Record<string, unknown> } = {},
71
+ ): ToolContext {
72
+ return {
73
+ payload: { cwd, ...(over.payload ?? {}) },
74
+ toolState: { ...(over.toolState ?? {}) },
75
+ tmpdir: makeDir(),
76
+ } as unknown as ToolContext;
77
+ }
78
+
79
+ type RawToolResult = Record<string, unknown>;
80
+
81
+ /** call a tool's (identity-mocked) execute and return the raw result object. */
82
+ function runTool(
83
+ toolDef: { execute: unknown },
84
+ params: Record<string, unknown> = {},
85
+ ): Promise<RawToolResult> {
86
+ const fn = toolDef.execute as (p: Record<string, unknown>) => Promise<RawToolResult>;
87
+ return fn(params);
88
+ }
89
+
90
+ /** type-tightened indexed access (keeps `noUncheckedIndexedAccess` happy
91
+ * without non-null assertions). */
92
+ function at<T = Record<string, unknown>>(value: unknown, index: number): T {
93
+ if (!Array.isArray(value)) throw new Error(`expected an array, got ${typeof value}`);
94
+ const item = value[index] as T | undefined;
95
+ if (item === undefined) throw new Error(`expected an element at index ${index}`);
96
+ return item;
97
+ }
98
+
99
+ beforeEach(() => {
100
+ dispatch = () => MISSING;
101
+ _clearProviderSchemaCache();
102
+ spawnSyncMock.mockReset();
103
+ spawnSyncMock.mockImplementation((cmd: unknown, args: unknown, opts: unknown) => {
104
+ const cwd = String((opts as { cwd?: string } | undefined)?.cwd ?? "");
105
+ const r = dispatch(String(cmd), Array.isArray(args) ? (args as string[]) : [], cwd);
106
+ if (r.missing) {
107
+ return {
108
+ error: Object.assign(new Error(`spawn ${String(cmd)} ENOENT`), { code: "ENOENT" }),
109
+ status: null,
110
+ stdout: "",
111
+ stderr: "",
112
+ };
113
+ }
114
+ return { status: r.status ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
115
+ });
116
+ });
117
+
118
+ afterEach(() => {
119
+ for (const dir of tempDirs.splice(0)) {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ // --- canned scanner outputs --------------------------------------------------
125
+
126
+ const FMT_UNFORMATTED: FakeSpawnResult = { status: 3, stdout: "main.tf\n" };
127
+ const VALIDATE_ERROR: FakeSpawnResult = {
128
+ status: 1,
129
+ stdout: JSON.stringify({
130
+ diagnostics: [
131
+ {
132
+ severity: "error",
133
+ summary: "Reference to undeclared resource",
134
+ detail: "A managed resource has not been declared.",
135
+ range: { filename: "main.tf", start: { line: 2 } },
136
+ },
137
+ ],
138
+ }),
139
+ };
140
+ const TFLINT_ISSUE: FakeSpawnResult = {
141
+ status: 2,
142
+ stdout: JSON.stringify({
143
+ issues: [
144
+ {
145
+ rule: { name: "terraform_unused_declarations", severity: "warning" },
146
+ message: 'variable "unused" is declared but not used',
147
+ range: { filename: "main.tf", start: { line: 4 } },
148
+ },
149
+ ],
150
+ }),
151
+ };
152
+ const TRIVY_CRITICAL: FakeSpawnResult = {
153
+ status: 0,
154
+ stdout: JSON.stringify({
155
+ Results: [
156
+ {
157
+ Target: "main.tf",
158
+ Misconfigurations: [
159
+ {
160
+ AVDID: "AVD-AWS-0001",
161
+ Severity: "CRITICAL",
162
+ Status: "FAIL",
163
+ Message: 'IAM policy allows all actions with "*"',
164
+ CauseMetadata: { StartLine: 2 },
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ }),
170
+ };
171
+ const CLEAN_JSON: FakeSpawnResult = { status: 0, stdout: "{}" };
172
+
173
+ function scannersDispatch(over: Partial<Record<string, FakeSpawnResult>> = {}): SpawnDispatch {
174
+ return (cmd, args) => {
175
+ if (cmd === "terraform" && args[0] === "fmt") return over.fmt ?? FMT_UNFORMATTED;
176
+ if (cmd === "terraform" && args[0] === "init") return over.init ?? { status: 0 };
177
+ if (cmd === "terraform" && args[0] === "validate") return over.validate ?? VALIDATE_ERROR;
178
+ if (cmd === "terraform" && args[0] === "providers") return over.providers ?? MISSING;
179
+ if (cmd === "tflint") return over.tflint ?? TFLINT_ISSUE;
180
+ if (cmd === "trivy") return over.trivy ?? TRIVY_CRITICAL;
181
+ if (cmd === "checkov") return over.checkov ?? MISSING;
182
+ return over.git ?? MISSING;
183
+ };
184
+ }
185
+
186
+ const FMT_CONCERN_ID = concernId("terraform-fmt", "unformatted", "main.tf", null);
187
+
188
+ // ============================================================================
189
+
190
+ describe("TerraformScanTool", () => {
191
+ const tf = 'resource "aws_s3_bucket" "b" {\n bucket = "x"\n}\n';
192
+
193
+ it("aggregates concerns from every scanner, groups by file, and reports skips", async () => {
194
+ const cwd = makeDir({ "main.tf": tf });
195
+ dispatch = scannersDispatch();
196
+ const ctx = makeCtx(cwd);
197
+
198
+ const result = await runTool(TerraformScanTool(ctx));
199
+
200
+ expect(result).toMatchObject({ ok: true, scanned_dir: cwd, scope: "full", grouping: "file" });
201
+ expect(result.scanners_ran).toEqual(["terraform-fmt", "terraform-validate", "tflint", "trivy"]);
202
+ expect(result.scanners_skipped).toEqual([
203
+ { source: "checkov", reason: "checkov not installed" },
204
+ ]);
205
+ expect(result.summary).toEqual({
206
+ total: 4,
207
+ groups: 1,
208
+ by_severity: { critical: 1, high: 1, medium: 1, low: 1 },
209
+ });
210
+ // sorted by severity: the trivy critical comes first, with a derived doc url.
211
+ expect(at(result.concerns, 0)).toMatchObject({
212
+ severity: "critical",
213
+ source: "trivy",
214
+ doc_url: "https://avd.aquasec.com/misconfig/avd-aws-0001",
215
+ });
216
+ // §1.4 baseline captured severity-unfiltered.
217
+ expect(ctx.toolState.baselineConcernIds).toHaveLength(4);
218
+ // one file-group, escalated by the critical security finding (§3.9).
219
+ expect(at(result.groups, 0)).toMatchObject({
220
+ file: "main.tf",
221
+ severity: "critical",
222
+ concern_count: 4,
223
+ autonomy: "needs-human",
224
+ });
225
+ // §3.10 — the escalated group is isolated, never batched.
226
+ expect(result.batch_plan).toMatchObject({ batchable: [], batch_branch: null });
227
+ // §30 — validate + trivy both flagged main.tf:2.
228
+ expect(result.co_located).toEqual([
229
+ expect.objectContaining({
230
+ file: "main.tf",
231
+ line: 2,
232
+ sources: ["terraform-validate", "trivy"],
233
+ }),
234
+ ]);
235
+ // §29 — the IAM wildcard finding is a refusal candidate.
236
+ expect(result.refusal_candidates).toHaveLength(1);
237
+ // §21 — a preventive control per distinct rule.
238
+ expect(Object.keys(result.prevention as Record<string, unknown>)).toEqual(
239
+ expect.arrayContaining(["trivy:AVD-AWS-0001", "tflint:terraform_unused_declarations"]),
240
+ );
241
+ });
242
+
243
+ it("filters by the explicit severity_threshold but keeps the baseline unfiltered", async () => {
244
+ const cwd = makeDir({ "main.tf": tf });
245
+ dispatch = scannersDispatch();
246
+ const ctx = makeCtx(cwd);
247
+
248
+ const result = await runTool(TerraformScanTool(ctx), { severity_threshold: "high" });
249
+
250
+ expect(result.summary).toEqual({
251
+ total: 2,
252
+ groups: 1,
253
+ by_severity: { critical: 1, high: 1 },
254
+ });
255
+ expect(ctx.toolState.baselineConcernIds).toHaveLength(4);
256
+ });
257
+
258
+ it("falls back to the run's configured severityThreshold when no arg is given", async () => {
259
+ const cwd = makeDir({ "main.tf": tf });
260
+ dispatch = scannersDispatch();
261
+ const ctx = makeCtx(cwd, { payload: { severityThreshold: "critical" } });
262
+
263
+ const result = await runTool(TerraformScanTool(ctx));
264
+
265
+ expect(result.summary).toMatchObject({ total: 1 });
266
+ });
267
+
268
+ it("diff scope falls back to full (with a note) when the base branch is unknown", async () => {
269
+ const cwd = makeDir({ "main.tf": tf });
270
+ dispatch = scannersDispatch(); // git is "missing" → no base ref
271
+ const ctx = makeCtx(cwd, { payload: { scanScope: "diff" } });
272
+
273
+ const result = await runTool(TerraformScanTool(ctx));
274
+
275
+ expect(result.scope).toBe("full");
276
+ expect(result.scope_note).toMatch(/base branch could not be determined/);
277
+ });
278
+
279
+ it("diff scope keeps only concerns in Terraform files changed vs the base", async () => {
280
+ const cwd = makeDir({ "main.tf": tf });
281
+ const scanners = scannersDispatch();
282
+ dispatch = (cmd, args, dir) => {
283
+ if (cmd === "git" && args[0] === "rev-parse" && args.includes("origin/HEAD")) {
284
+ return { status: 0, stdout: "origin/main\n" };
285
+ }
286
+ if (cmd === "git" && args[0] === "merge-base") return { status: 0, stdout: "abc123\n" };
287
+ if (cmd === "git" && args.includes("--show-prefix")) return { status: 0, stdout: "\n" };
288
+ if (cmd === "git" && args[0] === "diff") return { status: 0, stdout: "other.tf\n" };
289
+ return scanners(cmd, args, dir);
290
+ };
291
+ const ctx = makeCtx(cwd);
292
+
293
+ const result = await runTool(TerraformScanTool(ctx), { scan_scope: "diff" });
294
+
295
+ // every concern lives in main.tf, but only other.tf changed → all filtered.
296
+ expect(result.scope).toBe("diff");
297
+ expect(result.summary).toMatchObject({ total: 0, groups: 0 });
298
+ expect(result.scope_note).toBeUndefined();
299
+ });
300
+
301
+ it("group_by rule makes one group per rule across files (§3.11)", async () => {
302
+ const cwd = makeDir({ "main.tf": tf });
303
+ dispatch = scannersDispatch();
304
+ const ctx = makeCtx(cwd);
305
+
306
+ const result = await runTool(TerraformScanTool(ctx), { group_by: "rule" });
307
+
308
+ expect(result.grouping).toBe("rule");
309
+ expect(result.groups).toHaveLength(4);
310
+ expect(at(result.groups, 0)).toMatchObject({ grouping: "rule", concern_count: 1 });
311
+ });
312
+
313
+ it("degrades a scanner with unparseable output to skipped instead of failing", async () => {
314
+ const cwd = makeDir({ "main.tf": tf });
315
+ dispatch = scannersDispatch({
316
+ fmt: { status: 0 },
317
+ validate: { status: 0, stdout: "%% not json %%" },
318
+ tflint: { status: 0, stdout: "garbage" },
319
+ trivy: { status: 0, stdout: "also garbage" },
320
+ });
321
+ const ctx = makeCtx(cwd);
322
+
323
+ const result = await runTool(TerraformScanTool(ctx));
324
+
325
+ expect(result.scanners_ran).toEqual(["terraform-fmt"]);
326
+ expect(result.scanners_skipped).toEqual(
327
+ expect.arrayContaining([
328
+ { source: "terraform-validate", reason: expect.stringMatching(/could not parse/) },
329
+ { source: "tflint", reason: expect.stringMatching(/could not parse/) },
330
+ { source: "trivy", reason: expect.stringMatching(/could not parse/) },
331
+ ]),
332
+ );
333
+ expect(result.summary).toMatchObject({ total: 0 });
334
+ });
335
+
336
+ it("attempts `tflint --init` when the repo ships a .tflint.hcl", async () => {
337
+ const cwd = makeDir({ "main.tf": tf, ".tflint.hcl": 'plugin "aws" {}' });
338
+ dispatch = scannersDispatch({ tflint: CLEAN_JSON });
339
+ const ctx = makeCtx(cwd);
340
+
341
+ await runTool(TerraformScanTool(ctx));
342
+
343
+ const tflintCalls = spawnSyncMock.mock.calls.filter((call) => call[0] === "tflint");
344
+ expect(tflintCalls.some((call) => (call[1] as string[])[0] === "--init")).toBe(true);
345
+ });
346
+ });
347
+
348
+ describe("TerraformValidateTool", () => {
349
+ const versionsTf = `terraform {
350
+ required_providers {
351
+ aws = {
352
+ source = "hashicorp/aws"
353
+ version = "~> 5.0"
354
+ }
355
+ }
356
+ }
357
+ `;
358
+
359
+ it("degrades green when no tool is installed, still reporting pinned providers", async () => {
360
+ const cwd = makeDir({
361
+ "versions.tf": versionsTf,
362
+ "main.tf": 'resource "aws_s3_bucket" "b" {\n bucket = "x"\n}\n',
363
+ });
364
+ dispatch = () => MISSING;
365
+ const ctx = makeCtx(cwd);
366
+
367
+ const result = await runTool(TerraformValidateTool(ctx));
368
+
369
+ expect(result).toMatchObject({
370
+ ok: true,
371
+ passed: true,
372
+ checks_ran: [],
373
+ remaining_issues: [],
374
+ schema_checked: false,
375
+ unknown_arguments: [],
376
+ roots_validated: ["."],
377
+ });
378
+ expect(result.providers).toEqual([
379
+ { name: "aws", source: "hashicorp/aws", version: "~> 5.0", major: 5 },
380
+ ]);
381
+ });
382
+
383
+ it("fails the gate when fmt still flags a file", async () => {
384
+ const cwd = makeDir({ "main.tf": 'resource "x" "y" {}\n' });
385
+ dispatch = scannersDispatch({ validate: CLEAN_JSON, tflint: CLEAN_JSON });
386
+ const ctx = makeCtx(cwd);
387
+
388
+ const result = await runTool(TerraformValidateTool(ctx));
389
+
390
+ expect(result.passed).toBe(false);
391
+ expect(result.checks_ran).toEqual(["terraform-fmt", "terraform-validate", "tflint"]);
392
+ expect(at(result.remaining_issues, 0)).toMatchObject({
393
+ rule_id: "terraform-fmt:unformatted",
394
+ location: { file: "main.tf" },
395
+ });
396
+ });
397
+
398
+ it("fails closed when a root ran but `validate -json` was unparseable", async () => {
399
+ // terraform ran but emitted non-JSON (corrupted .terraform / crash): we
400
+ // genuinely don't know if the root is valid, so `passed` must be false and
401
+ // `validate_incomplete` flagged — never a green pass on an un-validated root.
402
+ const cwd = makeDir({ "main.tf": 'resource "x" "y" {}\n' });
403
+ dispatch = scannersDispatch({
404
+ fmt: { status: 0 },
405
+ validate: { status: 0, stdout: "%% not json %%" },
406
+ tflint: CLEAN_JSON,
407
+ });
408
+ const ctx = makeCtx(cwd);
409
+
410
+ const result = await runTool(TerraformValidateTool(ctx));
411
+
412
+ expect(result.passed).toBe(false);
413
+ expect(result.validate_incomplete).toBe(true);
414
+ expect(result.remaining_issues).toEqual([]);
415
+ // validate didn't cleanly run, so it's absent from checks_ran.
416
+ expect(result.checks_ran).not.toContain("terraform-validate");
417
+ });
418
+
419
+ it("cross-checks written arguments against the installed provider schema (§4.15-next)", async () => {
420
+ const cwd = makeDir({
421
+ "main.tf": 'resource "aws_s3_bucket" "b" {\n bucket = "x"\n bukcet_typo = "y"\n}\n',
422
+ });
423
+ const schema = JSON.stringify({
424
+ provider_schemas: {
425
+ "registry.terraform.io/hashicorp/aws": {
426
+ resource_schemas: {
427
+ aws_s3_bucket: {
428
+ block: { attributes: { bucket: {} }, block_types: { versioning: {} } },
429
+ },
430
+ },
431
+ },
432
+ },
433
+ });
434
+ dispatch = scannersDispatch({
435
+ fmt: { status: 0 },
436
+ validate: CLEAN_JSON,
437
+ tflint: CLEAN_JSON,
438
+ providers: { status: 0, stdout: schema },
439
+ });
440
+ const ctx = makeCtx(cwd);
441
+
442
+ const result = await runTool(TerraformValidateTool(ctx));
443
+
444
+ // the schema cross-check is ADVISORY — `passed` stays green.
445
+ expect(result.passed).toBe(true);
446
+ expect(result.schema_checked).toBe(true);
447
+ expect(result.unknown_arguments).toEqual([
448
+ { resource_type: "aws_s3_bucket", name: "b", file: "main.tf", unknown: ["bukcet_typo"] },
449
+ ]);
450
+ });
451
+ });
452
+
453
+ describe("TerraformVerifyRemediationTool", () => {
454
+ const cleanButFmt = () =>
455
+ scannersDispatch({ validate: CLEAN_JSON, tflint: MISSING, trivy: MISSING });
456
+
457
+ it("partitions concern ids into resolved vs remaining against the re-scan", async () => {
458
+ const cwd = makeDir({ "main.tf": "x\n" });
459
+ dispatch = cleanButFmt();
460
+ const ctx = makeCtx(cwd, { toolState: { baselineConcernIds: [FMT_CONCERN_ID] } });
461
+
462
+ const result = await runTool(TerraformVerifyRemediationTool(ctx), {
463
+ concern_ids: [FMT_CONCERN_ID, "feedface0000"],
464
+ });
465
+
466
+ expect(result).toMatchObject({
467
+ ok: true,
468
+ verified: false,
469
+ resolved_count: 1,
470
+ remaining_count: 1,
471
+ resolved: ["feedface0000"],
472
+ remaining: [FMT_CONCERN_ID],
473
+ has_regressions: false,
474
+ regressions: [],
475
+ confidence: "low",
476
+ });
477
+ expect(result.regressions_note).toBeUndefined();
478
+ expect(result.scanners_ran).toEqual(["terraform-fmt", "terraform-validate"]);
479
+ });
480
+
481
+ it("reports a regression when the fix introduced a concern absent from the baseline", async () => {
482
+ const cwd = makeDir({ "main.tf": "x\n" });
483
+ dispatch = cleanButFmt();
484
+ const ctx = makeCtx(cwd, { toolState: { baselineConcernIds: [] } });
485
+
486
+ const result = await runTool(TerraformVerifyRemediationTool(ctx), {
487
+ concern_ids: ["gone000000"],
488
+ });
489
+
490
+ expect(result).toMatchObject({
491
+ verified: true,
492
+ has_regressions: true,
493
+ regressions: [FMT_CONCERN_ID],
494
+ confidence: "low", // a regression caps confidence at low even when verified
495
+ });
496
+ });
497
+
498
+ it("marks regressions unknown (with a note) when no baseline was captured", async () => {
499
+ const cwd = makeDir({ "main.tf": "x\n" });
500
+ dispatch = cleanButFmt();
501
+ const ctx = makeCtx(cwd);
502
+
503
+ const result = await runTool(TerraformVerifyRemediationTool(ctx), {
504
+ concern_ids: ["gone000000"],
505
+ });
506
+
507
+ expect(result).toMatchObject({ verified: true, has_regressions: false, regressions: [] });
508
+ expect(result.regressions_note).toMatch(/no pre-fix baseline/);
509
+ // verified but missing plan/cost evidence → honest medium, not high (§5.19).
510
+ expect(result.confidence).toBe("medium");
511
+ });
512
+
513
+ it("is high-confidence only with the full evidence stack (§5.19)", async () => {
514
+ const cwd = makeDir({ "main.tf": "x\n" });
515
+ dispatch = cleanButFmt();
516
+ const ctx = makeCtx(cwd, {
517
+ toolState: {
518
+ baselineConcernIds: [FMT_CONCERN_ID],
519
+ lastIdempotent: true,
520
+ lastBlastTier: "low",
521
+ lastCostDirection: "no-change",
522
+ },
523
+ });
524
+
525
+ const result = await runTool(TerraformVerifyRemediationTool(ctx), { concern_ids: [] });
526
+
527
+ expect(result).toMatchObject({ verified: true, confidence: "high" });
528
+ });
529
+ });
530
+
531
+ describe("InfracostDiffTool", () => {
532
+ const savedKey = process.env.INFRACOST_API_KEY;
533
+
534
+ afterEach(() => {
535
+ if (savedKey === undefined) delete process.env.INFRACOST_API_KEY;
536
+ else process.env.INFRACOST_API_KEY = savedKey;
537
+ });
538
+
539
+ it("auto-skips when INFRACOST_API_KEY is unset", async () => {
540
+ delete process.env.INFRACOST_API_KEY;
541
+ const result = await runTool(InfracostDiffTool(makeCtx(makeDir())));
542
+ expect(result).toMatchObject({ ok: false, code: "infracost_key_unset", ran: false });
543
+ });
544
+
545
+ it("auto-skips when the infracost CLI is absent", async () => {
546
+ process.env.INFRACOST_API_KEY = "ico-key";
547
+ dispatch = () => MISSING;
548
+ const result = await runTool(InfracostDiffTool(makeCtx(makeDir())));
549
+ expect(result).toMatchObject({ ok: false, code: "infracost_not_installed" });
550
+ });
551
+
552
+ it("auto-skips (with the stderr excerpt) when infracost exits non-zero", async () => {
553
+ process.env.INFRACOST_API_KEY = "ico-key";
554
+ dispatch = (cmd) => (cmd === "infracost" ? { status: 1, stderr: "boom: bad key" } : MISSING);
555
+ const result = await runTool(InfracostDiffTool(makeCtx(makeDir())));
556
+ expect(result).toMatchObject({ ok: false, code: "infracost_failed" });
557
+ expect(result.detail).toMatch(/boom: bad key/);
558
+ });
559
+
560
+ it("auto-skips when the breakdown output is not JSON", async () => {
561
+ process.env.INFRACOST_API_KEY = "ico-key";
562
+ dispatch = (cmd) => (cmd === "infracost" ? { status: 0, stdout: "not json" } : MISSING);
563
+ const result = await runTool(InfracostDiffTool(makeCtx(makeDir())));
564
+ expect(result).toMatchObject({ ok: false, code: "infracost_parse_error" });
565
+ });
566
+
567
+ it("computes the delta vs the base-branch worktree and escalates a big increase", async () => {
568
+ process.env.INFRACOST_API_KEY = "ico-key";
569
+ const current = JSON.stringify({
570
+ currency: "USD",
571
+ totalMonthlyCost: "150",
572
+ projects: [
573
+ {
574
+ breakdown: {
575
+ resources: [
576
+ { name: "aws_db_instance.db", monthlyCost: "120" },
577
+ { name: "aws_instance.web", monthlyCost: "30" },
578
+ ],
579
+ },
580
+ },
581
+ ],
582
+ });
583
+ const baseline = JSON.stringify({ currency: "USD", totalMonthlyCost: "100" });
584
+ dispatch = (cmd, args, dir) => {
585
+ if (cmd === "infracost") {
586
+ const inWorktree = dir.replace(/\\/g, "/").includes("infracost-base-");
587
+ return { status: 0, stdout: inWorktree ? baseline : current };
588
+ }
589
+ if (cmd === "git" && args[0] === "rev-parse" && args.includes("origin/HEAD")) {
590
+ return { status: 0, stdout: "origin/main\n" };
591
+ }
592
+ if (cmd === "git" && args.includes("--show-prefix")) return { status: 0, stdout: "" };
593
+ if (cmd === "git" && args[0] === "worktree") return { status: 0 };
594
+ return MISSING;
595
+ };
596
+ const ctx = makeCtx(makeDir(), { payload: { costIncreaseBlockUsd: 25 } });
597
+
598
+ const result = await runTool(InfracostDiffTool(ctx));
599
+
600
+ expect(result).toMatchObject({
601
+ ok: true,
602
+ ran: true,
603
+ currency: "USD",
604
+ baseline_monthly_cost: 100,
605
+ current_monthly_cost: 150,
606
+ monthly_delta: 50,
607
+ direction: "increase",
608
+ needs_human: true,
609
+ });
610
+ expect(result.cost_escalation_reason).toMatch(/raises monthly cost by 50/);
611
+ expect(result.top_resource_costs).toEqual([
612
+ { name: "aws_db_instance.db", monthlyCost: 120 },
613
+ { name: "aws_instance.web", monthlyCost: 30 },
614
+ ]);
615
+ expect(result.note).toBeUndefined();
616
+ expect(ctx.toolState.lastCostDirection).toBe("increase");
617
+ });
618
+
619
+ it("reports current cost only (with a note) when no baseline is resolvable", async () => {
620
+ process.env.INFRACOST_API_KEY = "ico-key";
621
+ dispatch = (cmd) =>
622
+ cmd === "infracost"
623
+ ? { status: 0, stdout: JSON.stringify({ currency: "USD", totalMonthlyCost: "75" }) }
624
+ : MISSING; // git missing → no base ref
625
+ const ctx = makeCtx(makeDir());
626
+
627
+ const result = await runTool(InfracostDiffTool(ctx));
628
+
629
+ expect(result).toMatchObject({
630
+ ran: true,
631
+ current_monthly_cost: 75,
632
+ baseline_monthly_cost: null,
633
+ monthly_delta: null,
634
+ direction: "unknown",
635
+ needs_human: false,
636
+ });
637
+ expect(result.note).toMatch(/Baseline cost unavailable/);
638
+ expect(result.top_resource_costs).toBeUndefined();
639
+ });
640
+ });
641
+
642
+ describe("TerraformEmitSarifTool", () => {
643
+ const fmtOnly = () => scannersDispatch({ validate: CLEAN_JSON, tflint: MISSING, trivy: MISSING });
644
+
645
+ it("writes a SARIF report to the default path in the workspace", async () => {
646
+ const cwd = makeDir({ "main.tf": "x\n" });
647
+ dispatch = fmtOnly();
648
+
649
+ const result = await runTool(TerraformEmitSarifTool(makeCtx(cwd)));
650
+
651
+ const target = join(cwd, "terramend.sarif");
652
+ expect(result).toMatchObject({ ok: true, sarif_path: target, result_count: 1, rule_count: 1 });
653
+ const sarif = JSON.parse(readFileSync(target, "utf8")) as {
654
+ version?: string;
655
+ runs?: { results?: { ruleId?: string }[] }[];
656
+ };
657
+ expect(sarif.version).toBe("2.1.0");
658
+ expect(at(at<{ results?: unknown }>(sarif.runs, 0).results, 0)).toMatchObject({
659
+ ruleId: "terraform-fmt:unformatted",
660
+ });
661
+ });
662
+
663
+ it("records the emitted path in toolState so the end-of-run emit defers to it", async () => {
664
+ const cwd = makeDir({ "main.tf": "x\n" });
665
+ dispatch = fmtOnly();
666
+ const ctx = makeCtx(cwd);
667
+
668
+ await runTool(TerraformEmitSarifTool(ctx), { output_path: "custom.sarif" });
669
+
670
+ expect(ctx.toolState.emittedSarifPath).toBe(join(cwd, "custom.sarif"));
671
+ });
672
+
673
+ it("resolves a relative output_path against the workspace and an in-workspace absolute one as-is", async () => {
674
+ const cwd = makeDir({ "main.tf": "x\n" });
675
+ dispatch = fmtOnly();
676
+
677
+ const rel = await runTool(TerraformEmitSarifTool(makeCtx(cwd)), {
678
+ output_path: "report.sarif",
679
+ });
680
+ expect(rel.sarif_path).toBe(join(cwd, "report.sarif"));
681
+
682
+ // an absolute path that stays INSIDE the workspace is honored as-is.
683
+ const absInside = join(cwd, "abs.sarif");
684
+ const abs = await runTool(TerraformEmitSarifTool(makeCtx(cwd)), { output_path: absInside });
685
+ expect(abs.sarif_path).toBe(absInside);
686
+ });
687
+
688
+ it("rejects an output_path that escapes the workspace (no arbitrary file write)", async () => {
689
+ const cwd = makeDir({ "main.tf": "x\n" });
690
+ dispatch = fmtOnly();
691
+
692
+ // an absolute path in a different directory…
693
+ const outside = join(makeDir(), "abs.sarif");
694
+ await expect(
695
+ runTool(TerraformEmitSarifTool(makeCtx(cwd)), { output_path: outside }),
696
+ ).rejects.toThrow(/escapes the workspace/);
697
+
698
+ // …and relative `..` traversal.
699
+ await expect(
700
+ runTool(TerraformEmitSarifTool(makeCtx(cwd)), { output_path: "../escape.sarif" }),
701
+ ).rejects.toThrow(/escapes the workspace/);
702
+ });
703
+
704
+ it("emits an empty-result report when the threshold filters everything", async () => {
705
+ const cwd = makeDir({ "main.tf": "x\n" });
706
+ dispatch = fmtOnly();
707
+
708
+ const result = await runTool(TerraformEmitSarifTool(makeCtx(cwd)), {
709
+ severity_threshold: "high",
710
+ });
711
+
712
+ expect(result).toMatchObject({ ok: true, result_count: 0, rule_count: 0 });
713
+ });
714
+
715
+ it("degrades to a structured skip when the SARIF file cannot be written", async () => {
716
+ const cwd = makeDir({ "main.tf": "x\n" });
717
+ dispatch = fmtOnly();
718
+
719
+ const result = await runTool(TerraformEmitSarifTool(makeCtx(cwd)), {
720
+ output_path: join("no-such-dir", "report.sarif"),
721
+ });
722
+
723
+ expect(result).toMatchObject({ ok: false, code: "sarif_write_failed" });
724
+ expect(result.detail).toMatch(/could not write SARIF/);
725
+ });
726
+ });
727
+
728
+ describe("TerraformPlanTool", () => {
729
+ const CRED_KEYS = [
730
+ "AWS_ACCESS_KEY_ID",
731
+ "AWS_SECRET_ACCESS_KEY",
732
+ "AWS_PROFILE",
733
+ "AWS_ROLE_ARN",
734
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
735
+ "ARM_CLIENT_ID",
736
+ "ARM_USE_OIDC",
737
+ "AZURE_CLIENT_ID",
738
+ "GOOGLE_CREDENTIALS",
739
+ "GOOGLE_APPLICATION_CREDENTIALS",
740
+ "GOOGLE_OAUTH_ACCESS_TOKEN",
741
+ ];
742
+ const saved: Record<string, string | undefined> = {};
743
+
744
+ beforeEach(() => {
745
+ for (const k of CRED_KEYS) {
746
+ saved[k] = process.env[k];
747
+ delete process.env[k];
748
+ }
749
+ });
750
+
751
+ afterEach(() => {
752
+ for (const k of CRED_KEYS) {
753
+ if (saved[k] === undefined) delete process.env[k];
754
+ else process.env[k] = saved[k];
755
+ }
756
+ });
757
+
758
+ const PLAN_JSON = [
759
+ JSON.stringify({
760
+ type: "planned_change",
761
+ change: { action: "create", resource: { addr: "aws_s3_bucket.a" } },
762
+ }),
763
+ JSON.stringify({
764
+ type: "planned_change",
765
+ change: { action: "delete", resource: { addr: "aws_db_instance.db" } },
766
+ }),
767
+ JSON.stringify({ type: "change_summary", changes: { add: 1, change: 0, remove: 1 } }),
768
+ ].join("\n");
769
+
770
+ it("auto-skips when no cloud credentials are detected", async () => {
771
+ const result = await runTool(TerraformPlanTool(makeCtx(makeDir({ "main.tf": "x" }))));
772
+ expect(result).toMatchObject({ ok: false, code: "no_cloud_credentials", ran: false });
773
+ });
774
+
775
+ it("auto-skips when terraform is not installed", async () => {
776
+ process.env.AWS_ACCESS_KEY_ID = "akia";
777
+ dispatch = () => MISSING;
778
+ const result = await runTool(TerraformPlanTool(makeCtx(makeDir({ "main.tf": "x" }))));
779
+ expect(result).toMatchObject({ ok: false, code: "terraform_not_installed" });
780
+ });
781
+
782
+ it("auto-skips with the init error when terraform init fails", async () => {
783
+ process.env.AWS_ACCESS_KEY_ID = "akia";
784
+ dispatch = (cmd, args) =>
785
+ cmd === "terraform" && args[0] === "init"
786
+ ? { status: 1, stderr: "backend init error" }
787
+ : MISSING;
788
+ const result = await runTool(TerraformPlanTool(makeCtx(makeDir({ "main.tf": "x" }))));
789
+ expect(result).toMatchObject({ ok: false, code: "terraform_init_failed" });
790
+ expect(result.detail).toMatch(/backend init error/);
791
+ });
792
+
793
+ it("auto-skips when terraform plan fails", async () => {
794
+ process.env.AWS_ACCESS_KEY_ID = "akia";
795
+ dispatch = (cmd, args) => {
796
+ if (cmd === "terraform" && args[0] === "init") return { status: 0 };
797
+ if (cmd === "terraform" && args[0] === "plan") return { status: 1, stderr: "no backend" };
798
+ return MISSING;
799
+ };
800
+ const result = await runTool(TerraformPlanTool(makeCtx(makeDir({ "main.tf": "x" }))));
801
+ expect(result).toMatchObject({ ok: false, code: "terraform_plan_failed" });
802
+ });
803
+
804
+ it("plans, re-plans for stability, classifies destroys, and escalates stateful loss", async () => {
805
+ process.env.AWS_ACCESS_KEY_ID = "akia";
806
+ dispatch = (cmd, args) => {
807
+ if (cmd === "terraform" && args[0] === "init") return { status: 0 };
808
+ if (cmd === "terraform" && args[0] === "plan") {
809
+ return args.includes("-json")
810
+ ? { status: 0, stdout: PLAN_JSON }
811
+ : { status: 0, stdout: "Plan: 1 to add, 0 to change, 1 to destroy." };
812
+ }
813
+ return MISSING;
814
+ };
815
+ const ctx = makeCtx(makeDir({ "main.tf": "x" }));
816
+
817
+ const result = await runTool(TerraformPlanTool(ctx));
818
+
819
+ expect(result).toMatchObject({
820
+ ok: true,
821
+ ran: true,
822
+ roots_planned: ["."],
823
+ to_add: 1,
824
+ to_change: 0,
825
+ to_destroy: 1,
826
+ has_destroy_or_replace: true,
827
+ idempotent: true,
828
+ needs_human: true,
829
+ });
830
+ expect(result.destructive).toEqual([{ address: "aws_db_instance.db", action: "delete" }]);
831
+ expect(result.stateful_destructive).toEqual([
832
+ { address: "aws_db_instance.db", action: "delete", type: "aws_db_instance" },
833
+ ]);
834
+ expect(result.blast_radius).toEqual({ tier: "low", resourceCount: 2, modules: ["root"] });
835
+ expect(result.needs_human_reasons).toEqual([
836
+ "1 stateful resource(s) would be destroyed/replaced",
837
+ ]);
838
+ expect(result.plan_text).toContain("Plan: 1 to add");
839
+ // toolState recorded for the push-time destroy block + confidence label.
840
+ expect(ctx.toolState.plannedDestroy).toMatchObject({
841
+ stateful: [{ address: "aws_db_instance.db", action: "delete", type: "aws_db_instance" }],
842
+ });
843
+ expect(ctx.toolState.lastBlastTier).toBe("low");
844
+ expect(ctx.toolState.lastIdempotent).toBe(true);
845
+ });
846
+
847
+ it("flags a non-deterministic plan (second plan disagrees) as needs-human", async () => {
848
+ process.env.AWS_ACCESS_KEY_ID = "akia";
849
+ let jsonPlans = 0;
850
+ const secondPlan = [
851
+ JSON.stringify({
852
+ type: "planned_change",
853
+ change: { action: "create", resource: { addr: "aws_s3_bucket.a" } },
854
+ }),
855
+ JSON.stringify({ type: "change_summary", changes: { add: 1, change: 0, remove: 0 } }),
856
+ ].join("\n");
857
+ dispatch = (cmd, args) => {
858
+ if (cmd === "terraform" && args[0] === "init") return { status: 0 };
859
+ if (cmd === "terraform" && args[0] === "plan") {
860
+ if (!args.includes("-json")) return { status: 0, stdout: "Plan text" };
861
+ jsonPlans++;
862
+ return { status: 0, stdout: jsonPlans === 1 ? PLAN_JSON : secondPlan };
863
+ }
864
+ return MISSING;
865
+ };
866
+ const ctx = makeCtx(makeDir({ "main.tf": "x" }));
867
+
868
+ const result = await runTool(TerraformPlanTool(ctx));
869
+
870
+ expect(result).toMatchObject({ ran: true, idempotent: false, needs_human: true });
871
+ expect(result.idempotency_warning).toMatch(/not deterministic/);
872
+ expect(result.needs_human_reasons).toEqual(
873
+ expect.arrayContaining([expect.stringMatching(/non-deterministic plan/)]),
874
+ );
875
+ expect(ctx.toolState.lastIdempotent).toBe(false);
876
+ });
877
+
878
+ it("plans every discovered root and reports the ones that could not plan", async () => {
879
+ process.env.AWS_ACCESS_KEY_ID = "akia";
880
+ const cwd = makeDir({
881
+ "terraform/providers.tf": 'provider "aws" {}\n',
882
+ "terraform/core/providers.tf": 'provider "aws" {}\n',
883
+ });
884
+ const noChanges = JSON.stringify({
885
+ type: "change_summary",
886
+ changes: { add: 0, change: 0, remove: 0 },
887
+ });
888
+ dispatch = (cmd, args, dir) => {
889
+ if (cmd === "terraform" && args[0] === "init") return { status: 0 };
890
+ if (cmd === "terraform" && args[0] === "plan") {
891
+ const inCore = dir.replace(/\\/g, "/").endsWith("/core");
892
+ return inCore
893
+ ? { status: 1, stderr: "core backend unreachable" }
894
+ : { status: 0, stdout: noChanges };
895
+ }
896
+ return MISSING;
897
+ };
898
+ const ctx = makeCtx(cwd);
899
+
900
+ const result = await runTool(TerraformPlanTool(ctx));
901
+
902
+ expect(result).toMatchObject({
903
+ ran: true,
904
+ roots_planned: ["terraform"],
905
+ to_add: 0,
906
+ has_destroy_or_replace: false,
907
+ needs_human: false,
908
+ });
909
+ expect(result.plan_text).toBeUndefined();
910
+ expect(result.roots_skipped).toEqual([
911
+ { dir: "terraform/core", reason: expect.stringMatching(/terraform plan failed/) },
912
+ ]);
913
+ });
914
+ });
915
+
916
+ describe("ReadFindingsTool", () => {
917
+ const savedPath = process.env.TERRAMEND_FINDINGS_PATH;
918
+
919
+ beforeEach(() => {
920
+ delete process.env.TERRAMEND_FINDINGS_PATH;
921
+ });
922
+
923
+ afterEach(() => {
924
+ if (savedPath === undefined) delete process.env.TERRAMEND_FINDINGS_PATH;
925
+ else process.env.TERRAMEND_FINDINGS_PATH = savedPath;
926
+ });
927
+
928
+ const reviewerReport = JSON.stringify({
929
+ schema_version: "1.0",
930
+ findings: [
931
+ {
932
+ category: "security",
933
+ source: "checkov",
934
+ rule_id: "checkov:CKV_AWS_18",
935
+ state: "verified",
936
+ severity: "high",
937
+ evidence: "S3 bucket has no access logging",
938
+ location: { file: "main.tf", line: 5 },
939
+ remediation_hint: "enable access logging",
940
+ },
941
+ {
942
+ category: "style",
943
+ source: "tflint",
944
+ rule_id: "tflint:terraform_unused_declarations",
945
+ state: "verified",
946
+ severity: "low",
947
+ evidence: "unused variable",
948
+ location: { file: "vpc.tf", line: 3 },
949
+ },
950
+ {
951
+ category: "security",
952
+ source: "checkov",
953
+ rule_id: "checkov:CKV_AWS_99",
954
+ state: "human_only",
955
+ severity: "high",
956
+ location: { file: "main.tf", line: 9 },
957
+ },
958
+ ],
959
+ });
960
+
961
+ it("returns found: false (never an error) when no findings.json exists", async () => {
962
+ const cwd = makeDir();
963
+ const result = await runTool(ReadFindingsTool(makeCtx(cwd)));
964
+ expect(result).toMatchObject({
965
+ ok: false,
966
+ code: "findings_not_found",
967
+ found: false,
968
+ concerns: [],
969
+ groups: [],
970
+ });
971
+ });
972
+
973
+ it("returns a structured parse-error skip for an unusable findings file", async () => {
974
+ const cwd = makeDir({ "findings.json": '{"schema_version":"1.0","findings":42}' });
975
+ const result = await runTool(ReadFindingsTool(makeCtx(cwd)));
976
+ expect(result).toMatchObject({ ok: false, code: "findings_parse_error", found: false });
977
+ });
978
+
979
+ it("loads reviewer findings into the same shape terraform_scan returns", async () => {
980
+ const cwd = makeDir({ "findings.json": reviewerReport });
981
+ const ctx = makeCtx(cwd);
982
+
983
+ const result = await runTool(ReadFindingsTool(ctx));
984
+
985
+ expect(result).toMatchObject({
986
+ ok: true,
987
+ found: true,
988
+ source_file: join(cwd, "findings.json"),
989
+ grouping: "file",
990
+ });
991
+ expect(result.summary).toEqual({
992
+ total: 2, // the human_only finding is dropped
993
+ groups: 2,
994
+ by_severity: { high: 1, low: 1 },
995
+ });
996
+ expect(at(result.concerns, 0)).toMatchObject({
997
+ source: "checkov",
998
+ severity: "high",
999
+ doc_url: null,
1000
+ });
1001
+ // §1.4 baseline captured for the later regression diff.
1002
+ expect(ctx.toolState.baselineConcernIds).toHaveLength(2);
1003
+ });
1004
+
1005
+ it("honours an explicit in-workspace path, by-rule grouping, and the configured threshold", async () => {
1006
+ const cwd = makeDir({ "custom.json": reviewerReport });
1007
+ const ctx = makeCtx(cwd, { payload: { severityThreshold: "high" } });
1008
+
1009
+ const result = await runTool(ReadFindingsTool(ctx), {
1010
+ path: "custom.json",
1011
+ group_by: "rule",
1012
+ });
1013
+
1014
+ expect(result).toMatchObject({ found: true, grouping: "rule" });
1015
+ expect(result.summary).toMatchObject({ total: 1, groups: 1 });
1016
+ expect(at(result.groups, 0)).toMatchObject({ grouping: "rule", file: "main.tf" });
1017
+ });
1018
+
1019
+ it("rejects a path arg that escapes the workspace (no arbitrary file read)", async () => {
1020
+ const outside = makeDir({ "secret.json": reviewerReport });
1021
+ const cwd = makeDir();
1022
+ await expect(
1023
+ runTool(ReadFindingsTool(makeCtx(cwd)), { path: join(outside, "secret.json") }),
1024
+ ).rejects.toThrow(/escapes the workspace/);
1025
+ });
1026
+
1027
+ it("falls back to $TERRAMEND_FINDINGS_PATH when no path arg is given (operator-set, unconfined)", async () => {
1028
+ const dir = makeDir({ "elsewhere.json": reviewerReport });
1029
+ process.env.TERRAMEND_FINDINGS_PATH = join(dir, "elsewhere.json");
1030
+ const result = await runTool(ReadFindingsTool(makeCtx(makeDir())));
1031
+ expect(result).toMatchObject({ found: true, source_file: join(dir, "elsewhere.json") });
1032
+ });
1033
+ });
1034
+
1035
+ describe("changedTerraformFiles (diff scope plumbing)", () => {
1036
+ it("re-bases repo-root diff paths onto a scanned subdirectory", () => {
1037
+ dispatch = (cmd, args) => {
1038
+ if (cmd !== "git") return MISSING;
1039
+ if (args[0] === "rev-parse" && args.includes("origin/HEAD")) {
1040
+ return { status: 0, stdout: "origin/main\n" };
1041
+ }
1042
+ if (args[0] === "merge-base") return { status: 1 }; // falls back to the base ref
1043
+ if (args.includes("--show-prefix")) return { status: 0, stdout: "infra/\n" };
1044
+ if (args[0] === "diff") {
1045
+ return {
1046
+ status: 0,
1047
+ stdout: "infra/main.tf\ninfra/envs/prod.tfvars\nother/root.tf\nREADME.md\n",
1048
+ };
1049
+ }
1050
+ return MISSING;
1051
+ };
1052
+
1053
+ const changed = changedTerraformFiles("/repo/infra");
1054
+
1055
+ expect(changed).toEqual(new Set(["main.tf", "envs/prod.tfvars"]));
1056
+ });
1057
+
1058
+ it("returns null when the diff itself fails", () => {
1059
+ dispatch = (cmd, args) => {
1060
+ if (cmd !== "git") return MISSING;
1061
+ if (args[0] === "rev-parse" && args.includes("origin/HEAD")) {
1062
+ return { status: 0, stdout: "origin/main\n" };
1063
+ }
1064
+ if (args[0] === "merge-base") return { status: 0, stdout: "abc\n" };
1065
+ if (args[0] === "diff") return { status: 1, stderr: "fatal" };
1066
+ return MISSING;
1067
+ };
1068
+
1069
+ expect(changedTerraformFiles("/repo")).toBeNull();
1070
+ });
1071
+ });