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,1957 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ aggregatePlans,
7
+ annotateGroups,
8
+ buildRefusalReport,
9
+ buildSarifReport,
10
+ type Concern,
11
+ classifyAutonomy,
12
+ classifyCostEscalation,
13
+ classifyDestructive,
14
+ classifyRefusal,
15
+ clusterByLocation,
16
+ collectCloudCredentials,
17
+ comparePlanStability,
18
+ computeBlastRadius,
19
+ computeConfidence,
20
+ computeCostDelta,
21
+ computeRegressions,
22
+ computeRemediationVerdict,
23
+ groupConcerns,
24
+ groupConcernsByRule,
25
+ isPureMovePlan,
26
+ isSarif,
27
+ isTerraformConcern,
28
+ moduleAddressOf,
29
+ parseCheckovOutput,
30
+ parseFindingsFile,
31
+ parseFmtOutput,
32
+ parseInfracostBreakdown,
33
+ parseInfracostResources,
34
+ parseRequiredProviders,
35
+ parseResourceArguments,
36
+ parseReviewerFindings,
37
+ parseSarifFindings,
38
+ parseTerraformPlanJson,
39
+ parseTflintOutput,
40
+ parseTrivyOutput,
41
+ parseValidateOutput,
42
+ planBatches,
43
+ preventiveControlFor,
44
+ type RootPlan,
45
+ rebaseConcern,
46
+ resolveRoots,
47
+ resourceTypeOf,
48
+ ruleDocUrl,
49
+ shouldSuggestInline,
50
+ toRepoRelative,
51
+ } from "#app/mcp/terraform";
52
+
53
+ describe("parseFmtOutput", () => {
54
+ it("emits one low-severity style concern per unformatted file", () => {
55
+ const concerns = parseFmtOutput("main.tf\nmodules/net/vpc.tf\n");
56
+ expect(concerns).toHaveLength(2);
57
+ expect(concerns[0]).toMatchObject({
58
+ source: "terraform-fmt",
59
+ rule_id: "terraform-fmt:unformatted",
60
+ severity: "low",
61
+ category: "style",
62
+ location: { file: "main.tf", line: null },
63
+ });
64
+ expect(concerns[1]!.location.file).toBe("modules/net/vpc.tf");
65
+ });
66
+
67
+ it("returns nothing for empty output", () => {
68
+ expect(parseFmtOutput("\n \n")).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("parseTrivyOutput", () => {
73
+ const sample = JSON.stringify({
74
+ Results: [
75
+ {
76
+ Target: "main.tf",
77
+ Class: "config",
78
+ Type: "terraform",
79
+ Misconfigurations: [
80
+ {
81
+ ID: "AVD-AWS-0088",
82
+ AVDID: "AVD-AWS-0088",
83
+ Title: "S3 Data should be encrypted",
84
+ Description: "S3 encryption should be enabled for buckets at rest.",
85
+ Message: "Bucket does not have encryption enabled",
86
+ Resolution: "Enable server-side encryption",
87
+ Severity: "CRITICAL",
88
+ References: ["https://avd.aquasec.com/misconfig/avd-aws-0088"],
89
+ Status: "FAIL",
90
+ CauseMetadata: { StartLine: 1, EndLine: 1 },
91
+ },
92
+ ],
93
+ },
94
+ ],
95
+ });
96
+
97
+ it("maps a trivy misconfiguration to a security concern with lowercased severity", () => {
98
+ const [concern] = parseTrivyOutput(sample);
99
+ expect(concern).toMatchObject({
100
+ source: "trivy",
101
+ rule_id: "trivy:AVD-AWS-0088",
102
+ severity: "critical",
103
+ category: "security",
104
+ // the instance-specific Message is preferred over the generic Description
105
+ evidence: "Bucket does not have encryption enabled",
106
+ remediation_hint: "Enable server-side encryption",
107
+ location: { file: "main.tf", line: 1 },
108
+ });
109
+ });
110
+
111
+ it("drops Status: PASS misconfigurations (defensive against --include-non-failures)", () => {
112
+ const withPass = JSON.stringify({
113
+ Results: [
114
+ {
115
+ Target: "main.tf",
116
+ Misconfigurations: [
117
+ {
118
+ AVDID: "AVD-AWS-0001",
119
+ Severity: "HIGH",
120
+ Status: "PASS",
121
+ CauseMetadata: { StartLine: 4 },
122
+ },
123
+ {
124
+ AVDID: "AVD-AWS-0002",
125
+ Severity: "HIGH",
126
+ Status: "FAIL",
127
+ CauseMetadata: { StartLine: 9 },
128
+ },
129
+ ],
130
+ },
131
+ ],
132
+ });
133
+ const concerns = parseTrivyOutput(withPass);
134
+ expect(concerns).toHaveLength(1);
135
+ expect(concerns[0]!.rule_id).toBe("trivy:AVD-AWS-0002");
136
+ });
137
+
138
+ it("treats a zero/absent StartLine as a null line", () => {
139
+ const noLine = JSON.stringify({
140
+ Results: [{ Target: "main.tf", Misconfigurations: [{ AVDID: "AVD-X", Severity: "LOW" }] }],
141
+ });
142
+ expect(parseTrivyOutput(noLine)[0]!.location.line).toBeNull();
143
+ });
144
+
145
+ it("tolerates a null/empty Results array", () => {
146
+ expect(parseTrivyOutput(JSON.stringify({ Results: null }))).toEqual([]);
147
+ expect(parseTrivyOutput("{}")).toEqual([]);
148
+ });
149
+ });
150
+
151
+ describe("parseCheckovOutput", () => {
152
+ const single = JSON.stringify({
153
+ results: {
154
+ failed_checks: [
155
+ {
156
+ check_id: "CKV_AWS_18",
157
+ check_name: "Ensure the S3 bucket has access logging enabled",
158
+ severity: "HIGH",
159
+ file_path: "main.tf",
160
+ file_line_range: [3, 7],
161
+ guideline: "https://docs.example/s3-logging",
162
+ },
163
+ ],
164
+ },
165
+ });
166
+
167
+ it("maps a failed check to a concern using the range start line", () => {
168
+ const [concern] = parseCheckovOutput(single);
169
+ expect(concern).toMatchObject({
170
+ source: "checkov",
171
+ rule_id: "checkov:CKV_AWS_18",
172
+ severity: "high",
173
+ category: "security",
174
+ location: { file: "main.tf", line: 3 },
175
+ remediation_hint: "https://docs.example/s3-logging",
176
+ });
177
+ });
178
+
179
+ it("handles checkov's multi-framework array form", () => {
180
+ const arr = `[${single},${single}]`;
181
+ expect(parseCheckovOutput(arr)).toHaveLength(2);
182
+ });
183
+
184
+ it("defaults missing severity to medium", () => {
185
+ const noSev = JSON.stringify({
186
+ results: { failed_checks: [{ check_id: "CKV_X", file_path: "a.tf" }] },
187
+ });
188
+ expect(parseCheckovOutput(noSev)[0]!.severity).toBe("medium");
189
+ });
190
+
191
+ it("normalizes a 0 start line to null (matches the reviewer, so the id is stable)", () => {
192
+ const zeroLine = JSON.stringify({
193
+ results: {
194
+ failed_checks: [{ check_id: "CKV_X", file_path: "a.tf", file_line_range: [0, 0] }],
195
+ },
196
+ });
197
+ expect(parseCheckovOutput(zeroLine)[0]!.location.line).toBeNull();
198
+ });
199
+ });
200
+
201
+ describe("parseTflintOutput", () => {
202
+ it("maps tflint severities to the concern scale", () => {
203
+ const sample = JSON.stringify({
204
+ issues: [
205
+ {
206
+ rule: { name: "terraform_unused_declarations", severity: "warning", link: "https://x" },
207
+ message: 'variable "unused" is declared but not used',
208
+ range: { filename: "main.tf", start: { line: 9 } },
209
+ },
210
+ ],
211
+ });
212
+ const [concern] = parseTflintOutput(sample);
213
+ expect(concern).toMatchObject({
214
+ source: "tflint",
215
+ rule_id: "tflint:terraform_unused_declarations",
216
+ severity: "medium",
217
+ category: "style",
218
+ location: { file: "main.tf", line: 9 },
219
+ });
220
+ });
221
+ });
222
+
223
+ describe("parseValidateOutput", () => {
224
+ it("keeps real errors as high-severity correctness concerns", () => {
225
+ const sample = JSON.stringify({
226
+ diagnostics: [
227
+ {
228
+ severity: "error",
229
+ summary: "Reference to undeclared resource",
230
+ detail: "A managed resource has not been declared.",
231
+ range: { filename: "main.tf", start: { line: 12 } },
232
+ },
233
+ ],
234
+ });
235
+ const [concern] = parseValidateOutput(sample);
236
+ expect(concern).toMatchObject({
237
+ source: "terraform-validate",
238
+ severity: "high",
239
+ category: "correctness",
240
+ location: { file: "main.tf", line: 12 },
241
+ });
242
+ });
243
+
244
+ it("drops uninitialized-directory noise (not a real best-practice issue)", () => {
245
+ const sample = JSON.stringify({
246
+ diagnostics: [
247
+ {
248
+ severity: "error",
249
+ summary: "Missing required provider",
250
+ detail: "please run terraform init",
251
+ },
252
+ ],
253
+ });
254
+ expect(parseValidateOutput(sample)).toEqual([]);
255
+ });
256
+
257
+ it("ignores warning-level diagnostics", () => {
258
+ const sample = JSON.stringify({
259
+ diagnostics: [{ severity: "warning", summary: "Deprecated attribute" }],
260
+ });
261
+ expect(parseValidateOutput(sample)).toEqual([]);
262
+ });
263
+
264
+ it("drops environmental plugin-load errors (Bug 3 — not a best-practice defect)", () => {
265
+ // after `terraform init`, a crashed/absent provider plugin surfaces as an
266
+ // error diagnostic; it must not become a false-positive correctness concern.
267
+ const sample = JSON.stringify({
268
+ diagnostics: [
269
+ {
270
+ severity: "error",
271
+ summary: "Failed to load plugin schemas",
272
+ detail: "plugin did not respond",
273
+ },
274
+ ],
275
+ });
276
+ expect(parseValidateOutput(sample)).toEqual([]);
277
+ });
278
+ });
279
+
280
+ describe("concern ids", () => {
281
+ const trivy = (file: string, line: number): string =>
282
+ JSON.stringify({
283
+ Results: [
284
+ {
285
+ Target: file,
286
+ Misconfigurations: [
287
+ { AVDID: "r", Severity: "low", Status: "FAIL", CauseMetadata: { StartLine: line } },
288
+ ],
289
+ },
290
+ ],
291
+ });
292
+
293
+ it("are stable and content-derived (same input → same id)", () => {
294
+ const a = parseTrivyOutput(trivy("f.tf", 2))[0]!;
295
+ const b = parseTrivyOutput(trivy("f.tf", 2))[0]!;
296
+ expect(a.id).toBe(b.id);
297
+ });
298
+
299
+ it("differ when the location differs", () => {
300
+ const mk = (line: number): Concern => parseTrivyOutput(trivy("f.tf", line))[0]!;
301
+ expect(mk(2).id).not.toBe(mk(3).id);
302
+ });
303
+ });
304
+
305
+ describe("path normalization (Bug 1 — portable, repo-relative paths)", () => {
306
+ const trivyTarget = (target: string): string =>
307
+ JSON.stringify({
308
+ Results: [
309
+ {
310
+ Target: target,
311
+ Misconfigurations: [
312
+ { AVDID: "r", Severity: "high", Status: "FAIL", CauseMetadata: { StartLine: 5 } },
313
+ ],
314
+ },
315
+ ],
316
+ });
317
+ const trivyFile = (target: string, cwd: string): string =>
318
+ parseTrivyOutput(trivyTarget(target), cwd)[0]!.location.file;
319
+
320
+ it("rewrites an absolute scanner path to repo-relative posix", () => {
321
+ expect(trivyFile("/repo/sub/main.tf", "/repo/sub")).toBe("main.tf");
322
+ expect(trivyFile("D:\\repo\\sub\\net\\vpc.tf", "D:\\repo\\sub")).toBe("net/vpc.tf");
323
+ });
324
+
325
+ it("strips checkov's leading-slash path", () => {
326
+ const file = parseCheckovOutput(
327
+ JSON.stringify({
328
+ results: {
329
+ failed_checks: [{ check_id: "CKV_X", file_path: "/main.tf", file_line_range: [5] }],
330
+ },
331
+ }),
332
+ "/repo",
333
+ )[0]!.location.file;
334
+ expect(file).toBe("main.tf");
335
+ });
336
+
337
+ it("yields the SAME concern id regardless of the machine's absolute prefix", () => {
338
+ // the core Bug 1 regression: a Linux CI runner and a Windows dev box reported
339
+ // the same logical file under different absolute paths and got different ids.
340
+ const ci = parseTrivyOutput(
341
+ trivyTarget("/home/runner/work/repo/main.tf"),
342
+ "/home/runner/work/repo",
343
+ )[0]!;
344
+ const dev = parseTrivyOutput(
345
+ trivyTarget("D:\\Users\\dev\\repo\\main.tf"),
346
+ "D:\\Users\\dev\\repo",
347
+ )[0]!;
348
+ expect(ci.location.file).toBe("main.tf");
349
+ expect(dev.location.file).toBe("main.tf");
350
+ expect(ci.id).toBe(dev.id);
351
+ });
352
+ });
353
+
354
+ describe("groupConcerns (Bug 2 — one scoped group per file)", () => {
355
+ const concern = (file: string, severity: Concern["severity"], rule_id: string): Concern => ({
356
+ id: `${rule_id}|${file}`,
357
+ source: "trivy",
358
+ rule_id,
359
+ severity,
360
+ category: "security",
361
+ evidence: "x",
362
+ location: { file, line: 1 },
363
+ remediation_hint: null,
364
+ });
365
+
366
+ it("groups by file, ranks groups by max severity, and collects ids + rules", () => {
367
+ const groups = groupConcerns([
368
+ concern("main.tf", "low", "tflint:a"),
369
+ concern("main.tf", "high", "trivy:b"),
370
+ concern("vpc.tf", "medium", "trivy:c"),
371
+ ]);
372
+ expect(groups).toHaveLength(2);
373
+ expect(groups[0]).toMatchObject({ file: "main.tf", severity: "high", concern_count: 2 });
374
+ expect(groups[1]).toMatchObject({ file: "vpc.tf", severity: "medium", concern_count: 1 });
375
+ expect(groups[0]!.rule_ids).toEqual(["tflint:a", "trivy:b"]);
376
+ expect(groups[0]!.concern_ids).toHaveLength(2);
377
+ });
378
+
379
+ it("gives a stable group id for the same file (idempotent branch key)", () => {
380
+ const a = groupConcerns([concern("main.tf", "low", "x")])[0]!;
381
+ const b = groupConcerns([concern("main.tf", "high", "y")])[0]!;
382
+ expect(a.id).toBe(b.id);
383
+ });
384
+ });
385
+
386
+ describe("groupConcernsByRule (§3.11 — one group per rule across files)", () => {
387
+ const concern = (file: string, severity: Concern["severity"], rule_id: string): Concern => ({
388
+ id: `${rule_id}|${file}`,
389
+ source: "trivy",
390
+ rule_id,
391
+ severity,
392
+ category: "security",
393
+ evidence: "x",
394
+ location: { file, line: 1 },
395
+ remediation_hint: null,
396
+ });
397
+
398
+ it("groups a single rule's concerns across all files into one group", () => {
399
+ const groups = groupConcernsByRule([
400
+ concern("a.tf", "low", "tflint:missing_tags"),
401
+ concern("b.tf", "low", "tflint:missing_tags"),
402
+ concern("c.tf", "high", "trivy:AVD-1"),
403
+ ]);
404
+ expect(groups).toHaveLength(2);
405
+ const tagGroup = groups.find((g) => g.rule_ids[0] === "tflint:missing_tags")!;
406
+ expect(tagGroup.grouping).toBe("rule");
407
+ expect(tagGroup.files).toEqual(["a.tf", "b.tf"]);
408
+ expect(tagGroup.concern_count).toBe(2);
409
+ expect(tagGroup.file).toBe("2 files");
410
+ });
411
+
412
+ it("uses the single filename as the label when a rule fires in one file", () => {
413
+ const [g] = groupConcernsByRule([concern("only.tf", "low", "tflint:x")]);
414
+ expect(g!.file).toBe("only.tf");
415
+ expect(g!.files).toEqual(["only.tf"]);
416
+ });
417
+
418
+ it("gives a stable, rule-derived group id distinct from the by-file id", () => {
419
+ const byRule = groupConcernsByRule([concern("a.tf", "low", "tflint:x")])[0]!;
420
+ const byFile = groupConcerns([concern("a.tf", "low", "tflint:x")])[0]!;
421
+ expect(byRule.id).not.toBe(byFile.id);
422
+ // same rule → same id regardless of which files it spans
423
+ const again = groupConcernsByRule([
424
+ concern("a.tf", "low", "tflint:x"),
425
+ concern("z.tf", "low", "tflint:x"),
426
+ ])[0]!;
427
+ expect(again.id).toBe(byRule.id);
428
+ });
429
+ });
430
+
431
+ describe("annotateGroups (§3.9 — autonomy by concern membership, both groupings)", () => {
432
+ const concern = (
433
+ id: string,
434
+ file: string,
435
+ severity: Concern["severity"],
436
+ category: Concern["category"],
437
+ ): Concern => ({
438
+ id,
439
+ source: "trivy",
440
+ rule_id: "trivy:r",
441
+ severity,
442
+ category,
443
+ evidence: "x",
444
+ location: { file, line: 1 },
445
+ remediation_hint: null,
446
+ });
447
+
448
+ it("escalates a by-rule group whose concerns include a high security finding", () => {
449
+ const all = [
450
+ concern("1", "a.tf", "high", "security"),
451
+ concern("2", "b.tf", "high", "security"),
452
+ ];
453
+ const groups = groupConcernsByRule(all);
454
+ const annotated = annotateGroups(groups, all, "high");
455
+ expect(annotated[0]!.autonomy).toBe("needs-human");
456
+ });
457
+
458
+ it("marks a low-severity style group auto", () => {
459
+ const all = [concern("1", "a.tf", "low", "style")];
460
+ expect(annotateGroups(groupConcerns(all), all, "high")[0]!.autonomy).toBe("auto");
461
+ });
462
+ });
463
+
464
+ describe("planBatches (§3.10 — atomic vs batched PRs)", () => {
465
+ const grp = (id: string, severity: Concern["severity"], autonomy: "auto" | "needs-human") => ({
466
+ id,
467
+ file: `${id}.tf`,
468
+ severity,
469
+ concern_count: 1,
470
+ rule_ids: ["r"],
471
+ concern_ids: [id],
472
+ autonomy,
473
+ });
474
+
475
+ it("batches low/info auto groups and isolates the rest", () => {
476
+ const plan = planBatches([
477
+ grp("a", "low", "auto"),
478
+ grp("b", "info", "auto"),
479
+ grp("c", "high", "auto"), // higher severity → isolated
480
+ grp("d", "low", "needs-human"), // escalated → isolated even though low
481
+ ]);
482
+ expect(plan.batchable).toEqual(["a", "b"]);
483
+ expect(plan.isolated).toEqual(["c", "d"]);
484
+ expect(plan.batch_branch).toMatch(/^remediate\/batch-[0-9a-f]{12}$/);
485
+ });
486
+
487
+ it("does not create a batch branch for fewer than two batchable groups", () => {
488
+ expect(
489
+ planBatches([grp("a", "low", "auto"), grp("c", "high", "auto")]).batch_branch,
490
+ ).toBeNull();
491
+ });
492
+
493
+ it("is deterministic for the same member set regardless of order", () => {
494
+ const a = planBatches([grp("x", "low", "auto"), grp("y", "info", "auto")]).batch_branch;
495
+ const b = planBatches([grp("y", "info", "auto"), grp("x", "low", "auto")]).batch_branch;
496
+ expect(a).toBe(b);
497
+ });
498
+ });
499
+
500
+ describe("ruleDocUrl (§5.17)", () => {
501
+ const c = (rule_id: string, remediation_hint: string | null) => ({ rule_id, remediation_hint });
502
+
503
+ it("prefers an explicit URL remediation_hint", () => {
504
+ expect(ruleDocUrl(c("checkov:CKV_AWS_18", "https://docs.example/ckv"))).toBe(
505
+ "https://docs.example/ckv",
506
+ );
507
+ });
508
+
509
+ it("derives the Aqua AVD page for a trivy rule with no hint URL", () => {
510
+ expect(ruleDocUrl(c("trivy:AVD-AWS-0088", null))).toBe(
511
+ "https://avd.aquasec.com/misconfig/avd-aws-0088",
512
+ );
513
+ });
514
+
515
+ it("returns null when there is no URL hint and no known pattern", () => {
516
+ expect(ruleDocUrl(c("checkov:CKV_AWS_18", "enable encryption"))).toBeNull();
517
+ expect(ruleDocUrl(c("terraform-fmt:unformatted", null))).toBeNull();
518
+ });
519
+ });
520
+
521
+ describe("parseRequiredProviders (§4.15)", () => {
522
+ it("parses the object form with source + version and resolves the major", () => {
523
+ const hcl = `
524
+ terraform {
525
+ required_providers {
526
+ aws = {
527
+ source = "hashicorp/aws"
528
+ version = "~> 5.0"
529
+ }
530
+ random = {
531
+ source = "hashicorp/random"
532
+ version = ">= 3.1, < 4.0"
533
+ }
534
+ }
535
+ }`;
536
+ expect(parseRequiredProviders(hcl)).toEqual([
537
+ { name: "aws", source: "hashicorp/aws", version: "~> 5.0", major: 5 },
538
+ { name: "random", source: "hashicorp/random", version: ">= 3.1, < 4.0", major: 3 },
539
+ ]);
540
+ });
541
+
542
+ it("parses the legacy string form", () => {
543
+ const hcl = `required_providers { aws = "~> 4.0" }`;
544
+ expect(parseRequiredProviders(hcl)).toEqual([
545
+ { name: "aws", source: null, version: "~> 4.0", major: 4 },
546
+ ]);
547
+ });
548
+
549
+ it("does not mistake an object's inner source/version lines for providers", () => {
550
+ const hcl = `required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }`;
551
+ const out = parseRequiredProviders(hcl);
552
+ expect(out.map((p) => p.name)).toEqual(["aws"]);
553
+ });
554
+
555
+ it("returns nothing when there is no required_providers block", () => {
556
+ expect(parseRequiredProviders('resource "aws_s3_bucket" "b" {}')).toEqual([]);
557
+ });
558
+
559
+ it("dedups a provider declared in more than one block (first wins)", () => {
560
+ const hcl = `
561
+ required_providers { aws = { version = "~> 5.0" } }
562
+ required_providers { aws = { version = "~> 4.0" } }`;
563
+ const out = parseRequiredProviders(hcl);
564
+ expect(out).toHaveLength(1);
565
+ expect(out[0]!.major).toBe(5);
566
+ });
567
+ });
568
+
569
+ describe("classifyCostEscalation (§4.16-next)", () => {
570
+ it("escalates when the increase meets or exceeds the threshold", () => {
571
+ expect(classifyCostEscalation(50, 25).escalate).toBe(true);
572
+ expect(classifyCostEscalation(25, 25).escalate).toBe(true);
573
+ });
574
+
575
+ it("does not escalate below the threshold, on a decrease, or with no threshold", () => {
576
+ expect(classifyCostEscalation(10, 25).escalate).toBe(false);
577
+ expect(classifyCostEscalation(-30, 25).escalate).toBe(false);
578
+ expect(classifyCostEscalation(100, undefined).escalate).toBe(false);
579
+ expect(classifyCostEscalation(null, 25).escalate).toBe(false);
580
+ });
581
+ });
582
+
583
+ describe("rebaseConcern (multi-root — re-base a per-root concern onto cwd)", () => {
584
+ const concern = (file: string): Concern => ({
585
+ id: "orig",
586
+ source: "terraform-validate",
587
+ rule_id: "terraform-validate:Reference to undeclared resource",
588
+ severity: "high",
589
+ category: "correctness",
590
+ evidence: "e",
591
+ location: { file, line: 12 },
592
+ remediation_hint: null,
593
+ });
594
+
595
+ it("prefixes the root's relDir and recomputes the id (so ✗→✓ stays consistent)", () => {
596
+ const c = rebaseConcern(concern("main.tf"), "terraform/core");
597
+ expect(c.location.file).toBe("terraform/core/main.tf");
598
+ expect(c.id).not.toBe("orig");
599
+ // deterministic: re-basing the same input yields the same id.
600
+ expect(rebaseConcern(concern("main.tf"), "terraform/core").id).toBe(c.id);
601
+ });
602
+
603
+ it("is a no-op when the root IS cwd (relDir empty)", () => {
604
+ const c = concern("main.tf");
605
+ expect(rebaseConcern(c, "")).toBe(c);
606
+ });
607
+ });
608
+
609
+ describe("aggregatePlans (multi-root plan aggregation)", () => {
610
+ const ps = (over: Partial<ReturnType<typeof parseTerraformPlanJson>>) =>
611
+ ({
612
+ add: 0,
613
+ change: 0,
614
+ destroy: 0,
615
+ changed: [],
616
+ destructive: [],
617
+ hasDestroyOrReplace: false,
618
+ moved: [],
619
+ ...over,
620
+ }) as ReturnType<typeof parseTerraformPlanJson>;
621
+
622
+ it("sums counts and unions changed/destructive across roots", () => {
623
+ const roots: RootPlan[] = [
624
+ {
625
+ dir: "terraform",
626
+ summary: ps({ add: 1, changed: [{ address: "aws_s3_bucket.a", action: "create" }] }),
627
+ stable: true,
628
+ },
629
+ {
630
+ dir: "terraform/core",
631
+ summary: ps({
632
+ destroy: 1,
633
+ changed: [{ address: "aws_db_instance.db", action: "delete" }],
634
+ destructive: [{ address: "aws_db_instance.db", action: "delete" }],
635
+ hasDestroyOrReplace: true,
636
+ }),
637
+ stable: true,
638
+ },
639
+ ];
640
+ const agg = aggregatePlans(roots);
641
+ expect(agg).toMatchObject({ add: 1, destroy: 1, hasDestroyOrReplace: true });
642
+ expect(agg.changed).toHaveLength(2);
643
+ expect(agg.destructive).toEqual([{ address: "aws_db_instance.db", action: "delete" }]);
644
+ });
645
+
646
+ it("is non-idempotent if ANY root's plan was unstable", () => {
647
+ expect(aggregatePlans([{ dir: "a", summary: ps({}), stable: true }]).idempotent).toBe(true);
648
+ expect(
649
+ aggregatePlans([
650
+ { dir: "a", summary: ps({}), stable: true },
651
+ { dir: "b", summary: ps({ change: 1 }), stable: false },
652
+ ]).idempotent,
653
+ ).toBe(false);
654
+ });
655
+
656
+ it("passes a single root straight through", () => {
657
+ const agg = aggregatePlans([{ dir: ".", summary: ps({ add: 3, change: 2 }), stable: true }]);
658
+ expect(agg).toMatchObject({ add: 3, change: 2, destroy: 0, idempotent: true });
659
+ });
660
+ });
661
+
662
+ describe("resolveRoots (multi-root discovery → operate list)", () => {
663
+ it("returns one root per provider/backend dir, with relDir + absDir", () => {
664
+ const root = mkdtempSync(join(tmpdir(), "tf-resolve-"));
665
+ mkdirSync(join(root, "terraform"), { recursive: true });
666
+ mkdirSync(join(root, "terraform", "core"), { recursive: true });
667
+ writeFileSync(join(root, "terraform", "providers.tf"), 'provider "aws" {}');
668
+ writeFileSync(join(root, "terraform", "core", "providers.tf"), 'provider "aws" {}');
669
+ const roots = resolveRoots(root);
670
+ expect(roots.map((r) => r.relDir)).toEqual(["terraform", "terraform/core"]);
671
+ expect(roots[0]!.absDir).toBe(join(root, "terraform"));
672
+ rmSync(root, { recursive: true, force: true });
673
+ });
674
+
675
+ it("falls back to cwd itself as a single root when none is detected", () => {
676
+ const root = mkdtempSync(join(tmpdir(), "tf-resolve-none-"));
677
+ writeFileSync(join(root, "main.tf"), 'resource "aws_s3_bucket" "b" {}');
678
+ expect(resolveRoots(root)).toEqual([{ absDir: root, relDir: "" }]);
679
+ rmSync(root, { recursive: true, force: true });
680
+ });
681
+ });
682
+
683
+ describe("computeRemediationVerdict (C2 — independently re-verifiable ✗→✓)", () => {
684
+ it("verifies when every original concern id is gone from the re-scan", () => {
685
+ const verdict = computeRemediationVerdict(["a", "b", "c"], new Set(["x", "y"]));
686
+ expect(verdict).toEqual({ verified: true, resolved: ["a", "b", "c"], remaining: [] });
687
+ });
688
+
689
+ it("does NOT verify when a concern is still present — the claim can't outrun the re-scan", () => {
690
+ // the core C2 regression: even if the agent asserts success, a concern still
691
+ // present in the fresh scan lands in `remaining` and `verified` is false.
692
+ const verdict = computeRemediationVerdict(["a", "b"], new Set(["b"]));
693
+ expect(verdict.verified).toBe(false);
694
+ expect(verdict.resolved).toEqual(["a"]);
695
+ expect(verdict.remaining).toEqual(["b"]);
696
+ });
697
+
698
+ it("reports all remaining when nothing was fixed", () => {
699
+ const verdict = computeRemediationVerdict(["a", "b"], new Set(["a", "b", "c"]));
700
+ expect(verdict.verified).toBe(false);
701
+ expect(verdict.remaining).toEqual(["a", "b"]);
702
+ expect(verdict.resolved).toEqual([]);
703
+ });
704
+
705
+ it("verifies vacuously for an empty concern set", () => {
706
+ expect(computeRemediationVerdict([], new Set(["a"]))).toEqual({
707
+ verified: true,
708
+ resolved: [],
709
+ remaining: [],
710
+ });
711
+ });
712
+ });
713
+
714
+ describe("parseReviewerFindings", () => {
715
+ const finding = (over: Record<string, unknown> = {}) => ({
716
+ category: "security",
717
+ source: "checkov",
718
+ rule_id: "checkov:CKV_AWS_18",
719
+ state: "verified",
720
+ severity: "high",
721
+ evidence: "S3 bucket has no access logging",
722
+ location: { file: "main.tf", line: 5 },
723
+ remediation_hint: "enable access logging",
724
+ ...over,
725
+ });
726
+ const report = (findings: unknown[]) => JSON.stringify({ schema_version: "1.0", findings });
727
+
728
+ it("maps a finding to a Concern, keeping the namespaced rule_id", () => {
729
+ const [c] = parseReviewerFindings(report([finding()]));
730
+ expect(c).toMatchObject({
731
+ source: "checkov",
732
+ rule_id: "checkov:CKV_AWS_18",
733
+ severity: "high",
734
+ category: "security",
735
+ evidence: "S3 bucket has no access logging",
736
+ location: { file: "main.tf", line: 5 },
737
+ remediation_hint: "enable access logging",
738
+ });
739
+ });
740
+
741
+ it("reproduces the SAME content id Terramend's own checkov scan produces (✗→✓ verifiable)", () => {
742
+ // a reviewer checkov finding and Terramend's own checkov output for the same
743
+ // rule/file/line must hash to the same id, or verify can never confirm it.
744
+ const [reviewer] = parseReviewerFindings(report([finding()]));
745
+ const [own] = parseCheckovOutput(
746
+ JSON.stringify({
747
+ results: {
748
+ failed_checks: [
749
+ {
750
+ check_id: "CKV_AWS_18",
751
+ check_name: "S3 bucket has no access logging",
752
+ file_path: "/main.tf",
753
+ file_line_range: [5, 7],
754
+ },
755
+ ],
756
+ },
757
+ }),
758
+ );
759
+ expect(reviewer!.id).toBe(own!.id);
760
+ });
761
+
762
+ it("maps the same id whether the reviewer rule_id is namespaced or bare", () => {
763
+ const [namespaced] = parseReviewerFindings(
764
+ report([finding({ rule_id: "checkov:CKV_AWS_18" })]),
765
+ );
766
+ const [bare] = parseReviewerFindings(report([finding({ rule_id: "CKV_AWS_18" })]));
767
+ expect(namespaced!.id).toBe(bare!.id);
768
+ });
769
+
770
+ it("collapses reviewer-exclusive sources (tfsec/infracost/llm) to `reviewer`", () => {
771
+ expect(
772
+ parseReviewerFindings(report([finding({ source: "tfsec", rule_id: "tfsec:AWS017" })]))[0]!
773
+ .source,
774
+ ).toBe("reviewer");
775
+ expect(parseReviewerFindings(report([finding({ source: "llm" })]))[0]!.source).toBe("reviewer");
776
+ });
777
+
778
+ it("keeps known scanners (trivy/tflint) as themselves", () => {
779
+ expect(
780
+ parseReviewerFindings(
781
+ report([finding({ source: "trivy", rule_id: "trivy:AVD-AWS-0088" })]),
782
+ )[0]!.source,
783
+ ).toBe("trivy");
784
+ expect(
785
+ parseReviewerFindings(report([finding({ source: "tflint", rule_id: "tflint:foo" })]))[0]!
786
+ .source,
787
+ ).toBe("tflint");
788
+ });
789
+
790
+ it("drops human_only findings (out of scope — not auto-remediable)", () => {
791
+ const concerns = parseReviewerFindings(
792
+ report([finding(), finding({ state: "human_only", rule_id: "checkov:CKV_AWS_99" })]),
793
+ );
794
+ expect(concerns).toHaveLength(1);
795
+ expect(concerns[0]!.rule_id).toBe("checkov:CKV_AWS_18");
796
+ });
797
+
798
+ it("maps the cost category and defaults unknown categories to correctness", () => {
799
+ expect(
800
+ parseReviewerFindings(report([finding({ category: "cost", source: "infracost" })]))[0]!
801
+ .category,
802
+ ).toBe("cost");
803
+ expect(parseReviewerFindings(report([finding({ category: "weird" })]))[0]!.category).toBe(
804
+ "correctness",
805
+ );
806
+ });
807
+
808
+ it("drops findings not keyed to a Terraform file (e.g. infracost cost findings on a directory)", () => {
809
+ const concerns = parseReviewerFindings(
810
+ report([
811
+ finding({
812
+ category: "cost",
813
+ source: "infracost",
814
+ rule_id: "infracost:monthly-delta",
815
+ location: { file: "infra/", line: null },
816
+ }),
817
+ finding(),
818
+ ]),
819
+ );
820
+ expect(concerns).toHaveLength(1);
821
+ expect(concerns[0]!.location.file).toBe("main.tf");
822
+ });
823
+
824
+ it("normalizes absolute scanner paths to repo-relative POSIX", () => {
825
+ const [c] = parseReviewerFindings(
826
+ report([finding({ location: { file: "/repo/main.tf", line: 1 } })]),
827
+ "/repo",
828
+ );
829
+ expect(c!.location.file).toBe("main.tf");
830
+ });
831
+
832
+ it("returns nothing for an empty or findings-less report", () => {
833
+ expect(parseReviewerFindings("")).toEqual([]);
834
+ expect(parseReviewerFindings(JSON.stringify({ schema_version: "1.0" }))).toEqual([]);
835
+ });
836
+ });
837
+
838
+ describe("SARIF ingestion (read_findings)", () => {
839
+ const sarif = (driver: string, results: unknown[], rules: unknown[] = []) =>
840
+ JSON.stringify({
841
+ version: "2.1.0",
842
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
843
+ runs: [{ tool: { driver: { name: driver, rules } }, results }],
844
+ });
845
+
846
+ it("detects a SARIF report vs a reviewer findings.json", () => {
847
+ expect(isSarif(JSON.parse(sarif("Trivy", [])))).toBe(true);
848
+ expect(isSarif({ schema_version: "1.0", findings: [] })).toBe(false);
849
+ expect(isSarif(null)).toBe(false);
850
+ });
851
+
852
+ it("maps a Trivy SARIF result to a concern with a reproducible id", () => {
853
+ const report = sarif(
854
+ "Trivy",
855
+ [
856
+ {
857
+ ruleId: "AVD-AWS-0088",
858
+ level: "error",
859
+ message: { text: "Bucket is not encrypted" },
860
+ locations: [
861
+ {
862
+ physicalLocation: { artifactLocation: { uri: "main.tf" }, region: { startLine: 5 } },
863
+ },
864
+ ],
865
+ },
866
+ ],
867
+ [{ id: "AVD-AWS-0088", helpUri: "https://avd.aquasec.com/misconfig/avd-aws-0088" }],
868
+ );
869
+ const [c] = parseSarifFindings(report);
870
+ expect(c).toMatchObject({
871
+ source: "trivy",
872
+ rule_id: "trivy:AVD-AWS-0088",
873
+ severity: "high",
874
+ category: "security",
875
+ location: { file: "main.tf", line: 5 },
876
+ remediation_hint: "https://avd.aquasec.com/misconfig/avd-aws-0088",
877
+ });
878
+ // same id Terramend's own trivy scan of the same rule/file/line produces.
879
+ const [own] = parseTrivyOutput(
880
+ JSON.stringify({
881
+ Results: [
882
+ {
883
+ Target: "main.tf",
884
+ Misconfigurations: [
885
+ {
886
+ AVDID: "AVD-AWS-0088",
887
+ Severity: "HIGH",
888
+ Status: "FAIL",
889
+ CauseMetadata: { StartLine: 5 },
890
+ },
891
+ ],
892
+ },
893
+ ],
894
+ }),
895
+ );
896
+ expect(c!.id).toBe(own!.id);
897
+ });
898
+
899
+ it("uses security-severity to refine the level when present", () => {
900
+ const report = sarif("Checkov", [
901
+ {
902
+ ruleId: "CKV_AWS_18",
903
+ level: "warning",
904
+ message: { text: "x" },
905
+ properties: { "security-severity": "9.1" },
906
+ locations: [
907
+ { physicalLocation: { artifactLocation: { uri: "a.tf" }, region: { startLine: 1 } } },
908
+ ],
909
+ },
910
+ ]);
911
+ expect(parseSarifFindings(report)[0]!.severity).toBe("critical");
912
+ });
913
+
914
+ it("drops non-Terraform files and tolerates an empty report", () => {
915
+ const report = sarif("Trivy", [
916
+ {
917
+ ruleId: "X",
918
+ level: "error",
919
+ message: { text: "y" },
920
+ locations: [{ physicalLocation: { artifactLocation: { uri: "Dockerfile" } } }],
921
+ },
922
+ ]);
923
+ expect(parseSarifFindings(report)).toEqual([]);
924
+ expect(parseSarifFindings("{}")).toEqual([]);
925
+ });
926
+
927
+ it("parseFindingsFile dispatches to the right parser", () => {
928
+ const sarifReport = sarif("Trivy", [
929
+ {
930
+ ruleId: "AVD-AWS-1",
931
+ level: "error",
932
+ message: { text: "z" },
933
+ locations: [
934
+ { physicalLocation: { artifactLocation: { uri: "main.tf" }, region: { startLine: 2 } } },
935
+ ],
936
+ },
937
+ ]);
938
+ expect(parseFindingsFile(sarifReport)[0]!.source).toBe("trivy");
939
+ const reviewer = JSON.stringify({
940
+ schema_version: "1.0",
941
+ findings: [
942
+ {
943
+ source: "checkov",
944
+ rule_id: "checkov:CKV_AWS_18",
945
+ severity: "high",
946
+ location: { file: "main.tf", line: 5 },
947
+ },
948
+ ],
949
+ });
950
+ expect(parseFindingsFile(reviewer)[0]!.source).toBe("checkov");
951
+ });
952
+ });
953
+
954
+ describe("parseInfracostResources (resource-level cost breakdown)", () => {
955
+ it("extracts and sorts per-resource monthly costs (most expensive first)", () => {
956
+ const json = JSON.stringify({
957
+ projects: [
958
+ {
959
+ breakdown: {
960
+ resources: [
961
+ { name: "aws_instance.web", monthlyCost: "12.40" },
962
+ { name: "aws_db_instance.db", monthlyCost: "120.00" },
963
+ { name: "aws_s3_bucket.logs", monthlyCost: null },
964
+ { name: "aws_iam_role.r", monthlyCost: "0" },
965
+ ],
966
+ },
967
+ },
968
+ ],
969
+ });
970
+ expect(parseInfracostResources(json)).toEqual([
971
+ { name: "aws_db_instance.db", monthlyCost: 120 },
972
+ { name: "aws_instance.web", monthlyCost: 12.4 },
973
+ ]);
974
+ });
975
+
976
+ it("tolerates empty / malformed input", () => {
977
+ expect(parseInfracostResources("")).toEqual([]);
978
+ expect(parseInfracostResources("not json")).toEqual([]);
979
+ expect(parseInfracostResources(JSON.stringify({ projects: [] }))).toEqual([]);
980
+ });
981
+ });
982
+
983
+ describe("isTerraformConcern", () => {
984
+ const at = (file: string): Concern => ({
985
+ id: "x",
986
+ source: "checkov",
987
+ rule_id: "checkov:CKV_X",
988
+ severity: "high",
989
+ category: "security",
990
+ evidence: "e",
991
+ location: { file, line: 1 },
992
+ remediation_hint: null,
993
+ });
994
+
995
+ it("keeps .tf and .tfvars concerns", () => {
996
+ expect(isTerraformConcern(at("main.tf"))).toBe(true);
997
+ expect(isTerraformConcern(at("modules/net/vpc.tf"))).toBe(true);
998
+ expect(isTerraformConcern(at("envs/prod.tfvars"))).toBe(true);
999
+ expect(isTerraformConcern(at("MAIN.TF"))).toBe(true);
1000
+ });
1001
+
1002
+ it("drops non-Terraform concerns (checkov github_actions, trivy dockerfile)", () => {
1003
+ expect(isTerraformConcern(at(".github/workflows/ci.yml"))).toBe(false);
1004
+ expect(isTerraformConcern(at("Dockerfile"))).toBe(false);
1005
+ expect(isTerraformConcern(at("k8s/deployment.yaml"))).toBe(false);
1006
+ expect(isTerraformConcern(at("(unknown)"))).toBe(false);
1007
+ });
1008
+ });
1009
+
1010
+ describe("collectCloudCredentials", () => {
1011
+ const touched = [
1012
+ "AWS_ACCESS_KEY_ID",
1013
+ "AWS_SECRET_ACCESS_KEY",
1014
+ "GOOGLE_APPLICATION_CREDENTIALS",
1015
+ "GOOGLE_GENERATIVE_AI_API_KEY",
1016
+ "GEMINI_API_KEY",
1017
+ "ANTHROPIC_API_KEY",
1018
+ "OPENAI_API_KEY",
1019
+ "AWS_BEARER_TOKEN_BEDROCK",
1020
+ "AWS_REGION",
1021
+ ];
1022
+ const saved: Record<string, string | undefined> = {};
1023
+ for (const k of touched) saved[k] = process.env[k];
1024
+
1025
+ afterEach(() => {
1026
+ for (const k of touched) {
1027
+ if (saved[k] === undefined) delete process.env[k];
1028
+ else process.env[k] = saved[k];
1029
+ }
1030
+ });
1031
+
1032
+ it("passes real cloud creds to terraform but NOT LLM/provider secret keys", () => {
1033
+ process.env.AWS_ACCESS_KEY_ID = "akia";
1034
+ process.env.AWS_SECRET_ACCESS_KEY = "secret";
1035
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = "/creds.json";
1036
+ process.env.GOOGLE_GENERATIVE_AI_API_KEY = "gemini-key"; // must NOT leak (Gemini LLM key)
1037
+ process.env.GEMINI_API_KEY = "gemini-key-2"; // must NOT leak
1038
+ process.env.ANTHROPIC_API_KEY = "anthropic-key"; // must NOT leak
1039
+ process.env.OPENAI_API_KEY = "openai-key"; // must NOT leak (BYOK is multi-provider)
1040
+ process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-key"; // must NOT leak — Bedrock LLM key, despite AWS_ prefix
1041
+ process.env.AWS_REGION = "eu-west-1"; // MUST pass — a legit terraform/cloud setting
1042
+
1043
+ const env = collectCloudCredentials();
1044
+ expect(env.AWS_ACCESS_KEY_ID).toBe("akia");
1045
+ expect(env.AWS_SECRET_ACCESS_KEY).toBe("secret");
1046
+ expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBe("/creds.json");
1047
+ expect(env.AWS_REGION).toBe("eu-west-1");
1048
+ expect(env.GOOGLE_GENERATIVE_AI_API_KEY).toBeUndefined();
1049
+ expect(env.GEMINI_API_KEY).toBeUndefined();
1050
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
1051
+ expect(env.OPENAI_API_KEY).toBeUndefined();
1052
+ expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
1053
+ });
1054
+ });
1055
+
1056
+ describe("resourceTypeOf", () => {
1057
+ it("extracts the type from a plain address", () => {
1058
+ expect(resourceTypeOf("aws_db_instance.main")).toBe("aws_db_instance");
1059
+ });
1060
+
1061
+ it("strips module prefixes and index/key suffixes", () => {
1062
+ expect(resourceTypeOf("module.db.aws_db_instance.main")).toBe("aws_db_instance");
1063
+ expect(resourceTypeOf('aws_s3_bucket.data["prod"]')).toBe("aws_s3_bucket");
1064
+ expect(resourceTypeOf("module.a.module.b.google_storage_bucket.x[0]")).toBe(
1065
+ "google_storage_bucket",
1066
+ );
1067
+ });
1068
+
1069
+ it("returns '' for an unparseable address", () => {
1070
+ expect(resourceTypeOf("nodots")).toBe("");
1071
+ });
1072
+ });
1073
+
1074
+ describe("classifyDestructive (§2.5 — stateful vs ephemeral destroy/replace)", () => {
1075
+ it("routes data-bearing types to stateful and recreatable types to ephemeral", () => {
1076
+ const out = classifyDestructive([
1077
+ { address: "aws_db_instance.main", action: "delete" },
1078
+ { address: "module.web.aws_instance.app", action: "replace" },
1079
+ { address: 'aws_s3_bucket.data["prod"]', action: "replace" },
1080
+ ]);
1081
+ expect(out.stateful.map((r) => r.address)).toEqual([
1082
+ "aws_db_instance.main",
1083
+ 'aws_s3_bucket.data["prod"]',
1084
+ ]);
1085
+ expect(out.stateful[0]).toMatchObject({ type: "aws_db_instance", action: "delete" });
1086
+ expect(out.ephemeral.map((r) => r.address)).toEqual(["module.web.aws_instance.app"]);
1087
+ });
1088
+
1089
+ it("returns empty partitions for an empty destructive set", () => {
1090
+ expect(classifyDestructive([])).toEqual({ stateful: [], ephemeral: [] });
1091
+ });
1092
+ });
1093
+
1094
+ describe("parseTerraformPlanJson", () => {
1095
+ const lines = (...objs: unknown[]) => objs.map((o) => JSON.stringify(o)).join("\n");
1096
+
1097
+ it("reads add/change/destroy from change_summary", () => {
1098
+ const out = lines(
1099
+ { "@level": "info", type: "version" },
1100
+ { type: "change_summary", changes: { add: 2, change: 1, remove: 0, operation: "plan" } },
1101
+ );
1102
+ const s = parseTerraformPlanJson(out);
1103
+ expect(s).toMatchObject({ add: 2, change: 1, destroy: 0, hasDestroyOrReplace: false });
1104
+ expect(s.destructive).toEqual([]);
1105
+ });
1106
+
1107
+ it("collects deleted and replaced resources as destructive", () => {
1108
+ const out = lines(
1109
+ {
1110
+ type: "planned_change",
1111
+ change: { action: "create", resource: { addr: "aws_s3_bucket.a" } },
1112
+ },
1113
+ {
1114
+ type: "planned_change",
1115
+ change: { action: "delete", resource: { addr: "aws_db_instance.db" } },
1116
+ },
1117
+ {
1118
+ type: "planned_change",
1119
+ change: { action: "replace", resource: { addr: "aws_instance.web" } },
1120
+ },
1121
+ { type: "change_summary", changes: { add: 1, change: 0, remove: 2 } },
1122
+ );
1123
+ const s = parseTerraformPlanJson(out);
1124
+ expect(s.destroy).toBe(2);
1125
+ expect(s.hasDestroyOrReplace).toBe(true);
1126
+ expect(s.destructive).toEqual([
1127
+ { address: "aws_db_instance.db", action: "delete" },
1128
+ { address: "aws_instance.web", action: "replace" },
1129
+ ]);
1130
+ });
1131
+
1132
+ it("treats *-then-delete forms as destructive", () => {
1133
+ const out = lines({
1134
+ type: "planned_change",
1135
+ change: { action: "create-then-delete", resource: { addr: "aws_instance.web" } },
1136
+ });
1137
+ expect(parseTerraformPlanJson(out).hasDestroyOrReplace).toBe(true);
1138
+ });
1139
+
1140
+ it("ignores non-JSON / non-plan lines and tolerates empty output", () => {
1141
+ expect(parseTerraformPlanJson("not json\n\nProviders required...\n")).toEqual({
1142
+ add: 0,
1143
+ change: 0,
1144
+ destroy: 0,
1145
+ changed: [],
1146
+ destructive: [],
1147
+ hasDestroyOrReplace: false,
1148
+ moved: [],
1149
+ });
1150
+ expect(parseTerraformPlanJson("")).toMatchObject({ add: 0, hasDestroyOrReplace: false });
1151
+ });
1152
+
1153
+ it("tracks `move` actions separately — never in `changed` (§M2)", () => {
1154
+ const out = lines(
1155
+ {
1156
+ type: "planned_change",
1157
+ change: {
1158
+ action: "move",
1159
+ resource: { addr: "module.logging.aws_s3_bucket.this" },
1160
+ previous_resource: { addr: "aws_s3_bucket.logs" },
1161
+ },
1162
+ },
1163
+ { type: "change_summary", changes: { add: 0, change: 0, remove: 0 } },
1164
+ );
1165
+ const s = parseTerraformPlanJson(out);
1166
+ expect(s.changed).toEqual([]);
1167
+ expect(s.moved).toEqual([
1168
+ { address: "module.logging.aws_s3_bucket.this", previousAddress: "aws_s3_bucket.logs" },
1169
+ ]);
1170
+ });
1171
+
1172
+ it("collects every real action into `changed`, ignoring no-op / read", () => {
1173
+ const out = lines(
1174
+ {
1175
+ type: "planned_change",
1176
+ change: { action: "create", resource: { addr: "aws_s3_bucket.a" } },
1177
+ },
1178
+ {
1179
+ type: "planned_change",
1180
+ change: { action: "update", resource: { addr: "aws_instance.web" } },
1181
+ },
1182
+ { type: "planned_change", change: { action: "no-op", resource: { addr: "aws_vpc.main" } } },
1183
+ {
1184
+ type: "planned_change",
1185
+ change: { action: "read", resource: { addr: "data.aws_ami.ubuntu" } },
1186
+ },
1187
+ );
1188
+ expect(parseTerraformPlanJson(out).changed).toEqual([
1189
+ { address: "aws_s3_bucket.a", action: "create" },
1190
+ { address: "aws_instance.web", action: "update" },
1191
+ ]);
1192
+ });
1193
+
1194
+ it("ignores terraform's REAL no-op spelling `noop` (bug: was checking `no-op` only)", () => {
1195
+ // the machine-readable UI emits `"noop"` (no hyphen) for unchanged resources;
1196
+ // a `move` / `import` / `forget` is a state-only op that doesn't mutate live
1197
+ // infra. None should land in `changed` (they'd inflate the blast radius §2.6).
1198
+ const out = lines(
1199
+ { type: "planned_change", change: { action: "noop", resource: { addr: "aws_vpc.main" } } },
1200
+ { type: "planned_change", change: { action: "move", resource: { addr: "aws_s3_bucket.a" } } },
1201
+ {
1202
+ type: "planned_change",
1203
+ change: { action: "import", resource: { addr: "aws_s3_bucket.b" } },
1204
+ },
1205
+ {
1206
+ type: "planned_change",
1207
+ change: { action: "forget", resource: { addr: "aws_s3_bucket.c" } },
1208
+ },
1209
+ {
1210
+ type: "planned_change",
1211
+ change: { action: "update", resource: { addr: "aws_instance.web" } },
1212
+ },
1213
+ );
1214
+ expect(parseTerraformPlanJson(out).changed).toEqual([
1215
+ { address: "aws_instance.web", action: "update" },
1216
+ ]);
1217
+ });
1218
+ });
1219
+
1220
+ describe("computeRegressions (§1.4)", () => {
1221
+ it("returns ids present now but absent from the baseline (current − baseline)", () => {
1222
+ expect(computeRegressions(["a", "b"], ["b", "c", "d"])).toEqual(["c", "d"]);
1223
+ });
1224
+
1225
+ it("is empty when the fix introduced nothing new (even if it didn't resolve everything)", () => {
1226
+ expect(computeRegressions(["a", "b", "c"], ["a", "b"])).toEqual([]);
1227
+ });
1228
+
1229
+ it("sorts and de-dups its output for a stable PR body", () => {
1230
+ expect(computeRegressions(new Set(["a"]), ["z", "c", "c", "m"])).toEqual(["c", "m", "z"]);
1231
+ });
1232
+
1233
+ it("treats an empty baseline as everything-new", () => {
1234
+ expect(computeRegressions([], ["a", "b"])).toEqual(["a", "b"]);
1235
+ });
1236
+ });
1237
+
1238
+ describe("classifyAutonomy (§3.9)", () => {
1239
+ const c = (severity: Concern["severity"], category: Concern["category"]) => ({
1240
+ severity,
1241
+ category,
1242
+ });
1243
+
1244
+ it("auto-fixes trivial style/correctness findings", () => {
1245
+ const d = classifyAutonomy([c("low", "style"), c("medium", "correctness")]);
1246
+ expect(d.autonomy).toBe("auto");
1247
+ expect(d.reasons).toEqual([]);
1248
+ });
1249
+
1250
+ it("escalates a high/critical security finding (default threshold high)", () => {
1251
+ expect(classifyAutonomy([c("high", "security")]).autonomy).toBe("needs-human");
1252
+ expect(classifyAutonomy([c("critical", "security")]).autonomy).toBe("needs-human");
1253
+ });
1254
+
1255
+ it("does NOT escalate a medium/low security finding at the default threshold", () => {
1256
+ expect(classifyAutonomy([c("medium", "security")]).autonomy).toBe("auto");
1257
+ expect(classifyAutonomy([c("low", "security")]).autonomy).toBe("auto");
1258
+ });
1259
+
1260
+ it("respects a lowered threshold", () => {
1261
+ expect(classifyAutonomy([c("medium", "security")], "medium").autonomy).toBe("needs-human");
1262
+ });
1263
+
1264
+ it("a non-security finding never escalates on severity alone", () => {
1265
+ expect(classifyAutonomy([c("critical", "correctness")]).autonomy).toBe("auto");
1266
+ });
1267
+
1268
+ it("a high blast radius escalates regardless of severity (§2.6 override)", () => {
1269
+ const d = classifyAutonomy([c("low", "style")], "high", "high");
1270
+ expect(d.autonomy).toBe("needs-human");
1271
+ expect(d.reasons.join(" ")).toMatch(/blast radius/);
1272
+ });
1273
+
1274
+ it("medium/low blast radius does not escalate", () => {
1275
+ expect(classifyAutonomy([c("low", "style")], "high", "medium").autonomy).toBe("auto");
1276
+ });
1277
+ });
1278
+
1279
+ describe("classifyRefusal (§29 — honest refusal)", () => {
1280
+ const c = (rule_id: string, evidence: string) => ({ rule_id, evidence });
1281
+
1282
+ it("flags IAM least-privilege / wildcard concerns", () => {
1283
+ expect(
1284
+ classifyRefusal(c("checkov:CKV_AWS_1", 'IAM policy allows all actions with "*"')).refuse,
1285
+ ).toBe(true);
1286
+ expect(classifyRefusal(c("trivy:AVD-AWS-9", "ensure least-privilege IAM")).refuse).toBe(true);
1287
+ });
1288
+
1289
+ it("flags KMS key-policy and real-CIDR decisions", () => {
1290
+ expect(
1291
+ classifyRefusal(c("checkov:CKV_AWS_2", "the KMS key policy is too permissive")).refuse,
1292
+ ).toBe(true);
1293
+ expect(
1294
+ classifyRefusal(c("trivy:AVD-AWS-3", "restrict ingress to an allowed CIDR")).refuse,
1295
+ ).toBe(true);
1296
+ });
1297
+
1298
+ it("does NOT flag a mechanical secure-default fix", () => {
1299
+ expect(
1300
+ classifyRefusal(c("trivy:AVD-AWS-0088", "S3 bucket is not encrypted at rest")).refuse,
1301
+ ).toBe(false);
1302
+ expect(classifyRefusal(c("checkov:CKV_AWS_18", "S3 access logging is disabled")).refuse).toBe(
1303
+ false,
1304
+ );
1305
+ });
1306
+ });
1307
+
1308
+ describe("buildRefusalReport (§29)", () => {
1309
+ it("produces a structured non-fix issue body with location, why, and next step", () => {
1310
+ const body = buildRefusalReport({
1311
+ concern: {
1312
+ rule_id: "checkov:CKV_AWS_1",
1313
+ evidence: "wildcard IAM action",
1314
+ location: { file: "iam.tf", line: 12 },
1315
+ },
1316
+ whyNoAutoFix: "the exact action set is unknown",
1317
+ humanAction: "scope the policy to the actions the workload uses",
1318
+ });
1319
+ expect(body).toContain("iam.tf:12");
1320
+ expect(body).toContain("wildcard IAM action");
1321
+ expect(body).toContain("the exact action set is unknown");
1322
+ expect(body).toContain("scope the policy to the actions the workload uses");
1323
+ });
1324
+
1325
+ it("omits the line when there is none", () => {
1326
+ const body = buildRefusalReport({
1327
+ concern: { rule_id: "r", evidence: "e", location: { file: "main.tf", line: null } },
1328
+ whyNoAutoFix: "w",
1329
+ humanAction: "h",
1330
+ });
1331
+ expect(body).toContain("`main.tf`");
1332
+ expect(body).not.toContain("main.tf:");
1333
+ });
1334
+ });
1335
+
1336
+ describe("preventiveControlFor (§21 — fix once, prevent forever)", () => {
1337
+ it("suggests a Checkov hard-fail for a checkov rule", () => {
1338
+ const p = preventiveControlFor({ source: "checkov", rule_id: "checkov:CKV_AWS_18" })!;
1339
+ expect(p.mechanism).toMatch(/Checkov/);
1340
+ expect(p.snippet).toContain("CKV_AWS_18");
1341
+ });
1342
+
1343
+ it("suggests a tflint rule block and a trivy gate", () => {
1344
+ expect(
1345
+ preventiveControlFor({ source: "tflint", rule_id: "tflint:terraform_unused" })!.snippet,
1346
+ ).toContain('rule "terraform_unused"');
1347
+ expect(
1348
+ preventiveControlFor({ source: "trivy", rule_id: "trivy:AVD-AWS-1" })!.mechanism,
1349
+ ).toMatch(/Trivy/);
1350
+ });
1351
+
1352
+ it("returns null for the reviewer source (no natural CI gate)", () => {
1353
+ expect(preventiveControlFor({ source: "reviewer", rule_id: "reviewer:x" })).toBeNull();
1354
+ });
1355
+ });
1356
+
1357
+ describe("clusterByLocation (§30 — cross-tool co-location)", () => {
1358
+ const mk = (
1359
+ id: string,
1360
+ source: Concern["source"],
1361
+ file: string,
1362
+ line: number | null,
1363
+ ): Concern => ({
1364
+ id,
1365
+ source,
1366
+ rule_id: `${source}:r`,
1367
+ severity: "high",
1368
+ category: "security",
1369
+ evidence: "x",
1370
+ location: { file, line },
1371
+ remediation_hint: null,
1372
+ });
1373
+
1374
+ it("clusters concerns from different scanners at the same file:line", () => {
1375
+ const clusters = clusterByLocation([
1376
+ mk("1", "trivy", "main.tf", 5),
1377
+ mk("2", "checkov", "main.tf", 5),
1378
+ mk("3", "tflint", "vpc.tf", 9),
1379
+ ]);
1380
+ expect(clusters).toHaveLength(1);
1381
+ expect(clusters[0]).toMatchObject({ file: "main.tf", line: 5, sources: ["checkov", "trivy"] });
1382
+ expect(clusters[0]!.concern_ids).toEqual(["1", "2"]);
1383
+ });
1384
+
1385
+ it("does not cluster a single scanner's concerns or null-line concerns", () => {
1386
+ expect(
1387
+ clusterByLocation([mk("1", "trivy", "main.tf", 5), mk("2", "trivy", "main.tf", 5)]),
1388
+ ).toEqual([]);
1389
+ expect(
1390
+ clusterByLocation([mk("1", "trivy", "main.tf", null), mk("2", "checkov", "main.tf", null)]),
1391
+ ).toEqual([]);
1392
+ });
1393
+ });
1394
+
1395
+ describe("shouldSuggestInline (§5.18)", () => {
1396
+ const base = {
1397
+ hasPrContext: true,
1398
+ severity: "low" as const,
1399
+ fileCount: 1,
1400
+ hunkCount: 1,
1401
+ blastTier: "low" as const,
1402
+ };
1403
+
1404
+ it("suggests inline for a single-hunk low-risk fix on an existing PR", () => {
1405
+ expect(shouldSuggestInline(base).suggest).toBe(true);
1406
+ expect(shouldSuggestInline({ ...base, severity: "info" }).suggest).toBe(true);
1407
+ });
1408
+
1409
+ it("does not suggest without an existing PR context", () => {
1410
+ expect(shouldSuggestInline({ ...base, hasPrContext: false }).suggest).toBe(false);
1411
+ });
1412
+
1413
+ it("does not suggest for higher-severity fixes", () => {
1414
+ expect(shouldSuggestInline({ ...base, severity: "high" }).suggest).toBe(false);
1415
+ expect(shouldSuggestInline({ ...base, severity: "medium" }).suggest).toBe(false);
1416
+ });
1417
+
1418
+ it("does not suggest for multi-hunk / multi-file fixes", () => {
1419
+ expect(shouldSuggestInline({ ...base, hunkCount: 2 }).suggest).toBe(false);
1420
+ expect(shouldSuggestInline({ ...base, fileCount: 2 }).suggest).toBe(false);
1421
+ });
1422
+
1423
+ it("does not suggest for a medium/high blast radius", () => {
1424
+ expect(shouldSuggestInline({ ...base, blastTier: "high" }).suggest).toBe(false);
1425
+ expect(shouldSuggestInline({ ...base, blastTier: "medium" }).suggest).toBe(false);
1426
+ });
1427
+
1428
+ it("tolerates an unknown blast tier (plan didn't run)", () => {
1429
+ expect(shouldSuggestInline({ ...base, blastTier: undefined }).suggest).toBe(true);
1430
+ });
1431
+ });
1432
+
1433
+ describe("computeConfidence (§5.19)", () => {
1434
+ it("is high for a fully-proven fix (verified, no regressions, idempotent, low blast, no cost rise)", () => {
1435
+ expect(
1436
+ computeConfidence({
1437
+ verified: true,
1438
+ regressionCount: 0,
1439
+ idempotent: true,
1440
+ blastTier: "low",
1441
+ costDirection: "no-change",
1442
+ }).level,
1443
+ ).toBe("high");
1444
+ });
1445
+
1446
+ it("is low when the fix did not verify", () => {
1447
+ expect(computeConfidence({ verified: false, regressionCount: 0 }).level).toBe("low");
1448
+ });
1449
+
1450
+ it("is low when the fix introduced a regression, even if verified", () => {
1451
+ expect(computeConfidence({ verified: true, regressionCount: 2 }).level).toBe("low");
1452
+ });
1453
+
1454
+ it("caps at medium for a non-deterministic plan", () => {
1455
+ expect(
1456
+ computeConfidence({
1457
+ verified: true,
1458
+ regressionCount: 0,
1459
+ idempotent: false,
1460
+ blastTier: "low",
1461
+ costDirection: "no-change",
1462
+ }).level,
1463
+ ).toBe("medium");
1464
+ });
1465
+
1466
+ it("caps at medium for a high blast radius or a cost increase", () => {
1467
+ expect(
1468
+ computeConfidence({
1469
+ verified: true,
1470
+ regressionCount: 0,
1471
+ idempotent: true,
1472
+ blastTier: "high",
1473
+ costDirection: "no-change",
1474
+ }).level,
1475
+ ).toBe("medium");
1476
+ expect(
1477
+ computeConfidence({
1478
+ verified: true,
1479
+ regressionCount: 0,
1480
+ idempotent: true,
1481
+ blastTier: "low",
1482
+ costDirection: "increase",
1483
+ }).level,
1484
+ ).toBe("medium");
1485
+ });
1486
+
1487
+ it("caps at medium when plan/cost evidence is missing (no cloud creds) — high needs the full stack", () => {
1488
+ // verified + no regressions but no plan/infracost ran: honest medium, not high.
1489
+ expect(computeConfidence({ verified: true, regressionCount: 0 }).level).toBe("medium");
1490
+ });
1491
+ });
1492
+
1493
+ describe("moduleAddressOf", () => {
1494
+ it("returns root for a top-level resource", () => {
1495
+ expect(moduleAddressOf("aws_s3_bucket.b")).toBe("root");
1496
+ expect(moduleAddressOf("aws_s3_bucket.b[0]")).toBe("root");
1497
+ });
1498
+
1499
+ it("extracts the module call path, stripping the resource instance index", () => {
1500
+ expect(moduleAddressOf("module.db.aws_db_instance.main")).toBe("module.db");
1501
+ expect(moduleAddressOf("module.a.module.b.google_storage_bucket.x[0]")).toBe(
1502
+ "module.a.module.b",
1503
+ );
1504
+ });
1505
+
1506
+ it("collapses count/for_each module instances to a single module address", () => {
1507
+ expect(moduleAddressOf("module.net[0].aws_vpc.main")).toBe("module.net");
1508
+ expect(moduleAddressOf("module.net[1].aws_vpc.main")).toBe("module.net");
1509
+ expect(moduleAddressOf('module.net["prod"].aws_vpc.main')).toBe("module.net");
1510
+ });
1511
+ });
1512
+
1513
+ describe("computeBlastRadius (§2.6)", () => {
1514
+ const res = (...addrs: string[]) => addrs.map((address) => ({ address }));
1515
+
1516
+ it("tiers by resource count: 0-2 low, 3-10 medium, >10 high", () => {
1517
+ expect(computeBlastRadius(res()).tier).toBe("low");
1518
+ expect(computeBlastRadius(res("aws_s3_bucket.a", "aws_s3_bucket.b")).tier).toBe("low");
1519
+ expect(computeBlastRadius(res("a.1", "a.2", "a.3")).tier).toBe("medium");
1520
+ const eleven = Array.from({ length: 11 }, (_, i) => ({ address: `aws_instance.n${i}` }));
1521
+ expect(computeBlastRadius(eleven).tier).toBe("high");
1522
+ });
1523
+
1524
+ it("escalates to high when the change spans more than one module", () => {
1525
+ const out = computeBlastRadius(res("aws_s3_bucket.a", "module.net.aws_vpc.main"));
1526
+ expect(out.tier).toBe("high");
1527
+ expect(out.modules).toEqual(["module.net", "root"]);
1528
+ expect(out.resourceCount).toBe(2);
1529
+ });
1530
+
1531
+ it("does NOT escalate when count/for_each instances of ONE module change", () => {
1532
+ const out = computeBlastRadius(res("module.net[0].aws_vpc.main", "module.net[1].aws_vpc.main"));
1533
+ expect(out.modules).toEqual(["module.net"]);
1534
+ expect(out.tier).toBe("low");
1535
+ });
1536
+ });
1537
+
1538
+ describe("isPureMovePlan (§M2 modularization gate)", () => {
1539
+ const base = { add: 0, change: 0, destroy: 0, changed: [], moved: [] };
1540
+
1541
+ it("is true only for ≥1 move and zero mutations", () => {
1542
+ expect(isPureMovePlan({ ...base, moved: [{ address: "module.x.aws_s3_bucket.this" }] })).toBe(
1543
+ true,
1544
+ );
1545
+ });
1546
+
1547
+ it("is false without moves, or when anything mutates", () => {
1548
+ expect(isPureMovePlan(base)).toBe(false);
1549
+ expect(
1550
+ isPureMovePlan({
1551
+ ...base,
1552
+ moved: [{ address: "module.x.aws_s3_bucket.this" }],
1553
+ changed: [{ address: "aws_s3_bucket.other" }],
1554
+ }),
1555
+ ).toBe(false);
1556
+ expect(
1557
+ isPureMovePlan({ ...base, add: 1, moved: [{ address: "module.x.aws_s3_bucket.this" }] }),
1558
+ ).toBe(false);
1559
+ });
1560
+ });
1561
+
1562
+ describe("comparePlanStability (§1.3)", () => {
1563
+ const plan = (over: Partial<ReturnType<typeof parseTerraformPlanJson>>) =>
1564
+ ({
1565
+ add: 0,
1566
+ change: 0,
1567
+ destroy: 0,
1568
+ changed: [],
1569
+ destructive: [],
1570
+ hasDestroyOrReplace: false,
1571
+ moved: [],
1572
+ ...over,
1573
+ }) as ReturnType<typeof parseTerraformPlanJson>;
1574
+
1575
+ it("is stable when both plans have the same change set", () => {
1576
+ const a = plan({ change: 1, changed: [{ address: "aws_instance.web", action: "update" }] });
1577
+ const b = plan({ change: 1, changed: [{ address: "aws_instance.web", action: "update" }] });
1578
+ expect(comparePlanStability(a, b).stable).toBe(true);
1579
+ });
1580
+
1581
+ it("is unstable when the counts or addresses differ (perpetual-diff smell)", () => {
1582
+ const a = plan({ change: 1, changed: [{ address: "aws_instance.web", action: "update" }] });
1583
+ const b = plan({ change: 2, changed: [{ address: "aws_instance.web", action: "update" }] });
1584
+ const out = comparePlanStability(a, b);
1585
+ expect(out.stable).toBe(false);
1586
+ expect(out.reason).toMatch(/not deterministic/);
1587
+ });
1588
+
1589
+ it("ignores `changed` ordering", () => {
1590
+ const a = plan({
1591
+ add: 2,
1592
+ changed: [
1593
+ { address: "aws_s3_bucket.a", action: "create" },
1594
+ { address: "aws_s3_bucket.b", action: "create" },
1595
+ ],
1596
+ });
1597
+ const b = plan({
1598
+ add: 2,
1599
+ changed: [
1600
+ { address: "aws_s3_bucket.b", action: "create" },
1601
+ { address: "aws_s3_bucket.a", action: "create" },
1602
+ ],
1603
+ });
1604
+ expect(comparePlanStability(a, b).stable).toBe(true);
1605
+ });
1606
+ });
1607
+
1608
+ describe("parseInfracostBreakdown", () => {
1609
+ it("parses the decimal-string totalMonthlyCost and currency", () => {
1610
+ const json = JSON.stringify({ currency: "USD", totalMonthlyCost: "123.45" });
1611
+ expect(parseInfracostBreakdown(json)).toEqual({ totalMonthlyCost: 123.45, currency: "USD" });
1612
+ });
1613
+
1614
+ it("accepts a numeric totalMonthlyCost and a non-USD currency", () => {
1615
+ const json = JSON.stringify({ currency: "GBP", totalMonthlyCost: 10 });
1616
+ expect(parseInfracostBreakdown(json)).toEqual({ totalMonthlyCost: 10, currency: "GBP" });
1617
+ });
1618
+
1619
+ it("yields null cost (not 0) when nothing is priced, defaulting currency to USD", () => {
1620
+ expect(parseInfracostBreakdown(JSON.stringify({}))).toEqual({
1621
+ totalMonthlyCost: null,
1622
+ currency: "USD",
1623
+ });
1624
+ expect(parseInfracostBreakdown(JSON.stringify({ totalMonthlyCost: null }))).toEqual({
1625
+ totalMonthlyCost: null,
1626
+ currency: "USD",
1627
+ });
1628
+ });
1629
+
1630
+ it("treats empty output as no breakdown", () => {
1631
+ expect(parseInfracostBreakdown("")).toEqual({ totalMonthlyCost: null, currency: "USD" });
1632
+ });
1633
+ });
1634
+
1635
+ describe("computeCostDelta", () => {
1636
+ it("reports an increase, rounded to cents", () => {
1637
+ expect(
1638
+ computeCostDelta(
1639
+ { totalMonthlyCost: 100, currency: "USD" },
1640
+ { totalMonthlyCost: 112.405, currency: "USD" },
1641
+ ),
1642
+ ).toEqual({
1643
+ currency: "USD",
1644
+ baselineMonthly: 100,
1645
+ currentMonthly: 112.405,
1646
+ deltaMonthly: 12.41,
1647
+ direction: "increase",
1648
+ });
1649
+ });
1650
+
1651
+ it("reports a decrease", () => {
1652
+ const d = computeCostDelta(
1653
+ { totalMonthlyCost: 50, currency: "USD" },
1654
+ { totalMonthlyCost: 40, currency: "USD" },
1655
+ );
1656
+ expect(d.deltaMonthly).toBe(-10);
1657
+ expect(d.direction).toBe("decrease");
1658
+ });
1659
+
1660
+ it("reports no-change when costs are equal", () => {
1661
+ const d = computeCostDelta(
1662
+ { totalMonthlyCost: 7, currency: "USD" },
1663
+ { totalMonthlyCost: 7, currency: "USD" },
1664
+ );
1665
+ expect(d.deltaMonthly).toBe(0);
1666
+ expect(d.direction).toBe("no-change");
1667
+ });
1668
+
1669
+ it("is unknown when there is no baseline", () => {
1670
+ expect(computeCostDelta(null, { totalMonthlyCost: 30, currency: "USD" })).toEqual({
1671
+ currency: "USD",
1672
+ baselineMonthly: null,
1673
+ currentMonthly: 30,
1674
+ deltaMonthly: null,
1675
+ direction: "unknown",
1676
+ });
1677
+ });
1678
+
1679
+ it("is unknown when either side is unpriced, and falls back to the baseline currency", () => {
1680
+ expect(
1681
+ computeCostDelta(
1682
+ { totalMonthlyCost: 5, currency: "GBP" },
1683
+ { totalMonthlyCost: null, currency: "" },
1684
+ ),
1685
+ ).toMatchObject({ currency: "GBP", deltaMonthly: null, direction: "unknown" });
1686
+ });
1687
+ });
1688
+
1689
+ describe("parseResourceArguments (§4.15-next)", () => {
1690
+ it("extracts top-level attribute + nested-block names, excluding meta-args", () => {
1691
+ const hcl = `
1692
+ resource "aws_s3_bucket" "b" {
1693
+ bucket = "my-bucket"
1694
+ count = 2
1695
+ tags = { Env = "prod" }
1696
+ versioning {
1697
+ enabled = true
1698
+ }
1699
+ lifecycle {
1700
+ prevent_destroy = true
1701
+ }
1702
+ }`;
1703
+ const r = parseResourceArguments(hcl)[0]!;
1704
+ expect(r.resourceType).toBe("aws_s3_bucket");
1705
+ expect(r.name).toBe("b");
1706
+ expect([...r.args].sort()).toEqual(["bucket", "tags", "versioning"]);
1707
+ // count + lifecycle are meta-arguments, never schema args.
1708
+ expect(r.args).not.toContain("count");
1709
+ expect(r.args).not.toContain("lifecycle");
1710
+ });
1711
+
1712
+ it("reads a dynamic block's label as the generated block name", () => {
1713
+ const hcl = `
1714
+ resource "aws_security_group" "sg" {
1715
+ name = "sg"
1716
+ dynamic "ingress" {
1717
+ for_each = var.rules
1718
+ content { from_port = ingress.value.port }
1719
+ }
1720
+ }`;
1721
+ const r = parseResourceArguments(hcl)[0]!;
1722
+ expect(r.args).toContain("ingress");
1723
+ expect(r.args).toContain("name");
1724
+ expect(r.args).not.toContain("dynamic");
1725
+ });
1726
+
1727
+ it("is not fooled by interpolation braces or commented lines", () => {
1728
+ const hcl = `
1729
+ resource "aws_instance" "i" {
1730
+ ami = "\${data.aws_ami.x.id}"
1731
+ # bogus = "commented out"
1732
+ instance_type = "t3.micro"
1733
+ }`;
1734
+ const r = parseResourceArguments(hcl)[0]!;
1735
+ expect([...r.args].sort()).toEqual(["ami", "instance_type"]);
1736
+ expect(r.args).not.toContain("bogus");
1737
+ });
1738
+
1739
+ it("does not pick up nested-block attributes as top-level args", () => {
1740
+ const hcl = `
1741
+ resource "aws_s3_bucket" "b" {
1742
+ bucket = "x"
1743
+ server_side_encryption_configuration {
1744
+ rule {
1745
+ apply_server_side_encryption_by_default {
1746
+ sse_algorithm = "aws:kms"
1747
+ }
1748
+ }
1749
+ }
1750
+ }`;
1751
+ const r = parseResourceArguments(hcl)[0]!;
1752
+ expect(r.args).toContain("bucket");
1753
+ expect(r.args).toContain("server_side_encryption_configuration");
1754
+ expect(r.args).not.toContain("sse_algorithm");
1755
+ expect(r.args).not.toContain("rule");
1756
+ });
1757
+
1758
+ it("handles multiple resources and ignores non-resource blocks", () => {
1759
+ const hcl = `
1760
+ variable "x" { type = string }
1761
+ resource "aws_s3_bucket" "a" { bucket = "a" }
1762
+ resource "aws_s3_bucket" "b" { bucket = "b" }`;
1763
+ const rs = parseResourceArguments(hcl);
1764
+ expect(rs.map((r) => r.name)).toEqual(["a", "b"]);
1765
+ });
1766
+
1767
+ it("is not fooled by an escaped quote inside a string value", () => {
1768
+ const hcl = `
1769
+ resource "aws_iam_role" "r" {
1770
+ description = "a \\"quoted\\" word { not a block"
1771
+ name = "r"
1772
+ }`;
1773
+ const r = parseResourceArguments(hcl)[0]!;
1774
+ expect([...r.args].sort()).toEqual(["description", "name"]);
1775
+ });
1776
+
1777
+ it("skips a heredoc body (no fabricated args, no brace corruption)", () => {
1778
+ const hcl = `
1779
+ resource "aws_iam_role" "r" {
1780
+ name = "r"
1781
+ assume_role_policy = <<-EOT
1782
+ {
1783
+ "Version": "2012-10-17",
1784
+ "fake_arg": "should not be parsed"
1785
+ }
1786
+ EOT
1787
+ tags = { Env = "prod" }
1788
+ }`;
1789
+ const r = parseResourceArguments(hcl)[0]!;
1790
+ expect([...r.args].sort()).toEqual(["assume_role_policy", "name", "tags"]);
1791
+ expect(r.args).not.toContain("fake_arg");
1792
+ expect(r.args).not.toContain("Version");
1793
+ });
1794
+ });
1795
+
1796
+ describe("parseTerraformPlanJson (address + summary fallbacks)", () => {
1797
+ const lines = (...objs: unknown[]) => objs.map((o) => JSON.stringify(o)).join("\n");
1798
+
1799
+ it("falls back to resource.resource, then '(unknown)', for the address", () => {
1800
+ const out = lines(
1801
+ {
1802
+ type: "planned_change",
1803
+ change: { action: "update", resource: { resource: "aws_instance.web" } },
1804
+ },
1805
+ { type: "planned_change", change: { action: "update", resource: {} } },
1806
+ );
1807
+ expect(parseTerraformPlanJson(out).changed).toEqual([
1808
+ { address: "aws_instance.web", action: "update" },
1809
+ { address: "(unknown)", action: "update" },
1810
+ ]);
1811
+ });
1812
+
1813
+ it("coerces non-numeric change_summary counts to 0", () => {
1814
+ const out = lines({
1815
+ type: "change_summary",
1816
+ changes: { add: "nope", change: null, remove: undefined },
1817
+ });
1818
+ expect(parseTerraformPlanJson(out)).toMatchObject({ add: 0, change: 0, destroy: 0 });
1819
+ });
1820
+
1821
+ it("skips a planned_change with no action", () => {
1822
+ const out = lines({
1823
+ type: "planned_change",
1824
+ change: { resource: { addr: "aws_s3_bucket.a" } },
1825
+ });
1826
+ expect(parseTerraformPlanJson(out).changed).toEqual([]);
1827
+ });
1828
+ });
1829
+
1830
+ describe("aggregatePlans (empty input)", () => {
1831
+ it("returns a zeroed, idempotent aggregate for no roots", () => {
1832
+ expect(aggregatePlans([])).toEqual({
1833
+ add: 0,
1834
+ change: 0,
1835
+ destroy: 0,
1836
+ changed: [],
1837
+ destructive: [],
1838
+ hasDestroyOrReplace: false,
1839
+ idempotent: true,
1840
+ moved: [],
1841
+ });
1842
+ });
1843
+ });
1844
+
1845
+ describe("computeCostDelta (currency fallback)", () => {
1846
+ it("defaults to USD when neither side carries a currency", () => {
1847
+ const d = computeCostDelta(null, { totalMonthlyCost: 5, currency: "" });
1848
+ expect(d.currency).toBe("USD");
1849
+ });
1850
+ });
1851
+
1852
+ describe("parseRequiredProviders (constraint edge cases)", () => {
1853
+ it("yields a null major for an unconstrained provider", () => {
1854
+ const hcl = `required_providers { aws = { source = "hashicorp/aws" } }`;
1855
+ expect(parseRequiredProviders(hcl)).toEqual([
1856
+ { name: "aws", source: "hashicorp/aws", version: null, major: null },
1857
+ ]);
1858
+ });
1859
+
1860
+ it("reads a bare integer constraint's major", () => {
1861
+ const hcl = `required_providers { aws = { version = "5" } }`;
1862
+ expect(parseRequiredProviders(hcl)[0]).toMatchObject({ major: 5 });
1863
+ });
1864
+ });
1865
+
1866
+ describe("parseTflintOutput (severity + fallbacks)", () => {
1867
+ const issue = (rule: Record<string, unknown> | undefined, message?: string) =>
1868
+ JSON.stringify({ issues: [{ rule, message, range: { filename: "a.tf" } }] });
1869
+
1870
+ it("maps error to high and an unknown severity to low", () => {
1871
+ const [error] = parseTflintOutput(issue({ name: "r", severity: "error" }));
1872
+ expect(error).toMatchObject({ severity: "high" });
1873
+ const [notice] = parseTflintOutput(issue({ name: "r", severity: "notice" }));
1874
+ expect(notice).toMatchObject({ severity: "low" });
1875
+ });
1876
+
1877
+ it("falls back to 'issue' for a missing rule name and a null line", () => {
1878
+ const [c] = parseTflintOutput(issue(undefined));
1879
+ expect(c).toMatchObject({
1880
+ rule_id: "tflint:issue",
1881
+ evidence: "issue",
1882
+ location: { file: "a.tf", line: null },
1883
+ });
1884
+ });
1885
+ });
1886
+
1887
+ describe("parseCheckovOutput (missing check id)", () => {
1888
+ it("falls back to 'issue' for the rule and evidence", () => {
1889
+ const json = JSON.stringify({ results: { failed_checks: [{ file_path: "a.tf" }] } });
1890
+ const [c] = parseCheckovOutput(json);
1891
+ expect(c).toMatchObject({ rule_id: "checkov:issue", evidence: "issue" });
1892
+ });
1893
+ });
1894
+
1895
+ describe("toRepoRelative", () => {
1896
+ it("returns '(unknown)' for an empty path and strips ./ prefixes", () => {
1897
+ expect(toRepoRelative(undefined, "/repo")).toBe("(unknown)");
1898
+ expect(toRepoRelative("", "/repo")).toBe("(unknown)");
1899
+ expect(toRepoRelative("./main.tf", "")).toBe("main.tf");
1900
+ expect(toRepoRelative("/repo/", "/repo")).toBe("(unknown)");
1901
+ });
1902
+ });
1903
+
1904
+ describe("buildSarifReport (SARIF emit)", () => {
1905
+ const concern = (over: Partial<Concern> = {}): Concern => ({
1906
+ id: "abc123",
1907
+ source: "trivy",
1908
+ rule_id: "trivy:AVD-AWS-0088",
1909
+ severity: "high",
1910
+ category: "security",
1911
+ evidence: "S3 bucket is not encrypted",
1912
+ location: { file: "main.tf", line: 12 },
1913
+ remediation_hint: null,
1914
+ ...over,
1915
+ });
1916
+
1917
+ it("emits a valid SARIF 2.1.0 shape with a terramend driver", () => {
1918
+ const report = buildSarifReport([concern()]);
1919
+ expect(report.version).toBe("2.1.0");
1920
+ expect(report.runs?.[0]?.tool?.driver?.name).toBe("terramend");
1921
+ const result = report.runs?.[0]?.results?.[0];
1922
+ expect(result?.ruleId).toBe("trivy:AVD-AWS-0088");
1923
+ expect(result?.level).toBe("error"); // high → error
1924
+ expect(result?.locations?.[0]?.physicalLocation?.artifactLocation?.uri).toBe("main.tf");
1925
+ expect(result?.locations?.[0]?.physicalLocation?.region?.startLine).toBe(12);
1926
+ expect(result?.properties?.["security-severity"]).toBe("8.0");
1927
+ });
1928
+
1929
+ it("maps severities to SARIF levels", () => {
1930
+ const levels = (["critical", "high", "medium", "low", "info"] as const).map(
1931
+ (severity) => buildSarifReport([concern({ severity })]).runs?.[0]?.results?.[0]?.level,
1932
+ );
1933
+ expect(levels).toEqual(["error", "error", "warning", "note", "note"]);
1934
+ });
1935
+
1936
+ it("dedupes rules and sorts them by id", () => {
1937
+ const report = buildSarifReport([
1938
+ concern({ rule_id: "trivy:Z", id: "1" }),
1939
+ concern({ rule_id: "trivy:A", id: "2" }),
1940
+ concern({ rule_id: "trivy:Z", id: "3", location: { file: "b.tf", line: 1 } }),
1941
+ ]);
1942
+ const ruleIds = report.runs?.[0]?.tool?.driver?.rules?.map((r) => r.id);
1943
+ expect(ruleIds).toEqual(["trivy:A", "trivy:Z"]);
1944
+ });
1945
+
1946
+ it("omits the region when the concern has no line", () => {
1947
+ const report = buildSarifReport([concern({ location: { file: "main.tf", line: null } })]);
1948
+ expect(
1949
+ report.runs?.[0]?.results?.[0]?.locations?.[0]?.physicalLocation?.region,
1950
+ ).toBeUndefined();
1951
+ });
1952
+
1953
+ it("is deterministic (re-emit is identical)", () => {
1954
+ const cs = [concern(), concern({ rule_id: "checkov:CKV_AWS_19", id: "x" })];
1955
+ expect(JSON.stringify(buildSarifReport(cs))).toBe(JSON.stringify(buildSarifReport(cs)));
1956
+ });
1957
+ });