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,809 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { walkTfFiles } from "#app/mcp/modules";
4
+ import { loadProvidersSchema, unknownArgsForResource } from "#app/mcp/providerSchema";
5
+ import {
6
+ type Concern,
7
+ concernId,
8
+ dedupe,
9
+ lowerSeverity,
10
+ type ResolvedRoot,
11
+ rebaseConcern,
12
+ resolveBaseRef,
13
+ resolveRoots,
14
+ run,
15
+ type ScannerOutcome,
16
+ type Severity,
17
+ skipped,
18
+ toRepoRelative,
19
+ } from "#app/mcp/terraform/types";
20
+ import { log } from "#app/utils/cli";
21
+
22
+ // dirs already `terraform init`-ed this process, so repeated scans don't re-init.
23
+ const initedDirs = new Set<string>();
24
+
25
+ /**
26
+ * Run `terraform init -backend=false` once per dir so `terraform validate` has
27
+ * provider schemas to check against (Bug 3 / gap B). Without init, validate only
28
+ * emits "missing required provider" — which VALIDATE_NOISE drops — so it was
29
+ * effectively inert. `-backend=false` avoids needing real backend credentials;
30
+ * `-input=false` keeps it non-interactive. Network-dependent and best-effort: if
31
+ * it fails (offline, private module, etc.) validate still runs, just shallow.
32
+ */
33
+ function ensureTerraformInit(cwd: string): void {
34
+ if (initedDirs.has(cwd)) return;
35
+ const r = run("terraform", ["init", "-backend=false", "-input=false", "-no-color"], cwd);
36
+ // mark done even on non-zero: a failed init won't succeed on retry within the
37
+ // same run, and we don't want to re-run it for every scanner call.
38
+ initedDirs.add(cwd);
39
+ if (r.status !== 0 && !r.missing) {
40
+ log.info(`» terraform init (for validate) did not complete cleanly — validate may be shallow`);
41
+ }
42
+ }
43
+
44
+ // --- terraform fmt -------------------------------------------------------
45
+
46
+ export function scanFmt(cwd: string): ScannerOutcome {
47
+ const r = run("terraform", ["fmt", "-check", "-recursive", "-list=true"], cwd);
48
+ if (r.missing) return skipped("terraform-fmt", "terraform not installed");
49
+ // exit 0 = all formatted; exit 3 = files need formatting (lists them on stdout);
50
+ // other non-zero = real error (e.g. parse failure) — surface nothing, validate covers it.
51
+ if (r.status === 0) return { source: "terraform-fmt", ran: true, concerns: [] };
52
+ return { source: "terraform-fmt", ran: true, concerns: parseFmtOutput(r.stdout, cwd) };
53
+ }
54
+
55
+ /** `terraform fmt -check -list=true` prints one unformatted file path per line. */
56
+ export function parseFmtOutput(stdout: string, cwd = ""): Concern[] {
57
+ const files = stdout
58
+ .split("\n")
59
+ .map((l) => l.trim())
60
+ .filter(Boolean);
61
+ return files.map<Concern>((raw) => {
62
+ const file = toRepoRelative(raw, cwd);
63
+ return {
64
+ id: concernId("terraform-fmt", "unformatted", file, null),
65
+ source: "terraform-fmt",
66
+ rule_id: "terraform-fmt:unformatted",
67
+ severity: "low",
68
+ category: "style",
69
+ evidence: "File does not match `terraform fmt` canonical style.",
70
+ location: { file, line: null },
71
+ remediation_hint: "Run `terraform fmt` to apply canonical formatting.",
72
+ };
73
+ });
74
+ }
75
+
76
+ // --- terraform validate ---------------------------------------------------
77
+
78
+ // diagnostics that are environmental (the dir isn't initialized, or a provider
79
+ // plugin failed to install/launch) rather than a real best-practice issue.
80
+ // dropped so a scan can't emit false positives from toolchain hiccups — e.g.
81
+ // after `terraform init` (Bug 3), a crashed provider plugin surfaces as
82
+ // "Failed to load plugin schemas", which is noise, not a defect in the HCL.
83
+ const VALIDATE_NOISE = [
84
+ "terraform init",
85
+ "missing required provider",
86
+ "module not installed",
87
+ "module is not yet installed",
88
+ "required plugins are not installed",
89
+ "uninitialized",
90
+ "failed to load plugin",
91
+ "plugin did not respond",
92
+ "could not load plugin",
93
+ ];
94
+
95
+ /** run `terraform validate` in one root and return concerns re-based onto cwd. */
96
+ function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
97
+ ensureTerraformInit(root.absDir);
98
+ const r = run("terraform", ["validate", "-json"], root.absDir);
99
+ if (r.missing) return skipped("terraform-validate", "terraform not installed");
100
+ try {
101
+ const concerns = parseValidateOutput(r.stdout, root.absDir).map((c) =>
102
+ rebaseConcern(c, root.relDir),
103
+ );
104
+ return { source: "terraform-validate", ran: true, concerns };
105
+ } catch {
106
+ // terraform ran but emitted output we couldn't parse — a real CLI-level
107
+ // failure (corrupted .terraform, a crash, an ancient terraform without
108
+ // `-json`), NOT a clean tree. Flag it as unvalidated so the tool fails
109
+ // closed rather than reporting this root as passing.
110
+ return {
111
+ ...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
112
+ unvalidated: 1,
113
+ };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Run `terraform validate` across EVERY root and aggregate. `validate` is the
119
+ * one scanner that's per-root (fmt/tflint/trivy/checkov are recursive over the
120
+ * whole tree), so a multi-root repo only catches subdir-root validate errors
121
+ * when we visit each root.
122
+ */
123
+ export function scanValidate(cwd: string): ScannerOutcome {
124
+ const roots = resolveRoots(cwd);
125
+ const concerns: Concern[] = [];
126
+ let anyRan = false;
127
+ let sawMissing = false;
128
+ let unvalidated = 0;
129
+ for (const root of roots) {
130
+ const outcome = scanValidateRoot(root);
131
+ unvalidated += outcome.unvalidated ?? 0;
132
+ if (outcome.ran) {
133
+ anyRan = true;
134
+ concerns.push(...outcome.concerns);
135
+ } else if (outcome.skipped_reason?.includes("not installed")) {
136
+ sawMissing = true;
137
+ }
138
+ }
139
+ if (!anyRan) {
140
+ return sawMissing
141
+ ? skipped("terraform-validate", "terraform not installed")
142
+ : {
143
+ ...skipped("terraform-validate", "could not parse `terraform validate -json` output"),
144
+ unvalidated,
145
+ };
146
+ }
147
+ return { source: "terraform-validate", ran: true, concerns: dedupe(concerns), unvalidated };
148
+ }
149
+
150
+ /** parse `terraform validate -json`; keeps real errors, drops uninitialized-dir noise. */
151
+ export function parseValidateOutput(stdout: string, cwd = ""): Concern[] {
152
+ const parsed = JSON.parse(stdout || "{}") as { diagnostics?: ValidateDiagnostic[] };
153
+ const diags = (parsed.diagnostics ?? []).filter((d) => d.severity === "error");
154
+ const concerns: Concern[] = [];
155
+ for (const d of diags) {
156
+ const text = `${d.summary ?? ""} ${d.detail ?? ""}`.toLowerCase();
157
+ if (VALIDATE_NOISE.some((n) => text.includes(n))) continue;
158
+ const file = toRepoRelative(d.range?.filename, cwd);
159
+ const line = d.range?.start?.line ?? null;
160
+ concerns.push({
161
+ id: concernId("terraform-validate", d.summary ?? "error", file, line),
162
+ source: "terraform-validate",
163
+ rule_id: `terraform-validate:${d.summary ?? "error"}`,
164
+ severity: "high",
165
+ category: "correctness",
166
+ evidence: [d.summary, d.detail].filter(Boolean).join(" — "),
167
+ location: { file, line },
168
+ remediation_hint: null,
169
+ });
170
+ }
171
+ return concerns;
172
+ }
173
+
174
+ interface ValidateDiagnostic {
175
+ severity?: string;
176
+ summary?: string;
177
+ detail?: string;
178
+ range?: { filename?: string; start?: { line?: number } };
179
+ }
180
+
181
+ // --- provider-version awareness (§4.15) ------------------------------------
182
+
183
+ export interface ProviderRequirement {
184
+ /** local name, e.g. `aws`. */
185
+ name: string;
186
+ /** registry source, e.g. `hashicorp/aws`, or null (legacy string form). */
187
+ source: string | null;
188
+ /** raw version constraint, e.g. `~> 5.0`, or null when unconstrained. */
189
+ version: string | null;
190
+ /** the pinned MAJOR (the lower-bound major of the constraint) — the number a
191
+ * fix must target, since argument schemas differ across provider majors. */
192
+ major: number | null;
193
+ }
194
+
195
+ /** the lower-bound major version from a constraint string (`~> 5.0` → 5,
196
+ * `>= 3.1, < 4.0` → 3, `5` → 5). null when no number is present. */
197
+ function majorOf(version: string | null): number | null {
198
+ if (!version) return null;
199
+ const m = version.match(/(\d+)\s*\.\s*\d+/) ?? version.match(/(\d+)/);
200
+ return m ? Number(m[1]) : null;
201
+ }
202
+
203
+ /**
204
+ * Parse every `required_providers { … }` block in some HCL text into the pinned
205
+ * provider requirements. Handles the modern object form
206
+ * (`aws = { source = "hashicorp/aws", version = "~> 5.0" }`) and the legacy
207
+ * string form (`aws = "~> 5.0"`). A repo's "correct" fix depends on the provider
208
+ * MAJOR — argument names and valid blocks differ across AWS/Azure majors — so
209
+ * surfacing the pinned major lets a fix target the right schema instead of
210
+ * breaking `plan`. Brace-matched (not a fragile single regex) so nested objects
211
+ * don't confuse it. First declaration of a name wins (dedup across files).
212
+ */
213
+ export function parseRequiredProviders(hcl: string): ProviderRequirement[] {
214
+ const out: ProviderRequirement[] = [];
215
+ const seen = new Set<string>();
216
+ let searchFrom = 0;
217
+ for (;;) {
218
+ const idx = hcl.indexOf("required_providers", searchFrom);
219
+ if (idx === -1) break;
220
+ const braceStart = hcl.indexOf("{", idx);
221
+ if (braceStart === -1) break;
222
+ let depth = 0;
223
+ let end = -1;
224
+ for (let i = braceStart; i < hcl.length; i++) {
225
+ if (hcl[i] === "{") depth++;
226
+ else if (hcl[i] === "}") {
227
+ depth--;
228
+ if (depth === 0) {
229
+ end = i;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ if (end === -1) break;
235
+ const body = hcl.slice(braceStart + 1, end);
236
+ searchFrom = end + 1;
237
+
238
+ // object form: name = { source = "…", version = "…" }
239
+ const objRe = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{([^}]*)\}/g;
240
+ let m: RegExpExecArray | null;
241
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
242
+ while ((m = objRe.exec(body)) !== null) {
243
+ const name = m[1]!;
244
+ const inner = m[2]!;
245
+ if (seen.has(name)) continue;
246
+ seen.add(name);
247
+ const source = inner.match(/source\s*=\s*"([^"]+)"/)?.[1] ?? null;
248
+ const version = inner.match(/version\s*=\s*"([^"]+)"/)?.[1] ?? null;
249
+ out.push({ name, source, version, major: majorOf(version) });
250
+ }
251
+ // legacy string form: name = "version" — run on the body with object blocks
252
+ // stripped so an object's inner `source =`/`version =` lines aren't matched.
253
+ const bodyNoObjects = body.replace(/([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{[^}]*\}/g, "");
254
+ const strRe = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]+)"/g;
255
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
256
+ while ((m = strRe.exec(bodyNoObjects)) !== null) {
257
+ const name = m[1]!;
258
+ if (seen.has(name)) continue;
259
+ seen.add(name);
260
+ out.push({ name, source: null, version: m[2]!, major: majorOf(m[2]!) });
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /** read the repo's `*.tf` files (recursively — root + subdir roots + nested
267
+ * modules) and parse their pinned provider requirements (best-effort; an
268
+ * unreadable tree yields none). First declaration of a provider wins. */
269
+ export function collectProviderRequirements(cwd: string): ProviderRequirement[] {
270
+ let text = "";
271
+ for (const f of walkTfFiles(cwd)) {
272
+ try {
273
+ text += `${readFileSync(join(cwd, f), "utf8")}\n`;
274
+ } catch {
275
+ /* skip unreadable file */
276
+ }
277
+ }
278
+ return parseRequiredProviders(text);
279
+ }
280
+
281
+ // --- §4.15-next: argument-vs-schema validation ------------------------------
282
+
283
+ /** the top-level arguments of a single `resource` block. */
284
+ export interface ResourceArguments {
285
+ resourceType: string;
286
+ /** the resource's local name (`resource "aws_s3_bucket" "<name>"`). */
287
+ name: string;
288
+ /** top-level attribute + nested-block names (meta-arguments excluded). */
289
+ args: string[];
290
+ }
291
+
292
+ // Terraform meta-arguments are valid on EVERY resource and never appear in a
293
+ // provider's schema — exclude them so they're not flagged as unknown. `dynamic`
294
+ // is handled specially (its quoted label is the real block name).
295
+ const RESOURCE_META_ARGUMENTS: ReadonlySet<string> = new Set([
296
+ "count",
297
+ "for_each",
298
+ "provider",
299
+ "depends_on",
300
+ "lifecycle",
301
+ "provisioner",
302
+ "connection",
303
+ ]);
304
+
305
+ /**
306
+ * Parse every `resource "<type>" "<name>" { … }` block's TOP-LEVEL argument
307
+ * names (attributes assigned with `=` and nested block labels) from some HCL.
308
+ * Conservative by design — it skips `"…"` strings and `#`/`//` line comments so
309
+ * an interpolation's braces or a commented line can't corrupt the brace depth or
310
+ * fabricate an argument, and only reports depth-0 names. A `dynamic "x"` block
311
+ * contributes `x` (the generated block type). Used to cross-check written
312
+ * arguments against the installed provider schema; pure.
313
+ */
314
+ export function parseResourceArguments(hcl: string): ResourceArguments[] {
315
+ const out: ResourceArguments[] = [];
316
+ const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
317
+ let m: RegExpExecArray | null;
318
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
319
+ while ((m = re.exec(hcl)) !== null) {
320
+ const resourceType = m[1]!;
321
+ const name = m[2]!;
322
+ const braceStart = hcl.indexOf("{", m.index);
323
+ if (braceStart === -1) break;
324
+ const body = matchBraceBody(hcl, braceStart);
325
+ if (!body) break;
326
+ re.lastIndex = body.end + 1;
327
+ out.push({ resourceType, name, args: topLevelArgNames(body.text) });
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /**
333
+ * A non-code span in HCL — a `"…"` string, a `#`/`//` line comment, or a
334
+ * `<<EOF` heredoc — that the brace/argument scanners must skip wholesale (an
335
+ * interpolation's `${…}`, a commented `}`, or a heredoc's `key = value` lines
336
+ * would otherwise corrupt brace depth or fabricate arguments). Returned for a
337
+ * span starting at `i`: `end` is the index of its LAST char (caller resumes at
338
+ * `end + 1`), and `endsLine` is true when the span finished at a line boundary
339
+ * (comment / heredoc — the next token begins a fresh statement). null when `i`
340
+ * isn't the start of a non-code span. Single source of truth for both scanners.
341
+ */
342
+ function skipNonCode(s: string, i: number): { end: number; endsLine: boolean } | null {
343
+ const ch = s[i];
344
+ // double-quoted string (with `\` escapes). Ends mid-line.
345
+ if (ch === '"') {
346
+ let j = i + 1;
347
+ while (j < s.length && s[j] !== '"') {
348
+ if (s[j] === "\\") j++;
349
+ j++;
350
+ }
351
+ return { end: j, endsLine: false };
352
+ }
353
+ // heredoc body (`<<EOF` / `<<-EOF` / `<<~EOF`) — arbitrary text.
354
+ if (ch === "<" && s[i + 1] === "<") {
355
+ const m = /^<<[-~]?([A-Za-z_][A-Za-z0-9_]*)/.exec(s.slice(i, i + 80));
356
+ if (m) {
357
+ const delim = m[1];
358
+ const openNl = s.indexOf("\n", i);
359
+ if (openNl === -1) return { end: s.length - 1, endsLine: true };
360
+ // the closing delimiter sits alone on its own line (optionally indented).
361
+ const closeRe = new RegExp(`\\n[ \\t]*${delim}[ \\t]*(?=\\n|$)`);
362
+ const cm = closeRe.exec(s.slice(openNl));
363
+ const end = cm ? openNl + cm.index + cm[0].length - 1 : s.length - 1;
364
+ return { end, endsLine: true };
365
+ }
366
+ }
367
+ // `#` or `//` line comment.
368
+ if (ch === "#" || (ch === "/" && s[i + 1] === "/")) {
369
+ const nl = s.indexOf("\n", i);
370
+ return { end: nl === -1 ? s.length - 1 : nl, endsLine: true };
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /** brace-match from an opening `{` at `open`; returns the inner text + end index
376
+ * (the matching `}`), string/comment/heredoc-aware so interpolation braces, a
377
+ * commented `}`, or a heredoc body don't fool it. */
378
+ function matchBraceBody(hcl: string, open: string | number): { text: string; end: number } | null {
379
+ const start = typeof open === "number" ? open : -1;
380
+ if (start < 0) return null;
381
+ let depth = 0;
382
+ for (let i = start; i < hcl.length; i++) {
383
+ const span = skipNonCode(hcl, i);
384
+ if (span) {
385
+ i = span.end;
386
+ continue;
387
+ }
388
+ const ch = hcl[i];
389
+ if (ch === "{") depth++;
390
+ else if (ch === "}") {
391
+ depth--;
392
+ if (depth === 0) return { text: hcl.slice(start + 1, i), end: i };
393
+ }
394
+ }
395
+ return null;
396
+ }
397
+
398
+ /** extract the depth-0 argument names from a resource block body (string-,
399
+ * comment-, and heredoc-aware), excluding meta-arguments. */
400
+ function topLevelArgNames(body: string): string[] {
401
+ const names = new Set<string>();
402
+ let depth = 0;
403
+ let atStmtStart = true;
404
+ for (let i = 0; i < body.length; i++) {
405
+ const span = skipNonCode(body, i);
406
+ if (span) {
407
+ i = span.end;
408
+ // a comment/heredoc ends a line (next token is a fresh statement); a
409
+ // string ends mid-line (still inside the current statement).
410
+ atStmtStart = span.endsLine;
411
+ continue;
412
+ }
413
+ const ch = body[i]!;
414
+ if (ch === "{") {
415
+ depth++;
416
+ atStmtStart = false;
417
+ continue;
418
+ }
419
+ if (ch === "}") {
420
+ depth--;
421
+ atStmtStart = false;
422
+ continue;
423
+ }
424
+ if (ch === "\n") {
425
+ atStmtStart = true;
426
+ continue;
427
+ }
428
+ if (ch === " " || ch === "\t" || ch === "\r") continue;
429
+ // a non-space char that starts a statement at depth 0 → read an identifier.
430
+ if (depth === 0 && atStmtStart && /[A-Za-z_]/.test(ch)) {
431
+ let j = i;
432
+ while (j < body.length && /[A-Za-z0-9_-]/.test(body[j]!)) j++;
433
+ const ident = body.slice(i, j);
434
+ // skip whitespace after the identifier to classify it.
435
+ let k = j;
436
+ while (k < body.length && (body[k] === " " || body[k] === "\t")) k++;
437
+ const next = body[k];
438
+ if (next === "=" && body[k + 1] !== "=") {
439
+ // attribute assignment: `name = …`
440
+ if (!RESOURCE_META_ARGUMENTS.has(ident)) names.add(ident);
441
+ } else if (next === "{") {
442
+ // nested block: `name { … }`
443
+ if (!RESOURCE_META_ARGUMENTS.has(ident)) names.add(ident);
444
+ } else if (next === '"') {
445
+ // labeled block: `dynamic "x" {` → the generated block is `x`;
446
+ // other labeled blocks (`provisioner "remote-exec"`) are meta.
447
+ if (ident === "dynamic") {
448
+ const labelEnd = body.indexOf('"', k + 1);
449
+ if (labelEnd !== -1) names.add(body.slice(k + 1, labelEnd));
450
+ }
451
+ }
452
+ i = j - 1;
453
+ atStmtStart = false;
454
+ continue;
455
+ }
456
+ atStmtStart = false;
457
+ }
458
+ return [...names];
459
+ }
460
+
461
+ export interface UnknownArgument {
462
+ resource_type: string;
463
+ /** the resource's local name. */
464
+ name: string;
465
+ /** repo-relative file the resource is declared in. */
466
+ file: string;
467
+ /** the argument names not present in the installed provider's schema. */
468
+ unknown: string[];
469
+ }
470
+
471
+ /**
472
+ * §4.15-next — cross-check every resource's written arguments against the
473
+ * INSTALLED provider's schema, so an argument that's invalid for the pinned
474
+ * provider major (a "correct" fix for the wrong version) is caught at validate
475
+ * time, not as a later `plan` failure. Degrades green: returns
476
+ * `{ checked: false }` when the schema is unavailable (terraform not installed /
477
+ * dir not init-ed). A resource type absent from the schema is skipped (can't
478
+ * judge), never flagged.
479
+ */
480
+ export function checkArgumentsAgainstSchema(cwd: string): {
481
+ checked: boolean;
482
+ unknown_arguments: UnknownArgument[];
483
+ } {
484
+ const schema = loadProvidersSchema(cwd);
485
+ if (!schema) return { checked: false, unknown_arguments: [] };
486
+ const out: UnknownArgument[] = [];
487
+ for (const f of walkTfFiles(cwd)) {
488
+ let text: string;
489
+ try {
490
+ text = readFileSync(join(cwd, f), "utf8");
491
+ } catch {
492
+ continue;
493
+ }
494
+ for (const block of parseResourceArguments(text)) {
495
+ const verdict = unknownArgsForResource(schema, block.resourceType, block.args);
496
+ if (!verdict.unknownResourceType && verdict.unknown.length > 0) {
497
+ out.push({
498
+ resource_type: block.resourceType,
499
+ name: block.name,
500
+ file: f,
501
+ unknown: verdict.unknown,
502
+ });
503
+ }
504
+ }
505
+ }
506
+ return { checked: true, unknown_arguments: out };
507
+ }
508
+
509
+ // --- tflint ---------------------------------------------------------------
510
+
511
+ function tflintSeverity(s: string | undefined): Severity {
512
+ switch ((s ?? "").toLowerCase()) {
513
+ case "error":
514
+ return "high";
515
+ case "warning":
516
+ return "medium";
517
+ default:
518
+ return "low";
519
+ }
520
+ }
521
+
522
+ // dirs we've already attempted `tflint --init` in, so repeated scans don't re-init.
523
+ const tflintInitedDirs = new Set<string>();
524
+
525
+ /**
526
+ * Install tflint's provider ruleset plugins via `tflint --init` when the dir has
527
+ * a `.tflint.hcl` declaring them. Core `tflint --recursive` runs only the
528
+ * built-in rules; the high-value provider rules (deprecated args, invalid
529
+ * instance types, missing-tag policies, etc.) live in the aws/azurerm/google
530
+ * plugins, which must be installed first. Opt-in by design — we only init when
531
+ * the repo ships a `.tflint.hcl`, so we don't force AWS rules onto an Azure/GCP
532
+ * repo. Best-effort and network-dependent: a failed init just leaves tflint
533
+ * running its core rules, exactly as before.
534
+ */
535
+ function ensureTflintInit(cwd: string): void {
536
+ if (tflintInitedDirs.has(cwd)) return;
537
+ // mark first: a failed init won't succeed on retry within the same run, and
538
+ // we don't want to re-attempt the network fetch on every scanner call.
539
+ tflintInitedDirs.add(cwd);
540
+ if (!existsSync(join(cwd, ".tflint.hcl"))) return;
541
+ const r = run("tflint", ["--init"], cwd);
542
+ if (r.status !== 0 && !r.missing) {
543
+ log.info(
544
+ "» tflint --init did not complete cleanly — provider ruleset plugins may be unavailable",
545
+ );
546
+ }
547
+ }
548
+
549
+ export function scanTflint(cwd: string): ScannerOutcome {
550
+ ensureTflintInit(cwd);
551
+ const r = run("tflint", ["--format", "json", "--recursive"], cwd);
552
+ if (r.missing) return skipped("tflint", "tflint not installed");
553
+ try {
554
+ return { source: "tflint", ran: true, concerns: parseTflintOutput(r.stdout, cwd) };
555
+ } catch {
556
+ return skipped("tflint", "could not parse tflint json output");
557
+ }
558
+ }
559
+
560
+ /** parse `tflint --format json` output into concerns. */
561
+ export function parseTflintOutput(stdout: string, cwd = ""): Concern[] {
562
+ const parsed = JSON.parse(stdout || "{}") as { issues?: TflintIssue[] };
563
+ return (parsed.issues ?? []).map<Concern>((issue) => {
564
+ const rule = issue.rule?.name ?? "issue";
565
+ const file = toRepoRelative(issue.range?.filename, cwd);
566
+ const line = issue.range?.start?.line ?? null;
567
+ return {
568
+ id: concernId("tflint", rule, file, line),
569
+ source: "tflint",
570
+ rule_id: `tflint:${rule}`,
571
+ severity: tflintSeverity(issue.rule?.severity),
572
+ category: "style",
573
+ evidence: issue.message ?? rule,
574
+ location: { file, line },
575
+ remediation_hint: issue.rule?.link ?? null,
576
+ };
577
+ });
578
+ }
579
+
580
+ interface TflintIssue {
581
+ rule?: { name?: string; severity?: string; link?: string };
582
+ message?: string;
583
+ range?: { filename?: string; start?: { line?: number } };
584
+ }
585
+
586
+ // --- trivy ----------------------------------------------------------------
587
+
588
+ // tfsec was archived by Aqua and folded into Trivy; `trivy config` is its
589
+ // maintained successor with a larger ruleset (the AVD-* checks). `--quiet`
590
+ // keeps Trivy's progress chatter off stdout so the JSON parses cleanly.
591
+ function scanTrivy(cwd: string): ScannerOutcome {
592
+ const r = run("trivy", ["config", "--format", "json", "--quiet", "."], cwd);
593
+ if (r.missing) return skipped("trivy", "trivy not installed");
594
+ try {
595
+ return { source: "trivy", ran: true, concerns: parseTrivyOutput(r.stdout, cwd) };
596
+ } catch {
597
+ return skipped("trivy", "could not parse trivy json output");
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Parse `trivy config --format json` output into concerns. Trivy nests
603
+ * misconfigurations under `Results[].Misconfigurations[]`, keyed to the result's
604
+ * `Target` file. `trivy config` reports only failures by default, but we
605
+ * defensively drop any `Status: "PASS"` entry so an `--include-non-failures`
606
+ * run can't leak passing checks into the concern set.
607
+ */
608
+ export function parseTrivyOutput(stdout: string, cwd = ""): Concern[] {
609
+ const parsed = JSON.parse(stdout || "{}") as { Results?: TrivyResult[] | null };
610
+ const concerns: Concern[] = [];
611
+ for (const result of parsed.Results ?? []) {
612
+ const file = toRepoRelative(result.Target, cwd);
613
+ for (const m of result.Misconfigurations ?? []) {
614
+ if (m.Status === "PASS") continue;
615
+ const rule = m.AVDID || m.ID || "issue";
616
+ const start = m.CauseMetadata?.StartLine;
617
+ const line = typeof start === "number" && start > 0 ? start : null;
618
+ concerns.push({
619
+ id: concernId("trivy", rule, file, line),
620
+ source: "trivy",
621
+ rule_id: `trivy:${rule}`,
622
+ severity: lowerSeverity(m.Severity),
623
+ category: "security",
624
+ evidence: m.Message || m.Description || m.Title || rule,
625
+ location: { file, line },
626
+ remediation_hint: m.Resolution || m.References?.[0] || null,
627
+ });
628
+ }
629
+ }
630
+ return concerns;
631
+ }
632
+
633
+ interface TrivyMisconfiguration {
634
+ ID?: string;
635
+ AVDID?: string;
636
+ Title?: string;
637
+ Description?: string;
638
+ Message?: string;
639
+ Resolution?: string;
640
+ Severity?: string;
641
+ References?: string[];
642
+ Status?: string;
643
+ CauseMetadata?: { StartLine?: number; EndLine?: number };
644
+ }
645
+
646
+ interface TrivyResult {
647
+ Target?: string;
648
+ Class?: string;
649
+ Type?: string;
650
+ Misconfigurations?: TrivyMisconfiguration[];
651
+ }
652
+
653
+ // --- checkov --------------------------------------------------------------
654
+
655
+ function scanCheckov(cwd: string): ScannerOutcome {
656
+ // `--framework terraform` keeps checkov to Terraform only. By default checkov
657
+ // also scans github_actions / dockerfile / secrets / kubernetes / etc., which
658
+ // surfaces concerns in files Terramend can never remediate (the path guardrail
659
+ // blocks anything outside *.tf/*.tfvars) — pure noise. Terramend is
660
+ // Terraform-only, so we scope the scanner to match.
661
+ const r = run(
662
+ "checkov",
663
+ ["-d", ".", "--framework", "terraform", "-o", "json", "--compact", "--quiet"],
664
+ cwd,
665
+ );
666
+ if (r.missing) return skipped("checkov", "checkov not installed");
667
+ try {
668
+ return { source: "checkov", ran: true, concerns: parseCheckovOutput(r.stdout, cwd) };
669
+ } catch {
670
+ return skipped("checkov", "could not parse checkov json output");
671
+ }
672
+ }
673
+
674
+ /** parse `checkov -o json` output (object for one framework, array for several). */
675
+ export function parseCheckovOutput(stdout: string, cwd = ""): Concern[] {
676
+ const parsed = JSON.parse(stdout || "{}") as CheckovOutput | CheckovOutput[];
677
+ const blocks = Array.isArray(parsed) ? parsed : [parsed];
678
+ const concerns: Concern[] = [];
679
+ for (const block of blocks) {
680
+ for (const check of block.results?.failed_checks ?? []) {
681
+ const file = toRepoRelative(check.file_path, cwd);
682
+ // checkov emits 0 for "no specific line"; normalize to null (matching the
683
+ // trivy parser and the reviewer's findings.json) so the content id is
684
+ // stable and a reviewer-loaded checkov concern re-verifies ✗→✓.
685
+ const startLine = check.file_line_range?.[0];
686
+ const line = typeof startLine === "number" && startLine > 0 ? startLine : null;
687
+ const rule = check.check_id ?? "issue";
688
+ concerns.push({
689
+ id: concernId("checkov", rule, file, line),
690
+ source: "checkov",
691
+ rule_id: `checkov:${rule}`,
692
+ severity: lowerSeverity(check.severity ?? undefined),
693
+ category: "security",
694
+ evidence: check.check_name ?? rule,
695
+ location: { file, line },
696
+ remediation_hint: check.guideline ?? null,
697
+ });
698
+ }
699
+ }
700
+ return concerns;
701
+ }
702
+
703
+ interface CheckovOutput {
704
+ results?: {
705
+ failed_checks?: {
706
+ check_id?: string;
707
+ check_name?: string;
708
+ severity?: string | null;
709
+ file_path?: string;
710
+ file_line_range?: number[];
711
+ guideline?: string;
712
+ }[];
713
+ };
714
+ }
715
+
716
+ /**
717
+ * Terraform files changed on the current branch vs the base. Returns null when
718
+ * the base can't be determined (caller then falls back to a full scan).
719
+ */
720
+ export function changedTerraformFiles(cwd: string): Set<string> | null {
721
+ const base = resolveBaseRef(cwd);
722
+ if (!base) return null;
723
+ const mergeBase = run("git", ["merge-base", base, "HEAD"], cwd);
724
+ const from = mergeBase.status === 0 && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : base;
725
+ const diff = run("git", ["diff", "--name-only", from, "HEAD"], cwd);
726
+ if (diff.status !== 0) return null;
727
+ // `git diff` reports paths relative to the repo ROOT, but a concern's
728
+ // `location.file` is relative to the scan `cwd` (toRepoRelative). When `cwd`
729
+ // is a repo SUBDIRECTORY (the `cwd` action input resolved under
730
+ // GITHUB_WORKSPACE) the two path spaces disagree — e.g. git says
731
+ // `infra/main.tf` while the concern says `main.tf` — and the in-scope check
732
+ // would silently drop every concern. Re-base the diff paths onto `cwd` by
733
+ // stripping the cwd→root prefix and discarding anything outside it, so both
734
+ // sides are cwd-relative.
735
+ const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
736
+ const prefix = prefixResult.status === 0 ? prefixResult.stdout.trim().replace(/\\/g, "/") : "";
737
+ const files: string[] = [];
738
+ for (const raw of diff.stdout.split("\n")) {
739
+ let f = raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
740
+ if (!f) continue;
741
+ if (prefix) {
742
+ if (!f.startsWith(prefix)) continue; // changed file lives outside the scanned subdir
743
+ f = f.slice(prefix.length);
744
+ }
745
+ if (f.endsWith(".tf") || f.endsWith(".tfvars")) files.push(f);
746
+ }
747
+ return new Set(files);
748
+ }
749
+
750
+ /** run every scanner once over `cwd`. shared by `terraform_scan` and the
751
+ * deterministic remediation verifier so both see the identical toolchain. */
752
+ export function runScanners(cwd: string): ScannerOutcome[] {
753
+ return [scanFmt(cwd), scanValidate(cwd), scanTflint(cwd), scanTrivy(cwd), scanCheckov(cwd)];
754
+ }
755
+
756
+ export interface RemediationVerdict {
757
+ /** true only when every original concern id is absent from the re-scan. */
758
+ verified: boolean;
759
+ /** original ids no longer present (the fix cleared them). */
760
+ resolved: string[];
761
+ /** original ids still present (the fix did NOT clear them). */
762
+ remaining: string[];
763
+ }
764
+
765
+ /**
766
+ * Deterministic ✗→✓ check: partition the group's original `concern_ids` into
767
+ * those gone from a fresh scan (`resolved`) and those still present
768
+ * (`remaining`). Concern ids are content hashes (`sha1(source|rule|file|line)`),
769
+ * so a missing id means that exact concern is gone — the correct primitive for
770
+ * "did the fix clear it", independent of severity/scope filtering. This is the
771
+ * code-level replacement for the agent eyeballing a re-scan and self-reporting.
772
+ */
773
+ export function computeRemediationVerdict(
774
+ originalConcernIds: string[],
775
+ currentConcernIds: Set<string>,
776
+ ): RemediationVerdict {
777
+ const resolved: string[] = [];
778
+ const remaining: string[] = [];
779
+ for (const id of originalConcernIds) {
780
+ if (currentConcernIds.has(id)) remaining.push(id);
781
+ else resolved.push(id);
782
+ }
783
+ return { verified: remaining.length === 0, resolved, remaining };
784
+ }
785
+
786
+ /**
787
+ * §1.4 Regression guard. The full re-scan (`terraform_verify_remediation`)
788
+ * already sees the whole workspace, so a concern the fix *introduced* shows up
789
+ * in the current scan. Regressions are exactly the content ids present after the
790
+ * fix that were not in the pre-fix baseline — `current − baseline`. A non-empty
791
+ * result means the fix traded one defect for another (e.g. an encryption block
792
+ * that trips a different tflint rule) and must downgrade the PR to needs-human.
793
+ *
794
+ * Both id sets are computed the same way (the deduped union of every scanner's
795
+ * concern ids, unfiltered by severity) so the diff is apples-to-apples — a
796
+ * regression at ANY severity is caught, not just ones above the run threshold.
797
+ * Returns sorted ids for a stable PR body.
798
+ */
799
+ export function computeRegressions(
800
+ baselineConcernIds: Iterable<string>,
801
+ currentConcernIds: Iterable<string>,
802
+ ): string[] {
803
+ const baseline = new Set(baselineConcernIds);
804
+ const regressions = new Set<string>();
805
+ for (const id of currentConcernIds) {
806
+ if (!baseline.has(id)) regressions.add(id);
807
+ }
808
+ return [...regressions].sort();
809
+ }