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.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
5
|
+
import { resolveWithinCwd } from "#app/mcp/pathSafety";
|
|
6
|
+
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
7
|
+
import { log } from "#app/utils/cli";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Terraform module support (§4.14 + module catalogue). Two related capabilities:
|
|
11
|
+
*
|
|
12
|
+
* - **Module catalogue** — an operator-curated list of approved modules (a
|
|
13
|
+
* registry module like `terraform-aws-modules/vpc/aws`, or one of the org's
|
|
14
|
+
* own/house modules at a local path) that a fix/generation should PREFER over
|
|
15
|
+
* hand-rolling raw resources. Surfaced by `list_modules`.
|
|
16
|
+
* - **Module-source-aware fixes** — parse the repo's `module "x" { source = … }`
|
|
17
|
+
* blocks into a call-graph so a concern inside a *local* module is fixed at
|
|
18
|
+
* its SOURCE once (not patched at every call site), while a concern that would
|
|
19
|
+
* require editing a *registry/remote* module is flagged as out-of-repo.
|
|
20
|
+
* Surfaced by `terraform_module_graph`.
|
|
21
|
+
*
|
|
22
|
+
* The parsing is pure (no subprocess) and unit-tested; the tools just read files.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// --- module catalogue ------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface ModuleCatalogueEntry {
|
|
28
|
+
/** the local name to use in a `module "<name>"` block. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** the `source` value, e.g. `terraform-aws-modules/vpc/aws` or `./modules/vpc`. */
|
|
31
|
+
source: string;
|
|
32
|
+
/** optional version constraint for a registry module. */
|
|
33
|
+
version: string | null;
|
|
34
|
+
/** classification of the source (registry / local / git / remote). */
|
|
35
|
+
kind: ModuleSourceKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** a version-looking token: `~> 5.0`, `>= 1.2`, `1.0.0`, `v2`, `< 4`. */
|
|
39
|
+
function looksLikeVersion(token: string): boolean {
|
|
40
|
+
return /^[v~^]?[<>=]*\s*\d/.test(token) || /^[<>=]/.test(token);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** derive a stable module name from a source when none was given. The most
|
|
44
|
+
* meaningful name is the `//subdir`'s last segment when present
|
|
45
|
+
* (`…/modules.git//aws/kms` → `kms`, `terraform-aws-modules/cloudwatch/aws//modules/log-group`
|
|
46
|
+
* → `log-group`); else the registry module name; else the last path segment. */
|
|
47
|
+
function deriveName(source: string): string {
|
|
48
|
+
const parsed = splitModuleSource(source);
|
|
49
|
+
if (parsed.subdir) {
|
|
50
|
+
const seg = parsed.subdir.split("/").filter(Boolean).pop();
|
|
51
|
+
if (seg) return seg.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
52
|
+
}
|
|
53
|
+
const cleaned = parsed.base.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
54
|
+
const registry = cleaned.match(
|
|
55
|
+
/^(?:[^/]+\/)?([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)$/,
|
|
56
|
+
);
|
|
57
|
+
if (registry) return registry[2]!;
|
|
58
|
+
const seg = cleaned.split(/[/]/).filter(Boolean).pop() ?? source;
|
|
59
|
+
return seg.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ModuleSourceKind = "local" | "registry" | "git" | "remote" | "unknown";
|
|
63
|
+
|
|
64
|
+
export interface ParsedModuleSource {
|
|
65
|
+
/** the full original source string. */
|
|
66
|
+
raw: string;
|
|
67
|
+
/** the source with the `//subdir` selector and `?query` stripped. */
|
|
68
|
+
base: string;
|
|
69
|
+
/** the `//subdir` path within the module repo/package, or null. */
|
|
70
|
+
subdir: string | null;
|
|
71
|
+
/** the `?ref=` revision (git tag/branch/commit), or null. This is how git
|
|
72
|
+
* modules PIN a version — Terraform has no `version` attribute for them. */
|
|
73
|
+
ref: string | null;
|
|
74
|
+
kind: ModuleSourceKind;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Split a Terraform module `source` into its base, `//subdir` selector, and
|
|
79
|
+
* `?ref=` revision — the three parts Terraform's go-getter syntax composes
|
|
80
|
+
* (`git::https://host/repo.git//subdir?ref=v1`,
|
|
81
|
+
* `terraform-aws-modules/cloudwatch/aws//modules/log-group`). The `//` separator
|
|
82
|
+
* is the one NOT part of a `://` scheme. Pure; underpins classification + the
|
|
83
|
+
* version a git module is pinned at.
|
|
84
|
+
*/
|
|
85
|
+
export function splitModuleSource(raw: string): ParsedModuleSource {
|
|
86
|
+
let rest = raw.trim();
|
|
87
|
+
|
|
88
|
+
// `?query` → pull out ref=.
|
|
89
|
+
let ref: string | null = null;
|
|
90
|
+
const q = rest.indexOf("?");
|
|
91
|
+
if (q >= 0) {
|
|
92
|
+
const query = rest.slice(q + 1);
|
|
93
|
+
rest = rest.slice(0, q);
|
|
94
|
+
const refMatch = query.match(/(?:^|&)ref=([^&]+)/);
|
|
95
|
+
if (refMatch) {
|
|
96
|
+
try {
|
|
97
|
+
ref = decodeURIComponent(refMatch[1]!);
|
|
98
|
+
} catch {
|
|
99
|
+
ref = refMatch[1]!;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `//subdir` — the first `//` that isn't the `://` of a scheme.
|
|
105
|
+
let subdir: string | null = null;
|
|
106
|
+
const sep = rest.match(/(?<!:)\/\//);
|
|
107
|
+
if (sep && sep.index !== undefined) {
|
|
108
|
+
subdir = rest.slice(sep.index + 2) || null;
|
|
109
|
+
rest = rest.slice(0, sep.index);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { raw, base: rest, subdir, ref, kind: classifyBase(rest) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** classify a source's BASE (no subdir/query). */
|
|
116
|
+
function classifyBase(base: string): ModuleSourceKind {
|
|
117
|
+
const s = base.trim();
|
|
118
|
+
if (!s) return "unknown";
|
|
119
|
+
if (s.startsWith("./") || s.startsWith("../") || s.startsWith("/") || /^[A-Za-z]:[\\/]/.test(s)) {
|
|
120
|
+
return "local";
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
s.startsWith("git::") ||
|
|
124
|
+
s.startsWith("git@") ||
|
|
125
|
+
/(?:^|\/)github\.com\//.test(s) ||
|
|
126
|
+
/\.git$/.test(s) ||
|
|
127
|
+
s.includes("bitbucket.org") ||
|
|
128
|
+
s.startsWith("hg::")
|
|
129
|
+
) {
|
|
130
|
+
return "git";
|
|
131
|
+
}
|
|
132
|
+
if (/^(?:s3|gcs|http|https|mercurial|oci)[:]/.test(s)) {
|
|
133
|
+
return "remote";
|
|
134
|
+
}
|
|
135
|
+
// host-prefixed or bare registry shorthand: [host/]namespace/name/provider
|
|
136
|
+
if (/^(?:[A-Za-z0-9.-]+\/)?[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+$/.test(s)) {
|
|
137
|
+
return "registry";
|
|
138
|
+
}
|
|
139
|
+
return "unknown";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Classify a Terraform module `source` string (the full value, including any
|
|
144
|
+
* `//subdir` / `?ref=`). Mirrors Terraform's own source resolution: a
|
|
145
|
+
* `./`/`../`/absolute path is LOCAL; a `git::`/`github.com`/`.git`/`bitbucket.org`
|
|
146
|
+
* source is GIT; an `s3::`/`gcs::`/`http(s)` archive is REMOTE; a bare
|
|
147
|
+
* `namespace/name/provider` (optionally host-prefixed, optionally with a
|
|
148
|
+
* `//submodule` path) is a REGISTRY ref.
|
|
149
|
+
*/
|
|
150
|
+
export function classifyModuleSource(source: string): ModuleSourceKind {
|
|
151
|
+
return splitModuleSource(source).kind;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse the operator's `module_catalogue` input into structured entries. Accepts
|
|
156
|
+
* newline- and/or comma-separated entries, each:
|
|
157
|
+
* `[name=]<source>[ <version>]`
|
|
158
|
+
* e.g. `vpc=terraform-aws-modules/vpc/aws ~> 5.0`, `terraform-aws-modules/s3-bucket/aws`,
|
|
159
|
+
* `./modules/networking`. Name is optional (derived from the source when absent);
|
|
160
|
+
* version applies to registry sources.
|
|
161
|
+
*/
|
|
162
|
+
export function parseModuleCatalogue(raw: string | undefined): ModuleCatalogueEntry[] {
|
|
163
|
+
if (!raw) return [];
|
|
164
|
+
const out: ModuleCatalogueEntry[] = [];
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
for (const piece of raw.split(/[\n,]+/)) {
|
|
167
|
+
const entry = piece.trim();
|
|
168
|
+
if (!entry) continue;
|
|
169
|
+
// optional `name=` prefix: the part before `=` must be a bare identifier
|
|
170
|
+
// (no `/`, so a registry source's slashes aren't mistaken for a name split).
|
|
171
|
+
let body = entry;
|
|
172
|
+
let name: string | null = null;
|
|
173
|
+
const eq = entry.indexOf("=");
|
|
174
|
+
if (eq > 0) {
|
|
175
|
+
const lhs = entry.slice(0, eq).trim();
|
|
176
|
+
if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(lhs)) {
|
|
177
|
+
name = lhs;
|
|
178
|
+
body = entry.slice(eq + 1).trim();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// split source and optional trailing version constraint on whitespace.
|
|
182
|
+
const parts = body.split(/\s+/);
|
|
183
|
+
let source = parts[0] ?? "";
|
|
184
|
+
let version: string | null = null;
|
|
185
|
+
if (parts.length > 1 && looksLikeVersion(parts.slice(1).join(" "))) {
|
|
186
|
+
version = parts.slice(1).join(" ");
|
|
187
|
+
} else if (parts.length > 1) {
|
|
188
|
+
source = parts.join(" ");
|
|
189
|
+
}
|
|
190
|
+
if (!source) continue;
|
|
191
|
+
const parsed = splitModuleSource(source);
|
|
192
|
+
// a git module pins its version via `?ref=` (no `version` attribute), so
|
|
193
|
+
// fall back to the ref when no explicit version token was given.
|
|
194
|
+
const effectiveVersion = version ?? parsed.ref;
|
|
195
|
+
const finalName = name ?? deriveName(source);
|
|
196
|
+
const dedupeKey = `${finalName}|${source}`;
|
|
197
|
+
if (seen.has(dedupeKey)) continue;
|
|
198
|
+
seen.add(dedupeKey);
|
|
199
|
+
out.push({ name: finalName, source, version: effectiveVersion, kind: parsed.kind });
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- module call-graph (§4.14) ---------------------------------------------
|
|
205
|
+
|
|
206
|
+
export interface ModuleBlock {
|
|
207
|
+
/** the local name in `module "<name>" { … }`. */
|
|
208
|
+
name: string;
|
|
209
|
+
source: string;
|
|
210
|
+
/** the pinned version — the `version` attribute (registry) or the git `?ref=`
|
|
211
|
+
* (git), whichever is present; null when unpinned. */
|
|
212
|
+
version: string | null;
|
|
213
|
+
/** the `//subdir` selector within the module package, or null. */
|
|
214
|
+
subdir: string | null;
|
|
215
|
+
kind: ModuleSourceKind;
|
|
216
|
+
/** the file the `module` block was declared in (repo-relative). */
|
|
217
|
+
declaredIn: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse every `module "<name>" { … }` block in some HCL into its name, source,
|
|
222
|
+
* pinned version, and subdir. Brace-matched so nested blocks (e.g. a
|
|
223
|
+
* `providers = { … }` map inside the module block) don't confuse it. The version
|
|
224
|
+
* comes from the `version` attribute (registry modules) OR the source's `?ref=`
|
|
225
|
+
* (git modules), so a git-pinned module isn't reported as unpinned. `declaredIn`
|
|
226
|
+
* is filled by the caller; here it's "".
|
|
227
|
+
*/
|
|
228
|
+
export function parseModuleBlocks(hcl: string): ModuleBlock[] {
|
|
229
|
+
const out: ModuleBlock[] = [];
|
|
230
|
+
const re = /module\s+"([^"]+)"\s*\{/g;
|
|
231
|
+
let m: RegExpExecArray | null;
|
|
232
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
233
|
+
while ((m = re.exec(hcl)) !== null) {
|
|
234
|
+
const name = m[1]!;
|
|
235
|
+
const braceStart = re.lastIndex - 1;
|
|
236
|
+
let depth = 0;
|
|
237
|
+
let end = -1;
|
|
238
|
+
for (let i = braceStart; i < hcl.length; i++) {
|
|
239
|
+
if (hcl[i] === "{") depth++;
|
|
240
|
+
else if (hcl[i] === "}") {
|
|
241
|
+
depth--;
|
|
242
|
+
if (depth === 0) {
|
|
243
|
+
end = i;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (end === -1) break;
|
|
249
|
+
const body = hcl.slice(braceStart + 1, end);
|
|
250
|
+
const source = body.match(/(?:^|\n)\s*source\s*=\s*"([^"]+)"/)?.[1] ?? "";
|
|
251
|
+
const versionAttr = body.match(/(?:^|\n)\s*version\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
252
|
+
re.lastIndex = end + 1;
|
|
253
|
+
if (!source) continue;
|
|
254
|
+
const parsed = splitModuleSource(source);
|
|
255
|
+
out.push({
|
|
256
|
+
name,
|
|
257
|
+
source,
|
|
258
|
+
version: versionAttr ?? parsed.ref,
|
|
259
|
+
subdir: parsed.subdir,
|
|
260
|
+
kind: parsed.kind,
|
|
261
|
+
declaredIn: "",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface ModuleGraph {
|
|
268
|
+
/** every `module` block found, with its classified source. */
|
|
269
|
+
modules: ModuleBlock[];
|
|
270
|
+
/** local module source dirs (repo-relative), each with the caller files that
|
|
271
|
+
* reference it — fix a concern in one of these dirs ONCE at the source. */
|
|
272
|
+
localModuleDirs: { dir: string; callers: string[] }[];
|
|
273
|
+
/** count of registry/git/remote module references — concerns that live inside
|
|
274
|
+
* one of these are NOT editable in this repo (open an issue instead). */
|
|
275
|
+
externalCount: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// directories never worth descending into when walking for `.tf` (caches, VCS,
|
|
279
|
+
// venvs, deps). Keeps the recursive walk fast and noise-free.
|
|
280
|
+
const SKIP_DIRS = new Set([
|
|
281
|
+
".git",
|
|
282
|
+
".terraform",
|
|
283
|
+
".terragrunt-cache",
|
|
284
|
+
".venv",
|
|
285
|
+
"venv",
|
|
286
|
+
"node_modules",
|
|
287
|
+
".idea",
|
|
288
|
+
".vscode",
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Recursively list `*.tf` files under `cwd`, repo-relative (POSIX), skipping
|
|
293
|
+
* cache/VCS/dep dirs. Bounded by depth and a file cap so a huge monorepo can't
|
|
294
|
+
* stall the walk. Real Terraform repos (e.g. hepcare) keep their root config in
|
|
295
|
+
* a subdir (`terraform/`) with house modules in `terraform/modules/` and even a
|
|
296
|
+
* second root (`terraform/core/`) — a single-level read misses all of that, so
|
|
297
|
+
* we walk the tree.
|
|
298
|
+
*/
|
|
299
|
+
export function walkTfFiles(cwd: string, maxDepth = 8, cap = 2000): string[] {
|
|
300
|
+
const out: string[] = [];
|
|
301
|
+
const visit = (dir: string, rel: string, depth: number): void => {
|
|
302
|
+
if (depth > maxDepth || out.length >= cap) return;
|
|
303
|
+
let entries: import("node:fs").Dirent[];
|
|
304
|
+
try {
|
|
305
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
for (const e of entries) {
|
|
310
|
+
if (out.length >= cap) return;
|
|
311
|
+
if (e.isDirectory()) {
|
|
312
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
313
|
+
visit(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1);
|
|
314
|
+
} else if (e.isFile() && e.name.endsWith(".tf")) {
|
|
315
|
+
out.push(rel ? `${rel}/${e.name}` : e.name);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
visit(cwd, "", 0);
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** normalize a POSIX path, resolving `.`/`..` segments. */
|
|
324
|
+
function normalizeRel(path: string): string {
|
|
325
|
+
const parts: string[] = [];
|
|
326
|
+
for (const seg of path.split("/")) {
|
|
327
|
+
if (!seg || seg === ".") continue;
|
|
328
|
+
if (seg === "..") parts.pop();
|
|
329
|
+
else parts.push(seg);
|
|
330
|
+
}
|
|
331
|
+
return parts.join("/");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Walk `cwd` recursively and build the module call-graph across every Terraform
|
|
336
|
+
* file (root + subdir roots + nested modules). A local module's dir is resolved
|
|
337
|
+
* RELATIVE TO THE DECLARING FILE (`./modules/x` in `core/main.tf` → `core/modules/x`),
|
|
338
|
+
* not to `cwd`, so the graph is correct for multi-root repos.
|
|
339
|
+
*/
|
|
340
|
+
export function collectModuleGraph(cwd: string): ModuleGraph {
|
|
341
|
+
const modules: ModuleBlock[] = [];
|
|
342
|
+
const files = walkTfFiles(cwd);
|
|
343
|
+
for (const f of files) {
|
|
344
|
+
let text: string;
|
|
345
|
+
try {
|
|
346
|
+
text = readFileSync(join(cwd, f), "utf8");
|
|
347
|
+
} catch {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
for (const block of parseModuleBlocks(text)) {
|
|
351
|
+
modules.push({ ...block, declaredIn: f });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// group local module dirs → caller files. Resolve the source against the
|
|
355
|
+
// declaring file's directory so relative paths from a subdir root land right.
|
|
356
|
+
const byDir = new Map<string, Set<string>>();
|
|
357
|
+
let externalCount = 0;
|
|
358
|
+
for (const mod of modules) {
|
|
359
|
+
if (mod.kind === "local") {
|
|
360
|
+
const callerDir = mod.declaredIn.includes("/")
|
|
361
|
+
? mod.declaredIn.slice(0, mod.declaredIn.lastIndexOf("/"))
|
|
362
|
+
: "";
|
|
363
|
+
const raw = mod.source.replace(/\\/g, "/");
|
|
364
|
+
const dir = normalizeRel(callerDir ? `${callerDir}/${raw}` : raw);
|
|
365
|
+
const set = byDir.get(dir) ?? new Set<string>();
|
|
366
|
+
set.add(mod.declaredIn);
|
|
367
|
+
byDir.set(dir, set);
|
|
368
|
+
} else if (mod.kind !== "unknown") {
|
|
369
|
+
externalCount++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const localModuleDirs = [...byDir.entries()]
|
|
373
|
+
.map(([dir, callers]) => ({ dir, callers: [...callers].sort() }))
|
|
374
|
+
.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
375
|
+
return { modules, localModuleDirs, externalCount };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* §24 dependency-aware fix ordering. Order the repo's LOCAL module dirs so a
|
|
380
|
+
* module that others depend on is fixed BEFORE its dependents — sequencing
|
|
381
|
+
* remediation PRs so they don't conflict (a fix at a shared module source
|
|
382
|
+
* propagates to callers; fixing the caller first would be redundant or clash).
|
|
383
|
+
*
|
|
384
|
+
* Builds edges among local module dirs: a `module` block declared inside local
|
|
385
|
+
* dir A whose source resolves to local dir B means "A depends on B", so B must
|
|
386
|
+
* come first. Returns a stable topological order (Kahn's algorithm, ties broken
|
|
387
|
+
* by path) of the local module dirs; cycle-safe (any leftover nodes are appended
|
|
388
|
+
* in sorted order). Pure. The remaining non-module (root) files are fixed after
|
|
389
|
+
* every local module they call — the caller dirs are in each entry's `callers`.
|
|
390
|
+
*/
|
|
391
|
+
export function dependencyOrderedModuleDirs(graph: ModuleGraph): string[] {
|
|
392
|
+
const dirs = graph.localModuleDirs.map((d) => d.dir);
|
|
393
|
+
const dirSet = new Set(dirs);
|
|
394
|
+
// dependsOn[A] = set of local dirs A depends on (A's blocks point at them).
|
|
395
|
+
const dependsOn = new Map<string, Set<string>>();
|
|
396
|
+
for (const d of dirs) dependsOn.set(d, new Set());
|
|
397
|
+
for (const mod of graph.modules) {
|
|
398
|
+
if (mod.kind !== "local") continue;
|
|
399
|
+
// which local module dir does the DECLARING file live in? (else it's a root)
|
|
400
|
+
const declDir = mod.declaredIn.includes("/")
|
|
401
|
+
? mod.declaredIn.slice(0, mod.declaredIn.lastIndexOf("/"))
|
|
402
|
+
: "";
|
|
403
|
+
// the MOST-SPECIFIC (longest) containing local module dir — not just the
|
|
404
|
+
// first match, or a block in a nested module (`modules/net/subnet`) would be
|
|
405
|
+
// mis-attributed to an outer one (`modules/net`), corrupting the order.
|
|
406
|
+
const ownerDir = [...dirSet]
|
|
407
|
+
.filter((d) => declDir === d || declDir.startsWith(`${d}/`))
|
|
408
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
409
|
+
if (!ownerDir) continue; // declared in a root, not inside a local module
|
|
410
|
+
// resolve the block's source dir (relative to the declaring file's dir).
|
|
411
|
+
const callerDir = declDir;
|
|
412
|
+
const raw = mod.source.replace(/\\/g, "/");
|
|
413
|
+
const targetDir = normalizeRel(callerDir ? `${callerDir}/${raw}` : raw);
|
|
414
|
+
if (dirSet.has(targetDir) && targetDir !== ownerDir) {
|
|
415
|
+
dependsOn.get(ownerDir)?.add(targetDir);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Kahn: emit a dir once all dirs it depends on are emitted. Ties by path.
|
|
419
|
+
const emitted: string[] = [];
|
|
420
|
+
const done = new Set<string>();
|
|
421
|
+
const remaining = new Set(dirs);
|
|
422
|
+
while (remaining.size > 0) {
|
|
423
|
+
const ready = [...remaining]
|
|
424
|
+
.filter((d) => [...(dependsOn.get(d) ?? [])].every((dep) => done.has(dep)))
|
|
425
|
+
.sort((a, b) => a.localeCompare(b));
|
|
426
|
+
if (ready.length === 0) {
|
|
427
|
+
// a cycle — append the rest deterministically and stop.
|
|
428
|
+
for (const d of [...remaining].sort((a, b) => a.localeCompare(b))) emitted.push(d);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
for (const d of ready) {
|
|
432
|
+
emitted.push(d);
|
|
433
|
+
done.add(d);
|
|
434
|
+
remaining.delete(d);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return emitted;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** true when `file` sits under one of the graph's local module dirs — i.e. a
|
|
441
|
+
* concern there should be fixed at the module SOURCE (it propagates to callers). */
|
|
442
|
+
export function isInLocalModule(
|
|
443
|
+
file: string,
|
|
444
|
+
graph: ModuleGraph,
|
|
445
|
+
): { dir: string; callers: string[] } | null {
|
|
446
|
+
const f = file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
447
|
+
for (const entry of graph.localModuleDirs) {
|
|
448
|
+
if (f === entry.dir || f.startsWith(`${entry.dir}/`)) return entry;
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- module interface introspection ----------------------------------------
|
|
454
|
+
|
|
455
|
+
export interface ModuleVariable {
|
|
456
|
+
name: string;
|
|
457
|
+
/** the `type` expression as written (`string`, `list(string)`, …), or null. */
|
|
458
|
+
type: string | null;
|
|
459
|
+
description: string | null;
|
|
460
|
+
/** true when the variable has NO `default` (the caller must set it). */
|
|
461
|
+
required: boolean;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface ModuleOutput {
|
|
465
|
+
name: string;
|
|
466
|
+
description: string | null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface ModuleInterface {
|
|
470
|
+
variables: ModuleVariable[];
|
|
471
|
+
outputs: ModuleOutput[];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** collapse every nested `{…}` span (to a fixpoint) so a `default`/`source`
|
|
475
|
+
* etc. token INSIDE an object type or a nested block isn't mistaken for a
|
|
476
|
+
* top-level attribute of the enclosing block. */
|
|
477
|
+
function stripNestedBraces(s: string): string {
|
|
478
|
+
let prev: string;
|
|
479
|
+
let cur = s;
|
|
480
|
+
do {
|
|
481
|
+
prev = cur;
|
|
482
|
+
cur = cur.replace(/\{[^{}]*\}/g, " ");
|
|
483
|
+
} while (cur !== prev);
|
|
484
|
+
return cur;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** brace-match the body of the first `<kw> "<name>" {` block at/after `from`. */
|
|
488
|
+
function blockBody(hcl: string, kw: string): { name: string; body: string; end: number }[] {
|
|
489
|
+
const out: { name: string; body: string; end: number }[] = [];
|
|
490
|
+
const re = new RegExp(`${kw}\\s+"([^"]+)"\\s*\\{`, "g");
|
|
491
|
+
let m: RegExpExecArray | null;
|
|
492
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
493
|
+
while ((m = re.exec(hcl)) !== null) {
|
|
494
|
+
const name = m[1]!;
|
|
495
|
+
const braceStart = re.lastIndex - 1;
|
|
496
|
+
let depth = 0;
|
|
497
|
+
let end = -1;
|
|
498
|
+
for (let i = braceStart; i < hcl.length; i++) {
|
|
499
|
+
if (hcl[i] === "{") depth++;
|
|
500
|
+
else if (hcl[i] === "}") {
|
|
501
|
+
depth--;
|
|
502
|
+
if (depth === 0) {
|
|
503
|
+
end = i;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (end === -1) break;
|
|
509
|
+
out.push({ name, body: hcl.slice(braceStart + 1, end), end });
|
|
510
|
+
re.lastIndex = end + 1;
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Parse a module's `variable`/`output` blocks into its public interface — the
|
|
517
|
+
* real names, types, descriptions, and which variables are REQUIRED (no
|
|
518
|
+
* `default`). Used so a generated `module` block (or a Terratest/example
|
|
519
|
+
* fixture) sets the module's ACTUAL variables instead of guessed ones. Pure;
|
|
520
|
+
* brace-matched so a nested `type = object({…})` or `validation {…}` doesn't
|
|
521
|
+
* confuse it.
|
|
522
|
+
*/
|
|
523
|
+
export function parseModuleInterface(hcl: string): ModuleInterface {
|
|
524
|
+
const variables: ModuleVariable[] = [];
|
|
525
|
+
for (const { name, body } of blockBody(hcl, "variable")) {
|
|
526
|
+
// `type = <expr>` up to end-of-line (the expr can contain braces/parens).
|
|
527
|
+
const typeMatch = body.match(/(?:^|\n)\s*type\s*=\s*([^\n]+)/);
|
|
528
|
+
const description = body.match(/(?:^|\n)\s*description\s*=\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? null;
|
|
529
|
+
// a TOP-LEVEL `default` attribute makes the variable optional. Strip nested
|
|
530
|
+
// `{…}` spans first so an object-type FIELD named `default`
|
|
531
|
+
// (`type = object({ default = string })`) or a `validation {}` block isn't
|
|
532
|
+
// mistaken for the attribute — that would wrongly mark a REQUIRED var optional.
|
|
533
|
+
const hasDefault = /(?:^|\n)\s*default\s*=/.test(stripNestedBraces(body));
|
|
534
|
+
variables.push({
|
|
535
|
+
name,
|
|
536
|
+
type: typeMatch ? typeMatch[1]!.trim() : null,
|
|
537
|
+
description,
|
|
538
|
+
required: !hasDefault,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
const outputs: ModuleOutput[] = [];
|
|
542
|
+
for (const { name, body } of blockBody(hcl, "output")) {
|
|
543
|
+
const description = body.match(/(?:^|\n)\s*description\s*=\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? null;
|
|
544
|
+
outputs.push({ name, description });
|
|
545
|
+
}
|
|
546
|
+
return { variables, outputs };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** read a module DIR's `*.tf` (non-recursive — a module is a single dir) and
|
|
550
|
+
* parse its interface. */
|
|
551
|
+
export function collectModuleInterface(cwd: string, moduleDir: string): ModuleInterface {
|
|
552
|
+
let text = "";
|
|
553
|
+
const full = moduleDir ? join(cwd, moduleDir) : cwd;
|
|
554
|
+
try {
|
|
555
|
+
for (const f of readdirSync(full)) {
|
|
556
|
+
if (!f.endsWith(".tf")) continue;
|
|
557
|
+
try {
|
|
558
|
+
text += `${readFileSync(join(full, f), "utf8")}\n`;
|
|
559
|
+
} catch {
|
|
560
|
+
/* skip */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
return { variables: [], outputs: [] };
|
|
565
|
+
}
|
|
566
|
+
return parseModuleInterface(text);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// --- the tools -------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
export const ListModulesParams = type({});
|
|
572
|
+
|
|
573
|
+
export function ListModulesTool(ctx: LocalToolContext) {
|
|
574
|
+
return tool({
|
|
575
|
+
name: "list_modules",
|
|
576
|
+
description:
|
|
577
|
+
"List the modules to PREFER over hand-rolling raw resources. Combines the operator-approved " +
|
|
578
|
+
"`module_catalogue` input (registry modules like `terraform-aws-modules/vpc/aws` or house modules at " +
|
|
579
|
+
"a local path) with `discovered_house_modules` — local modules already used in THIS repo (e.g. " +
|
|
580
|
+
"`modules/cloudwatch_logs`), auto-detected from the call-graph so an existing house module is reused " +
|
|
581
|
+
"with its real interface rather than re-implemented. Use a module's exact variable names and pin its " +
|
|
582
|
+
"`version` (a git module's pin is its `?ref=`). When nothing is configured or discovered, fall back to " +
|
|
583
|
+
"a well-maintained public registry module (pinned) or well-formed raw resources.",
|
|
584
|
+
parameters: ListModulesParams,
|
|
585
|
+
execute: execute(async () => {
|
|
586
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
587
|
+
const entries = parseModuleCatalogue(ctx.payload.moduleCatalogue);
|
|
588
|
+
// auto-discover house modules already used in this repo (the convention
|
|
589
|
+
// hepcare-style repos follow: `modules/<name>` referenced from the root).
|
|
590
|
+
const graph = collectModuleGraph(cwd);
|
|
591
|
+
const discovered = graph.localModuleDirs.map((d) => ({
|
|
592
|
+
name: d.dir.split("/").pop() ?? d.dir,
|
|
593
|
+
path: d.dir,
|
|
594
|
+
callers: d.callers,
|
|
595
|
+
exists: moduleDirExists(cwd, d.dir),
|
|
596
|
+
}));
|
|
597
|
+
log.info(
|
|
598
|
+
`» list_modules: ${entries.length} catalogue module(s), ${discovered.length} discovered house module(s)`,
|
|
599
|
+
);
|
|
600
|
+
return toolOk({
|
|
601
|
+
configured: entries.length > 0,
|
|
602
|
+
modules: entries,
|
|
603
|
+
discovered_house_modules: discovered,
|
|
604
|
+
note:
|
|
605
|
+
entries.length > 0 || discovered.length > 0
|
|
606
|
+
? "Prefer these modules (exact variable names; pin the version). Reuse a discovered house module with its real interface rather than re-implementing it."
|
|
607
|
+
: "No catalogue or house modules. Prefer a well-maintained public registry module (pinned) or well-formed raw resources.",
|
|
608
|
+
});
|
|
609
|
+
}),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export const TerraformModuleGraphParams = type({});
|
|
614
|
+
|
|
615
|
+
export function TerraformModuleGraphTool(ctx: LocalToolContext) {
|
|
616
|
+
return tool({
|
|
617
|
+
name: "terraform_module_graph",
|
|
618
|
+
description:
|
|
619
|
+
"Build the repo's module call-graph (§4.14) so a fix lands in the right place. Parses every " +
|
|
620
|
+
'`module "x" { source = … }` block and classifies each source as local / registry / git / remote. ' +
|
|
621
|
+
"Returns `local_module_dirs` (each with the caller files that use it) — a concern INSIDE one of these " +
|
|
622
|
+
"dirs should be fixed ONCE at the module source (the fix propagates to every caller), not patched at " +
|
|
623
|
+
"each call site. A concern whose fix would require editing a registry/git/remote module is NOT " +
|
|
624
|
+
"editable in this repo — open an issue naming the upstream module + version instead of attempting it.",
|
|
625
|
+
parameters: TerraformModuleGraphParams,
|
|
626
|
+
execute: execute(async () => {
|
|
627
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
628
|
+
const graph = collectModuleGraph(cwd);
|
|
629
|
+
log.info(
|
|
630
|
+
`» terraform_module_graph: ${graph.modules.length} module block(s), ` +
|
|
631
|
+
`${graph.localModuleDirs.length} local dir(s), ${graph.externalCount} external`,
|
|
632
|
+
);
|
|
633
|
+
return toolOk({
|
|
634
|
+
modules: graph.modules.map((m) => ({
|
|
635
|
+
name: m.name,
|
|
636
|
+
source: m.source,
|
|
637
|
+
version: m.version,
|
|
638
|
+
subdir: m.subdir,
|
|
639
|
+
kind: m.kind,
|
|
640
|
+
declared_in: m.declaredIn,
|
|
641
|
+
})),
|
|
642
|
+
local_module_dirs: graph.localModuleDirs,
|
|
643
|
+
external_module_count: graph.externalCount,
|
|
644
|
+
// §24 — fix shared/depended-on modules BEFORE their dependents so
|
|
645
|
+
// sequenced remediation PRs don't conflict; advisory ordering of the
|
|
646
|
+
// local module dirs (a module others depend on comes first).
|
|
647
|
+
dependency_order: dependencyOrderedModuleDirs(graph),
|
|
648
|
+
});
|
|
649
|
+
}),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export const TerraformModuleInterfaceParams = type({
|
|
654
|
+
module_dir: type.string.describe(
|
|
655
|
+
"the module's repo-relative dir (e.g. 'modules/cloudwatch_logs' or a discovered house module's path).",
|
|
656
|
+
),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
export function TerraformModuleInterfaceTool(ctx: LocalToolContext) {
|
|
660
|
+
return tool({
|
|
661
|
+
name: "terraform_module_interface",
|
|
662
|
+
description:
|
|
663
|
+
"Parse a module's PUBLIC INTERFACE — its `variable`s (name, type, description, and whether each is " +
|
|
664
|
+
"REQUIRED, i.e. has no default) and its `output`s — so a `module` block you write (or a Terratest " +
|
|
665
|
+
"scaffold) uses the module's REAL variable names and sets every required input, instead of " +
|
|
666
|
+
"guessing. Point it at a local/house module dir (from `list_modules` / `terraform_module_graph`).",
|
|
667
|
+
parameters: TerraformModuleInterfaceParams,
|
|
668
|
+
execute: execute(async ({ module_dir }) => {
|
|
669
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
670
|
+
// SECURITY: confine the agent-supplied module dir to the workspace so it
|
|
671
|
+
// can't read `*.tf` files from outside the repo (e.g. '../../etc').
|
|
672
|
+
resolveWithinCwd(cwd, module_dir);
|
|
673
|
+
const iface = collectModuleInterface(cwd, module_dir);
|
|
674
|
+
log.info(
|
|
675
|
+
`» terraform_module_interface(${module_dir}): ${iface.variables.length} var(s), ${iface.outputs.length} output(s)`,
|
|
676
|
+
);
|
|
677
|
+
return {
|
|
678
|
+
ok: true,
|
|
679
|
+
module_dir,
|
|
680
|
+
required_variables: iface.variables.filter((v) => v.required).map((v) => v.name),
|
|
681
|
+
variables: iface.variables,
|
|
682
|
+
outputs: iface.outputs,
|
|
683
|
+
};
|
|
684
|
+
}),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** best-effort existence check used by tests/tools — exported for reuse. */
|
|
689
|
+
export function moduleDirExists(cwd: string, dir: string): boolean {
|
|
690
|
+
try {
|
|
691
|
+
const full = join(cwd, dir);
|
|
692
|
+
return existsSync(full) && statSync(full).isDirectory();
|
|
693
|
+
} catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|