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,175 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ detectEnvironmentTwins,
7
+ discoverTerraformRoots,
8
+ isRootModuleHcl,
9
+ TerraformRootsTool,
10
+ } from "#app/mcp/roots";
11
+ import type { ToolContext } from "#app/mcp/server";
12
+
13
+ describe("isRootModuleHcl", () => {
14
+ it("detects a provider configuration block (root)", () => {
15
+ expect(isRootModuleHcl('provider "aws" {\n region = "eu-west-2"\n}')).toEqual({
16
+ hasBackend: false,
17
+ hasProviderConfig: true,
18
+ });
19
+ });
20
+
21
+ it("detects a backend block (root)", () => {
22
+ expect(isRootModuleHcl('terraform {\n backend "s3" {}\n}').hasBackend).toBe(true);
23
+ });
24
+
25
+ it("does NOT treat a child module's required_providers as a root", () => {
26
+ const moduleHcl = `terraform {
27
+ required_providers {
28
+ aws = { source = "hashicorp/aws", version = ">= 5.0" }
29
+ }
30
+ }`;
31
+ expect(isRootModuleHcl(moduleHcl)).toEqual({ hasBackend: false, hasProviderConfig: false });
32
+ });
33
+ });
34
+
35
+ describe("discoverTerraformRoots (multi-root, hepcare layout)", () => {
36
+ let root: string;
37
+
38
+ beforeAll(() => {
39
+ root = mkdtempSync(join(tmpdir(), "tf-roots-"));
40
+ // root #1: terraform/ with backend + provider config
41
+ mkdirSync(join(root, "terraform"), { recursive: true });
42
+ writeFileSync(
43
+ join(root, "terraform", "providers.tf"),
44
+ 'terraform {\n backend "s3" {}\n}\nprovider "aws" {\n region = "eu-west-2"\n}',
45
+ );
46
+ writeFileSync(join(root, "terraform", "main.tf"), 'resource "aws_s3_bucket" "b" {}');
47
+ // root #2: terraform/core/ with a provider config
48
+ mkdirSync(join(root, "terraform", "core"), { recursive: true });
49
+ writeFileSync(join(root, "terraform", "core", "providers.tf"), 'provider "aws" {}');
50
+ // child module: terraform/modules/x — only required_providers (NOT a root)
51
+ mkdirSync(join(root, "terraform", "modules", "x"), { recursive: true });
52
+ writeFileSync(
53
+ join(root, "terraform", "modules", "x", "versions.tf"),
54
+ 'terraform {\n required_providers {\n aws = { source = "hashicorp/aws" }\n }\n}',
55
+ );
56
+ });
57
+
58
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
59
+
60
+ it("finds the two roots and excludes the child module", () => {
61
+ const roots = discoverTerraformRoots(root);
62
+ expect(roots.map((r) => r.dir)).toEqual(["terraform", "terraform/core"]);
63
+ expect(roots[0]).toMatchObject({ hasBackend: true, hasProviderConfig: true });
64
+ expect(roots[1]).toMatchObject({ hasBackend: false, hasProviderConfig: true });
65
+ expect(roots.some((r) => r.dir.includes("modules"))).toBe(false);
66
+ });
67
+
68
+ it("returns empty when no provider/backend is configured anywhere", () => {
69
+ const empty = mkdtempSync(join(tmpdir(), "tf-noroot-"));
70
+ writeFileSync(join(empty, "main.tf"), 'resource "aws_s3_bucket" "b" {}');
71
+ expect(discoverTerraformRoots(empty)).toEqual([]);
72
+ rmSync(empty, { recursive: true, force: true });
73
+ });
74
+ });
75
+
76
+ describe("detectEnvironmentTwins (§22)", () => {
77
+ it("groups dev/staging/prod stacks that differ only by an env segment", () => {
78
+ const twins = detectEnvironmentTwins([
79
+ "environments/dev",
80
+ "environments/staging",
81
+ "environments/prod",
82
+ ]);
83
+ expect(twins).toHaveLength(1);
84
+ expect(twins[0]!.pattern).toBe("environments/{env}");
85
+ expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod", "staging"]);
86
+ });
87
+
88
+ it("matches the LAST env segment so nested paths key correctly", () => {
89
+ const twins = detectEnvironmentTwins(["infra/prod/network", "infra/dev/network"]);
90
+ expect(twins[0]!.pattern).toBe("infra/{env}/network");
91
+ expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod"]);
92
+ });
93
+
94
+ it("detects per-region twins", () => {
95
+ const twins = detectEnvironmentTwins(["stacks/eu-west-2", "stacks/eu-west-1"]);
96
+ expect(twins[0]!.pattern).toBe("stacks/{env}");
97
+ expect(twins[0]!.members.map((m) => m.environment)).toEqual(["eu-west-1", "eu-west-2"]);
98
+ });
99
+
100
+ it("matches a <env>.tfvars filename", () => {
101
+ const twins = detectEnvironmentTwins(["env/dev.tfvars", "env/prod.tfvars"]);
102
+ expect(twins[0]!.pattern).toBe("env/{env}.tfvars");
103
+ expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod"]);
104
+ });
105
+
106
+ it("does NOT group a single environment (needs ≥2 distinct)", () => {
107
+ expect(detectEnvironmentTwins(["environments/prod"])).toEqual([]);
108
+ // same env twice → still one distinct → not a twin set.
109
+ expect(detectEnvironmentTwins(["a/prod", "a/prod"])).toEqual([]);
110
+ });
111
+
112
+ it("ignores paths with no env/region segment", () => {
113
+ expect(detectEnvironmentTwins(["modules/vpc", "modules/s3"])).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe("TerraformRootsTool", () => {
118
+ async function runRootsTool(cwd: string): Promise<string> {
119
+ const tool = TerraformRootsTool({ payload: { cwd } } as unknown as ToolContext);
120
+ const exec = tool.execute as (
121
+ p: unknown,
122
+ c: unknown,
123
+ ) => Promise<{ content: [{ type: "text"; text: string }] }>;
124
+ const result = await exec({}, {});
125
+ return result.content[0].text;
126
+ }
127
+
128
+ let multiRoot: string;
129
+ let twinRoot: string;
130
+ let singleRoot: string;
131
+
132
+ beforeAll(() => {
133
+ multiRoot = mkdtempSync(join(tmpdir(), "tf-rootstool-"));
134
+ mkdirSync(join(multiRoot, "core"), { recursive: true });
135
+ writeFileSync(join(multiRoot, "main.tf"), 'provider "aws" {}');
136
+ writeFileSync(join(multiRoot, "core", "main.tf"), 'terraform {\n backend "s3" {}\n}');
137
+
138
+ twinRoot = mkdtempSync(join(tmpdir(), "tf-rootstwin-"));
139
+ for (const env of ["dev", "prod"]) {
140
+ mkdirSync(join(twinRoot, "stacks", env), { recursive: true });
141
+ writeFileSync(join(twinRoot, "stacks", env, "main.tf"), 'provider "aws" {}');
142
+ }
143
+
144
+ singleRoot = mkdtempSync(join(tmpdir(), "tf-rootsone-"));
145
+ writeFileSync(join(singleRoot, "main.tf"), 'provider "aws" {}');
146
+ });
147
+
148
+ afterAll(() => {
149
+ rmSync(multiRoot, { recursive: true, force: true });
150
+ rmSync(twinRoot, { recursive: true, force: true });
151
+ rmSync(singleRoot, { recursive: true, force: true });
152
+ });
153
+
154
+ it("reports multiple roots with the per-root flags and the multi-root note", async () => {
155
+ const text = await runRootsTool(multiRoot);
156
+ expect(text).toContain("root_count: 2");
157
+ expect(text).toContain("has_backend");
158
+ expect(text).toContain("has_provider_config");
159
+ expect(text).toContain("Multiple roots");
160
+ expect(text).toContain("environment_twins: []");
161
+ });
162
+
163
+ it("reports environment twins among parallel stacks", async () => {
164
+ const text = await runRootsTool(twinRoot);
165
+ expect(text).toContain("root_count: 2");
166
+ expect(text).toContain("stacks/{env}");
167
+ expect(text).toContain("Detected environment twins");
168
+ });
169
+
170
+ it("reports a single root with the single-root note", async () => {
171
+ const text = await runRootsTool(singleRoot);
172
+ expect(text).toContain("root_count: 1");
173
+ expect(text).toContain("Single root (or none detected)");
174
+ });
175
+ });
@@ -0,0 +1,217 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type } from "arktype";
4
+ import type { LocalToolContext } from "#app/mcp/localContext";
5
+ import { walkTfFiles } from "#app/mcp/modules";
6
+ import { execute, tool } from "#app/mcp/shared";
7
+ import { log } from "#app/utils/cli";
8
+
9
+ /**
10
+ * Multi-root awareness. A repo can hold SEVERAL Terraform root modules — the
11
+ * dirs you'd run `terraform init/plan/apply` in — not one. hepcare, for example,
12
+ * has `terraform/` AND `terraform/core/`, plus a child module under
13
+ * `terraform/modules/`. The scanners run recursively, but plan/validate assume a
14
+ * single `cwd`; this surfaces the roots so a run can act per-root.
15
+ *
16
+ * Heuristic (validated against real repos): a ROOT is a dir whose `*.tf`
17
+ * declares a PROVIDER CONFIGURATION (`provider "<name>" { … }`) or a BACKEND
18
+ * (`backend "<type>" { … }`). A CHILD MODULE never configures a provider or a
19
+ * backend (it only declares `required_providers`), so this cleanly separates the
20
+ * two. Pure parsing + a single fs walk; no subprocess.
21
+ */
22
+
23
+ export interface TerraformRoot {
24
+ /** repo-relative dir (POSIX); "" for the top-level. */
25
+ dir: string;
26
+ hasBackend: boolean;
27
+ hasProviderConfig: boolean;
28
+ tfFileCount: number;
29
+ }
30
+
31
+ // a provider CONFIGURATION block — `provider "aws" {`. Distinct from a
32
+ // `required_providers` block (which child modules also have). The negative
33
+ // lookbehind avoids matching `required_providers`.
34
+ const PROVIDER_CONFIG = /(?:^|\n)\s*provider\s+"[^"]+"\s*\{/;
35
+ const BACKEND_BLOCK = /(?:^|\n)\s*backend\s+"[^"]+"\s*\{/;
36
+
37
+ /** detect whether some concatenated HCL marks a root module. */
38
+ export function isRootModuleHcl(hcl: string): { hasBackend: boolean; hasProviderConfig: boolean } {
39
+ return {
40
+ hasBackend: BACKEND_BLOCK.test(hcl),
41
+ hasProviderConfig: PROVIDER_CONFIG.test(hcl),
42
+ };
43
+ }
44
+
45
+ /** the directory of a repo-relative file path ("" for a top-level file). */
46
+ function dirOf(file: string): string {
47
+ const i = file.lastIndexOf("/");
48
+ return i === -1 ? "" : file.slice(0, i);
49
+ }
50
+
51
+ /**
52
+ * Discover the Terraform root modules under `cwd`. Walks `*.tf` recursively
53
+ * (skipping cache/VCS dirs via walkTfFiles), groups by directory, and keeps the
54
+ * dirs that configure a provider or a backend. Sorted by dir. An empty result
55
+ * means no obvious root was found — the caller falls back to scanning `cwd`
56
+ * itself as a single root.
57
+ */
58
+ export function discoverTerraformRoots(cwd: string): TerraformRoot[] {
59
+ const byDir = new Map<string, { files: string[]; hcl: string }>();
60
+ for (const f of walkTfFiles(cwd)) {
61
+ const dir = dirOf(f);
62
+ const entry = byDir.get(dir) ?? { files: [], hcl: "" };
63
+ entry.files.push(f);
64
+ try {
65
+ entry.hcl += `${readFileSync(join(cwd, f), "utf8")}\n`;
66
+ } catch {
67
+ /* skip unreadable */
68
+ }
69
+ byDir.set(dir, entry);
70
+ }
71
+ const roots: TerraformRoot[] = [];
72
+ for (const [dir, entry] of byDir) {
73
+ const { hasBackend, hasProviderConfig } = isRootModuleHcl(entry.hcl);
74
+ if (hasBackend || hasProviderConfig) {
75
+ roots.push({ dir, hasBackend, hasProviderConfig, tfFileCount: entry.files.length });
76
+ }
77
+ }
78
+ return roots.sort((a, b) => a.dir.localeCompare(b.dir));
79
+ }
80
+
81
+ // --- §22 environment / region fan-out --------------------------------------
82
+
83
+ // tokens that mark an environment twin when they appear as a whole path segment
84
+ // or a `<token>.tfvars` filename. Lowercased; matched exactly against a segment.
85
+ const ENV_TOKENS = new Set([
86
+ "dev",
87
+ "develop",
88
+ "development",
89
+ "stg",
90
+ "stage",
91
+ "staging",
92
+ "prod",
93
+ "prd",
94
+ "production",
95
+ "test",
96
+ "testing",
97
+ "qa",
98
+ "uat",
99
+ "sandbox",
100
+ "sbx",
101
+ "preprod",
102
+ "pre-prod",
103
+ "preproduction",
104
+ "demo",
105
+ "nonprod",
106
+ "non-prod",
107
+ ]);
108
+
109
+ // AWS-style region segment, e.g. `eu-west-2`, `eu-west-1` — the other common
110
+ // fan-out axis (the same stack replicated per region).
111
+ const REGION_RE = /^[a-z]{2}-[a-z]+-\d$/;
112
+
113
+ export interface EnvironmentTwinGroup {
114
+ /** the shared path shape with the env/region segment replaced by `{env}`. */
115
+ pattern: string;
116
+ /** the matched twins: each a dir + the environment/region token it carries. */
117
+ members: { dir: string; environment: string }[];
118
+ }
119
+
120
+ /**
121
+ * Detect environment/region TWINS among a set of repo-relative paths (root dirs
122
+ * and/or `*.tfvars` files): parallel stacks that differ only by an environment
123
+ * (`dev`/`staging`/`prod`/…) or region (`eu-west-2`) segment. A fix applied to
124
+ * one should usually be offered for its twins too (§22 — backport / fan-out).
125
+ *
126
+ * For each path, finds the LAST segment that is an env token or a region (a
127
+ * `<env>.tfvars` file counts via its basename), replaces it with `{env}` to form
128
+ * a pattern, and groups by that pattern. Only groups with ≥2 DISTINCT
129
+ * environments are returned (a single match isn't a twin set). Pure +
130
+ * deterministic (sorted).
131
+ */
132
+ export function detectEnvironmentTwins(paths: string[]): EnvironmentTwinGroup[] {
133
+ const byPattern = new Map<string, Map<string, string>>(); // pattern → env → dir
134
+ for (const raw of paths) {
135
+ const path = raw.replace(/\\/g, "/").replace(/^\.\//, "");
136
+ const segments = path.split("/").filter(Boolean);
137
+ // a `<env>.tfvars` filename: treat the basename (sans .tfvars) as a segment.
138
+ const last = segments[segments.length - 1] ?? "";
139
+ if (last.endsWith(".tfvars")) {
140
+ segments[segments.length - 1] = last.slice(0, -".tfvars".length);
141
+ }
142
+ // find the LAST env/region segment (so `infra/prod/network` keys on `prod`).
143
+ let idx = -1;
144
+ let token = "";
145
+ for (let i = segments.length - 1; i >= 0; i--) {
146
+ const seg = segments[i]!.toLowerCase();
147
+ if (ENV_TOKENS.has(seg) || REGION_RE.test(seg)) {
148
+ idx = i;
149
+ token = seg;
150
+ break;
151
+ }
152
+ }
153
+ if (idx === -1) continue;
154
+ const patternSegs = [...segments];
155
+ patternSegs[idx] = "{env}";
156
+ const pattern = patternSegs.join("/") + (last.endsWith(".tfvars") ? ".tfvars" : "");
157
+ const map = byPattern.get(pattern) ?? new Map<string, string>();
158
+ if (!map.has(token)) map.set(token, path);
159
+ byPattern.set(pattern, map);
160
+ }
161
+ const groups: EnvironmentTwinGroup[] = [];
162
+ for (const [pattern, envMap] of byPattern) {
163
+ if (envMap.size < 2) continue;
164
+ const members = [...envMap.entries()]
165
+ .map(([environment, dir]) => ({ dir, environment }))
166
+ .sort((a, b) => a.environment.localeCompare(b.environment));
167
+ groups.push({ pattern, members });
168
+ }
169
+ return groups.sort((a, b) => a.pattern.localeCompare(b.pattern));
170
+ }
171
+
172
+ export const TerraformRootsParams = type({});
173
+
174
+ export function TerraformRootsTool(ctx: LocalToolContext) {
175
+ return tool({
176
+ name: "terraform_roots",
177
+ description:
178
+ "Discover the Terraform ROOT modules in the repo — the dirs you'd run `terraform init/plan/apply` in " +
179
+ "(they configure a `provider` or a `backend`), as opposed to child modules under `modules/`. A repo " +
180
+ "can have several (e.g. `terraform/` and `terraform/core/`). `terraform_scan` already scans the whole " +
181
+ "tree, but `terraform_plan`/`terraform_validate` act on one dir — run them once PER ROOT (set the " +
182
+ "`cwd` accordingly) when there is more than one, so each root's real-world effect is checked. Returns " +
183
+ "an empty list when no root is detected (then treat the scan `cwd` as the single root).",
184
+ parameters: TerraformRootsParams,
185
+ execute: execute(async () => {
186
+ const cwd = ctx.payload.cwd ?? process.cwd();
187
+ const roots = discoverTerraformRoots(cwd);
188
+ // §22 — parallel per-environment/region stacks among the roots; a fix to
189
+ // one twin should usually be offered for the others.
190
+ const twins = detectEnvironmentTwins(roots.map((r) => r.dir).filter(Boolean));
191
+ log.info(
192
+ `» terraform_roots: ${roots.length} root(s) [${roots.map((r) => r.dir || ".").join(", ")}]` +
193
+ (twins.length ? `, ${twins.length} env-twin group(s)` : ""),
194
+ );
195
+ return {
196
+ ok: true,
197
+ root_count: roots.length,
198
+ roots: roots.map((r) => ({
199
+ dir: r.dir || ".",
200
+ has_backend: r.hasBackend,
201
+ has_provider_config: r.hasProviderConfig,
202
+ tf_file_count: r.tfFileCount,
203
+ })),
204
+ // §22 — environment/region twin groups (≥2 stacks differing only by an
205
+ // env/region segment). Empty when there are none.
206
+ environment_twins: twins,
207
+ note:
208
+ roots.length > 1
209
+ ? "Multiple roots — terraform_plan/terraform_validate run per root automatically. " +
210
+ (twins.length
211
+ ? "Detected environment twins: a fix to one stack should usually be offered for its twins (§22)."
212
+ : "")
213
+ : "Single root (or none detected) — the scan cwd is the root.",
214
+ };
215
+ }),
216
+ });
217
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assertTargetInScope, isTargetInScope, recordCreatedTarget } from "#app/mcp/scope";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+
5
+ function makeCtx(opts: { issueNumber?: number; created?: number[] } = {}): ToolContext {
6
+ const event =
7
+ opts.issueNumber === undefined
8
+ ? { trigger: "unknown" }
9
+ : { trigger: "pull_request_opened", issue_number: opts.issueNumber, is_pr: true };
10
+ return {
11
+ payload: { event },
12
+ toolState: { createdTargets: new Set<number>(opts.created ?? []) },
13
+ } as unknown as ToolContext;
14
+ }
15
+
16
+ describe("scope guard", () => {
17
+ it("allows any target on standalone runs (no triggering issue/PR)", () => {
18
+ const ctx = makeCtx({});
19
+ expect(isTargetInScope(ctx, 999)).toBe(true);
20
+ expect(() => assertTargetInScope(ctx, 999, "comment on")).not.toThrow();
21
+ });
22
+
23
+ it("allows the run's triggering issue/PR", () => {
24
+ expect(isTargetInScope(makeCtx({ issueNumber: 5 }), 5)).toBe(true);
25
+ });
26
+
27
+ it("blocks a different issue/PR in the same repo", () => {
28
+ const ctx = makeCtx({ issueNumber: 5 });
29
+ expect(isTargetInScope(ctx, 6)).toBe(false);
30
+ expect(() => assertTargetInScope(ctx, 6, "comment on")).toThrow(
31
+ /scoped to #5; refusing to comment on #6/,
32
+ );
33
+ });
34
+
35
+ it("allows a PR/issue the run created", () => {
36
+ expect(isTargetInScope(makeCtx({ issueNumber: 5, created: [50] }), 50)).toBe(true);
37
+ });
38
+
39
+ it("recordCreatedTarget widens scope to the new target", () => {
40
+ const ctx = makeCtx({ issueNumber: 5 });
41
+ expect(isTargetInScope(ctx, 77)).toBe(false);
42
+ recordCreatedTarget(ctx, 77);
43
+ expect(isTargetInScope(ctx, 77)).toBe(true);
44
+ });
45
+
46
+ it("recordCreatedTarget initializes the set when absent", () => {
47
+ const ctx = {
48
+ payload: { event: { trigger: "issues_opened", issue_number: 1 } },
49
+ toolState: {},
50
+ } as unknown as ToolContext;
51
+ recordCreatedTarget(ctx, 42);
52
+ expect(isTargetInScope(ctx, 42)).toBe(true);
53
+ });
54
+
55
+ it("degrades to a no-op when payload/event is missing (defensive)", () => {
56
+ const ctx = { toolState: {} } as unknown as ToolContext;
57
+ expect(isTargetInScope(ctx, 123)).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,65 @@
1
+ import type { ToolContext } from "#app/mcp/server";
2
+
3
+ /**
4
+ * Bind the GitHub REST write tools to the issue/PR the run is scoped to.
5
+ *
6
+ * Terramend's threat model treats the agent as semi-trusted: attacker-controlled
7
+ * PR/issue content can prompt-inject it. The git tools already refuse to act on a
8
+ * PR the run was not triggered against (the cross-PR clobber guard in
9
+ * `mcp/git.ts`). These helpers extend the same scoping to the REST write tools
10
+ * (comment, review, labels, PR-body update, review reply, thread resolve) so an
11
+ * injected agent cannot comment on / approve / label an UNRELATED issue or PR in
12
+ * the same repo. The installation token already prevents cross-REPO writes; this
13
+ * closes the remaining cross-issue/PR-within-the-repo gap.
14
+ *
15
+ * In scope = the triggering issue/PR (`event.issue_number`) OR a PR/issue THIS
16
+ * run created (`create_pull_request` / `create_issue`, recorded via
17
+ * `recordCreatedTarget`). A merely checked-out PR is NOT in scope: `checkout_pr`
18
+ * is agent-controlled, so letting it widen write scope would defeat the guard.
19
+ *
20
+ * Standalone runs (workflow_dispatch / CLI: `event.trigger === "unknown"`) carry
21
+ * no triggering issue/PR and therefore no injection surface — there is nothing to
22
+ * bind to, so the guard is a no-op and the operator-supplied target is honored.
23
+ */
24
+
25
+ /** the PR/issue this run was triggered against, or undefined for standalone runs. */
26
+ function scopedTarget(ctx: ToolContext): number | undefined {
27
+ // optional chain: production always sets `payload.event` (resolvePayload
28
+ // defaults event to {trigger:"unknown"}), but degrade to "no scope" if it's
29
+ // somehow absent rather than throwing from a guard.
30
+ return ctx.payload?.event?.issue_number;
31
+ }
32
+
33
+ /**
34
+ * Record a PR/issue this run created so later body edits / comments / reviews on
35
+ * it pass {@link assertTargetInScope}. Call after a successful
36
+ * `create_pull_request` / `create_issue`.
37
+ */
38
+ export function recordCreatedTarget(ctx: ToolContext, target: number): void {
39
+ ctx.toolState.createdTargets ??= new Set();
40
+ ctx.toolState.createdTargets.add(target);
41
+ }
42
+
43
+ /** true when `target` is the run's triggering issue/PR or one it created. */
44
+ export function isTargetInScope(ctx: ToolContext, target: number): boolean {
45
+ const scoped = scopedTarget(ctx);
46
+ // standalone run — no triggering issue/PR to bind to (no injection surface).
47
+ if (scoped === undefined) return true;
48
+ if (target === scoped) return true;
49
+ return ctx.toolState.createdTargets?.has(target) ?? false;
50
+ }
51
+
52
+ /**
53
+ * Throw if `target` is neither the run's triggering issue/PR nor one it created.
54
+ * `action` is a short verb phrase used in the error (e.g. "comment on",
55
+ * "submit a review on", "add labels to").
56
+ */
57
+ export function assertTargetInScope(ctx: ToolContext, target: number, action: string): void {
58
+ if (isTargetInScope(ctx, target)) return;
59
+ const scoped = scopedTarget(ctx);
60
+ throw new Error(
61
+ `blocked: this run is scoped to #${scoped}; refusing to ${action} #${target}. ` +
62
+ `terramend only writes to the issue/PR that triggered the run (or one it opened during the run). ` +
63
+ `if acting on #${target} is intended, trigger a run against #${target}.`,
64
+ );
65
+ }