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,313 @@
1
+ /**
2
+ * Modularization-as-remediation (M2, the hepcare pattern) — DETECTION.
3
+ *
4
+ * Finds clusters of raw resources that look like they should be a module call,
5
+ * and matches each cluster against the modules the repo already trusts: the
6
+ * operator's `module_catalogue` and the repo's own local ("house") modules.
7
+ * The agent turns a candidate into a refactor PR; `terraform_plan`'s
8
+ * `refactor_safe` gate (pure `moved {}` plan) is what makes that PR provably a
9
+ * no-op on live infrastructure.
10
+ *
11
+ * Everything here is pure parsing + set arithmetic over files already on disk —
12
+ * no subprocess, no network — mirroring the modules.ts design.
13
+ */
14
+
15
+ import { readdirSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { type } from "arktype";
18
+ import type { LocalToolContext } from "#app/mcp/localContext";
19
+ import {
20
+ collectModuleGraph,
21
+ collectModuleInterface,
22
+ isInLocalModule,
23
+ type ModuleCatalogueEntry,
24
+ type ModuleSourceKind,
25
+ parseModuleCatalogue,
26
+ walkTfFiles,
27
+ } from "#app/mcp/modules";
28
+ import { execute, tool, toolOk } from "#app/mcp/shared";
29
+ import { log } from "#app/utils/cli";
30
+
31
+ // --- resource parsing (pure) -------------------------------------------------
32
+
33
+ export interface ParsedResource {
34
+ type: string;
35
+ name: string;
36
+ }
37
+
38
+ /** parse every `resource "<type>" "<name>" {` header in some HCL. Headers are
39
+ * enough here — clustering and signature matching only need type + name. */
40
+ export function parseResourceBlocks(hcl: string): ParsedResource[] {
41
+ const out: ParsedResource[] = [];
42
+ const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
43
+ let m: RegExpExecArray | null;
44
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
45
+ while ((m = re.exec(hcl)) !== null) {
46
+ const resourceType = m[1];
47
+ const name = m[2];
48
+ if (resourceType !== undefined && name !== undefined) {
49
+ out.push({ type: resourceType, name });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // --- clustering (pure) ---------------------------------------------------------
56
+
57
+ /** a cluster below this size isn't worth a module refactor PR. */
58
+ const MIN_CLUSTER_SIZE = 3;
59
+
60
+ export interface ExtractionCluster {
61
+ /** the file the resources live in (clusters never span files — one file is
62
+ * the unit a reviewer can hold in their head, and the unit `moved {}` blocks
63
+ * keep reviewable). */
64
+ file: string;
65
+ /** the shared resource-NAME prefix that bound the cluster, or null for a
66
+ * whole-file cluster. */
67
+ name_prefix: string | null;
68
+ resources: ParsedResource[];
69
+ /** distinct resource types, sorted — the cluster's signature. */
70
+ resource_types: string[];
71
+ }
72
+
73
+ function distinctTypes(resources: ParsedResource[]): string[] {
74
+ return [...new Set(resources.map((r) => r.type))].sort();
75
+ }
76
+
77
+ /** the leading token of a resource name (`web_server` → `web`, `db-main` → `db`). */
78
+ function namePrefix(name: string): string {
79
+ const token = name.split(/[_-]/, 1)[0] ?? name;
80
+ return token.toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * Cluster one file's resources into extraction candidates:
85
+ * - groups sharing a name prefix with ≥ MIN_CLUSTER_SIZE members, else
86
+ * - the whole file when it holds ≥ MIN_CLUSTER_SIZE+1 resources of ≥2 types
87
+ * (a single-type pile is usually `count`/`for_each` material, not a module).
88
+ * Prefix clusters that would equal the whole-file cluster collapse into one.
89
+ */
90
+ export function clusterResources(file: string, resources: ParsedResource[]): ExtractionCluster[] {
91
+ if (resources.length < MIN_CLUSTER_SIZE) return [];
92
+
93
+ const byPrefix = new Map<string, ParsedResource[]>();
94
+ for (const r of resources) {
95
+ const prefix = namePrefix(r.name);
96
+ const list = byPrefix.get(prefix) ?? [];
97
+ list.push(r);
98
+ byPrefix.set(prefix, list);
99
+ }
100
+ const clusters: ExtractionCluster[] = [];
101
+ for (const [prefix, members] of [...byPrefix.entries()].sort(([a], [b]) => a.localeCompare(b))) {
102
+ if (members.length < MIN_CLUSTER_SIZE) continue;
103
+ clusters.push({
104
+ file,
105
+ name_prefix: members.length === resources.length ? null : prefix,
106
+ resources: members,
107
+ resource_types: distinctTypes(members),
108
+ });
109
+ }
110
+ // whole-file fallback: cohesive multi-type file none of whose prefixes qualified.
111
+ if (clusters.length === 0) {
112
+ const types = distinctTypes(resources);
113
+ if (resources.length > MIN_CLUSTER_SIZE && types.length >= 2) {
114
+ clusters.push({ file, name_prefix: null, resources, resource_types: types });
115
+ }
116
+ }
117
+ return clusters;
118
+ }
119
+
120
+ // --- candidate matching (pure) ------------------------------------------------
121
+
122
+ export interface CandidateModule {
123
+ name: string;
124
+ source: string;
125
+ version: string | null;
126
+ kind: ModuleSourceKind;
127
+ /** how the match was made: a house module's actual resource types, or a
128
+ * catalogue module's name matching the cluster's service keywords. */
129
+ match: "resource_signature" | "name_keyword";
130
+ /** fraction of the cluster's resource types the candidate covers (signature
131
+ * matches) or whose service keyword hits the candidate's name (keyword). */
132
+ overlap: number;
133
+ /** house modules only — what the call site must wire up. */
134
+ required_variables?: string[];
135
+ }
136
+
137
+ /** minimum signature/keyword overlap for a candidate to be worth reporting. */
138
+ const MIN_OVERLAP = 0.5;
139
+
140
+ /** provider prefixes carry no service meaning for keyword matching. */
141
+ const PROVIDER_PREFIXES = new Set(["aws", "azurerm", "google", "azuread", "kubernetes", "helm"]);
142
+
143
+ /** service keywords of a resource type: `aws_s3_bucket` → ["s3", "bucket"]. */
144
+ export function serviceKeywords(resourceType: string): string[] {
145
+ return resourceType
146
+ .split("_")
147
+ .filter((tok) => tok.length > 1 && !PROVIDER_PREFIXES.has(tok))
148
+ .map((tok) => tok.toLowerCase());
149
+ }
150
+
151
+ export interface HouseModuleSignature {
152
+ dir: string;
153
+ resourceTypes: string[];
154
+ requiredVariables: string[];
155
+ }
156
+
157
+ /** fraction of cluster types present in the module's own resource-type set. */
158
+ function signatureOverlap(clusterTypes: string[], moduleTypes: string[]): number {
159
+ if (clusterTypes.length === 0) return 0;
160
+ const moduleSet = new Set(moduleTypes);
161
+ const hit = clusterTypes.filter((t) => moduleSet.has(t)).length;
162
+ return hit / clusterTypes.length;
163
+ }
164
+
165
+ /** fraction of cluster types with ≥1 service keyword in the candidate's name/source. */
166
+ function keywordOverlap(clusterTypes: string[], haystack: string): number {
167
+ if (clusterTypes.length === 0) return 0;
168
+ const target = haystack.toLowerCase();
169
+ const hit = clusterTypes.filter((t) =>
170
+ serviceKeywords(t).some((kw) => target.includes(kw)),
171
+ ).length;
172
+ return hit / clusterTypes.length;
173
+ }
174
+
175
+ export function matchCluster(
176
+ cluster: ExtractionCluster,
177
+ houseModules: HouseModuleSignature[],
178
+ catalogue: ModuleCatalogueEntry[],
179
+ ): CandidateModule[] {
180
+ const out: CandidateModule[] = [];
181
+ for (const house of houseModules) {
182
+ const overlap = signatureOverlap(cluster.resource_types, house.resourceTypes);
183
+ if (overlap < MIN_OVERLAP) continue;
184
+ out.push({
185
+ name: house.dir.split("/").filter(Boolean).pop() ?? house.dir,
186
+ source: `./${house.dir}`,
187
+ version: null,
188
+ kind: "local",
189
+ match: "resource_signature",
190
+ overlap: Number(overlap.toFixed(2)),
191
+ required_variables: house.requiredVariables,
192
+ });
193
+ }
194
+ for (const entry of catalogue) {
195
+ // local catalogue entries are house modules — already covered by signature.
196
+ if (entry.kind === "local") continue;
197
+ const overlap = keywordOverlap(cluster.resource_types, `${entry.name} ${entry.source}`);
198
+ if (overlap < MIN_OVERLAP) continue;
199
+ out.push({
200
+ name: entry.name,
201
+ source: entry.source,
202
+ version: entry.version,
203
+ kind: entry.kind,
204
+ match: "name_keyword",
205
+ overlap: Number(overlap.toFixed(2)),
206
+ });
207
+ }
208
+ // strongest candidates first; signature beats keyword at equal overlap.
209
+ return out.sort((a, b) => {
210
+ if (b.overlap !== a.overlap) return b.overlap - a.overlap;
211
+ if (a.match !== b.match) return a.match === "resource_signature" ? -1 : 1;
212
+ return a.name.localeCompare(b.name);
213
+ });
214
+ }
215
+
216
+ // --- workspace scan (I/O composition) ------------------------------------------
217
+
218
+ export interface ExtractionCandidate {
219
+ cluster: ExtractionCluster;
220
+ candidates: CandidateModule[];
221
+ }
222
+
223
+ /** non-recursive resource-type signature of a local module dir. */
224
+ function houseModuleSignature(cwd: string, dir: string): HouseModuleSignature {
225
+ let text = "";
226
+ try {
227
+ for (const f of readdirSync(join(cwd, dir))) {
228
+ if (!f.endsWith(".tf")) continue;
229
+ try {
230
+ text += `${readFileSync(join(cwd, dir, f), "utf8")}\n`;
231
+ } catch {
232
+ /* skip unreadable file */
233
+ }
234
+ }
235
+ } catch {
236
+ /* missing dir → empty signature */
237
+ }
238
+ return {
239
+ dir,
240
+ resourceTypes: distinctTypes(parseResourceBlocks(text)),
241
+ requiredVariables: collectModuleInterface(cwd, dir)
242
+ .variables.filter((v) => v.required)
243
+ .map((v) => v.name),
244
+ };
245
+ }
246
+
247
+ const MAX_CANDIDATES = 20;
248
+
249
+ export function findExtractionCandidates(
250
+ cwd: string,
251
+ rawCatalogue: string | undefined,
252
+ ): ExtractionCandidate[] {
253
+ const graph = collectModuleGraph(cwd);
254
+ const houseModules = graph.localModuleDirs.map((d) => houseModuleSignature(cwd, d.dir));
255
+ const catalogue = parseModuleCatalogue(rawCatalogue);
256
+
257
+ const out: ExtractionCandidate[] = [];
258
+ for (const file of walkTfFiles(cwd)) {
259
+ // resources already inside a module dir ARE the module — never re-extract.
260
+ if (isInLocalModule(file, graph)) continue;
261
+ let text: string;
262
+ try {
263
+ text = readFileSync(join(cwd, file), "utf8");
264
+ } catch {
265
+ continue;
266
+ }
267
+ for (const cluster of clusterResources(file, parseResourceBlocks(text))) {
268
+ out.push({ cluster, candidates: matchCluster(cluster, houseModules, catalogue) });
269
+ if (out.length >= MAX_CANDIDATES) return out;
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+
275
+ // --- the tool -------------------------------------------------------------------
276
+
277
+ export const ModuleExtractionCandidatesParams = type({});
278
+
279
+ export function ModuleExtractionCandidatesTool(ctx: LocalToolContext) {
280
+ return tool({
281
+ name: "module_extraction_candidates",
282
+ description:
283
+ "Find clusters of RAW resources that should likely be a module call (M2 modularization-as-" +
284
+ "remediation). Deterministically clusters each root file's resources by shared name prefix " +
285
+ "(falling back to a cohesive whole file), then matches every cluster against the repo's house " +
286
+ "modules (by their REAL resource-type signature, with `required_variables` so you wire the actual " +
287
+ "interface) and the operator's `module_catalogue` (by service keyword). Files already inside a " +
288
+ "local module dir are never re-extracted. Refactor contract: ONE PR per cluster on branch " +
289
+ "`remediate/modularize-<group>`; replace the raw resources with the candidate module call; add a " +
290
+ "`moved {}` block per resource (old address → module address) so state is preserved; the PR may " +
291
+ "proceed ONLY when terraform_validate passes AND terraform_plan reports `refactor_safe: true` " +
292
+ "(a pure-move plan — zero add/change/destroy). A missing required variable is a PR question for " +
293
+ "the reviewer, never a guessed value.",
294
+ parameters: ModuleExtractionCandidatesParams,
295
+ execute: execute(async () => {
296
+ const cwd = ctx.payload.cwd ?? process.cwd();
297
+ const candidates = findExtractionCandidates(cwd, ctx.payload.moduleCatalogue);
298
+ const matched = candidates.filter((c) => c.candidates.length > 0);
299
+ log.info(
300
+ `» module_extraction_candidates: ${candidates.length} cluster(s), ${matched.length} with a module match`,
301
+ );
302
+ return toolOk({
303
+ cluster_count: candidates.length,
304
+ matched_count: matched.length,
305
+ candidates,
306
+ note:
307
+ candidates.length === 0
308
+ ? "no extraction-worthy resource clusters found (root files with ≥3 related raw resources)"
309
+ : "verify any refactor with terraform_validate + terraform_plan (refactor_safe must be true)",
310
+ });
311
+ }),
312
+ });
313
+ }
@@ -0,0 +1,269 @@
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 type { LocalToolContext } from "#app/mcp/localContext";
6
+ import {
7
+ analyzeModuleTests,
8
+ computeInterfaceDrift,
9
+ discoverModuleTestAssets,
10
+ parseExampleModuleVariables,
11
+ parseGoTestVariables,
12
+ parseNativeTestVariables,
13
+ TerraformModuleTestsTool,
14
+ topLevelAttributeNames,
15
+ } from "#app/mcp/moduleTests";
16
+
17
+ describe("topLevelAttributeNames", () => {
18
+ it("captures top-level attributes and ignores nested-block/object keys", () => {
19
+ const body = `
20
+ bucket_name = "b"
21
+ tags = {
22
+ Name = "x"
23
+ default = "y"
24
+ }
25
+ logging {
26
+ target_bucket = "t"
27
+ }
28
+ `;
29
+ // `logging { … }` is a nested block (no `=`), so it is not an attribute; the
30
+ // nested `Name`/`default`/`target_bucket` keys are stripped with the braces.
31
+ expect(topLevelAttributeNames(body)).toEqual(["bucket_name", "tags"]);
32
+ });
33
+
34
+ it("does not treat `==`/`>=` comparisons as assignments", () => {
35
+ const body = `count = var.enabled == true ? 1 : 0`;
36
+ expect(topLevelAttributeNames(body)).toEqual(["count"]);
37
+ });
38
+ });
39
+
40
+ describe("parseExampleModuleVariables", () => {
41
+ const hcl = `
42
+ module "vpc" {
43
+ source = "../../modules/vpc"
44
+ cidr = "10.0.0.0/16"
45
+ name = "ex"
46
+ tags = { env = "dev" }
47
+ }
48
+ module "other" {
49
+ source = "../../modules/other"
50
+ foo = "bar"
51
+ }
52
+ `;
53
+
54
+ it("returns the args of only the module blocks whose source matches, minus meta-args", () => {
55
+ const vars = parseExampleModuleVariables(hcl, (s) => s === "../../modules/vpc");
56
+ expect(vars.sort()).toEqual(["cidr", "name", "tags"]);
57
+ expect(vars).not.toContain("source");
58
+ expect(vars).not.toContain("foo"); // belongs to the non-matching module
59
+ });
60
+
61
+ it("returns [] when no module block targets the module", () => {
62
+ expect(parseExampleModuleVariables(hcl, () => false)).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe("parseNativeTestVariables", () => {
67
+ it("unions variables across the top-level and per-run variables blocks", () => {
68
+ const hcl = `
69
+ variables {
70
+ bucket_name = "b"
71
+ }
72
+ run "plan" {
73
+ command = plan
74
+ variables {
75
+ tags = { env = "dev" }
76
+ }
77
+ assert {
78
+ condition = output.id != ""
79
+ error_message = "needs id"
80
+ }
81
+ }
82
+ `;
83
+ expect(parseNativeTestVariables(hcl).sort()).toEqual(["bucket_name", "tags"]);
84
+ });
85
+ });
86
+
87
+ describe("parseGoTestVariables", () => {
88
+ it("extracts the Vars map keys, including commented TODO placeholders", () => {
89
+ const go = `
90
+ opts := &terraform.Options{
91
+ TerraformDir: "../modules/s3",
92
+ Vars: map[string]interface{}{
93
+ "bucket_name": "b",
94
+ // "tags": nil, // (optional)
95
+ },
96
+ }
97
+ `;
98
+ expect(parseGoTestVariables(go).sort()).toEqual(["bucket_name", "tags"]);
99
+ });
100
+
101
+ it("supports the map[string]any form and returns [] when there's no Vars map", () => {
102
+ expect(parseGoTestVariables(`Vars: map[string]any{ "x": 1 }`)).toEqual(["x"]);
103
+ expect(parseGoTestVariables(`no vars here`)).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe("computeInterfaceDrift", () => {
108
+ it("flags missing required vars and unknown set vars", () => {
109
+ const d = computeInterfaceDrift({
110
+ setVariables: ["name", "old_name"],
111
+ requiredVariables: ["name", "new_required"],
112
+ variableNames: ["name", "new_required", "tags"],
113
+ });
114
+ expect(d.missing_required).toEqual(["new_required"]);
115
+ expect(d.unknown_set).toEqual(["old_name"]);
116
+ });
117
+
118
+ it("is clean when the asset matches the interface", () => {
119
+ const d = computeInterfaceDrift({
120
+ setVariables: ["name", "tags"],
121
+ requiredVariables: ["name"],
122
+ variableNames: ["name", "tags"],
123
+ });
124
+ expect(d.missing_required).toEqual([]);
125
+ expect(d.unknown_set).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe("discoverModuleTestAssets + analyzeModuleTests (fs)", () => {
130
+ let root: string;
131
+
132
+ beforeAll(() => {
133
+ root = mkdtempSync(join(tmpdir(), "tf-modtests-"));
134
+ // the module under test: requires `bucket_name`, optional `tags`.
135
+ mkdirSync(join(root, "modules", "s3"), { recursive: true });
136
+ writeFileSync(
137
+ join(root, "modules", "s3", "variables.tf"),
138
+ `variable "bucket_name" { type = string }
139
+ variable "tags" { type = map(string)
140
+ default = {}
141
+ }`,
142
+ );
143
+ // a module-local example that is STALE: it sets a removed `acl` var and
144
+ // omits the required `bucket_name`.
145
+ mkdirSync(join(root, "modules", "s3", "examples", "basic"), { recursive: true });
146
+ writeFileSync(
147
+ join(root, "modules", "s3", "examples", "basic", "main.tf"),
148
+ `module "s3" {
149
+ source = "../../"
150
+ acl = "private"
151
+ tags = { env = "dev" }
152
+ }`,
153
+ );
154
+ // a repo-root example that is CONSISTENT (sets the required var).
155
+ mkdirSync(join(root, "examples", "complete"), { recursive: true });
156
+ writeFileSync(
157
+ join(root, "examples", "complete", "main.tf"),
158
+ `module "s3" {
159
+ source = "../../modules/s3"
160
+ bucket_name = "b"
161
+ }`,
162
+ );
163
+ // a repo-root example for a DIFFERENT module — must be ignored.
164
+ mkdirSync(join(root, "examples", "other"), { recursive: true });
165
+ writeFileSync(
166
+ join(root, "examples", "other", "main.tf"),
167
+ `module "vpc" { source = "../../modules/vpc"\n cidr = "10.0.0.0/16" }`,
168
+ );
169
+ // a native test in the module's tests/ dir, missing the required var.
170
+ mkdirSync(join(root, "modules", "s3", "tests"), { recursive: true });
171
+ writeFileSync(
172
+ join(root, "modules", "s3", "tests", "s3.tftest.hcl"),
173
+ `run "plan" {
174
+ command = plan
175
+ variables {
176
+ tags = {}
177
+ }
178
+ }`,
179
+ );
180
+ });
181
+
182
+ afterAll(() => {
183
+ rmSync(root, { recursive: true, force: true });
184
+ });
185
+
186
+ it("discovers the module's own examples, repo-root examples, and native tests (not sibling modules')", () => {
187
+ const assets = discoverModuleTestAssets(root, "modules/s3");
188
+ const paths = assets.map((a) => a.path).sort();
189
+ expect(paths).toEqual([
190
+ "examples/complete",
191
+ "modules/s3/examples/basic",
192
+ "modules/s3/tests/s3.tftest.hcl",
193
+ ]);
194
+ // the vpc example under examples/other is not attributed to s3.
195
+ expect(paths).not.toContain("examples/other");
196
+ });
197
+
198
+ it("computes drift only for the stale assets", () => {
199
+ const report = analyzeModuleTests(root, "modules/s3");
200
+ expect(report.required_variables).toEqual(["bucket_name"]);
201
+ expect(report.variable_names.sort()).toEqual(["bucket_name", "tags"]);
202
+
203
+ const byPath = Object.fromEntries(report.drift.map((d) => [d.path, d]));
204
+ // module-local example: unknown `acl`, missing required `bucket_name`.
205
+ expect(byPath["modules/s3/examples/basic"]).toMatchObject({
206
+ missing_required: ["bucket_name"],
207
+ unknown_set: ["acl"],
208
+ });
209
+ // native test: missing the required `bucket_name`.
210
+ expect(byPath["modules/s3/tests/s3.tftest.hcl"]).toMatchObject({
211
+ missing_required: ["bucket_name"],
212
+ unknown_set: [],
213
+ });
214
+ // the consistent repo-root example does not appear in drift.
215
+ expect(byPath["examples/complete"]).toBeUndefined();
216
+ });
217
+
218
+ it("returns no assets for a module that ships none", () => {
219
+ mkdirSync(join(root, "modules", "bare"), { recursive: true });
220
+ writeFileSync(join(root, "modules", "bare", "main.tf"), `variable "x" { type = string }`);
221
+ expect(discoverModuleTestAssets(root, "modules/bare")).toEqual([]);
222
+ });
223
+ });
224
+
225
+ describe("TerraformModuleTestsTool", () => {
226
+ let root: string;
227
+
228
+ beforeAll(() => {
229
+ root = mkdtempSync(join(tmpdir(), "tf-modtests-tool-"));
230
+ mkdirSync(join(root, "modules", "s3", "examples", "basic"), { recursive: true });
231
+ writeFileSync(
232
+ join(root, "modules", "s3", "variables.tf"),
233
+ `variable "bucket_name" { type = string }`,
234
+ );
235
+ writeFileSync(
236
+ join(root, "modules", "s3", "examples", "basic", "main.tf"),
237
+ `module "s3" { source = "../../"\n acl = "private" }`,
238
+ );
239
+ });
240
+
241
+ afterAll(() => {
242
+ rmSync(root, { recursive: true, force: true });
243
+ });
244
+
245
+ async function run(moduleDir: string): Promise<string> {
246
+ const ctx = { payload: { cwd: root } } as unknown as LocalToolContext;
247
+ const t = TerraformModuleTestsTool(ctx);
248
+ const exec = t.execute as (
249
+ p: unknown,
250
+ c: unknown,
251
+ ) => Promise<{ content: [{ type: "text"; text: string }] }>;
252
+ const result = await exec({ module_dir: moduleDir }, {});
253
+ return result.content[0].text;
254
+ }
255
+
256
+ it("reports drift for a stale example and the consistency note", async () => {
257
+ const text = await run("modules/s3");
258
+ expect(text).toContain("ok: true");
259
+ expect(text).toContain("modules/s3/examples/basic");
260
+ expect(text).toContain("bucket_name"); // missing required
261
+ expect(text).toContain("acl"); // unknown set
262
+ expect(text).toContain("never weaken an assertion");
263
+ });
264
+
265
+ it("rejects a module dir that escapes the workspace", async () => {
266
+ const text = await run("../../../etc");
267
+ expect(text).toMatch(/Error:|escapes the workspace/);
268
+ });
269
+ });