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,333 @@
1
+ import { ruleDocUrl } from "#app/mcp/terraform/decisions";
2
+ import {
3
+ type Concern,
4
+ concernId,
5
+ isTerraformFile,
6
+ lowerSeverity,
7
+ type Severity,
8
+ toRepoRelative,
9
+ } from "#app/mcp/terraform/types";
10
+
11
+ // --- reviewer findings (read_findings) ------------------------------------
12
+
13
+ /**
14
+ * The subset of a terraform-reviewer (Assessor) `findings.json` finding that
15
+ * Terramend consumes. The reviewer's contract is a deliberate SUPERSET of the
16
+ * Concern model (same content-id formula, same severity enum) — see
17
+ * ../terraform-reviewer/schemas/findings.schema.json. Extra fields
18
+ * (lens/standard/control_id/confidence/state) are ignored except `state`.
19
+ */
20
+ interface ReviewerFinding {
21
+ category?: string;
22
+ source?: string;
23
+ rule_id?: string;
24
+ /** verified (deterministic) | evidence (confirm manually) | human_only (out of scope). */
25
+ state?: string;
26
+ severity?: string;
27
+ evidence?: string;
28
+ location?: { file?: string; line?: number | null };
29
+ remediation_hint?: string | null;
30
+ }
31
+
32
+ interface ReviewerFindingsReport {
33
+ schema_version?: string;
34
+ findings?: ReviewerFinding[] | null;
35
+ }
36
+
37
+ /**
38
+ * Map a reviewer `source` to a Concern source. The scanners Terramend also runs
39
+ * (checkov / tflint / trivy / terraform-fmt / terraform-validate) keep their
40
+ * name, so re-running them reproduces the identical content id — those concerns
41
+ * are ✗→✓ verifiable. Everything else (tfsec — whose rule ids differ from
42
+ * trivy's — plus infracost, llm, …) collapses to `reviewer`: the original tool
43
+ * stays visible in `rule_id`, but Terramend can't reproduce the id, so
44
+ * `terraform_verify_remediation` will honestly report it unresolved.
45
+ *
46
+ * NB: trivy ✗→✓ verifiability assumes the reviewer's trivy `rule_id` equals
47
+ * Terramend's (both `trivy:<AVDID>`). That holds today (the reviewer's SARIF
48
+ * `ruleId` is the AVD id); if a future Trivy diverges SARIF ruleId from the
49
+ * JSON AVDID, trivy-source findings would stop matching on re-scan.
50
+ */
51
+ function mapReviewerSource(source: string | undefined): Concern["source"] {
52
+ switch (source) {
53
+ case "checkov":
54
+ case "tflint":
55
+ case "trivy":
56
+ case "terraform-fmt":
57
+ case "terraform-validate":
58
+ return source;
59
+ default:
60
+ return "reviewer";
61
+ }
62
+ }
63
+
64
+ function mapReviewerCategory(category: string | undefined): Concern["category"] {
65
+ switch (category) {
66
+ case "security":
67
+ return "security";
68
+ case "style":
69
+ return "style";
70
+ case "cost":
71
+ return "cost";
72
+ default:
73
+ return "correctness";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Map a reviewer `findings.json` body into Concern[]. Drops `human_only`
79
+ * findings (out of scope — not auto-remediable). Paths are normalized to
80
+ * repo-relative POSIX (same as the scanners) so ids and grouping stay portable.
81
+ */
82
+ export function parseReviewerFindings(json: string, cwd = ""): Concern[] {
83
+ const parsed = JSON.parse(json || "{}") as ReviewerFindingsReport;
84
+ const out: Concern[] = [];
85
+ for (const f of parsed.findings ?? []) {
86
+ if (f.state === "human_only") continue;
87
+ const file = toRepoRelative(f.location?.file, cwd);
88
+ // Skip findings that don't point at a Terraform file — they aren't per-file
89
+ // remediable. In particular the reviewer's infracost/cost findings are keyed
90
+ // to a project *directory* (not a `.tf`), so they land here; cost is surfaced
91
+ // during remediation by `infracost_diff` (E1), not by editing a directory.
92
+ if (!isTerraformFile(file)) continue;
93
+ const line = f.location?.line ?? null;
94
+ const source = mapReviewerSource(f.source);
95
+ const ruleId = f.rule_id || "finding";
96
+ // Terramend's own parsers store `rule_id` namespaced (`${source}:${rule}`)
97
+ // but hash only the bare rule into the content id. Strip a matching
98
+ // `${source}:` prefix so a checkov/tflint/trivy/fmt finding from the reviewer
99
+ // reproduces the SAME id Terramend's own scan would — keeping it ✗→✓
100
+ // verifiable. `reviewer`-source findings keep the full rule_id in the hash
101
+ // (they aren't reproducible anyway). Both namespaced and bare inputs work.
102
+ const bareRule =
103
+ source !== "reviewer" && ruleId.startsWith(`${source}:`)
104
+ ? ruleId.slice(source.length + 1)
105
+ : ruleId;
106
+ out.push({
107
+ id: concernId(source, bareRule, file, line),
108
+ source,
109
+ rule_id: ruleId,
110
+ severity: lowerSeverity(f.severity),
111
+ category: mapReviewerCategory(f.category),
112
+ evidence: f.evidence || ruleId,
113
+ location: { file, line },
114
+ remediation_hint: f.remediation_hint ?? null,
115
+ });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // --- SARIF ingestion (read_findings) --------------------------------------
121
+
122
+ interface SarifLocation {
123
+ physicalLocation?: {
124
+ artifactLocation?: { uri?: string };
125
+ region?: { startLine?: number };
126
+ };
127
+ }
128
+ interface SarifResult {
129
+ ruleId?: string;
130
+ level?: string;
131
+ message?: { text?: string };
132
+ locations?: SarifLocation[];
133
+ properties?: { "security-severity"?: string };
134
+ }
135
+ interface SarifRule {
136
+ id?: string;
137
+ helpUri?: string;
138
+ shortDescription?: { text?: string };
139
+ }
140
+ interface SarifRun {
141
+ tool?: { driver?: { name?: string; rules?: SarifRule[] } };
142
+ results?: SarifResult[];
143
+ }
144
+ interface SarifReport {
145
+ version?: string;
146
+ $schema?: string;
147
+ runs?: SarifRun[];
148
+ }
149
+
150
+ /** true when a parsed JSON object looks like a SARIF report (the standard
151
+ * scanner-output format) rather than a terraform-reviewer findings.json. */
152
+ export function isSarif(parsed: unknown): boolean {
153
+ if (!parsed || typeof parsed !== "object") return false;
154
+ const o = parsed as Record<string, unknown>;
155
+ const schema = typeof o.$schema === "string" ? o.$schema.toLowerCase() : "";
156
+ return Array.isArray(o.runs) && (schema.includes("sarif") || typeof o.version === "string");
157
+ }
158
+
159
+ /** map a SARIF driver name to a Concern source (so a re-run reproduces the id
160
+ * for the scanners Terramend also runs; everything else → `reviewer`). */
161
+ function mapSarifDriver(name: string | undefined): Concern["source"] {
162
+ switch ((name ?? "").toLowerCase()) {
163
+ case "trivy":
164
+ return "trivy";
165
+ case "checkov":
166
+ return "checkov";
167
+ case "tflint":
168
+ return "tflint";
169
+ default:
170
+ return "reviewer";
171
+ }
172
+ }
173
+
174
+ /** SARIF `level` → severity (a `security-severity` property refines it). */
175
+ function sarifSeverity(level: string | undefined, securitySeverity: string | undefined): Severity {
176
+ const score = securitySeverity ? Number.parseFloat(securitySeverity) : Number.NaN;
177
+ if (Number.isFinite(score)) {
178
+ if (score >= 9) return "critical";
179
+ if (score >= 7) return "high";
180
+ if (score >= 4) return "medium";
181
+ if (score > 0) return "low";
182
+ }
183
+ switch ((level ?? "").toLowerCase()) {
184
+ case "error":
185
+ return "high";
186
+ case "warning":
187
+ return "medium";
188
+ case "note":
189
+ return "low";
190
+ default:
191
+ return "info";
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Parse a SARIF 2.1.0 report (the standard scanner-output format Trivy /
197
+ * Checkov / tflint all emit) into Concern[]. The driver name picks the source so
198
+ * a finding from a scanner Terramend re-runs reproduces the SAME content id
199
+ * (✗→✓ verifiable); other tools collapse to `reviewer`. Rule docs come from the
200
+ * matching `tool.driver.rules[].helpUri`. Non-Terraform files are dropped.
201
+ */
202
+ export function parseSarifFindings(json: string, cwd = ""): Concern[] {
203
+ let report: SarifReport;
204
+ try {
205
+ report = JSON.parse(json || "{}") as SarifReport;
206
+ } catch {
207
+ return [];
208
+ }
209
+ const out: Concern[] = [];
210
+ for (const run of report.runs ?? []) {
211
+ const source = mapSarifDriver(run.tool?.driver?.name);
212
+ const ruleDocs = new Map<string, string>();
213
+ for (const rule of run.tool?.driver?.rules ?? []) {
214
+ if (rule.id && rule.helpUri) ruleDocs.set(rule.id, rule.helpUri);
215
+ }
216
+ for (const result of run.results ?? []) {
217
+ const loc = result.locations?.[0]?.physicalLocation;
218
+ const file = toRepoRelative(loc?.artifactLocation?.uri, cwd);
219
+ if (!isTerraformFile(file)) continue;
220
+ const start = loc?.region?.startLine;
221
+ const line = typeof start === "number" && start > 0 ? start : null;
222
+ const rawRule = result.ruleId || "finding";
223
+ // strip a `${source}:` prefix if a tool already namespaced it, so the
224
+ // content id matches Terramend's own scan of the same rule.
225
+ const bareRule =
226
+ source !== "reviewer" && rawRule.startsWith(`${source}:`)
227
+ ? rawRule.slice(source.length + 1)
228
+ : rawRule;
229
+ const ruleId = source === "reviewer" ? rawRule : `${source}:${bareRule}`;
230
+ out.push({
231
+ id: concernId(source, bareRule, file, line),
232
+ source,
233
+ rule_id: ruleId,
234
+ severity: sarifSeverity(result.level, result.properties?.["security-severity"]),
235
+ category: source === "tflint" ? "style" : "security",
236
+ evidence: result.message?.text || ruleId,
237
+ location: { file, line },
238
+ remediation_hint: ruleDocs.get(rawRule) ?? null,
239
+ });
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** dispatch a findings file to the right parser: SARIF (standard scanner
246
+ * output) or a terraform-reviewer findings.json. */
247
+ export function parseFindingsFile(json: string, cwd = ""): Concern[] {
248
+ let parsed: unknown;
249
+ try {
250
+ parsed = JSON.parse(json || "{}");
251
+ } catch {
252
+ return [];
253
+ }
254
+ return isSarif(parsed) ? parseSarifFindings(json, cwd) : parseReviewerFindings(json, cwd);
255
+ }
256
+
257
+ // --- SARIF emit (GitHub code-scanning) ------------------------------------
258
+
259
+ /** Concern severity → SARIF `level`. SARIF has only error/warning/note, so
260
+ * critical+high collapse to `error`, medium → `warning`, low+info → `note`. The
261
+ * finer grade survives in the `security-severity` property below. */
262
+ function severityToSarifLevel(s: Severity): "error" | "warning" | "note" {
263
+ switch (s) {
264
+ case "critical":
265
+ case "high":
266
+ return "error";
267
+ case "medium":
268
+ return "warning";
269
+ default:
270
+ return "note";
271
+ }
272
+ }
273
+
274
+ /** Concern severity → the numeric `security-severity` GitHub reads to colour the
275
+ * alert (0–10 CVSS-like scale). */
276
+ function securitySeverityScore(s: Severity): string {
277
+ switch (s) {
278
+ case "critical":
279
+ return "9.5";
280
+ case "high":
281
+ return "8.0";
282
+ case "medium":
283
+ return "5.0";
284
+ case "low":
285
+ return "2.0";
286
+ default:
287
+ return "0.0";
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Emit a set of concerns as a SARIF 2.1.0 report for GitHub code-scanning (the
293
+ * inverse of `parseSarifFindings` — close the loop so a Terramend scan can
294
+ * populate the repo's Security tab via `github/codeql-action/upload-sarif`). One
295
+ * `run` with the `terramend` driver, a deduped `rules` array (each rule's
296
+ * `helpUri` from `ruleDocUrl`), and one `result` per concern carrying its
297
+ * `level`, `security-severity`, message, and `file:line`. Pure + deterministic
298
+ * (rules sorted, stable partialFingerprints from the content id) so re-emitting
299
+ * an unchanged scan yields a byte-identical report.
300
+ */
301
+ export function buildSarifReport(concerns: Concern[]): SarifReport {
302
+ // deduped rule metadata, keyed by the namespaced rule_id (the SARIF ruleId).
303
+ const rulesById = new Map<string, SarifRule>();
304
+ for (const c of concerns) {
305
+ if (rulesById.has(c.rule_id)) continue;
306
+ const helpUri = ruleDocUrl(c);
307
+ rulesById.set(c.rule_id, {
308
+ id: c.rule_id,
309
+ ...(helpUri ? { helpUri } : {}),
310
+ shortDescription: { text: c.evidence.slice(0, 200) },
311
+ });
312
+ }
313
+ const rules = [...rulesById.values()].sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""));
314
+ const results: SarifResult[] = concerns.map((c) => ({
315
+ ruleId: c.rule_id,
316
+ level: severityToSarifLevel(c.severity),
317
+ message: { text: c.evidence },
318
+ properties: { "security-severity": securitySeverityScore(c.severity) },
319
+ locations: [
320
+ {
321
+ physicalLocation: {
322
+ artifactLocation: { uri: c.location.file },
323
+ ...(c.location.line ? { region: { startLine: c.location.line } } : {}),
324
+ },
325
+ },
326
+ ],
327
+ }));
328
+ return {
329
+ version: "2.1.0",
330
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
331
+ runs: [{ tool: { driver: { name: "terramend", rules } }, results }],
332
+ };
333
+ }
@@ -0,0 +1,348 @@
1
+ import type { BlastTier } from "#app/mcp/terraform/types";
2
+
3
+ // --- terraform plan (the safety gate) -------------------------------------
4
+
5
+ export interface PlanSummary {
6
+ /** resources to add / change / destroy, from the plan's change_summary. */
7
+ add: number;
8
+ change: number;
9
+ destroy: number;
10
+ /** every resource with a real action (create/update/delete/replace) — the set
11
+ * that powers blast-radius (§2.6) and plan-stability (§1.3). */
12
+ changed: { address: string; action: string }[];
13
+ /** resources that would be deleted or replaced — the destructive set. */
14
+ destructive: { address: string; action: string }[];
15
+ hasDestroyOrReplace: boolean;
16
+ /** state-only moves (`moved {}` blocks / refactors): the new address and the
17
+ * address it came from. Moves don't mutate live infrastructure, so they are
18
+ * NOT in `changed` — they power the M2 modularization gate (`isPureMovePlan`). */
19
+ moved: { address: string; previousAddress: string | null }[];
20
+ }
21
+
22
+ /**
23
+ * Parse `terraform plan -json` (newline-delimited JSON). `change_summary` gives
24
+ * the add/change/destroy totals; each `planned_change` with a real action is
25
+ * collected into `changed`, and the delete/replace subset into `destructive`
26
+ * (the high-risk set a reviewer must scrutinise). Non-mutating actions are
27
+ * ignored, as are non-JSON / non-plan lines, so a noisy stream (provider logs,
28
+ * diagnostics) parses cleanly.
29
+ *
30
+ * NB on the action enum: terraform's machine-readable UI (the `-json` stream)
31
+ * spells no-op as `"noop"` — NOT `"no-op"` — and also emits `"move"` / `"import"`
32
+ * / `"forget"` for state-only operations that don't mutate live infrastructure.
33
+ * None of those should count toward `changed` (they'd inflate the blast radius
34
+ * §2.6). We skip them explicitly; `"no-op"` is tolerated too in case a wrapper
35
+ * or older format hyphenates it. See
36
+ * https://developer.hashicorp.com/terraform/internals/machine-readable-ui.
37
+ */
38
+ const NON_MUTATING_PLAN_ACTIONS: ReadonlySet<string> = new Set([
39
+ "noop",
40
+ "no-op",
41
+ "read",
42
+ "move",
43
+ "import",
44
+ "forget",
45
+ ]);
46
+
47
+ export function parseTerraformPlanJson(stdout: string): PlanSummary {
48
+ let add = 0;
49
+ let change = 0;
50
+ let destroy = 0;
51
+ const changed: { address: string; action: string }[] = [];
52
+ const destructive: { address: string; action: string }[] = [];
53
+ const moved: { address: string; previousAddress: string | null }[] = [];
54
+ for (const line of stdout.split("\n")) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed.startsWith("{")) continue;
57
+ let msg: {
58
+ type?: string;
59
+ changes?: { add?: number; change?: number; remove?: number };
60
+ change?: {
61
+ action?: string;
62
+ resource?: { addr?: string; resource?: string };
63
+ previous_resource?: { addr?: string; resource?: string };
64
+ };
65
+ };
66
+ try {
67
+ msg = JSON.parse(trimmed);
68
+ } catch {
69
+ continue;
70
+ }
71
+ if (msg.type === "change_summary" && msg.changes) {
72
+ add = Number(msg.changes.add) || 0;
73
+ change = Number(msg.changes.change) || 0;
74
+ destroy = Number(msg.changes.remove) || 0;
75
+ } else if (msg.type === "planned_change" && msg.change) {
76
+ const action = String(msg.change.action ?? "");
77
+ // moves are state-only (no infra mutation) but are tracked separately —
78
+ // they prove an M2 modularization refactor is a no-op (`isPureMovePlan`).
79
+ if (action === "move") {
80
+ const address = msg.change.resource?.addr || msg.change.resource?.resource || "(unknown)";
81
+ const previousAddress =
82
+ msg.change.previous_resource?.addr || msg.change.previous_resource?.resource || null;
83
+ moved.push({ address, previousAddress });
84
+ continue;
85
+ }
86
+ if (!action || NON_MUTATING_PLAN_ACTIONS.has(action)) continue;
87
+ const address = msg.change.resource?.addr || msg.change.resource?.resource || "(unknown)";
88
+ changed.push({ address, action });
89
+ // "delete", "replace", and the "*-then-delete" / "delete-then-*" forms.
90
+ if (action.includes("delete") || action === "replace") {
91
+ destructive.push({ address, action });
92
+ }
93
+ }
94
+ }
95
+ return {
96
+ add,
97
+ change,
98
+ destroy,
99
+ changed,
100
+ destructive,
101
+ hasDestroyOrReplace: destructive.length > 0,
102
+ moved,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * §M2 modularization gate — true when the plan is PURELY state moves: at least
108
+ * one `moved` entry and zero create/update/delete/replace. That proves a
109
+ * resources→module refactor preserved every resource address (via `moved {}`
110
+ * blocks) and is a no-op on live infrastructure — the condition under which a
111
+ * modularization PR may proceed without human escalation.
112
+ */
113
+ export function isPureMovePlan(plan: {
114
+ add: number;
115
+ change: number;
116
+ destroy: number;
117
+ changed: { address: string }[];
118
+ moved: { address: string }[];
119
+ }): boolean {
120
+ return (
121
+ plan.moved.length > 0 &&
122
+ plan.changed.length === 0 &&
123
+ plan.add + plan.change + plan.destroy === 0
124
+ );
125
+ }
126
+
127
+ // --- stateful destroy/replace classification (safety gate §2.5) ------------
128
+
129
+ /**
130
+ * Resource types that hold data/state — destroying or replacing one of these
131
+ * means data loss, not just recreation. A remediation that would delete or
132
+ * replace one is hard-blocked at push time unless the operator opts in via the
133
+ * `allow_replace` input. Not exhaustive: it covers the common managed
134
+ * datastores across AWS / Azure / GCP; extend as new ones come up.
135
+ */
136
+ export const STATEFUL_RESOURCE_TYPES: ReadonlySet<string> = new Set([
137
+ // AWS
138
+ "aws_db_instance",
139
+ "aws_rds_cluster",
140
+ "aws_rds_cluster_instance",
141
+ "aws_s3_bucket",
142
+ "aws_ebs_volume",
143
+ "aws_efs_file_system",
144
+ "aws_dynamodb_table",
145
+ "aws_dynamodb_global_table",
146
+ "aws_elasticache_cluster",
147
+ "aws_elasticache_replication_group",
148
+ "aws_redshift_cluster",
149
+ "aws_docdb_cluster",
150
+ "aws_neptune_cluster",
151
+ "aws_opensearch_domain",
152
+ "aws_elasticsearch_domain",
153
+ // Azure
154
+ "azurerm_sql_database",
155
+ "azurerm_mssql_database",
156
+ "azurerm_postgresql_database",
157
+ "azurerm_postgresql_flexible_server",
158
+ "azurerm_mysql_database",
159
+ "azurerm_mysql_flexible_server",
160
+ "azurerm_cosmosdb_account",
161
+ "azurerm_cosmosdb_sql_database",
162
+ "azurerm_storage_account",
163
+ "azurerm_managed_disk",
164
+ // GCP
165
+ "google_sql_database_instance",
166
+ "google_storage_bucket",
167
+ "google_bigtable_instance",
168
+ "google_bigquery_dataset",
169
+ "google_spanner_database",
170
+ "google_redis_instance",
171
+ "google_filestore_instance",
172
+ "google_compute_disk",
173
+ ]);
174
+
175
+ /**
176
+ * Extract the Terraform resource TYPE from a plan address, stripping any
177
+ * `module.<name>.` prefixes and an instance index/key suffix:
178
+ * `module.db.aws_db_instance.main` -> `aws_db_instance`
179
+ * `aws_s3_bucket.data["prod"]` -> `aws_s3_bucket`
180
+ * `module.a.module.b.google_storage_bucket.x[0]` -> `google_storage_bucket`
181
+ * Returns "" when the address has no parseable `type.name` pair.
182
+ */
183
+ export function resourceTypeOf(address: string): string {
184
+ const withoutModules = address.replace(/^(?:module\.[^.]+\.)+/, "");
185
+ const cleaned = withoutModules.replace(/\[[^\]]*\]$/, "");
186
+ const segments = cleaned.split(".");
187
+ return segments.at(-2) ?? "";
188
+ }
189
+
190
+ export interface DestroyClassification {
191
+ /** destroy/replace of a data-bearing type — high-risk, blocked by default. */
192
+ stateful: { address: string; action: string; type: string }[];
193
+ /** destroy/replace of a recreatable type — recorded, not blocked. */
194
+ ephemeral: { address: string; action: string; type: string }[];
195
+ }
196
+
197
+ /** partition a plan's destructive set into stateful (blocked) vs ephemeral. */
198
+ export function classifyDestructive(
199
+ destructive: { address: string; action: string }[],
200
+ ): DestroyClassification {
201
+ const stateful: DestroyClassification["stateful"] = [];
202
+ const ephemeral: DestroyClassification["ephemeral"] = [];
203
+ for (const d of destructive) {
204
+ const type = resourceTypeOf(d.address);
205
+ (STATEFUL_RESOURCE_TYPES.has(type) ? stateful : ephemeral).push({ ...d, type });
206
+ }
207
+ return { stateful, ephemeral };
208
+ }
209
+
210
+ // --- blast-radius scoring (§2.6) -------------------------------------------
211
+
212
+ export interface BlastRadius {
213
+ tier: BlastTier;
214
+ /** count of resources the plan would create/update/delete/replace. */
215
+ resourceCount: number;
216
+ /** distinct module addresses touched (root resources count as `root`). */
217
+ modules: string[];
218
+ }
219
+
220
+ /**
221
+ * Extract the module address from a resource address: the `module.X[.module.Y]`
222
+ * call path, or `root` for a top-level resource. Strips instance index/key from
223
+ * EVERY segment — a `count`/`for_each` MODULE carries its key on the module
224
+ * segment (`module.net[0]`), so all instances of one module collapse to one
225
+ * address (else a single-module fix would look cross-module). Removing keys
226
+ * first also tolerates a `.` inside a `for_each` string key.
227
+ * `aws_s3_bucket.b` -> `root`
228
+ * `module.db.aws_db_instance.main` -> `module.db`
229
+ * `module.net[0].aws_vpc.main` -> `module.net`
230
+ * `module.a.module.b.google_x.y[0]` -> `module.a.module.b`
231
+ */
232
+ export function moduleAddressOf(address: string): string {
233
+ const cleaned = address.replace(/\[[^\]]*\]/g, "");
234
+ const segments = cleaned.split(".");
235
+ // the resource is the final `type.name` pair; anything before is the module path.
236
+ return segments.length <= 2 ? "root" : segments.slice(0, segments.length - 2).join(".");
237
+ }
238
+
239
+ /**
240
+ * Score how much a fix touches, to route large changes through stricter review:
241
+ * 1–2 resources = `low`, 3–10 = `medium`, more than 10 OR spanning more than one
242
+ * module = `high`. A `high` blast radius should force human-in-the-loop
243
+ * regardless of finding severity (feeds §3.9). 0 changes is `low` (nothing to do).
244
+ */
245
+ export function computeBlastRadius(changed: { address: string }[]): BlastRadius {
246
+ const resourceCount = changed.length;
247
+ const modules = [...new Set(changed.map((c) => moduleAddressOf(c.address)))].sort();
248
+ const crossModule = modules.length > 1;
249
+ let tier: BlastTier;
250
+ if (resourceCount > 10 || crossModule) tier = "high";
251
+ else if (resourceCount >= 3) tier = "medium";
252
+ else tier = "low";
253
+ return { tier, resourceCount, modules };
254
+ }
255
+
256
+ // --- plan stability / idempotency (§1.3) -----------------------------------
257
+
258
+ export interface StabilityResult {
259
+ /** true when a second plan produced the identical change set. */
260
+ stable: boolean;
261
+ reason?: string;
262
+ }
263
+
264
+ /** a normalized signature of a plan's change set (summary counts + sorted
265
+ * address:action pairs) — two plans with the same signature are equivalent. */
266
+ function planSignature(s: PlanSummary): string {
267
+ const set = s.changed
268
+ .map((c) => `${c.address}:${c.action}`)
269
+ .sort()
270
+ .join(",");
271
+ return `+${s.add}~${s.change}-${s.destroy}|${set}`;
272
+ }
273
+
274
+ /**
275
+ * Compare two consecutive plans for stability. Terramend never `apply`s (it only
276
+ * opens PRs), so a true "no perpetual diff after apply" cannot be proven here —
277
+ * but a fix whose plan is non-deterministic (e.g. `timestamp()`, `uuid()`, an
278
+ * unkeyed `random_*`, or a data source that varies run-to-run) yields a DIFFERENT
279
+ * plan on the second run, and that is a real perpetual-diff smell we can catch
280
+ * without applying. Stable ⇒ the two plans matched; unstable ⇒ report it.
281
+ */
282
+ export function comparePlanStability(first: PlanSummary, second: PlanSummary): StabilityResult {
283
+ if (planSignature(first) === planSignature(second)) return { stable: true };
284
+ return {
285
+ stable: false,
286
+ reason:
287
+ `the plan is not deterministic — a second \`terraform plan\` (same state, no apply) produced a ` +
288
+ `different change set (first: +${first.add} ~${first.change} -${first.destroy}; ` +
289
+ `second: +${second.add} ~${second.change} -${second.destroy}). This is a perpetual-diff smell, ` +
290
+ `usually a non-deterministic value in the config (timestamp()/uuid()/unkeyed random_*/a varying data source).`,
291
+ };
292
+ }
293
+
294
+ // --- multi-root plan aggregation -------------------------------------------
295
+
296
+ export interface RootPlan {
297
+ /** display label for the root ("." for the top-level root). */
298
+ dir: string;
299
+ summary: PlanSummary;
300
+ stable: boolean;
301
+ }
302
+
303
+ export interface AggregatedPlan {
304
+ add: number;
305
+ change: number;
306
+ destroy: number;
307
+ changed: { address: string; action: string }[];
308
+ destructive: { address: string; action: string }[];
309
+ hasDestroyOrReplace: boolean;
310
+ idempotent: boolean;
311
+ moved: { address: string; previousAddress: string | null }[];
312
+ }
313
+
314
+ /**
315
+ * Aggregate per-root plan results into one view: SUM the add/change/destroy
316
+ * counts, UNION the changed + destructive sets (so blast-radius and the
317
+ * destroy-block see every root's effect), and treat the whole run as
318
+ * non-idempotent if ANY root's plan was unstable. Pure. Single-root input passes
319
+ * straight through (identical to the pre-multi-root behaviour).
320
+ */
321
+ export function aggregatePlans(roots: RootPlan[]): AggregatedPlan {
322
+ let add = 0;
323
+ let change = 0;
324
+ let destroy = 0;
325
+ let idempotent = true;
326
+ const changed: { address: string; action: string }[] = [];
327
+ const destructive: { address: string; action: string }[] = [];
328
+ const moved: { address: string; previousAddress: string | null }[] = [];
329
+ for (const r of roots) {
330
+ add += r.summary.add;
331
+ change += r.summary.change;
332
+ destroy += r.summary.destroy;
333
+ changed.push(...r.summary.changed);
334
+ destructive.push(...r.summary.destructive);
335
+ moved.push(...r.summary.moved);
336
+ if (!r.stable) idempotent = false;
337
+ }
338
+ return {
339
+ add,
340
+ change,
341
+ destroy,
342
+ changed,
343
+ destructive,
344
+ hasDestroyOrReplace: destructive.length > 0,
345
+ idempotent,
346
+ moved,
347
+ };
348
+ }