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,338 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Unwrap the ToolResult envelope so tests assert on the raw object a tool
7
+ // returns instead of decoding the encoded MCP text content.
8
+ vi.mock("#app/mcp/shared", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("#app/mcp/shared")>();
10
+ return {
11
+ ...actual,
12
+ execute: <T, R>(fn: (params: T) => Promise<R>): ((params: T) => Promise<R>) => fn,
13
+ };
14
+ });
15
+
16
+ import type { LocalToolContext } from "#app/mcp/localContext";
17
+ import {
18
+ checkVersionCurrency,
19
+ classifyCurrency,
20
+ fetchModuleVersions,
21
+ fetchProviderVersions,
22
+ terraformConstraintToRange,
23
+ } from "#app/mcp/terraform/currency";
24
+ import { TerraformVersionCurrencyTool } from "#app/mcp/terraform/tools";
25
+
26
+ const tempDirs: string[] = [];
27
+
28
+ function makeDir(files: Record<string, string> = {}): string {
29
+ const dir = mkdtempSync(join(tmpdir(), "terramend-currency-"));
30
+ tempDirs.push(dir);
31
+ for (const [rel, content] of Object.entries(files)) {
32
+ const abs = join(dir, rel);
33
+ mkdirSync(dirname(abs), { recursive: true });
34
+ writeFileSync(abs, content);
35
+ }
36
+ return dir;
37
+ }
38
+
39
+ function makeCtx(cwd: string): LocalToolContext {
40
+ return {
41
+ payload: { cwd },
42
+ toolState: {},
43
+ tmpdir: makeDir(),
44
+ } as unknown as LocalToolContext;
45
+ }
46
+
47
+ /** stub global fetch with a per-URL dispatcher returning JSON bodies. */
48
+ function stubFetch(dispatch: (url: string) => { status: number; body?: unknown } | "reject") {
49
+ vi.stubGlobal(
50
+ "fetch",
51
+ vi.fn(async (input: string | URL) => {
52
+ const result = dispatch(String(input));
53
+ if (result === "reject") throw new Error("network down");
54
+ return {
55
+ ok: result.status >= 200 && result.status < 300,
56
+ status: result.status,
57
+ json: async () => result.body,
58
+ } as Response;
59
+ }),
60
+ );
61
+ }
62
+
63
+ function providerBody(versions: string[]): unknown {
64
+ return { versions: versions.map((v) => ({ version: v })) };
65
+ }
66
+
67
+ function moduleBody(versions: string[]): unknown {
68
+ return { modules: [{ versions: versions.map((v) => ({ version: v })) }] };
69
+ }
70
+
71
+ afterEach(() => {
72
+ vi.unstubAllGlobals();
73
+ for (const dir of tempDirs.splice(0)) {
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ describe("terraformConstraintToRange", () => {
79
+ it.each([
80
+ ["~> 5.0", ">=5.0.0 <6.0.0"],
81
+ ["~> 5.1.2", ">=5.1.2 <5.2.0"],
82
+ ["~> 5", ">=5.0.0 <6.0.0"],
83
+ [">= 1.2, < 2.0", ">=1.2.0 <2.0.0"],
84
+ ["= 5.1.0", "5.1.0"],
85
+ ["5.1.0", "5.1.0"],
86
+ ["v1.2", "1.2.0"],
87
+ ["<= 3", "<=3.0.0"],
88
+ ])("converts %s → %s", (constraint, expected) => {
89
+ expect(terraformConstraintToRange(constraint)).toBe(expected);
90
+ });
91
+
92
+ it("skips != comparators but keeps the rest", () => {
93
+ expect(terraformConstraintToRange(">= 1.0, != 1.5")).toBe(">=1.0.0");
94
+ });
95
+
96
+ it("returns null for garbage or empty input", () => {
97
+ expect(terraformConstraintToRange("latest")).toBeNull();
98
+ expect(terraformConstraintToRange("")).toBeNull();
99
+ expect(terraformConstraintToRange("~> banana")).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe("classifyCurrency", () => {
104
+ const available = ["4.9.0", "5.0.0", "5.31.0", "6.2.1", "7.0.0-beta1"];
105
+
106
+ it("flags a pessimistic pin a major behind (prereleases ignored)", () => {
107
+ const verdict = classifyCurrency({ constraint: "~> 5.0", available });
108
+ expect(verdict).toEqual({
109
+ latest: "6.2.1",
110
+ newestSatisfying: "5.31.0",
111
+ outdated: true,
112
+ majorsBehind: 1,
113
+ });
114
+ });
115
+
116
+ it("reports current when the constraint admits the latest", () => {
117
+ const verdict = classifyCurrency({ constraint: ">= 5.0", available });
118
+ expect(verdict).toEqual({
119
+ latest: "6.2.1",
120
+ newestSatisfying: "6.2.1",
121
+ outdated: false,
122
+ majorsBehind: 0,
123
+ });
124
+ });
125
+
126
+ it("is never outdated without a constraint (that's `unpinned`, not outdated)", () => {
127
+ const verdict = classifyCurrency({ constraint: null, available });
128
+ expect(verdict).toEqual({
129
+ latest: "6.2.1",
130
+ newestSatisfying: null,
131
+ outdated: false,
132
+ majorsBehind: 0,
133
+ });
134
+ });
135
+
136
+ it("degrades green when the registry publishes nothing stable", () => {
137
+ const verdict = classifyCurrency({ constraint: "~> 1.0", available: ["2.0.0-rc1"] });
138
+ expect(verdict).toEqual({
139
+ latest: null,
140
+ newestSatisfying: null,
141
+ outdated: false,
142
+ majorsBehind: 0,
143
+ });
144
+ });
145
+
146
+ it("treats an exact old pin as outdated", () => {
147
+ const verdict = classifyCurrency({ constraint: "4.9.0", available });
148
+ expect(verdict.outdated).toBe(true);
149
+ expect(verdict.majorsBehind).toBe(2);
150
+ });
151
+ });
152
+
153
+ describe("registry fetchers", () => {
154
+ it("fetches provider versions from the v1 providers endpoint", async () => {
155
+ stubFetch((url) => {
156
+ expect(url).toBe("https://registry.terraform.io/v1/providers/hashicorp/aws/versions");
157
+ return { status: 200, body: providerBody(["5.0.0", "5.1.0"]) };
158
+ });
159
+ await expect(fetchProviderVersions("hashicorp/aws")).resolves.toEqual({
160
+ status: "ok",
161
+ versions: ["5.0.0", "5.1.0"],
162
+ });
163
+ });
164
+
165
+ it("strips the default registry host and rejects other hosts", async () => {
166
+ stubFetch((url) => {
167
+ expect(url).toContain("/v1/providers/hashicorp/aws/versions");
168
+ return { status: 200, body: providerBody(["5.0.0"]) };
169
+ });
170
+ await expect(
171
+ fetchProviderVersions("registry.terraform.io/hashicorp/aws"),
172
+ ).resolves.toMatchObject({ status: "ok" });
173
+ await expect(fetchProviderVersions("tfe.example.com/org/aws")).resolves.toEqual({
174
+ status: "unsupported_source",
175
+ versions: [],
176
+ });
177
+ });
178
+
179
+ it("reads the first module record from the modules endpoint", async () => {
180
+ stubFetch((url) => {
181
+ expect(url).toBe(
182
+ "https://registry.terraform.io/v1/modules/terraform-aws-modules/vpc/aws/versions",
183
+ );
184
+ return { status: 200, body: moduleBody(["5.0.0", "5.8.1"]) };
185
+ });
186
+ await expect(fetchModuleVersions("terraform-aws-modules/vpc/aws")).resolves.toEqual({
187
+ status: "ok",
188
+ versions: ["5.0.0", "5.8.1"],
189
+ });
190
+ });
191
+
192
+ it("maps 404 / network failure / bad body to per-lookup statuses", async () => {
193
+ stubFetch(() => ({ status: 404 }));
194
+ await expect(fetchProviderVersions("hashicorp/gone")).resolves.toMatchObject({
195
+ status: "not_found",
196
+ });
197
+ stubFetch(() => "reject");
198
+ await expect(fetchProviderVersions("hashicorp/aws")).resolves.toMatchObject({
199
+ status: "error",
200
+ });
201
+ stubFetch(() => ({ status: 200, body: { unexpected: true } }));
202
+ await expect(fetchProviderVersions("hashicorp/aws")).resolves.toMatchObject({
203
+ status: "error",
204
+ });
205
+ });
206
+ });
207
+
208
+ const tf = `
209
+ terraform {
210
+ required_providers {
211
+ aws = {
212
+ source = "hashicorp/aws"
213
+ version = "~> 4.0"
214
+ }
215
+ }
216
+ }
217
+
218
+ module "vpc" {
219
+ source = "terraform-aws-modules/vpc/aws"
220
+ version = "3.0.0"
221
+ }
222
+
223
+ module "unpinned" {
224
+ source = "terraform-aws-modules/s3-bucket/aws"
225
+ }
226
+
227
+ module "local_helper" {
228
+ source = "./modules/helper"
229
+ }
230
+ `;
231
+
232
+ describe("checkVersionCurrency", () => {
233
+ it("reports outdated providers + modules, unpinned modules, and skips local modules", async () => {
234
+ const cwd = makeDir({ "main.tf": tf, "modules/helper/main.tf": "" });
235
+ stubFetch((url) => {
236
+ if (url.includes("/v1/providers/hashicorp/aws/"))
237
+ return { status: 200, body: providerBody(["4.67.0", "5.31.0"]) };
238
+ if (url.includes("/v1/modules/terraform-aws-modules/vpc/aws/"))
239
+ return { status: 200, body: moduleBody(["3.0.0", "5.8.1"]) };
240
+ if (url.includes("/v1/modules/terraform-aws-modules/s3-bucket/aws/"))
241
+ return { status: 200, body: moduleBody(["4.1.2"]) };
242
+ throw new Error(`unexpected url: ${url}`);
243
+ });
244
+
245
+ const report = await checkVersionCurrency(cwd);
246
+
247
+ expect(report.providers).toEqual([
248
+ {
249
+ name: "aws",
250
+ source: "hashicorp/aws",
251
+ constraint: "~> 4.0",
252
+ latest: "5.31.0",
253
+ newest_satisfying: "4.67.0",
254
+ outdated: true,
255
+ majors_behind: 1,
256
+ lookup: "ok",
257
+ },
258
+ ]);
259
+ // local module dropped; registry modules classified.
260
+ expect(report.modules).toHaveLength(2);
261
+ expect(report.modules[0]).toMatchObject({
262
+ name: "vpc",
263
+ version: "3.0.0",
264
+ latest: "5.8.1",
265
+ outdated: true,
266
+ unpinned: false,
267
+ declared_in: "main.tf",
268
+ });
269
+ expect(report.modules[1]).toMatchObject({
270
+ name: "unpinned",
271
+ latest: "4.1.2",
272
+ outdated: false,
273
+ unpinned: true,
274
+ });
275
+ expect(report.outdated_count).toBe(2);
276
+ expect(report.unpinned_count).toBe(1);
277
+ expect(report.lookups_failed).toBe(0);
278
+ });
279
+
280
+ it("degrades one failed lookup to its row without hiding the rest", async () => {
281
+ const cwd = makeDir({ "main.tf": tf, "modules/helper/main.tf": "" });
282
+ stubFetch((url) => {
283
+ if (url.includes("/v1/providers/")) return "reject";
284
+ return { status: 200, body: moduleBody(["9.0.0"]) };
285
+ });
286
+
287
+ const report = await checkVersionCurrency(cwd);
288
+
289
+ expect(report.providers[0]).toMatchObject({ lookup: "error", outdated: false });
290
+ expect(report.modules.every((m) => m.lookup === "ok")).toBe(true);
291
+ expect(report.lookups_failed).toBe(1);
292
+ expect(report.lookups_attempted).toBe(3);
293
+ });
294
+ });
295
+
296
+ describe("TerraformVersionCurrencyTool", () => {
297
+ it("returns the ok envelope with the report", async () => {
298
+ const cwd = makeDir({ "main.tf": tf, "modules/helper/main.tf": "" });
299
+ stubFetch((url) =>
300
+ url.includes("/v1/providers/")
301
+ ? { status: 200, body: providerBody(["4.67.0", "5.31.0"]) }
302
+ : { status: 200, body: moduleBody(["5.8.1"]) },
303
+ );
304
+ const fn = TerraformVersionCurrencyTool(makeCtx(cwd)).execute as (
305
+ p: Record<string, unknown>,
306
+ ) => Promise<Record<string, unknown>>;
307
+
308
+ const result = await fn({});
309
+
310
+ expect(result).toMatchObject({ ok: true, outdated_count: 2, unpinned_count: 1 });
311
+ });
312
+
313
+ it("returns the registry_unreachable skip envelope when every lookup fails", async () => {
314
+ const cwd = makeDir({ "main.tf": tf, "modules/helper/main.tf": "" });
315
+ stubFetch(() => "reject");
316
+ const fn = TerraformVersionCurrencyTool(makeCtx(cwd)).execute as (
317
+ p: Record<string, unknown>,
318
+ ) => Promise<Record<string, unknown>>;
319
+
320
+ const result = await fn({});
321
+
322
+ expect(result).toMatchObject({ ok: false, code: "registry_unreachable" });
323
+ });
324
+
325
+ it("degrades green on a workspace with nothing to check", async () => {
326
+ const cwd = makeDir({ "main.tf": `resource "null_resource" "x" {}` });
327
+ stubFetch(() => {
328
+ throw new Error("no lookups expected");
329
+ });
330
+ const fn = TerraformVersionCurrencyTool(makeCtx(cwd)).execute as (
331
+ p: Record<string, unknown>,
332
+ ) => Promise<Record<string, unknown>>;
333
+
334
+ const result = await fn({});
335
+
336
+ expect(result).toMatchObject({ ok: true, outdated_count: 0, unpinned_count: 0 });
337
+ });
338
+ });
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Version currency (§P1 provider currency / M3 module upgrades).
3
+ *
4
+ * Answers one question the scanners can't: "is a NEWER version available?"
5
+ * tflint checks that versions are *pinned*; nothing we run checks that they're
6
+ * *current*. This module compares the workspace's pinned provider requirements
7
+ * and registry-module pins against the Terraform Registry's published versions.
8
+ *
9
+ * Deliberately NOT a scanner source: results are advisory intelligence the
10
+ * Remediate mode turns into `chore(deps)` upgrade PRs, not `Concern`s — the
11
+ * finding baseline stays scanner-owned (see docs/workplan/04-implementation-plan.md).
12
+ *
13
+ * Degrades green everywhere: a per-source lookup failure is reported on that
14
+ * row (`lookup: "error" | "not_found" | "unsupported_source"`), and only a
15
+ * fully-unreachable registry (every lookup failed) becomes a tool-level skip.
16
+ */
17
+
18
+ import semver from "semver";
19
+ import { collectModuleGraph, splitModuleSource } from "#app/mcp/modules";
20
+ import { collectProviderRequirements } from "#app/mcp/terraform/scanners";
21
+
22
+ export const DEFAULT_REGISTRY_BASE_URL = "https://registry.terraform.io";
23
+ const FETCH_TIMEOUT_MS = 5_000;
24
+
25
+ // --- constraint parsing (pure) ----------------------------------------------
26
+
27
+ /** one comparator of a Terraform constraint: `~> 5.0`, `>= 1.2`, `5.1.0`. */
28
+ const COMPARATOR_RE = /^(~>|>=|<=|!=|=|>|<)?\s*v?(\d+(?:\.\d+){0,2})$/;
29
+
30
+ /** pad a 1-3 part version to full semver: `5` → `5.0.0`, `5.1` → `5.1.0`. */
31
+ function padVersion(version: string): string {
32
+ const parts = version.split(".");
33
+ while (parts.length < 3) parts.push("0");
34
+ return parts.join(".");
35
+ }
36
+
37
+ /**
38
+ * Convert ONE Terraform version constraint string (comma-separated comparators,
39
+ * AND semantics) into an npm semver range, or null when any comparator is
40
+ * unparseable. `~>` is Terraform's pessimistic operator: the RIGHTMOST given
41
+ * component may float (`~> 5.0` → `>=5.0.0 <6.0.0`, `~> 5.1.2` → `>=5.1.2 <5.2.0`,
42
+ * `~> 5` → `>=5.0.0 <6.0.0`). `!=` comparators have no single-range npm
43
+ * equivalent and are skipped — acceptable here because the result only ranks
44
+ * candidate versions, it never selects what gets installed.
45
+ */
46
+ export function terraformConstraintToRange(constraint: string): string | null {
47
+ const parts = constraint
48
+ .split(",")
49
+ .map((p) => p.trim())
50
+ .filter(Boolean);
51
+ if (parts.length === 0) return null;
52
+
53
+ const ranges: string[] = [];
54
+ for (const part of parts) {
55
+ const m = part.match(COMPARATOR_RE);
56
+ if (!m) return null;
57
+ const op = m[1] ?? "=";
58
+ const raw = m[2];
59
+ if (raw === undefined) return null;
60
+ if (op === "!=") continue; // see docstring — skipped, never fatal
61
+ const full = padVersion(raw);
62
+ if (op === "~>") {
63
+ const given = raw.split(".").length;
64
+ // bump the component LEFT of the floating one: `~> 5.1.2` floats patch
65
+ // (< 5.2.0); `~> 5.0` and `~> 5` float minor/major-rightmost (< 6.0.0).
66
+ const upper =
67
+ given >= 3
68
+ ? `${semver.major(full)}.${semver.minor(full) + 1}.0`
69
+ : `${semver.major(full) + 1}.0.0`;
70
+ ranges.push(`>=${full} <${upper}`);
71
+ } else if (op === "=") {
72
+ ranges.push(full);
73
+ } else {
74
+ ranges.push(`${op}${full}`);
75
+ }
76
+ }
77
+ return ranges.length > 0 ? ranges.join(" ") : null;
78
+ }
79
+
80
+ // --- currency classification (pure) -----------------------------------------
81
+
82
+ export interface CurrencyVerdict {
83
+ /** newest stable version the registry publishes (null: nothing published). */
84
+ latest: string | null;
85
+ /** newest published version the written constraint admits (null: no
86
+ * constraint, unparseable constraint, or nothing satisfies). */
87
+ newestSatisfying: string | null;
88
+ /** true when the registry has a stable version the constraint does NOT admit
89
+ * — i.e. an upgrade PR is available. Always false without a constraint. */
90
+ outdated: boolean;
91
+ /** how many MAJORs the constraint's best version trails the latest by —
92
+ * >0 signals an interface-risk upgrade that must be `needs-human`. */
93
+ majorsBehind: number;
94
+ }
95
+
96
+ /** drop non-semver tokens and prereleases; registries publish plain versions
97
+ * but git refs (`v1.2.0-rc1`, branch names) also land here via module pins.
98
+ * Valid-but-prerelease versions must be dropped BEFORE any coercion — coercing
99
+ * `7.0.0-beta1` would strip the suffix and misreport a beta as stable. */
100
+ function stableVersions(available: string[]): string[] {
101
+ const out: string[] = [];
102
+ for (const raw of available) {
103
+ const exact = semver.valid(raw);
104
+ if (exact !== null) {
105
+ if (semver.prerelease(exact) === null) out.push(exact);
106
+ continue;
107
+ }
108
+ const coerced = semver.coerce(raw);
109
+ if (coerced !== null) out.push(coerced.version);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ export function classifyCurrency(params: {
115
+ constraint: string | null;
116
+ available: string[];
117
+ }): CurrencyVerdict {
118
+ const versions = stableVersions(params.available);
119
+ const latest = versions.length > 0 ? (versions.sort(semver.rcompare)[0] ?? null) : null;
120
+ if (latest === null) {
121
+ return { latest: null, newestSatisfying: null, outdated: false, majorsBehind: 0 };
122
+ }
123
+ if (params.constraint === null) {
124
+ return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
125
+ }
126
+ const range = terraformConstraintToRange(params.constraint);
127
+ if (range === null) {
128
+ return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
129
+ }
130
+ const newestSatisfying = semver.maxSatisfying(versions, range);
131
+ const outdated = newestSatisfying === null || semver.lt(newestSatisfying, latest);
132
+ const majorsBehind = newestSatisfying
133
+ ? Math.max(0, semver.major(latest) - semver.major(newestSatisfying))
134
+ : Math.max(0, semver.major(latest));
135
+ return { latest, newestSatisfying, outdated, majorsBehind };
136
+ }
137
+
138
+ // --- registry lookups (I/O) --------------------------------------------------
139
+
140
+ export type LookupStatus = "ok" | "not_found" | "error" | "unsupported_source";
141
+
142
+ /** strip a leading default-registry host; reject other hosts (private/TFE
143
+ * registries use different auth + paths — out of scope for the public check). */
144
+ function normalizeRegistryPath(base: string, expectedSegments: number): string | null {
145
+ const segments = base.split("/").filter(Boolean);
146
+ if (segments.length === expectedSegments + 1 && segments[0]?.includes(".")) {
147
+ if (segments[0] !== "registry.terraform.io") return null;
148
+ segments.shift();
149
+ }
150
+ return segments.length === expectedSegments ? segments.join("/") : null;
151
+ }
152
+
153
+ async function fetchVersionList(
154
+ url: string,
155
+ extract: (body: unknown) => string[] | null,
156
+ ): Promise<{ status: LookupStatus; versions: string[] }> {
157
+ let response: Response;
158
+ try {
159
+ response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
160
+ } catch {
161
+ return { status: "error", versions: [] };
162
+ }
163
+ if (response.status === 404) return { status: "not_found", versions: [] };
164
+ if (!response.ok) return { status: "error", versions: [] };
165
+ let body: unknown;
166
+ try {
167
+ body = await response.json();
168
+ } catch {
169
+ return { status: "error", versions: [] };
170
+ }
171
+ const versions = extract(body);
172
+ if (versions === null) return { status: "error", versions: [] };
173
+ return { status: "ok", versions };
174
+ }
175
+
176
+ function isRecord(value: unknown): value is Record<string, unknown> {
177
+ return typeof value === "object" && value !== null && !Array.isArray(value);
178
+ }
179
+
180
+ /** pull `[{version}, …]` out of a `{ …, versions: [...] }` payload. */
181
+ function extractVersionsArray(container: unknown): string[] | null {
182
+ if (!isRecord(container) || !Array.isArray(container.versions)) return null;
183
+ const out: string[] = [];
184
+ for (const entry of container.versions) {
185
+ if (isRecord(entry) && typeof entry.version === "string") out.push(entry.version);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** GET /v1/providers/{namespace}/{type}/versions — provider release list. */
191
+ export async function fetchProviderVersions(
192
+ source: string,
193
+ opts: { baseUrl?: string } = {},
194
+ ): Promise<{ status: LookupStatus; versions: string[] }> {
195
+ const path = normalizeRegistryPath(source, 2);
196
+ if (path === null) return { status: "unsupported_source", versions: [] };
197
+ const base = opts.baseUrl ?? DEFAULT_REGISTRY_BASE_URL;
198
+ return fetchVersionList(`${base}/v1/providers/${path}/versions`, extractVersionsArray);
199
+ }
200
+
201
+ /** GET /v1/modules/{namespace}/{name}/{provider}/versions — registry-module
202
+ * release list (the response nests per-module records; the first is the
203
+ * requested module, the rest are dependency records we don't want). */
204
+ export async function fetchModuleVersions(
205
+ sourceBase: string,
206
+ opts: { baseUrl?: string } = {},
207
+ ): Promise<{ status: LookupStatus; versions: string[] }> {
208
+ const path = normalizeRegistryPath(sourceBase, 3);
209
+ if (path === null) return { status: "unsupported_source", versions: [] };
210
+ const base = opts.baseUrl ?? DEFAULT_REGISTRY_BASE_URL;
211
+ return fetchVersionList(`${base}/v1/modules/${path}/versions`, (body) => {
212
+ if (!isRecord(body) || !Array.isArray(body.modules)) return null;
213
+ return extractVersionsArray(body.modules[0]);
214
+ });
215
+ }
216
+
217
+ // --- workspace report (orchestration) ----------------------------------------
218
+
219
+ export interface ProviderCurrencyRow {
220
+ name: string;
221
+ source: string;
222
+ constraint: string | null;
223
+ latest: string | null;
224
+ newest_satisfying: string | null;
225
+ outdated: boolean;
226
+ majors_behind: number;
227
+ lookup: LookupStatus;
228
+ }
229
+
230
+ export interface ModuleCurrencyRow {
231
+ name: string;
232
+ source: string;
233
+ version: string | null;
234
+ latest: string | null;
235
+ newest_satisfying: string | null;
236
+ outdated: boolean;
237
+ /** registry module with no `version` attribute — pin it (to `latest`). */
238
+ unpinned: boolean;
239
+ lookup: LookupStatus;
240
+ declared_in: string;
241
+ }
242
+
243
+ export interface CurrencyReport {
244
+ providers: ProviderCurrencyRow[];
245
+ modules: ModuleCurrencyRow[];
246
+ outdated_count: number;
247
+ unpinned_count: number;
248
+ lookups_attempted: number;
249
+ lookups_failed: number;
250
+ }
251
+
252
+ /** bare `aws` in a legacy required_providers block means `hashicorp/aws`. */
253
+ function providerSource(name: string, source: string | null): string {
254
+ return source ?? `hashicorp/${name}`;
255
+ }
256
+
257
+ export async function checkVersionCurrency(
258
+ cwd: string,
259
+ opts: { baseUrl?: string } = {},
260
+ ): Promise<CurrencyReport> {
261
+ const providerReqs = collectProviderRequirements(cwd);
262
+ const registryModules = collectModuleGraph(cwd).modules.filter((m) => m.kind === "registry");
263
+
264
+ // one lookup per distinct source — a workspace redeclaring `hashicorp/aws`
265
+ // in every root must not turn into N identical registry calls.
266
+ const providerLookups = new Map<string, Promise<{ status: LookupStatus; versions: string[] }>>();
267
+ for (const req of providerReqs) {
268
+ const source = providerSource(req.name, req.source);
269
+ if (!providerLookups.has(source)) {
270
+ providerLookups.set(source, fetchProviderVersions(source, opts));
271
+ }
272
+ }
273
+ const moduleLookups = new Map<string, Promise<{ status: LookupStatus; versions: string[] }>>();
274
+ for (const mod of registryModules) {
275
+ const base = splitModuleSource(mod.source).base;
276
+ if (!moduleLookups.has(base)) {
277
+ moduleLookups.set(base, fetchModuleVersions(base, opts));
278
+ }
279
+ }
280
+ // resolve every lookup up front so the row loops below read settled results
281
+ // (and so a missing map entry can degrade to an error row, not a crash).
282
+ const noLookup = { status: "error" as LookupStatus, versions: [] as string[] };
283
+ const providerResults = new Map(
284
+ await Promise.all([...providerLookups].map(async ([source, p]) => [source, await p] as const)),
285
+ );
286
+ const moduleResults = new Map(
287
+ await Promise.all([...moduleLookups].map(async ([base, p]) => [base, await p] as const)),
288
+ );
289
+
290
+ const providers: ProviderCurrencyRow[] = [];
291
+ for (const req of providerReqs) {
292
+ const source = providerSource(req.name, req.source);
293
+ const result = providerResults.get(source) ?? noLookup;
294
+ const verdict = classifyCurrency({ constraint: req.version, available: result.versions });
295
+ providers.push({
296
+ name: req.name,
297
+ source,
298
+ constraint: req.version,
299
+ latest: verdict.latest,
300
+ newest_satisfying: verdict.newestSatisfying,
301
+ outdated: result.status === "ok" && verdict.outdated,
302
+ majors_behind: result.status === "ok" ? verdict.majorsBehind : 0,
303
+ lookup: result.status,
304
+ });
305
+ }
306
+
307
+ const modules: ModuleCurrencyRow[] = [];
308
+ for (const mod of registryModules) {
309
+ const base = splitModuleSource(mod.source).base;
310
+ const result = moduleResults.get(base) ?? noLookup;
311
+ const verdict = classifyCurrency({ constraint: mod.version, available: result.versions });
312
+ modules.push({
313
+ name: mod.name,
314
+ source: mod.source,
315
+ version: mod.version,
316
+ latest: verdict.latest,
317
+ newest_satisfying: verdict.newestSatisfying,
318
+ outdated: result.status === "ok" && verdict.outdated,
319
+ unpinned: mod.version === null,
320
+ lookup: result.status,
321
+ declared_in: mod.declaredIn,
322
+ });
323
+ }
324
+
325
+ const failed = [...providers, ...modules].filter(
326
+ (row) => row.lookup === "error" || row.lookup === "not_found",
327
+ ).length;
328
+ return {
329
+ providers,
330
+ modules,
331
+ outdated_count: [...providers, ...modules].filter((r) => r.outdated).length,
332
+ unpinned_count: modules.filter((m) => m.unpinned).length,
333
+ lookups_attempted: providers.length + modules.length,
334
+ lookups_failed: failed,
335
+ };
336
+ }