infraweaver 0.3.7 → 0.3.8

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 (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31903 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31987 -18749
  8. package/dist/internal.js +132 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +34 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +6 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -12,6 +12,7 @@ import {
12
12
  AZURE_PRICE_REGION,
13
13
  } from "#app/mcp/terraform/azurePrices";
14
14
  import type { TfResourceEntry } from "#app/mcp/terraform/cspm";
15
+ import { tableCell } from "#app/utils/markdownTable";
15
16
 
16
17
  /**
17
18
  * Idle-floor detection — the resources that
@@ -464,7 +465,7 @@ export function renderCostMarkdown(report: IdleFloorReport, toolVersion: string)
464
465
  "| --- | --- | --- | --- |",
465
466
  ...provider.items.map(
466
467
  (i) =>
467
- `| \`${i.address}\` | ${i.resourceType} | ${i.file}:${i.line} | ${money(i.currency, i.monthly_cost)} |`,
468
+ `| \`${i.address}\` | ${i.resourceType} | ${tableCell(`${i.file}:${i.line}`)} | ${money(i.currency, i.monthly_cost)} |`,
468
469
  ),
469
470
  "",
470
471
  );
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `terraform init` output a tool leaves in the checkout, and undoing it.
3
+ *
4
+ * Validate inits every root in place. In a review that dirties the checkout the
5
+ * agent then works in; in a write mode it is worse — the push refuses a dirty
6
+ * tree, and an editor that deletes the files by hand spends turns on it, or
7
+ * commits a lock file the repository never had.
8
+ */
9
+ import { rmSync } from "node:fs";
10
+ import { join, posix } from "node:path";
11
+ import { resetTerraformInitCache } from "#app/mcp/terraform/scanners";
12
+ import { run } from "#app/mcp/terraform/subprocess";
13
+ import { log } from "#app/utils/cli";
14
+
15
+ const INIT_ARTEFACTS = new Set([".terraform", ".terraform.lock.hcl"]);
16
+
17
+ export interface InitArtefacts {
18
+ /** init output git neither tracks nor ignores. */
19
+ untracked: Set<string>;
20
+ /** committed lock files with uncommitted changes. */
21
+ modifiedLocks: Set<string>;
22
+ }
23
+
24
+ /**
25
+ * The `terraform init` output in the checkout, as repo-relative paths; null
26
+ * outside a git checkout. A committed lock file counts once it is modified:
27
+ * init adds the runner's platform hash to one made on another platform (a
28
+ * lock file from a Mac lacks the linux hash).
29
+ */
30
+ export function initArtefacts(cwd: string): InitArtefacts | null {
31
+ const r = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], cwd);
32
+ if (r.status !== 0) return null;
33
+ const entries = r.stdout.split("\0");
34
+ const isArtefact = (p: string) => INIT_ARTEFACTS.has(posix.basename(p.replace(/\/$/, "")));
35
+ return {
36
+ untracked: new Set(
37
+ entries
38
+ .filter((e) => e.startsWith("?? "))
39
+ .map((e) => e.slice(3))
40
+ .filter(isArtefact),
41
+ ),
42
+ modifiedLocks: new Set(
43
+ entries
44
+ .filter((e) => e[1] === "M" && e[2] === " ")
45
+ .map((e) => e.slice(3))
46
+ .filter((p) => posix.basename(p) === ".terraform.lock.hcl"),
47
+ ),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Undo what the gate scan's init did to the checkout: remove the artefacts it
53
+ * created and restore the committed lock files it changed. Validate inits every
54
+ * root in place, and a checkout that is dirty before the agent starts makes
55
+ * `checkout_pr` refuse — or, on a committing run, ships the change in the fix.
56
+ * What was there before the scan is left alone.
57
+ */
58
+ export function removeNewInitArtefacts(
59
+ cwd: string,
60
+ before: InitArtefacts | null,
61
+ by: string,
62
+ ): void {
63
+ if (!before) return;
64
+ const top = run("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
65
+ const after = initArtefacts(cwd);
66
+ const created = [...(after?.untracked ?? [])].filter((p) => !before.untracked.has(p));
67
+ const changed = [...(after?.modifiedLocks ?? [])].filter((p) => !before.modifiedLocks.has(p));
68
+ if (!top || created.length + changed.length === 0) return;
69
+ for (const p of created) rmSync(join(top, p), { recursive: true, force: true });
70
+ if (changed.length > 0) run("git", ["checkout", "--", ...changed], top);
71
+ resetTerraformInitCache();
72
+ log.info(
73
+ `» ${by}: removed ${created.length} terraform init artefact(s) from the checkout` +
74
+ (changed.length ? `, restored ${changed.length} committed lock file(s)` : ""),
75
+ );
76
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The local `module` calls a Terraform root reaches, followed through every
3
+ * local module it calls in turn, each resolved against the disk.
4
+ *
5
+ * `terraform validate` cannot report a local source that points nowhere: init
6
+ * fails to install the module, and validate's "Module not installed" is
7
+ * indistinguishable from an init that failed for an environmental reason (no
8
+ * network, a private registry), so it is dropped as noise. A local source needs
9
+ * neither — whether `./modules/s3` exists is a fact about the tree — so it is
10
+ * read here instead, and a missing one is reported as the defect it is.
11
+ */
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { join, posix, resolve } from "node:path";
14
+ import { parseModuleBlocks } from "#app/mcp/modules";
15
+
16
+ /**
17
+ * The `source` a module block in `fromDir` writes to call the module at
18
+ * `moduleDir`, both repo-relative POSIX paths. A local source is relative to the
19
+ * CALLER's directory, so `./modules/s3` is right only from the repo root; from
20
+ * `env/dev` the same module is `../../modules/s3`.
21
+ */
22
+ export function localSourceFrom(fromDir: string, moduleDir: string): string {
23
+ const rel = posix.relative(fromDir || ".", moduleDir || ".") || ".";
24
+ return rel === "." || rel.startsWith("../") || rel === ".." ? rel : `./${rel}`;
25
+ }
26
+
27
+ const CONFIG_SUFFIXES = [".tf", ".tf.json", ".tofu"] as const;
28
+
29
+ interface LocalModuleCall {
30
+ /** absolute path of the file declaring the call. */
31
+ file: string;
32
+ /** 1-based line of the `module` header; null in JSON configuration. */
33
+ line: number | null;
34
+ name: string;
35
+ source: string;
36
+ /** absolute directory the source resolves to. */
37
+ dir: string;
38
+ /** whether that directory holds any Terraform configuration. */
39
+ exists: boolean;
40
+ }
41
+
42
+ export interface LocalModuleClosure {
43
+ /** every local call reached from the root, missing ones included. */
44
+ calls: LocalModuleCall[];
45
+ /** the root and every existing local module directory it reaches. */
46
+ dirs: string[];
47
+ }
48
+
49
+ /** the configuration files directly in `dir` (not beneath it). */
50
+ export function configFilesIn(dir: string): string[] {
51
+ try {
52
+ return readdirSync(dir)
53
+ .filter((f) => CONFIG_SUFFIXES.some((s) => f.endsWith(s)))
54
+ .map((f) => join(dir, f))
55
+ .filter((f) => statSync(f).isFile())
56
+ .sort();
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function callsIn(file: string): Omit<LocalModuleCall, "dir" | "exists">[] {
63
+ let text: string;
64
+ try {
65
+ text = readFileSync(file, "utf-8");
66
+ } catch {
67
+ return [];
68
+ }
69
+ if (file.endsWith(".json")) {
70
+ try {
71
+ const doc = JSON.parse(text) as { module?: Record<string, { source?: unknown }> };
72
+ return Object.entries(doc.module ?? {}).flatMap(([name, block]) =>
73
+ typeof block?.source === "string" && /^\.\.?[\\/]/.test(block.source)
74
+ ? [{ file, line: null, name, source: block.source }]
75
+ : [],
76
+ );
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+ return parseModuleBlocks(text)
82
+ .filter((b) => b.kind === "local")
83
+ .map((b) => {
84
+ const at = text.search(new RegExp(`module\\s+"${RegExp.escape(b.name)}"`));
85
+ return {
86
+ file,
87
+ line: at < 0 ? null : text.slice(0, at).split("\n").length,
88
+ name: b.name,
89
+ source: b.source,
90
+ };
91
+ });
92
+ }
93
+
94
+ export function localModuleClosure(rootDir: string): LocalModuleClosure {
95
+ const calls: LocalModuleCall[] = [];
96
+ const seen = new Set<string>([resolve(rootDir)]);
97
+ const queue = [resolve(rootDir)];
98
+ for (let dir = queue.shift(); dir !== undefined; dir = queue.shift()) {
99
+ for (const file of configFilesIn(dir)) {
100
+ for (const call of callsIn(file)) {
101
+ const target = resolve(dir, call.source);
102
+ const exists = existsSync(target) && configFilesIn(target).length > 0;
103
+ calls.push({ ...call, dir: target, exists });
104
+ if (exists && !seen.has(target)) {
105
+ seen.add(target);
106
+ queue.push(target);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return { calls, dirs: [...seen] };
112
+ }
@@ -1,5 +1,6 @@
1
1
  import { PRODUCT_NAME } from "#app/brand";
2
2
  import type { ModuleInterface } from "#app/mcp/modules";
3
+ import { tableCell } from "#app/utils/markdownTable";
3
4
  import packageJson from "#package.json" with { type: "json" };
4
5
 
5
6
  export type ModuleDocsVerdict =
@@ -41,11 +42,7 @@ const VERDICT_CLAIM: Record<ModuleDocsVerdict, string> = {
41
42
  };
42
43
 
43
44
  function escapeCell(text: string | null): string {
44
- if (!text) return "";
45
- return text
46
- .replaceAll("|", "\\|")
47
- .replaceAll(/\s*\n\s*/g, " ")
48
- .trim();
45
+ return text ? tableCell(text) : "";
49
46
  }
50
47
 
51
48
  function moduleName(moduleDir: string): string {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Findings inside a module the repository does not hold — a registry or git
3
+ * module that `terraform init` or checkov fetched — moved to the module call
4
+ * that instantiates it.
5
+ *
6
+ * Both scanners evaluate such a module with the caller's inputs, which is how an
7
+ * insecure input (`block_public_acls = false`) shows up at all. But they report
8
+ * the finding against the fetched copy: a path under `.terraform/modules`, under
9
+ * checkov's download directory, or one trivy synthesises from the module source.
10
+ * None of those is a file in the repository, so a review scoped to the roots a
11
+ * change reaches drops the finding, and a fix loop points the agent at code it
12
+ * must not edit. The call is where the finding is answered — through the
13
+ * module's inputs — and where a reviewer reads the change.
14
+ */
15
+ import { isAbsolute, join, posix, relative, resolve } from "node:path";
16
+ import { readTextCapped } from "#app/mcp/modules";
17
+ import { buildConcern, type Concern } from "#app/mcp/terraform/types";
18
+
19
+ /** Where a scanner was told to put the modules it fetched, outside the tree. */
20
+ export interface FetchDir {
21
+ /** the directory the scanner's paths are relative to. */
22
+ cwd: string;
23
+ dir: string;
24
+ }
25
+
26
+ /**
27
+ * True when a scanner-reported path is a fetched module's copy, not repository
28
+ * code: under terraform's or checkov's module directory in the tree, or under
29
+ * the directory a scan had checkov download into. A path that merely leaves the
30
+ * scanned directory is not enough — a scan of one root reports the repository's
31
+ * own local modules as `../../modules/x`.
32
+ */
33
+ export function isFetchedModulePath(file: string, fetchedInto?: FetchDir): boolean {
34
+ const segments = file.replace(/\\/g, "/").split("/");
35
+ if (segments.some((s) => s === ".terraform" || s === ".external_modules")) return true;
36
+ if (!fetchedInto) return false;
37
+ const rel = relative(fetchedInto.dir, resolve(fetchedInto.cwd, file));
38
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
39
+ }
40
+
41
+ /** 1-based line of `module "<name>"` in `file`, or null when it is not there. */
42
+ export function moduleCallLine(cwd: string, file: string, name: string): number | null {
43
+ const text = readTextCapped(join(cwd, file));
44
+ if (text === null) return null;
45
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ const at = text.search(new RegExp(`^[ \\t]*module[ \\t]+"${escaped}"[ \\t]*\\{`, "m"));
47
+ return at < 0 ? null : text.slice(0, at).split("\n").length;
48
+ }
49
+
50
+ /** `module.<name>…` → `<name>`, the call in the root that instantiates it. */
51
+ export function topModuleName(address: string): string | null {
52
+ return /^module\.([^.[]+)/.exec(address)?.[1] ?? null;
53
+ }
54
+
55
+ export interface ModuleFinding {
56
+ /** the concern as the scanner reported it, against the fetched copy. */
57
+ concern: Concern;
58
+ /** the call that instantiates the module, in the repository. */
59
+ call: { file: string; line: number | null };
60
+ /** the module resource the finding is about, e.g. `module.data.aws_s3_bucket.this`. */
61
+ address: string;
62
+ }
63
+
64
+ /**
65
+ * Re-locate each finding to its call, saying in the evidence which module
66
+ * resource it is about and where in the module. Findings that land on the same
67
+ * call under the same rule are one concern — a concern's identity is its rule
68
+ * and location — so they are merged, every resource named: one concern per
69
+ * resource would be collapsed by the dedupe anyway, and silently.
70
+ */
71
+ export function relocateToCalls(findings: ModuleFinding[]): Concern[] {
72
+ const merged = new Map<string, { concern: Concern; addresses: string[]; where: string[] }>();
73
+ for (const { concern, call, address } of findings) {
74
+ const bareRule = concern.rule_id.slice(concern.source.length + 1);
75
+ const at = buildConcern({
76
+ source: concern.source,
77
+ rule: bareRule,
78
+ file: call.file,
79
+ line: call.line,
80
+ severity: concern.severity,
81
+ category: concern.category,
82
+ evidence: concern.evidence,
83
+ remediation_hint: concern.remediation_hint,
84
+ // the scanner's snippet is the module's code; the location is now the call
85
+ code_snippet: null,
86
+ ...(concern.language ? { language: concern.language } : {}),
87
+ });
88
+ const where = `${posix.basename(concern.location.file)}:${concern.location.line ?? "?"}`;
89
+ const prior = merged.get(at.id);
90
+ if (!prior) merged.set(at.id, { concern: at, addresses: [address], where: [where] });
91
+ else if (!prior.addresses.includes(address)) {
92
+ prior.addresses.push(address);
93
+ prior.where.push(where);
94
+ }
95
+ }
96
+ return [...merged.values()].map(({ concern, addresses, where }) => ({
97
+ ...concern,
98
+ evidence:
99
+ `${concern.evidence} — inside the module: ` +
100
+ addresses.map((a, i) => `${a} (${where[i]})`).join(", "),
101
+ }));
102
+ }
@@ -46,7 +46,7 @@ export interface UnpinnedModule {
46
46
 
47
47
  /** the 1-based line of a `module "<name>"` header in a file. */
48
48
  function moduleLine(hcl: string, name: string): number {
49
- const re = new RegExp(`module\\s+"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*\\{`);
49
+ const re = new RegExp(`module\\s+"${RegExp.escape(name)}"\\s*\\{`);
50
50
  const at = re.exec(hcl)?.index;
51
51
  return at === undefined ? 1 : hcl.slice(0, at).split("\n").length;
52
52
  }
@@ -33,6 +33,7 @@
33
33
  * network. The plan file is supplied by the caller.
34
34
  */
35
35
 
36
+ import { isDeepStrictEqual } from "node:util";
36
37
  import type { PlanResourceChange } from "#app/mcp/terraform/plan";
37
38
 
38
39
  // --- ignore rules ------------------------------------------------------------
@@ -172,25 +173,6 @@ export function isUnknownPath(unknown: Set<string>, path: string): boolean {
172
173
  return false;
173
174
  }
174
175
 
175
- /** structural equality over JSON values (the only shapes a plan contains). */
176
- function deepEqual(a: unknown, b: unknown): boolean {
177
- if (a === b) return true;
178
- if (typeof a !== typeof b) return false;
179
- if (Array.isArray(a) && Array.isArray(b)) {
180
- return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
181
- }
182
- if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
183
- if (Array.isArray(a) !== Array.isArray(b)) return false;
184
- const ka = Object.keys(a);
185
- const kb = Object.keys(b);
186
- if (ka.length !== kb.length) return false;
187
- return ka.every((k) =>
188
- deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
189
- );
190
- }
191
- return false;
192
- }
193
-
194
176
  // --- pair comparison ---------------------------------------------------------
195
177
 
196
178
  export interface PairComparison {
@@ -239,7 +221,7 @@ export function comparePair(
239
221
  }
240
222
  const a = before.get(path);
241
223
  const b = after.get(path);
242
- if (deepEqual(a, b)) {
224
+ if (isDeepStrictEqual(a, b)) {
243
225
  equal++;
244
226
  continue;
245
227
  }
@@ -404,11 +404,6 @@ function blockKey(block: { keyword: string; labels: string[] }): string {
404
404
  return [block.keyword, ...block.labels].join(".");
405
405
  }
406
406
 
407
- /** A `resource` or `module` header whose first label is NOT quoted. Valid HCL,
408
- * but `parseBlocks` reads quoted labels only, so such a block would be invisible
409
- * — and an invisible violation is a pass. */
410
- const UNQUOTED_BLOCK = /(?:^|\n)[ \t]*(?:resource|module)[ \t]+[A-Za-z_][A-Za-z0-9_-]*[ \t{]/;
411
-
412
407
  /** Why the walk below never reads a module block's code, or null when it does.
413
408
  * A local source is walked where it lives — unless its path leaves `cwd` or
414
409
  * lands in a directory the walk skips; then it is as unseen as a registry
@@ -506,16 +501,6 @@ function evaluateRulesStatically(
506
501
  const types = new Set(rules.map((r) => r.resource_type));
507
502
  for (const { rel, text } of files) {
508
503
  const file = posix(rel);
509
- if (blindSpotMatters && UNQUOTED_BLOCK.test(text)) {
510
- blind(
511
- file,
512
- "a resource or module block with an unquoted label is not read by the static path",
513
- {
514
- file,
515
- },
516
- );
517
- }
518
-
519
504
  for (const { block, unread } of modules.get(rel) ?? []) {
520
505
  if (!unread) continue; // its resources are walked where they live
521
506
  const changed = scope.kind === "all" || scope.isChanged(rel, blockKey(block), block.body);
@@ -23,14 +23,15 @@
23
23
 
24
24
  import { posix } from "node:path";
25
25
  import { parseModuleBlocks } from "#app/mcp/modules";
26
- import type { FetchedModules } from "#app/mcp/terraform/refactor/workspace";
27
26
  import { type MovedBlock, parseMovedBlocks } from "#app/mcp/terraform/refactor/movedBlocks";
28
27
  import {
29
28
  type Multiplicity,
30
29
  moduleCallMultiplicity,
30
+ moduleCallProviders,
31
31
  parseResourcesWithArgs,
32
32
  type ResourceRef,
33
33
  } from "#app/mcp/terraform/refactor/resources";
34
+ import type { FetchedModules } from "#app/mcp/terraform/refactor/workspace";
34
35
  import type { SourceFile } from "#app/mcp/terraform/tree";
35
36
  import { pushInto } from "#app/utils/collect";
36
37
 
@@ -52,6 +53,35 @@ interface ModuleCall {
52
53
  /** the CALL's own `count`/`for_each` — the outer half of the effective
53
54
  * multiplicity chain. See [[Multiplicity]]. */
54
55
  multiplicity: Multiplicity | null;
56
+ /** the CALL's `providers` map, or null when it passes none — the module then
57
+ * inherits the caller's default configurations. See [[parseProvider]]. */
58
+ providers: Map<string, string> | null;
59
+ }
60
+
61
+ /** a module's name for a provider configuration → the root's, or null when the
62
+ * module cannot reach that configuration at all. */
63
+ type ProviderScope = (local: string) => string | null;
64
+
65
+ /** the scope inside a module called with `providers` (or without it). Without
66
+ * the map a module inherits only the caller's DEFAULT configurations; an
67
+ * aliased one must be passed explicitly. */
68
+ function childScope(parent: ProviderScope, providers: Map<string, string> | null): ProviderScope {
69
+ if (providers === null) return (local) => (local.includes(".") ? null : parent(local));
70
+ return (local) => {
71
+ const passed = providers.get(local);
72
+ return passed === undefined ? null : parent(passed);
73
+ };
74
+ }
75
+
76
+ /** the configuration a resource is managed through, as the root names it. */
77
+ function effectiveProvider(
78
+ r: { type: string; provider: string | null },
79
+ scope: ProviderScope,
80
+ ): string {
81
+ // a resource with no `provider` uses the default configuration of the
82
+ // provider its type names: `aws_s3_bucket` → `aws`
83
+ const local = r.provider ?? r.type.split("_")[0] ?? r.type;
84
+ return scope(local) ?? `${local} (not passed to this module)`;
55
85
  }
56
86
 
57
87
  /** one `.tf` file, parsed once — everything the attribution walk needs. */
@@ -73,6 +103,7 @@ function parseTree(
73
103
  for (const f of files) {
74
104
  const dir = posix.dirname(f.path);
75
105
  const callMultiplicity = moduleCallMultiplicity(f.hcl);
106
+ const callProviders = moduleCallProviders(f.hcl);
76
107
  const calls: ModuleCall[] = [];
77
108
  for (const block of parseModuleBlocks(f.hcl)) {
78
109
  const targetDir =
@@ -86,6 +117,7 @@ function parseTree(
86
117
  dir: targetDir,
87
118
  callName: block.name,
88
119
  multiplicity: callMultiplicity.get(block.name) ?? null,
120
+ providers: callProviders.get(block.name) ?? null,
89
121
  });
90
122
  }
91
123
  const parsed: ParsedFile = {
@@ -189,6 +221,7 @@ export function attributeAddresses(
189
221
  prefix: string,
190
222
  chain: Multiplicity[],
191
223
  path: string[],
224
+ scope: ProviderScope,
192
225
  ): void => {
193
226
  if (path.includes(dir)) {
194
227
  ambiguous.add(`${root}: module call cycle ${[...path, dir].join(" -> ")}`);
@@ -216,6 +249,7 @@ export function attributeAddresses(
216
249
  multiplicity: r.multiplicity
217
250
  ? [...chain, { ...r.multiplicity, at: "resource" as const }]
218
251
  : chain,
252
+ provider: effectiveProvider(r, scope),
219
253
  });
220
254
  }
221
255
  for (const mv of p.moved) {
@@ -232,12 +266,14 @@ export function attributeAddresses(
232
266
  `${prefix}module.${c.callName}.`,
233
267
  c.multiplicity ? [...chain, { ...c.multiplicity, at: "module" as const }] : chain,
234
268
  [...path, dir],
269
+ childScope(scope, c.providers),
235
270
  );
236
271
  }
237
272
  }
238
273
  };
239
274
 
240
- for (const root of roots) visit(root, root, "", [], []);
275
+ // at a root every configuration is its own name
276
+ for (const root of roots) visit(root, root, "", [], [], (local) => local);
241
277
 
242
278
  const uninstantiated = [...byDir.keys()].filter((d) => !reached.has(d)).sort();
243
279
  return { resources, ambiguous: [...ambiguous].sort(), roots, moved, uninstantiated };
@@ -279,9 +315,7 @@ export function attributePair(
279
315
  const present = new Set(files.map((f) => f.path));
280
316
  return [...files, ...fetched.files.filter((f) => !present.has(f.path))];
281
317
  };
282
- const opts = fetched
283
- ? { moduleDirs, fetchedBySource: fetched.bySource }
284
- : { moduleDirs };
318
+ const opts = fetched ? { moduleDirs, fetchedBySource: fetched.bySource } : { moduleDirs };
285
319
  return {
286
320
  before: attributeAddresses(withFetched(beforeFiles), opts),
287
321
  after: attributeAddresses(withFetched(afterFiles), opts),
@@ -97,6 +97,9 @@ export interface EquivalenceResult {
97
97
  multiplicity_preserved: boolean;
98
98
  /** per-resource multiplicity drift, human-readable on both sides. */
99
99
  multiplicity_diffs: { resource: string; before: string; after: string }[];
100
+ /** relocations that changed the provider configuration managing the resource
101
+ * (G16) — another region or account, with every argument unchanged. */
102
+ provider_diffs: { resource: string; before: string; after: string }[];
100
103
  /** relocations where the repetition changed LEVEL — it sat on the resource and
101
104
  * now sits on a module call, or the reverse (G15). The instance key moves to a
102
105
  * different position in the address, so the same instances live at entirely
@@ -399,12 +402,19 @@ function uncoveredInstanceKeys(p: Pairing): string[] | null {
399
402
  const written = (k: string) => (kind === "count" ? k : `"${k}"`);
400
403
  const declared = new Set<string>();
401
404
  for (const blk of p.blocks) {
402
- // read the key off whichever side carries it: the point of a relevelling is
403
- // that only one of them does, and which one depends on the direction.
404
- const keys = instanceKeysOf(blk.from).concat(instanceKeysOf(blk.to));
405
- const unique = new Set(keys);
406
- if (unique.size !== 1) return null;
407
- for (const k of unique) declared.add(k);
405
+ // A relevelling repeats BOTH sides, at different levels: the instance is
406
+ // `aws_s3_bucket.data["logs"]` before and `module.data["logs"].…` after, so a
407
+ // block covers it only when each side names that same key. A key on one side
408
+ // alone (`aws_s3_bucket.data["logs"]` → `module.data.aws_s3_bucket.this`)
409
+ // points at an address the repetition no longer has, and Terraform plans it
410
+ // as a destroy and a create.
411
+ const from = instanceKeysOf(blk.from);
412
+ const to = instanceKeysOf(blk.to);
413
+ // more than one key per side is a chain this proof does not read; no key on
414
+ // either side names no single instance
415
+ if (from.length > 1 || to.length > 1) return null;
416
+ if (from.length === 0 && to.length === 0) return null;
417
+ if (from.length === 1 && to.length === 1 && from[0] === to[0]) declared.add(from[0] as string);
408
418
  }
409
419
  return expected.filter((k) => !declared.has(written(k)));
410
420
  }
@@ -492,6 +502,23 @@ export function computeEquivalence(
492
502
  }
493
503
  const multiplicityPreserved = multiplicityDiffs.length === 0;
494
504
 
505
+ // 2d. the provider configuration per pair (G16). A resource on
506
+ // `provider = aws.replica` moved into a module called without a `providers`
507
+ // map is managed by the module's default `aws` afterwards: every argument,
508
+ // move and planned value unchanged, and the resource in another region.
509
+ // Compared only where both sides were attributed (a hand-built ref has none).
510
+ const providerDiffs: EquivalenceResult["provider_diffs"] = [];
511
+ const seenProviderDiff = new Set<string>();
512
+ for (const p of corr.pairs) {
513
+ const before = p.before.provider;
514
+ const after = p.after.provider;
515
+ if (before === undefined || after === undefined || before === after) continue;
516
+ const id = `${p.before.key}|${before}|${after}`;
517
+ if (seenProviderDiff.has(id)) continue;
518
+ seenProviderDiff.add(id);
519
+ providerDiffs.push({ resource: p.before.address, before, after });
520
+ }
521
+
495
522
  // 2c. WHERE the repetition sits, not just how much of it there is (G15).
496
523
  //
497
524
  // Moving a `for_each` from the resource onto the module call that now wraps it
@@ -514,7 +541,9 @@ export function computeEquivalence(
514
541
  if (seenRelevel.has(id)) continue;
515
542
  seenRelevel.add(id);
516
543
  const missing = p.keyed ? uncoveredInstanceKeys(p) : null;
517
- if (missing !== null && missing.length > 0) missingKeys.set(p.before.key, missing);
544
+ // per relocation, not per logical key: `aws_s3_bucket.data` at the root and
545
+ // inside a module share one key and are missing different instances
546
+ if (missing !== null && missing.length > 0) missingKeys.set(id, missing);
518
547
  relevelled.push({
519
548
  resource: p.before.key,
520
549
  from: p.before.address,
@@ -536,14 +565,16 @@ export function computeEquivalence(
536
565
  `instance key. Terraform would DESTROY and RECREATE every instance. Write one per-key ` +
537
566
  `\`moved {}\` block instead.`,
538
567
  );
539
- for (const r of relevelledKeyedIncomplete)
568
+ for (const r of relevelledKeyedIncomplete) {
569
+ const missing = missingKeys.get(`${r.from}=>${r.to}`) ?? [];
540
570
  reasons.push(
541
571
  `${r.resource}: the repetition moved between levels (${r.from} → ${r.to}) and the collection ` +
542
572
  `is written out in the configuration, so the instance keys are known — and ` +
543
- `${(missingKeys.get(r.resource) ?? []).length} of them have NO \`moved {}\` block: ` +
544
- `${(missingKeys.get(r.resource) ?? []).map((k) => `"${k}"`).join(", ")}. Terraform would ` +
573
+ `${missing.length} of them have NO \`moved {}\` block: ` +
574
+ `${missing.map((k) => `"${k}"`).join(", ")}. Terraform would ` +
545
575
  `DESTROY and RECREATE those instances. Write one per-key block for each.`,
546
576
  );
577
+ }
547
578
  for (const r of relevelledKeyedUnread)
548
579
  reasons.push(
549
580
  `${r.resource}: the repetition moved between levels (${r.from} → ${r.to}) and the per-key ` +
@@ -556,6 +587,12 @@ export function computeEquivalence(
556
587
  `instance multiplicity CHANGED for ${d.resource}: ${d.before} → ${d.after} — this adds or ` +
557
588
  `destroys instances, whatever the argument names say`,
558
589
  );
590
+ for (const d of providerDiffs)
591
+ reasons.push(
592
+ `provider configuration CHANGED for ${d.resource}: ${d.before} → ${d.after} — the resource ` +
593
+ `would be managed through another configuration (region or account) with every argument ` +
594
+ `unchanged. Pass it to the module call: \`providers = { ${d.after.split(" ")[0]?.split(".")[0]} = ${d.before} }\`.`,
595
+ );
559
596
 
560
597
  // 3. moved-block exhaustiveness. A pair whose address changed is a relocation;
561
598
  // it is COVERED exactly when a declared block established the pairing, and
@@ -639,6 +676,7 @@ export function computeEquivalence(
639
676
  !resourceSetEquivalent ||
640
677
  !argNamesEquivalent ||
641
678
  !multiplicityPreserved ||
679
+ providerDiffs.length > 0 ||
642
680
  !movesExhaustive ||
643
681
  illegalMoves.length > 0 ||
644
682
  relevelledUnkeyed.length > 0 ||
@@ -671,6 +709,7 @@ export function computeEquivalence(
671
709
  arg_diffs: argDiffs,
672
710
  multiplicity_preserved: multiplicityPreserved,
673
711
  multiplicity_diffs: multiplicityDiffs,
712
+ provider_diffs: providerDiffs,
674
713
  relevelled_repetitions: relevelled,
675
714
  unsettled_only_keyed_relevel:
676
715
  verdict === "unverifiable" && relevelledKeyedUnread.length > 0 && !otherDoubt,