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,538 @@
1
+ import { createSign } from "node:crypto";
2
+ import { rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import * as core from "@actions/core";
5
+ import { throttling } from "@octokit/plugin-throttling";
6
+ import { Octokit } from "@octokit/rest";
7
+ import { apiFetch } from "#app/utils/apiFetch";
8
+ import { retry } from "#app/utils/retry";
9
+
10
+ function isObject(value: unknown) {
11
+ return typeof value === "object" && value !== null;
12
+ }
13
+
14
+ // we don't get access to the actual class from @octokit/rest
15
+ // it's reachable from @octokit/request-error but we'd have to add a dependency on it
16
+ // and it would pose a risk of accidentally pulling a different version of that class (node_modules dep graphs ❤️)
17
+ // so it's safer to ducktype this
18
+ interface OctokitResponseShim {
19
+ headers: Record<string, string | number | undefined>;
20
+ }
21
+
22
+ export interface InstallationToken {
23
+ token: string;
24
+ expires_at: string;
25
+ installation_id: number;
26
+ repository: string;
27
+ ref: string;
28
+ runner_environment: string;
29
+ owner?: string;
30
+ }
31
+
32
+ interface GitHubAppConfig {
33
+ appId: string;
34
+ privateKey: string;
35
+ repoOwner: string;
36
+ repoName: string;
37
+ }
38
+
39
+ interface Installation {
40
+ id: number;
41
+ account: {
42
+ login: string;
43
+ type: string;
44
+ };
45
+ }
46
+
47
+ interface Repository {
48
+ owner: {
49
+ login: string;
50
+ };
51
+ name: string;
52
+ }
53
+
54
+ interface InstallationTokenResponse {
55
+ token: string;
56
+ expires_at: string;
57
+ }
58
+
59
+ interface RepositoriesResponse {
60
+ repositories: Repository[];
61
+ }
62
+
63
+ function isOIDCAvailable(): boolean {
64
+ // OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
65
+ return Boolean(
66
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
67
+ );
68
+ }
69
+
70
+ type ReadWrite = "read" | "write";
71
+ type WriteOnly = "write";
72
+
73
+ /**
74
+ * GitHub App installation access token permissions.
75
+ * passed to `POST /app/installations/{id}/access_tokens` to scope the token.
76
+ * fields and allowed values come from the `app-permissions` OpenAPI schema.
77
+ * @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
78
+ * @see https://github.com/github/rest-api-description — components.schemas.app-permissions
79
+ */
80
+ type GitHubAppPermissions = {
81
+ actions?: ReadWrite;
82
+ artifact_metadata?: ReadWrite;
83
+ attestations?: ReadWrite;
84
+ checks?: ReadWrite;
85
+ contents?: ReadWrite;
86
+ deployments?: ReadWrite;
87
+ discussions?: ReadWrite;
88
+ issues?: ReadWrite;
89
+ packages?: ReadWrite;
90
+ pages?: ReadWrite;
91
+ pull_requests?: ReadWrite;
92
+ security_events?: ReadWrite;
93
+ statuses?: ReadWrite;
94
+ workflows?: WriteOnly;
95
+ };
96
+
97
+ type AcquireTokenOptions = {
98
+ repos?: string[];
99
+ permissions?: GitHubAppPermissions;
100
+ };
101
+
102
+ /**
103
+ * Thrown when our token-exchange endpoint returns a non-2xx response.
104
+ * The retry policy in `acquireNewToken` looks for this concrete type to
105
+ * skip retries — 4xx is terminal user state (not-installed, not-authorized)
106
+ * and 5xx is rare enough that re-running the workflow is the right escape
107
+ * hatch. Genuine network failures throw plain `Error` and stay retryable.
108
+ */
109
+ class TokenExchangeError extends Error {
110
+ readonly status: number;
111
+ constructor(status: number, message: string) {
112
+ super(message);
113
+ this.name = "TokenExchangeError";
114
+ this.status = status;
115
+ }
116
+ }
117
+
118
+ async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
119
+ const oidcToken = await core.getIDToken("terramend-api");
120
+
121
+ const repos = [...(opts?.repos ?? [])];
122
+ const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
123
+ if (targetRepo) {
124
+ repos.push(targetRepo);
125
+ }
126
+ const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
127
+
128
+ const timeoutMs = 30000;
129
+ const controller = new AbortController();
130
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
131
+
132
+ try {
133
+ const tokenResponse = await apiFetch({
134
+ path: `/api/github/installation-token${reposParam}`,
135
+ method: "POST",
136
+ headers: {
137
+ Authorization: `Bearer ${oidcToken}`,
138
+ "Content-Type": "application/json",
139
+ },
140
+ body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
141
+ signal: controller.signal,
142
+ });
143
+
144
+ clearTimeout(timeoutId);
145
+
146
+ if (!tokenResponse.ok) {
147
+ // prefer the server-side `error` field — it's the single source of
148
+ // truth for the install URL (uses GITHUB_APP_INSTALL_URL, which
149
+ // varies per env / GITHUB_APP_SLUG). fall back to a generic message
150
+ // if the body isn't JSON or doesn't carry an `error` field.
151
+ let serverMessage: string | undefined;
152
+ try {
153
+ const body = (await tokenResponse.json()) as { error?: unknown };
154
+ if (typeof body.error === "string") serverMessage = body.error;
155
+ } catch {
156
+ // body wasn't JSON — fall through to the generic message
157
+ }
158
+ throw new TokenExchangeError(
159
+ tokenResponse.status,
160
+ serverMessage ??
161
+ `Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`,
162
+ );
163
+ }
164
+
165
+ const tokenData = (await tokenResponse.json()) as InstallationToken;
166
+ return tokenData.token;
167
+ } catch (error) {
168
+ clearTimeout(timeoutId);
169
+
170
+ if (error instanceof Error && error.name === "AbortError") {
171
+ throw new Error(`Token exchange timed out after ${timeoutMs}ms`);
172
+ }
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ const base64UrlEncode = (str: string): string => {
178
+ return Buffer.from(str)
179
+ .toString("base64")
180
+ .replace(/\+/g, "-")
181
+ .replace(/\//g, "_")
182
+ .replace(/=/g, "");
183
+ };
184
+
185
+ const generateJWT = (appId: string, privateKey: string): string => {
186
+ const now = Math.floor(Date.now() / 1000);
187
+ const payload = {
188
+ iat: now - 60,
189
+ exp: now + 5 * 60,
190
+ iss: appId,
191
+ };
192
+
193
+ const header = {
194
+ alg: "RS256",
195
+ typ: "JWT",
196
+ };
197
+
198
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
199
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
200
+ const signaturePart = `${encodedHeader}.${encodedPayload}`;
201
+
202
+ const signature = createSign("RSA-SHA256")
203
+ .update(signaturePart)
204
+ .sign(privateKey, "base64")
205
+ .replace(/\+/g, "-")
206
+ .replace(/\//g, "_")
207
+ .replace(/=/g, "");
208
+
209
+ return `${signaturePart}.${signature}`;
210
+ };
211
+
212
+ const githubRequest = async <T>(
213
+ path: string,
214
+ options: {
215
+ method?: string;
216
+ headers?: Record<string, string>;
217
+ body?: string;
218
+ } = {},
219
+ ): Promise<T> => {
220
+ const { method = "GET", headers = {}, body } = options;
221
+
222
+ const url = `https://api.github.com${path}`;
223
+ const requestHeaders = {
224
+ Accept: "application/vnd.github.v3+json",
225
+ "User-Agent": "Terramend-Installation-Token-Generator/1.0",
226
+ ...headers,
227
+ };
228
+
229
+ const response = await fetch(url, {
230
+ method,
231
+ headers: requestHeaders,
232
+ ...(body && { body }),
233
+ });
234
+
235
+ if (!response.ok) {
236
+ const errorText = await response.text();
237
+ throw new Error(
238
+ `GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`,
239
+ );
240
+ }
241
+
242
+ return response.json() as T;
243
+ };
244
+
245
+ const checkRepositoryAccess = async (
246
+ token: string,
247
+ repoOwner: string,
248
+ repoName: string,
249
+ ): Promise<boolean> => {
250
+ try {
251
+ const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
252
+ headers: { Authorization: `token ${token}` },
253
+ });
254
+
255
+ const ownerLower = repoOwner.toLowerCase();
256
+ const nameLower = repoName.toLowerCase();
257
+ return response.repositories.some(
258
+ (repo) =>
259
+ repo.owner.login.toLowerCase() === ownerLower && repo.name.toLowerCase() === nameLower,
260
+ );
261
+ } catch {
262
+ return false;
263
+ }
264
+ };
265
+
266
+ const createInstallationToken = async (
267
+ jwt: string,
268
+ installationId: number,
269
+ permissions?: GitHubAppPermissions,
270
+ ): Promise<string> => {
271
+ const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
272
+ method: "POST",
273
+ headers: { Authorization: `Bearer ${jwt}` },
274
+ };
275
+ if (permissions) {
276
+ requestOpts.body = JSON.stringify({ permissions });
277
+ }
278
+ const response = await githubRequest<InstallationTokenResponse>(
279
+ `/app/installations/${installationId}/access_tokens`,
280
+ requestOpts,
281
+ );
282
+
283
+ return response.token;
284
+ };
285
+
286
+ const findInstallationId = async (
287
+ jwt: string,
288
+ repoOwner: string,
289
+ repoName: string,
290
+ ): Promise<number> => {
291
+ const installations = await githubRequest<Installation[]>("/app/installations", {
292
+ headers: { Authorization: `Bearer ${jwt}` },
293
+ });
294
+
295
+ for (const installation of installations) {
296
+ try {
297
+ const tempToken = await createInstallationToken(jwt, installation.id);
298
+ const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
299
+
300
+ if (hasAccess) {
301
+ return installation.id;
302
+ }
303
+ } catch {}
304
+ }
305
+
306
+ throw new Error(
307
+ `No installation found with access to ${repoOwner}/${repoName}. ` +
308
+ "Ensure the GitHub App is installed on the target repository.",
309
+ );
310
+ };
311
+
312
+ // for local development only
313
+ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
314
+ if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
315
+ throw new Error(
316
+ "cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set",
317
+ );
318
+ }
319
+
320
+ const repoContext = parseRepoContext();
321
+
322
+ const config: GitHubAppConfig = {
323
+ appId: process.env.GITHUB_APP_ID,
324
+ privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
325
+ repoOwner: repoContext.owner,
326
+ repoName: repoContext.name,
327
+ };
328
+
329
+ const jwt = generateJWT(config.appId, config.privateKey);
330
+ const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
331
+ return await createInstallationToken(jwt, installationId, opts?.permissions);
332
+ }
333
+
334
+ /**
335
+ * ensure a GitHub token is available in the environment.
336
+ *
337
+ * when OIDC is available (CI), always mints a fresh token scoped to
338
+ * GITHUB_REPOSITORY — overriding any inherited GITHUB_TOKEN that may
339
+ * be scoped to the wrong repo.
340
+ *
341
+ * otherwise falls back to GitHub App credentials for local development.
342
+ *
343
+ * only called from dev-run.ts (test/dev path) — the live action calls
344
+ * main() directly and never calls this.
345
+ */
346
+ export async function ensureGitHubToken(): Promise<void> {
347
+ // when OIDC is available, always mint a fresh token scoped to
348
+ // GITHUB_REPOSITORY. the inherited GITHUB_TOKEN may be scoped to a
349
+ // different repo (e.g., runner token for terramend/app when tests
350
+ // target terramend/test-repo).
351
+ if (isOIDCAvailable()) {
352
+ const token = await acquireNewToken();
353
+ process.env.GITHUB_TOKEN = token;
354
+ return;
355
+ }
356
+
357
+ if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
358
+ const token = await acquireNewToken();
359
+ process.env.GITHUB_TOKEN = token;
360
+ }
361
+ }
362
+
363
+ export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
364
+ if (isOIDCAvailable()) {
365
+ return await retry(() => acquireTokenViaOIDC(opts), {
366
+ label: "token exchange",
367
+ shouldRetry: (error) => {
368
+ // 4xx is terminal user state (app not installed, permissions wrong) —
369
+ // retrying just triples our log noise and the user's CI bill (see
370
+ // #693). 5xx/429 are transient (vercel cold start, github outage,
371
+ // rate limit) and should ride the existing backoff.
372
+ if (error instanceof TokenExchangeError) return error.status >= 500 || error.status === 429;
373
+ return (
374
+ error instanceof Error &&
375
+ (error.message.includes("timed out") ||
376
+ error.message.includes("fetch failed") ||
377
+ error.message.includes("ECONNRESET") ||
378
+ error.message.includes("ETIMEDOUT"))
379
+ );
380
+ },
381
+ });
382
+ }
383
+ // running inside GitHub Actions but the OIDC env vars are absent — the
384
+ // workflow is missing `permissions: id-token: write`. surface an
385
+ // actionable, customer-facing message; the GitHub-App branch below is
386
+ // local-dev only. see #739.
387
+ if (process.env.GITHUB_ACTIONS === "true") {
388
+ throw new Error(
389
+ "missing `permissions: id-token: write` on the Terramend workflow job.\n" +
390
+ "\n" +
391
+ "Terramend mints short-lived GitHub App installation tokens via OIDC and\n" +
392
+ "requires `id-token: write` to be granted at the job level. add the\n" +
393
+ "following to your workflow yaml:\n" +
394
+ "\n" +
395
+ " jobs:\n" +
396
+ " terramend:\n" +
397
+ " permissions:\n" +
398
+ " id-token: write # mint Terramend installation tokens via OIDC\n" +
399
+ " contents: read # for actions/checkout\n" +
400
+ "\n" +
401
+ "see https://docs.terramend.com/headless-action#required-permissions for the full template.",
402
+ );
403
+ }
404
+ // local development via GitHub App
405
+ return await acquireTokenViaGitHubApp(opts);
406
+ }
407
+
408
+ export interface RepoContext {
409
+ owner: string;
410
+ name: string;
411
+ }
412
+
413
+ /**
414
+ * Parse repository context from GITHUB_REPOSITORY environment variable.
415
+ */
416
+ export function parseRepoContext(): RepoContext {
417
+ const githubRepo = process.env.GITHUB_REPOSITORY;
418
+ if (!githubRepo) {
419
+ throw new Error("GITHUB_REPOSITORY environment variable is required");
420
+ }
421
+
422
+ const [owner, name] = githubRepo.split("/");
423
+ if (!owner || !name) {
424
+ throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
425
+ }
426
+
427
+ return { owner, name };
428
+ }
429
+
430
+ export type OctokitWithPlugins = InstanceType<
431
+ ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
432
+ >;
433
+
434
+ export interface ResourceUsage {
435
+ requestCount: number;
436
+ rateLimitRemaining: number | null;
437
+ rateLimitResetMs: number | null;
438
+ }
439
+
440
+ function emptyResourceUsage(): ResourceUsage {
441
+ return {
442
+ requestCount: 0,
443
+ rateLimitRemaining: null,
444
+ rateLimitResetMs: null,
445
+ };
446
+ }
447
+
448
+ // `core` and `graphql` are always seeded below, so they're declared as required
449
+ // (the index signature still admits other resources, which are added on demand).
450
+ const usageByResource: Record<string, ResourceUsage> & {
451
+ core: ResourceUsage;
452
+ graphql: ResourceUsage;
453
+ } = {
454
+ core: emptyResourceUsage(),
455
+ graphql: emptyResourceUsage(),
456
+ };
457
+
458
+ export interface UsageSummary {
459
+ version: 1;
460
+ github: {
461
+ core: ResourceUsage;
462
+ graphql: ResourceUsage;
463
+ };
464
+ }
465
+
466
+ function getGitHubUsageSummary(): UsageSummary {
467
+ return {
468
+ version: 1,
469
+ github: {
470
+ core: usageByResource.core,
471
+ graphql: usageByResource.graphql,
472
+ },
473
+ };
474
+ }
475
+
476
+ export async function writeGitHubUsageSummaryToFile(path: string): Promise<void> {
477
+ const summary = getGitHubUsageSummary();
478
+ const tmpPath = join(dirname(path), `.usage-summary-${process.pid}.tmp`);
479
+ await writeFile(tmpPath, JSON.stringify(summary));
480
+ await rename(tmpPath, path);
481
+ }
482
+
483
+ export function createOctokit(token: string): OctokitWithPlugins {
484
+ // `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
485
+ // we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
486
+ const OctokitWithPlugins = Octokit.plugin(throttling);
487
+ const octokit = new OctokitWithPlugins({
488
+ auth: token,
489
+ throttle: {
490
+ onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
491
+ return retryCount <= 2;
492
+ },
493
+ onSecondaryRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
494
+ return retryCount <= 2;
495
+ },
496
+ },
497
+ });
498
+
499
+ const onResponse = (response: OctokitResponseShim) => {
500
+ const resource = response.headers["x-ratelimit-resource"];
501
+ if (!resource) {
502
+ return response;
503
+ }
504
+ usageByResource[resource] ??= emptyResourceUsage();
505
+ const usage = usageByResource[resource];
506
+ usage.requestCount++;
507
+ const remaining = response.headers["x-ratelimit-remaining"];
508
+ const reset = response.headers["x-ratelimit-reset"];
509
+ if (remaining !== undefined) {
510
+ usage.rateLimitRemaining = Number(remaining);
511
+ }
512
+ if (reset !== undefined) {
513
+ usage.rateLimitResetMs = Number(reset) * 1000;
514
+ }
515
+ return response;
516
+ };
517
+
518
+ octokit.hook.wrap("request", async (request, options) => {
519
+ try {
520
+ const response = await request(options);
521
+ onResponse(response);
522
+ return response;
523
+ } catch (error) {
524
+ if (
525
+ isObject(error) &&
526
+ "response" in error &&
527
+ isObject(error.response) &&
528
+ "headers" in error.response &&
529
+ isObject(error.response.headers)
530
+ ) {
531
+ onResponse(error.response as OctokitResponseShim);
532
+ }
533
+ throw error;
534
+ }
535
+ });
536
+
537
+ return octokit;
538
+ }
@@ -0,0 +1,9 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ export const isCloudflareSandbox =
4
+ !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION;
5
+
6
+ export const isGitHubActions = !!process.env.GITHUB_ACTIONS;
7
+
8
+ // detect if running inside Docker container (CI tests run in Docker with host env vars)
9
+ export const isInsideDocker = existsSync("/.dockerenv");
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ captureRemediationOutcome,
4
+ computeHumanEditDelta,
5
+ deriveRemediationOutcome,
6
+ } from "#app/utils/humanEditCapture";
7
+
8
+ describe("computeHumanEditDelta (§6.20)", () => {
9
+ const diff = (...added: string[]) =>
10
+ ["+++ b/main.tf", "@@ -0,0 +1 @@", ...added.map((l) => `+${l}`)].join("\n");
11
+
12
+ it("flags no intervention when the merged diff matches Terramend's fix", () => {
13
+ const r = computeHumanEditDelta({
14
+ concernIds: ["a"],
15
+ originalFixDiff: diff(" encrypted = true"),
16
+ mergedDiff: diff(" encrypted = true"),
17
+ outcome: "merged_clean",
18
+ });
19
+ expect(r.humanIntervened).toBe(false);
20
+ expect(r.humanAddedLines).toEqual([]);
21
+ expect(r.removedFromOriginal).toEqual([]);
22
+ });
23
+
24
+ it("captures lines the human added and removed when merged with edits", () => {
25
+ const r = computeHumanEditDelta({
26
+ concernIds: ["a"],
27
+ originalFixDiff: diff(" encrypted = true"),
28
+ mergedDiff: diff(" encrypted = true", " kms_key_id = aws_kms_key.this.arn"),
29
+ outcome: "merged_with_edits",
30
+ });
31
+ expect(r.humanIntervened).toBe(true);
32
+ expect(r.humanAddedLines).toContain(" kms_key_id = aws_kms_key.this.arn");
33
+ expect(r.removedFromOriginal).toContain(" encrypted = true");
34
+ });
35
+
36
+ it("always treats a rejected PR as an intervention", () => {
37
+ const r = computeHumanEditDelta({
38
+ concernIds: ["a"],
39
+ originalFixDiff: diff(" encrypted = true"),
40
+ mergedDiff: "",
41
+ outcome: "rejected",
42
+ });
43
+ expect(r.humanIntervened).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe("deriveRemediationOutcome (§6.20)", () => {
48
+ const diff = (...added: string[]) => added.map((l) => `+${l}`).join("\n");
49
+
50
+ it("classifies an unmerged close as rejected", () => {
51
+ expect(deriveRemediationOutcome(false, diff("x = 1"), "")).toBe("rejected");
52
+ });
53
+
54
+ it("classifies an identical merge as merged_clean", () => {
55
+ expect(deriveRemediationOutcome(true, diff("x = 1"), diff("x = 1"))).toBe("merged_clean");
56
+ });
57
+
58
+ it("classifies a merge with added lines as merged_with_edits", () => {
59
+ expect(deriveRemediationOutcome(true, diff("x = 1"), diff("x = 1", "y = 2"))).toBe(
60
+ "merged_with_edits",
61
+ );
62
+ });
63
+
64
+ it("classifies a merge that dropped a Terramend line as merged_with_edits", () => {
65
+ expect(deriveRemediationOutcome(true, diff("x = 1", "y = 2"), diff("x = 1"))).toBe(
66
+ "merged_with_edits",
67
+ );
68
+ });
69
+ });
70
+
71
+ describe("captureRemediationOutcome (§6.20 persistence seam)", () => {
72
+ const prevApiUrl = process.env.API_URL;
73
+ beforeEach(() => {
74
+ delete process.env.API_URL;
75
+ });
76
+ afterEach(() => {
77
+ if (prevApiUrl === undefined) delete process.env.API_URL;
78
+ else process.env.API_URL = prevApiUrl;
79
+ });
80
+
81
+ it("builds the record and no-ops persistence when no backend is configured", async () => {
82
+ const result = await captureRemediationOutcome({
83
+ repo: { owner: "acme", name: "infra" },
84
+ apiToken: "t",
85
+ event: {
86
+ prNumber: 42,
87
+ merged: true,
88
+ concernIds: ["c1"],
89
+ originalFixDiff: "+x = 1",
90
+ mergedDiff: "+x = 1\n+y = 2",
91
+ },
92
+ });
93
+ expect(result.persisted).toBe(false);
94
+ expect(result.reason).toBe("no_backend");
95
+ // the record is still built (the pure part runs regardless of persistence).
96
+ expect(result.record.outcome).toBe("merged_with_edits");
97
+ expect(result.record.humanIntervened).toBe(true);
98
+ expect(result.record.concernIds).toEqual(["c1"]);
99
+ });
100
+ });