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
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Which root module each directory and variable file belongs to.
3
+ *
4
+ * Two jobs need it. trivy and checkov evaluate variables, so a value set in a
5
+ * `.tfvars` file — an open CIDR, a public flag — is a finding only when the
6
+ * scan loads that file, and loads it for the root that uses it rather than for
7
+ * every root in the tree. And a pull-request review only needs to scan the
8
+ * roots a change can reach: a root none of whose files, local modules or
9
+ * variable files changed has the same findings on both sides of the PR.
10
+ *
11
+ * Terraform loads `terraform.tfvars` and `*.auto.tfvars` from the root module's
12
+ * own directory by itself. Any other variable file — `prod.tfvars` next to the
13
+ * root, `envs/prod/vars/prod.tfvars`, `config/staging.tfvars` — is passed with
14
+ * `-var-file`, so its root is not written anywhere a scan can read. It is
15
+ * matched by the variables it sets, then by where it lives; a file that could
16
+ * belong to several roots is left out and reported rather than guessed, and
17
+ * `var_files` names its root explicitly.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { join, posix } from "node:path";
21
+ import { parseModuleBlocks, walkFilesBySuffix } from "#app/mcp/modules";
22
+ import { skipNonCode } from "#app/mcp/terraform/hcl";
23
+ import { resolveRoots } from "#app/mcp/terraform/paths";
24
+
25
+ /** The variable files Terraform loads by itself in a root module, in its order. */
26
+ const AUTO_TFVARS = /^(?:terraform\.tfvars|.+\.auto\.tfvars)(?:\.json)?$/;
27
+
28
+ /** A variable file assigned to a root by the `var_files` setting. */
29
+ export interface VarFileMapping {
30
+ /** the root module's directory, relative to the scan directory ("" = itself) */
31
+ root: string;
32
+ /** the variable file, relative to the scan directory */
33
+ file: string;
34
+ }
35
+
36
+ export interface RootLayout {
37
+ /** the root module's directory, relative to the scan directory ("" = itself) */
38
+ dir: string;
39
+ /** the root's directory and every local module directory it calls, transitively */
40
+ closure: string[];
41
+ /** the variable files Terraform loads by itself, in its load order */
42
+ autoVarFiles: string[];
43
+ /** variable files passed with `-var-file`: from `var_files`, or matched to this root */
44
+ varFiles: string[];
45
+ /** the root's configuration and its modules', for finding files it reads by name */
46
+ source: string;
47
+ }
48
+
49
+ export interface TreeLayout {
50
+ roots: RootLayout[];
51
+ /** every directory that holds Terraform configuration */
52
+ tfDirs: string[];
53
+ /** variable files no root could be matched to, and why */
54
+ unmatched: { file: string; reason: string }[];
55
+ }
56
+
57
+ const CONFIG_SUFFIXES = [".tf", ".tf.json", ".tofu"] as const;
58
+
59
+ function dirOf(file: string): string {
60
+ const d = posix.dirname(file);
61
+ return d === "." ? "" : d;
62
+ }
63
+
64
+ /** `dir/rel` resolved; null when it climbs out of the scan directory. */
65
+ function joinRel(dir: string, rel: string): string | null {
66
+ const joined = posix.normalize(dir ? `${dir}/${rel}` : rel);
67
+ if (joined === "." || joined === "./") return "";
68
+ if (joined === ".." || joined.startsWith("../")) return null;
69
+ return joined.replace(/^\.\//, "").replace(/\/$/, "");
70
+ }
71
+
72
+ function read(cwd: string, rel: string): string {
73
+ try {
74
+ return readFileSync(join(cwd, rel), "utf-8");
75
+ } catch {
76
+ return "";
77
+ }
78
+ }
79
+
80
+ /** True when `dir` is `ancestor` or lies beneath it. */
81
+ export function isWithin(dir: string, ancestor: string): boolean {
82
+ return ancestor === "" || dir === ancestor || dir.startsWith(`${ancestor}/`);
83
+ }
84
+
85
+ /** Local module sources and declared variable names of one directory's configuration. */
86
+ function dirFacts(cwd: string, files: string[]): { modules: string[]; variables: Set<string> } {
87
+ const modules: string[] = [];
88
+ const variables = new Set<string>();
89
+ for (const file of files) {
90
+ const text = read(cwd, file);
91
+ if (file.endsWith(".json")) {
92
+ try {
93
+ const doc = JSON.parse(text) as Record<string, unknown>;
94
+ for (const name of Object.keys((doc.variable as object | undefined) ?? {})) {
95
+ variables.add(name);
96
+ }
97
+ for (const block of Object.values((doc.module as object | undefined) ?? {})) {
98
+ const source = (block as { source?: unknown }).source;
99
+ if (typeof source === "string" && /^\.\.?\//.test(source)) modules.push(source);
100
+ }
101
+ } catch {
102
+ // configuration that does not parse declares nothing we can use
103
+ }
104
+ continue;
105
+ }
106
+ for (const block of parseModuleBlocks(text)) {
107
+ if (block.kind === "local") modules.push(block.source);
108
+ }
109
+ for (const m of text.matchAll(/(?:^|\n)\s*variable\s+"?([A-Za-z0-9_-]+)"?/g)) {
110
+ if (m[1]) variables.add(m[1]);
111
+ }
112
+ }
113
+ return { modules, variables };
114
+ }
115
+
116
+ /** The names a variable file assigns at its top level. */
117
+ export function varFileKeys(text: string, json: boolean): string[] {
118
+ if (json) {
119
+ try {
120
+ const doc = JSON.parse(text) as unknown;
121
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc) : [];
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+ const keys: string[] = [];
127
+ let depth = 0;
128
+ for (let i = 0; i < text.length; i++) {
129
+ const skip = skipNonCode(text, i);
130
+ if (skip) {
131
+ i = skip.end;
132
+ continue;
133
+ }
134
+ const ch = text[i] as string;
135
+ if ("{[(".includes(ch)) depth++;
136
+ else if ("}])".includes(ch)) depth = Math.max(0, depth - 1);
137
+ else if (depth === 0 && /[A-Za-z_]/.test(ch) && !/[\w-]/.test(text[i - 1] ?? "")) {
138
+ const m = /^([A-Za-z_][\w-]*)[ \t]*=(?!=)/.exec(text.slice(i, i + 200));
139
+ if (m?.[1]) {
140
+ keys.push(m[1]);
141
+ i += m[0].length - 1;
142
+ }
143
+ }
144
+ }
145
+ return keys;
146
+ }
147
+
148
+ const tokens = (path: string): string[] =>
149
+ path
150
+ .replace(/\.auto\.tfvars(?:\.json)?$|\.tfvars(?:\.json)?$/, "")
151
+ .toLowerCase()
152
+ .split(/[-_./]+/)
153
+ .filter(Boolean);
154
+
155
+ /**
156
+ * The root a `-var-file` file belongs to, or why none could be chosen. A
157
+ * candidate declares at least half of the variables the file sets. Among
158
+ * several, the deepest one the file lives inside wins, then the one whose path
159
+ * shares the most words with the file's (`config/staging.tfvars` and
160
+ * `envs/staging`), then a root at the top of the tree.
161
+ */
162
+ function matchRoot(
163
+ file: string,
164
+ keys: string[],
165
+ roots: { dir: string; variables: Set<string> }[],
166
+ ): { root: string } | { reason: string } {
167
+ if (keys.length === 0) return { reason: "it sets no variables" };
168
+ const candidates = roots.filter((r) => {
169
+ const declared = keys.filter((k) => r.variables.has(k)).length;
170
+ return declared > 0 && declared * 2 >= keys.length;
171
+ });
172
+ if (candidates.length === 0) return { reason: "no root module declares its variables" };
173
+ if (candidates.length === 1) return { root: (candidates[0] as { dir: string }).dir };
174
+ const dir = dirOf(file);
175
+ const inside = candidates
176
+ .filter((r) => r.dir !== "" && isWithin(dir, r.dir))
177
+ .sort((a, b) => b.dir.length - a.dir.length);
178
+ if (inside[0]) return { root: inside[0].dir };
179
+ const words = new Set(tokens(file));
180
+ const scored = candidates
181
+ .map((r) => ({ dir: r.dir, score: tokens(r.dir).filter((t) => words.has(t)).length }))
182
+ .sort((a, b) => b.score - a.score);
183
+ if (scored[0] && scored[0].score > 0 && scored[0].score !== scored[1]?.score) {
184
+ return { root: scored[0].dir };
185
+ }
186
+ const top = candidates.find((r) => r.dir === "");
187
+ if (top) return { root: "" };
188
+ return {
189
+ reason: `it fits several root modules (${candidates.map((r) => r.dir).join(", ")}) — name its root in var_files`,
190
+ };
191
+ }
192
+
193
+ /** Terraform's load order: terraform.tfvars, terraform.tfvars.json, then *.auto.tfvars by name. */
194
+ function autoOrder(files: string[]): string[] {
195
+ const first = files.filter((f) => posix.basename(f).startsWith("terraform.tfvars")).sort();
196
+ const rest = files.filter((f) => !posix.basename(f).startsWith("terraform.tfvars"));
197
+ return [
198
+ ...first,
199
+ ...rest.sort((a, b) => posix.basename(a).localeCompare(posix.basename(b), "en")),
200
+ ];
201
+ }
202
+
203
+ /** The tree's root modules, what each reaches, and the variable files each loads. */
204
+ export function treeLayout(cwd: string, explicit: readonly VarFileMapping[] = []): TreeLayout {
205
+ const filesByDir = new Map<string, string[]>();
206
+ for (const suffix of CONFIG_SUFFIXES) {
207
+ for (const file of walkFilesBySuffix(cwd, suffix)) {
208
+ const dir = dirOf(file);
209
+ filesByDir.set(dir, [...(filesByDir.get(dir) ?? []), file]);
210
+ }
211
+ }
212
+ const tfDirs = [...filesByDir.keys()].sort();
213
+ const facts = new Map(tfDirs.map((d) => [d, dirFacts(cwd, filesByDir.get(d) ?? [])]));
214
+ const modulesOf = (dir: string): string[] =>
215
+ (facts.get(dir)?.modules ?? [])
216
+ .map((source) => joinRel(dir, source))
217
+ .filter((d): d is string => d !== null && facts.has(d));
218
+
219
+ const rootDirs = resolveRoots(cwd).map((r) => r.relDir);
220
+ const closures = new Map<string, string[]>();
221
+ for (const root of rootDirs) {
222
+ const seen = new Set<string>([root]);
223
+ const queue = [root];
224
+ for (let dir = queue.shift(); dir !== undefined; dir = queue.shift()) {
225
+ for (const next of modulesOf(dir)) {
226
+ if (!seen.has(next)) {
227
+ seen.add(next);
228
+ queue.push(next);
229
+ }
230
+ }
231
+ }
232
+ closures.set(root, [...seen].sort());
233
+ }
234
+ const rootSet = new Set(rootDirs);
235
+ const moduleDirs = new Set([...closures.values()].flat().filter((d) => !rootSet.has(d)));
236
+
237
+ const varFiles = [
238
+ ...walkFilesBySuffix(cwd, ".tfvars"),
239
+ ...walkFilesBySuffix(cwd, ".tfvars.json"),
240
+ ].sort();
241
+ const auto = new Map<string, string[]>();
242
+ const passed = new Map<string, string[]>();
243
+ const unmatched: TreeLayout["unmatched"] = [];
244
+ const add = (m: Map<string, string[]>, root: string, file: string) => {
245
+ const list = m.get(root) ?? [];
246
+ if (!list.includes(file)) m.set(root, [...list, file]);
247
+ };
248
+
249
+ const named = new Set<string>();
250
+ for (const { root, file } of explicit) {
251
+ const r = joinRel("", root);
252
+ const f = joinRel("", file);
253
+ if (r === null || f === null || !rootSet.has(r)) {
254
+ unmatched.push({
255
+ file,
256
+ reason: `var_files names ${root || "."}, which is not a root module`,
257
+ });
258
+ continue;
259
+ }
260
+ named.add(f);
261
+ add(passed, r, f);
262
+ }
263
+
264
+ const variablesOf = rootDirs.map((dir) => ({
265
+ dir,
266
+ variables: facts.get(dir)?.variables ?? new Set<string>(),
267
+ }));
268
+ for (const file of varFiles) {
269
+ if (named.has(file)) continue;
270
+ const dir = dirOf(file);
271
+ if (rootSet.has(dir) && AUTO_TFVARS.test(posix.basename(file))) {
272
+ add(auto, dir, file);
273
+ continue;
274
+ }
275
+ // a module's own example values are not any root's
276
+ if (moduleDirs.has(dir)) continue;
277
+ const match = matchRoot(
278
+ file,
279
+ varFileKeys(read(cwd, file), file.endsWith(".json")),
280
+ variablesOf,
281
+ );
282
+ if ("root" in match) add(passed, match.root, file);
283
+ else unmatched.push({ file, reason: match.reason });
284
+ }
285
+
286
+ const roots = rootDirs.map((dir) => {
287
+ const closure = closures.get(dir) ?? [dir];
288
+ return {
289
+ dir,
290
+ closure,
291
+ autoVarFiles: autoOrder(auto.get(dir) ?? []),
292
+ varFiles: passed.get(dir) ?? [],
293
+ source: closure
294
+ .flatMap((d) => filesByDir.get(d) ?? [])
295
+ .map((f) => read(cwd, f))
296
+ .join("\n"),
297
+ };
298
+ });
299
+ return { roots, tfDirs, unmatched };
300
+ }
301
+
302
+ /** `root=file` lines (the `var_files` setting) as mappings; malformed lines are reported. */
303
+ export function parseVarFileMappings(raw: string | undefined): {
304
+ mappings: VarFileMapping[];
305
+ invalid: string[];
306
+ } {
307
+ const mappings: VarFileMapping[] = [];
308
+ const invalid: string[] = [];
309
+ for (const entry of (raw ?? "").split(/[\n,]/)) {
310
+ const line = entry.trim();
311
+ if (!line) continue;
312
+ const eq = line.indexOf("=");
313
+ const root = line.slice(0, eq).trim();
314
+ const file = line.slice(eq + 1).trim();
315
+ if (eq === -1 || !file) invalid.push(line);
316
+ else mappings.push({ root: root === "." ? "" : root, file });
317
+ }
318
+ return { mappings, invalid };
319
+ }
320
+
321
+ /** Settings files that can change any finding in the tree. */
322
+ const SCANNER_SETTINGS =
323
+ /^(?:\.checkov\.ya?ml|\.trivyignore(?:\.yaml)?|trivy\.ya?ml|\.tflint\.hcl|\.infraweaver(?:\.baseline)?\.(?:ya?ml|json)|\.terraformrc|terraform\.rc)$/;
324
+
325
+ /**
326
+ * The directories a pull request's scan must cover, from the layouts of both
327
+ * sides and the files it changed (relative to the scan directory). A root is
328
+ * reached when a changed file lies in a directory of its closure, is one of
329
+ * its variable files, or is named in its configuration (a policy read with
330
+ * `file()`, wherever it lives); every directory of a reached root's closure is kept.
331
+ * A changed configuration file no root reaches keeps its own directory, as a
332
+ * whole-tree scan would have scanned it. Null — scan everything — when a
333
+ * scanner or Infraweaver settings file changed.
334
+ */
335
+ export function scopeFor(
336
+ layouts: readonly TreeLayout[],
337
+ changed: Iterable<string>,
338
+ ): string[] | null {
339
+ const keep = new Set<string>();
340
+ const tfDirs = new Set(layouts.flatMap((l) => l.tfDirs));
341
+ for (const raw of changed) {
342
+ const file = raw.replace(/\\/g, "/");
343
+ const dir = dirOf(file);
344
+ const name = posix.basename(file);
345
+ const isConfig = CONFIG_SUFFIXES.some((s) => name.endsWith(s));
346
+ let reached = false;
347
+ for (const layout of layouts) {
348
+ for (const root of layout.roots) {
349
+ // a closure directory itself, not what lies beneath it: a module at the
350
+ // top of the tree would otherwise reach every root for any file at all
351
+ const hit =
352
+ root.closure.includes(dir) ||
353
+ root.autoVarFiles.includes(file) ||
354
+ root.varFiles.includes(file) ||
355
+ (!isConfig && root.source.includes(name));
356
+ if (hit) {
357
+ for (const d of root.closure) keep.add(d);
358
+ reached = true;
359
+ }
360
+ }
361
+ }
362
+ if (reached) continue;
363
+ if (isConfig && tfDirs.has(dir)) keep.add(dir);
364
+ else if (SCANNER_SETTINGS.test(name)) return null;
365
+ }
366
+ return [...keep].sort();
367
+ }
368
+
369
+ /**
370
+ * The directories to leave out of a scan that should cover only `keep`: every
371
+ * configuration directory that is neither kept nor above a kept one, and only
372
+ * the topmost of those, since skipping a directory skips what is beneath it.
373
+ */
374
+ export function skipDirsFor(tfDirs: readonly string[], keep: readonly string[]): string[] {
375
+ const kept = new Set(keep);
376
+ const skip = tfDirs.filter((d) => d !== "" && !kept.has(d) && !keep.some((k) => isWithin(k, d)));
377
+ return skip.filter((d) => !skip.some((o) => o !== d && isWithin(d, o))).sort();
378
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { posix } from "node:path";
2
3
  import { toolSkip } from "#app/mcp/shared";
3
4
  import type { IacLanguage } from "#app/mcp/terraform/iacLanguages";
4
5
  import type { EgressIsolation } from "#app/mcp/terraform/scannerNetns";
@@ -57,6 +58,7 @@ export interface Concern {
57
58
  | "trivy"
58
59
  | "checkov"
59
60
  | "infraweaver"
61
+ | "betterleaks"
60
62
  | "reviewer";
61
63
  /** original namespaced rule, e.g. "trivy:AVD-AWS-0088" */
62
64
  rule_id: string;
@@ -295,7 +297,8 @@ export function canonicalTrivyRule(bare: string): string {
295
297
  */
296
298
  export function rebaseConcern(c: Concern, relDir: string): Concern {
297
299
  if (!relDir) return c;
298
- const file = `${relDir}/${c.location.file}`.replace(/\/+/g, "/");
300
+ // normalized: checkov run over one root reports its modules as `../../modules/x`
301
+ const file = posix.normalize(`${relDir}/${c.location.file}`);
299
302
  const prefix = `${c.source}:`;
300
303
  const bareRule = c.rule_id.startsWith(prefix) ? c.rule_id.slice(prefix.length) : c.rule_id;
301
304
  return {
@@ -198,6 +198,15 @@ const ROOT_BECAUSE: Record<RootRequirements["via"], string> = {
198
198
  "configures a provider or a backend",
199
199
  };
200
200
 
201
+ /** the pessimistic pin for the lowest major a constraint admits — `>= 6.42`
202
+ * becomes `~> 6.42`, staying on the major the repo already uses rather than
203
+ * steering it back to one it has left. */
204
+ function pessimisticFor(constraint: string | null): string {
205
+ const floor = constraint === null ? null : /(\d+)(?:\.(\d+))?/.exec(constraint);
206
+ if (!floor) return "`~> <major>.0`, on the major you have tested";
207
+ return `\`~> ${floor[1]}.${floor[2] ?? "0"}\``;
208
+ }
209
+
201
210
  /** Turn the per-root findings into native concerns. */
202
211
  function versionRequirementsToConcerns(roots: RootRequirements[]): Concern[] {
203
212
  const concerns: Concern[] = [];
@@ -284,8 +293,8 @@ function versionRequirementsToConcerns(roots: RootRequirements[]): Concern[] {
284
293
  : `provider \`${p.name}\` is constrained to \`${p.version}\`, which admits more than one ` +
285
294
  "major — a future `init` can install a new major with no commit in this repo",
286
295
  remediation_hint:
287
- "Constrain the provider to a single major with the pessimistic operator — `~> 5.0` — or to " +
288
- "an exact version. A major renames arguments and removes blocks, which is why the push " +
296
+ `Constrain the provider to a single major with the pessimistic operator — ${pessimisticFor(p.version)} — ` +
297
+ "or to an exact version. A major renames arguments and removes blocks, which is why the push " +
289
298
  "guardrail refuses a constraint that crosses or floats across one.",
290
299
  }),
291
300
  );
@@ -410,6 +410,7 @@ exports[`assemblePrompt — outline snapshot per built-in mode > every built-in
410
410
  "infraweaver_reply_to_review_comment",
411
411
  "infraweaver_resolve_review_thread",
412
412
  "infraweaver_review_scan_delta",
413
+ "infraweaver_stage_review",
413
414
  ],
414
415
  },
415
416
  "UpdateDependencies": {
@@ -656,6 +657,7 @@ exports[`assemblePrompt — outline snapshot per built-in mode > tool references
656
657
  "mcp__infraweaver__reply_to_review_comment",
657
658
  "mcp__infraweaver__resolve_review_thread",
658
659
  "mcp__infraweaver__review_scan_delta",
660
+ "mcp__infraweaver__stage_review",
659
661
  ],
660
662
  "UpdateDependencies": [
661
663
  "mcp__infraweaver__add_labels",
@@ -5,7 +5,7 @@ export function modernizeDeprecatedMode(t: ToolRef): Mode {
5
5
  return {
6
6
  name: "ModernizeDeprecated",
7
7
  description:
8
- "Migrate deprecated Terraform patterns to their modern equivalents (aws_launch_configuration → aws_launch_template, the template_file data source → the templatefile() function, EOL provider pins), one PR per pattern class. These ALTER the resource set, so they are proven by a reviewed plan delta (or shipped `proposed — unproven`) with a `moved {}` block wherever an address can be preserved; equivalence is never claimed.",
8
+ "Migrate deprecated Terraform patterns to their modern equivalents (aws_launch_configuration → aws_launch_template, the template_file data source → the templatefile() function, EOL provider pins), one PR per pattern class. These ALTER the resource set, so they are proven by a reviewed plan delta (or shipped proposed — unproven) with a `moved {}` block wherever an address can be preserved; equivalence is never claimed.",
9
9
  prompt: `### Checklist
10
10
 
11
11
  This mode migrates DEPRECATED Terraform patterns to modern ones. Unlike Refactor, these migrations swap a resource TYPE / data source / function and therefore ALTER the resource set — so equivalence is FALSE and must NEVER be claimed (the equivalence guard would block such a claim anyway). The proof is a reviewed plan delta when cloud credentials exist, otherwise the change ships labelled \`proposed — unproven\`. Author a \`moved {}\` block wherever an address genuinely carries over, and use \`create_before_destroy\` where a replace is unavoidable.
@@ -22,7 +22,7 @@ This mode migrates DEPRECATED Terraform patterns to modern ones. Unlike Refactor
22
22
 
23
23
  6. **prove via plan, never equivalence**: do NOT call \`${t("terraform_equivalence_check")}\` — this change is behaviour-altering and an equivalence claim would be false (and is hard-blocked at push). Instead:
24
24
  - **cloud credentials present**: call \`${t("terraform_plan")}\` and attach the **reviewed plan delta**. Treat \`has_destroy_or_replace\`/\`stateful_destructive\` exactly as Remediate — a stateful destroy/replace is hard-blocked at push unless \`allow_replace\` covers it; a high \`blast_radius.tier\` or \`needs_human\` adds the \`needs-human\` label (\`${t("add_labels")}\`) + a prominent callout.
25
- - **no credentials**: ship \`proposed — unproven\` — add the label, put the proposed-unproven banner unmissably at the top of the body, and state plainly that this migration ALTERS resources and the plan must be reviewed before merge.
25
+ - **no credentials**: ship it proposed — unproven — add the \`proposed-unproven\` label (exactly that name), put the proposed-unproven banner unmissably at the top of the body, and state plainly that this migration ALTERS resources and the plan must be reviewed before merge.
26
26
  - record the outcome with \`${t("terraform_emit_evidence")}\`.
27
27
 
28
28
  7. **keep module tests consistent** (if a local module changed): \`${t("terraform_module_tests")}\` — update drifting \`examples/\`/tests, never weaken an assertion.
@@ -16,21 +16,21 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
16
16
  2. **detect (two kinds of behaviour-preserving refactor — call BOTH)**:
17
17
  - \`${t("module_extraction_candidates")}\` → resource \`clusters\` (raw root resources that should likely be a module call), each with matched \`candidates\` (house modules by resource-type signature; catalogue modules by real signature when terraform init already fetched their content, else by keyword; plus 1–2-resource groups that EXACTLY duplicate an existing module) and per-candidate \`expected_moves\` (every cluster resource → its new module address).
18
18
  - \`${t("terraform_normalization_candidates")}\` → in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING — zero \`moved {}\` blocks — and are proven by the same equivalence check.
19
- - \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment.
19
+ - \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either — a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
20
20
 
21
21
  **Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently — and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked — the module is external and its code is not in this repo — which is not the same as clean.
22
22
 
23
- Pick the highest-value refactor across ALL THREE detectors. Only when they ALL come back empty is there genuinely nothing to do: call \`${t("report_progress")}\` with an ACCURATE message — e.g. "No behaviour-preserving refactor found: no extraction clusters and no idiomatic-normalisation candidates." — and **stop**. Two anti-patterns to avoid in that message: (i) never call a repo "already idiomatic" while normalisation candidates remain; (ii) when what's left is behaviour-CHANGING modernisation — a deprecated-resource swap (\`aws_launch_configuration\`→\`aws_launch_template\`, \`aws_elb\`→\`aws_lb\`, \`template_file\`→\`templatefile()\`) — do NOT report "nothing to refactor": that is real work, but it ALTERS the resource set, so it belongs to Remediate. Say so explicitly rather than implying the code is clean.
23
+ Pick the highest-value refactor across ALL THREE detectors. Only when they ALL come back empty — or all that remains is a cluster in a near-group root — is there genuinely nothing to do: call \`${t("report_progress")}\` with an ACCURATE message — e.g. "No behaviour-preserving refactor found: no extraction clusters and no idiomatic-normalisation candidates." — and **stop**. Two anti-patterns to avoid in that message: (i) never call a repo "already idiomatic" while normalisation candidates remain; (ii) when what's left is behaviour-CHANGING modernisation — a deprecated-resource swap (\`aws_launch_configuration\`→\`aws_launch_template\`, \`aws_elb\`→\`aws_lb\`, \`template_file\`→\`templatefile()\`) — do NOT report "nothing to refactor": that is real work, but it ALTERS the resource set, so it belongs to Remediate. Say so explicitly rather than implying the code is clean.
24
24
 
25
25
  3. **pick scope — ONE refactor per PR**: take the largest / highest-cohesion extraction cluster, the file(s) with the most normalisation sites, or ONE consolidation group (which is one refactor spanning its member roots — see b.5), first. **Targeted directive override:** if YOUR TASK names specific files/resources, act only on those. Unless the task explicitly asks for more, open **at most one PR this run**.
26
26
 
27
27
  **If you chose an in-place NORMALISATION** (not a module extraction), nothing relocates: SKIP steps 4–6 (no module source, no wiring, no \`moved {}\` blocks). Apply the \`${t("terraform_normalization_candidates")}\` edits directly (rewrite \`"\${expr}"\` → \`expr\`, \`vars {\` → \`vars = {\`, etc.) — only the literal cleanups, never a resource-type swap — then jump to step 7. The equivalence check (step 8) proves the no-op: same resource set, identical argument names, zero moves.
28
28
 
29
29
  4. **resolve the module source (risk order — PRESERVING variants only)**: choose where the module code comes from. This mode ships only behaviour-preserving refactors; pick the lowest-risk viable source:
30
- - **b.2 — module exists, same repo** (candidate \`kind: local\`): call it by its \`./modules/x\` path. No credential. *Prefer this.*
30
+ - **b.2 — module exists, same repo** (candidate \`kind: local\`): write the candidate's \`call_source\` as the module block's \`source\` — the path from the calling root, e.g. \`../../modules/x\` from \`env/dev\`. The candidate's \`source\` is repo-relative and resolves only from the repo root. No credential. *Prefer this.*
31
31
  - **b.1 — module exists, separate org repo** (candidate \`kind: git\`): use a \`git::…?ref=<pinned-sha>\` source (pin an exact commit, never a branch). The equivalence proof (step 8) attributes the module's resources from what \`terraform init\` fetched at that ref — the init runs inside \`${t("terraform_validate")}\` and the equivalence check itself — so a true relocation into an org module proves \`equivalent: true\` exactly like a local one. If the fetch fails (no access, offline), the module stays invisible to the proof and it DECLINES: fix access or abandon, never relabel. Access is via the GitHub App token (or the operator's read-only deploy key) and is **READ-ONLY** — ⚠️ **NEVER open a write PR against the module repo.** If a concern lives inside the shared module, fix it at the CALL SITE via inputs, or note "fix needed in module repo \`<repo>\`" — do not edit vendored module code. A candidate's \`expected_moves\` for a git module is a TEMPLATE built from the root's own leaf names — correct each \`to\` against the module's REAL resource addresses (read the fetched module, or the equivalence check's \`uncovered_moves\`/\`resource_set_diff\` will name them).
32
- - **b.3 — no module exists, author it** (no candidate): MECHANICAL extraction. Take the cluster's existing resource blocks, move them verbatim into \`./modules/<name>/\`, parameterise each hardcoded value into a \`variable\` (with a SECURE \`default\`, or **no default** so the caller must set it — NEVER preserve an insecure value as a default), expose the needed \`output\`s, and replace the root resources with a \`module\` block passing the ORIGINAL values. You do the naming + structure; the equivalence check (step 8) is the guardrail that proves you changed nothing.
33
- - **b.5 — consolidation: one module, N environment roots** (a \`${t("terraform_consolidation_candidates")}\` group): the b.3 extraction done once for a shape several roots duplicate. Author \`./modules/<name>/\` from the group's resources, then convert EVERY member root in this same PR. Rules that are not optional: (i) move each resource block **verbatim, keeping its resource name** — the group's \`expected_moves_by_member\` assumes the leaf name survives, and renaming it to \`this\` invalidates every move; (ii) put each \`module_body_values\` entry in the module exactly as written — \`kind: "attribute"\` is a right-hand side (\`bucket = <value>\`), \`kind: "block"\` is a nested block's BODY and goes back inside its own braces (\`versioning_configuration { <value> }\`), never as \`versioning_configuration = { … }\`; (iii) replace ONLY the \`variables\` attributes with \`var.<name>\`, and copy each member's \`per_member.value\` into THAT member's call **verbatim — never retyped, never reformatted, never swapped between environments**. A \`classification: reference\` variable (identical text like \`var.bucket_name\` in every root) is still per-environment: the text matches, the value does not. Never fold differing values into one interpolated template (\`"acme-\${var.env}-logs"\`) — the job is to preserve N values, not to invent a rule that reproduces them. Paste each root's \`moved {}\` blocks, then prove ALL the roots with a single \`${t("terraform_equivalence_check")}\`.
32
+ - **b.3 — no module exists, author it** (no candidate): MECHANICAL extraction. Take the cluster's existing resource blocks, move them verbatim into \`./modules/<name>/\`, parameterise each hardcoded value into a \`variable\` (with a SECURE \`default\`, or **no default** so the caller must set it — NEVER preserve an insecure value as a default), expose the needed \`output\`s, and replace the root resources with a \`module\` block passing the ORIGINAL values. Its \`source\` is relative to the root's directory: \`../../modules/<name>\` from \`env/dev\`, \`./modules/<name>\` only from the repo root. You do the naming + structure; the equivalence check (step 8) is the guardrail that proves you changed nothing.
33
+ - **b.5 — consolidation: one module, N environment roots** (a \`${t("terraform_consolidation_candidates")}\` group): the b.3 extraction done once for a shape several roots duplicate. Author \`./modules/<name>/\` from the group's resources, then convert EVERY member root in this same PR; each member's call writes that member's \`call_source\` as its \`source\`. Rules that are not optional: (i) move each resource block **verbatim, keeping its resource name** — the group's \`expected_moves_by_member\` assumes the leaf name survives, and renaming it to \`this\` invalidates every move; a \`module\` entry in the group's \`resources\` is a module CALL the shape includes — move that call block too, keeping its name, and write its \`wrapped_calls[].source\` (relative to the NEW module, e.g. \`../s3\`) as its \`source\`, since the root's \`../../modules/s3\` no longer resolves from there; (ii) put each \`module_body_values\` entry in the module exactly as written — \`kind: "attribute"\` is a right-hand side (\`bucket = <value>\`), \`kind: "block"\` is a nested block's BODY and goes back inside its own braces (\`versioning_configuration { <value> }\`), never as \`versioning_configuration = { … }\`; (iii) replace ONLY the \`variables\` attributes with \`var.<name>\`, and copy each member's \`per_member.value\` into THAT member's call **verbatim — never retyped, never reformatted, never swapped between environments**. A \`classification: reference\` variable (identical text like \`var.bucket_name\` in every root) is still per-environment: the text matches, the value does not. Never fold differing values into one interpolated template (\`"acme-\${var.env}-logs"\`) — the job is to preserve N values, not to invent a rule that reproduces them. Paste each root's \`moved {}\` blocks, then prove ALL the roots with a single \`${t("terraform_equivalence_check")}\`.
34
34
  - **b.4 — third-party module (behaviour-ALTERING)**: adopting a community/registry module changes defaults and often adds resources, so equivalence is FALSE and must **never** be claimed. This is a DIFFERENT proof (a reviewed plan delta, opt-in; \`proposed — unproven\` by default) — follow the **b.4 path** at the end of this checklist INSTEAD of steps 5–12's equivalence gate. Reserve it for a genuine module adoption; a plain relocation of your own resources is b.1/b.2/b.3.
35
35
 
36
36
  5. **wire the module call**: get the chosen module's REAL interface so the \`module\` block passes its actual \`variable\` names (a missing required variable is a PR question for the reviewer, never a guessed value). For a LOCAL module dir (b.2/b.3) call \`${t("terraform_module_interface")}\`; for a REGISTRY-sourced module — a \`module_catalogue\` entry like \`terraform-aws-modules/vpc/aws\`, or the b.4 adoption — call \`${t("terraform_module_lookup")}\` to read its published \`required_inputs\`/\`outputs\` + resolved \`version\` from the registry. \`${t("terraform_module_graph")}\` shows existing local module dirs + callers. Apply the same secure-default discipline as Remediate: a parameterised value's default must be the secure choice or absent — never a hidden-insecure default.
@@ -42,7 +42,7 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
42
42
  8. **PROVE equivalence (the gate)**: call \`${t("terraform_equivalence_check")}\`. It compares the working tree against the run-start commit and must return \`equivalent: true\` — meaning: same resource multiset (\`resource_set_diff\` empty), identical per-resource argument names, **zero \`uncovered_moves\`**, and validate + fmt clean. If \`equivalent\` is false:
43
43
  - any \`uncovered_moves\` → add the missing \`moved {}\` block(s) for those addresses and re-run;
44
44
  - a non-empty \`resource_set_diff.added\`/\`removed\` or \`arg_diffs\` → the refactor changed behaviour; fix it back to a true no-op, or ABANDON the cluster (it may be a behaviour-altering adoption — see step 4).
45
- - a non-empty \`value_mismatches\` (\`values_preserved: false\`) → a relocated argument's VALUE changed: the check resolved \`var.<x>\` back through the call site and it no longer yields what that root had. This is the b.5 failure nothing else can see — the resource set, the argument names, the moves, validate and even a plan are all clean, and the apply applies another environment's configuration. Each row names the root, the argument, the \`before\` value and what it now resolves to: put that root's OWN value back at its OWN call site, copied verbatim from the before tree.
45
+ - a non-empty \`value_mismatches\` (\`values_preserved: false\`) → a VALUE changed: the check follows each value — \`var.<x>\` back through the call site, locals, module outputs, data sources, nested and \`dynamic\` blocks — and it no longer yields what the before tree had. This is the b.5 failure nothing else can see — the resource set, the argument names, the moves, validate and even a plan are all clean, and the apply applies another environment's configuration. Each row names the root, the resource, the argument path, the \`before\` value and what it now resolves to: put that root's OWN value back at its OWN call site, copied verbatim from the before tree. A row on a resource you did not move means a shared module's body or an untouched call site changed under it — undo that edit.
46
46
  - a \`verdict: "unverifiable"\` naming a repetition that moved between levels → the per-key \`moved {}\` blocks are the right shape and the proof will not evaluate the \`for_each\` expression to confirm the key set is complete. This is the ONE case a plan settles: run \`${t("terraform_plan")}\` and reach \`refactor_safe: true\` on the tree you are pushing, and the push is allowed with the verdict left at \`unverifiable\` (never restated as proven). Without cloud credentials, escalate the cluster rather than pushing.
47
47
  Do not proceed to push until \`equivalent: true\`, or until that one \`unverifiable\` is plan-corroborated. **Optional stronger proof:** when cloud credentials are present, call \`${t("terraform_plan")}\` — a \`refactor_safe: true\` (pure-move) plan corroborates the keys-free proof; attach the plan to the PR. Then **re-run \`${t("terraform_equivalence_check")}\`** so the plan's own reading of what moved folds into the recorded proof (\`plan_corroborated: true\`). Make no edits between the two calls — the pairing is tied to the exact tree the plan saw and is discarded if the tree changed.
48
48
 
@@ -52,7 +52,7 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
52
52
 
53
53
  11. **branch + commit + push**: create \`infraweaver/refactor-<slug>\` from the **current HEAD** (the scanned checkout) via \`${t("git")}\` — do NOT switch base first. \`git add\` only the files you changed, commit with a message naming the module + cluster (e.g. \`refactor(tf): extract S3 logging resources into module.logging\`), then \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*). The push guardrails enforce Terraform-only paths, no inlined secrets, AND the equivalence hard-fail — which refuses an unproven refactor AND one that recorded no proof at all, so skipping step 8 blocks the push rather than slipping past it.
54
54
 
55
- 12. **open the PR (MANDATORY body)**: \`${t("create_pull_request")}\` (omit \`base\` — it resolves to the run's base branch). Build the body with the **Refactor PR format** at the end of this checklist — status banner → title → \`## What changed\` (include the adopted candidate's \`selection_reason\` VERBATIM; if you omit it, \`${t("create_pull_request")}\` appends it under \`### Module selection\`) → \`## Equivalence proof\` (built ONLY from \`${t("terraform_equivalence_check")}\`, the proof not a self-report) → composition note. Optionally call \`${t("terraform_emit_evidence")}\` — it records the equivalence statement with the proof behind it; when its result says \`signed: true\`, add a one-line evidence note to the PR body naming the artefact and the signing key fingerprint (\`key_fingerprint_sha256\`) so a reviewer can verify with \`infraweaver verify-evidence\`. Never auto-merge.
55
+ 12. **open the PR (MANDATORY body)**: \`${t("create_pull_request")}\` (omit \`base\` — it resolves to the run's base branch). Build the body with the **Refactor PR format** at the end of this checklist — status banner → title → \`## What changed\` (include the adopted candidate's \`selection_reason\` VERBATIM; if you omit it, \`${t("create_pull_request")}\` appends it under \`### Module selection\`) → \`## Equivalence proof\` (built ONLY from \`${t("terraform_equivalence_check")}\`, the proof not a self-report) → composition note. Optionally call \`${t("terraform_emit_evidence")}\` — it records the equivalence statement with the proof behind it; when its result says \`signed: true\`, add a one-line evidence note to the PR body naming the artefact and the signing key fingerprint (\`key_fingerprint_sha256\`) so a reviewer can verify with \`infraweaver verify-evidence\`. Never auto-merge. If \`${t("create_pull_request")}\` refuses because an open PR already changes the same files, that PR is this refactor waiting for review: delete the branch you pushed and report it — never re-open under another branch name.
56
56
 
57
57
  13. **finalize**: call \`${t("report_progress")}\` once with a summary — which cluster was modularised, into which module, the PR link, and the equivalence verdict (or the exact tool error if a step failed).
58
58
 
@@ -61,11 +61,11 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
61
61
  Use this ONLY when the chosen source is a community/registry module that changes behaviour — equivalence is false here and must NEVER be claimed. The proof is a reviewed plan delta, gated behind opt-in read creds; by default the change ships labelled \`proposed — unproven\` and asserts nothing about runtime.
62
62
 
63
63
  - **Find + read the module**: when the operator's \`module_catalogue\` / \`refactor_source\` doesn't already name the module, call \`${t("terraform_module_search")}\` to find a published one — pass your org's \`namespace\` to prefer your OWN vetted modules over a stranger's. Search covers the PUBLIC registry only: a private-registry module must be named in \`module_catalogue\` / \`refactor_source\`, or looked up directly by its \`host/namespace/name/provider\` address. Then call \`${t("terraform_module_lookup")}\` on the chosen \`namespace/name/provider\` to read its \`required_inputs\` / \`outputs\` + latest \`version\`, so the \`module\` block wires the real interface and pins an exact version.
64
- - **Scan with external-module resolution ON**: call \`${t("terraform_scan")}\` with \`download_external_modules: true\` (and verify with \`${t("terraform_verify_remediation")}\`'s \`download_external_modules: true\`) so the adopted module's INTERNALS stay in scope. Skipping it would let the module's findings vanish unfixed — a false ✗→✓ ("finding-laundering"). Non-negotiable.
64
+ - **Scan with external-module resolution ON**: call \`${t("terraform_scan")}\` with \`download_external_modules: true\` (and verify with \`${t("terraform_verify_remediation")}\`'s \`download_external_modules: true\`) so the adopted module's INTERNALS stay in scope. Skipping it would let the module's findings vanish unfixed — a false ✗→✓ ("finding-laundering"). Non-negotiable. A finding inside the module is reported on the line of the \`module\` block that calls it, with the module resource it is about named in the evidence ("— inside the module: module.data.aws_s3_bucket.this (main.tf:25)").
65
65
  - **Remediate module-sourced findings via INPUTS**: set the hardening variable at the call site — never edit vendored module code. Where the module exposes no knob for a control, label it \`needs-fork\` and surface it; never a silent pass.
66
66
  - **Pin the exact version**: a registry \`version\` or a git \`?ref=<sha>\` (provenance discipline — the analog to a SHA-pinned action); it is recorded in the evidence bundle.
67
67
  - **Proof**:
68
- - **Default (no read creds)**: do NOT run a plan. Call \`${t("terraform_emit_evidence")}\` with \`proposed_unproven: true\` **before you push** — the bundle records that the change asserts nothing about runtime, and \`${t("push_branch")}\` refuses a refactor that reaches a reviewer with neither a proof nor that marker. Then add the \`proposed-unproven\` label (\`${t("add_labels")}\`) and put the proposed-unproven banner unmissably at the top of the body. It must NEVER read as a proven / equivalence change.
68
+ - **Default (no read creds)**: do NOT run a plan. Call \`${t("terraform_emit_evidence")}\` with \`proposed_unproven: true\` **before you push** — the bundle records that the change asserts nothing about runtime (commit the file it writes with the change, unedited: the push admits exactly those bytes), and \`${t("push_branch")}\` refuses a refactor that reaches a reviewer with neither a proof nor that marker. Then add the \`proposed-unproven\` label (\`${t("add_labels")}\`) and put the proposed-unproven banner unmissably at the top of the body. It must NEVER read as a proven / equivalence change.
69
69
  - **Opt-in (read-only backend/state supplied)**: call \`${t("terraform_plan")}\` **before you push**, then record the outcome with \`${t("terraform_emit_evidence")}\` passing \`reviewed_plan: true\` (refused if no plan ran) — that marker is what the push gate accepts for a behaviour-altering adoption; a plan alone no longer discharges it. Emit the **reviewed plan delta** in the PR body as the proof (replacing the proposed-unproven section). No supplied credential is stored, and no plan attribute VALUES are kept — only the aggregate totals and affected addresses the push gates read.
70
70
  - Same guardrails: one cluster per PR, never auto-merge, Terraform-only, never write-PR a read-only module repo.
71
71
 
@@ -33,7 +33,7 @@ export function remediateMode(t: ToolRef): Mode {
33
33
  **Bulk remediation (\`fix rule <rule-id>\` / \`fix all rule <rule-id>\`)**: a request to fix ONE scanner rule everywhere it fires (e.g. \`${COMMENT_COMMAND} fix rule CKV_AWS_23\` — "add a description to every security group", or \`fix rule terraform_required_version\`). Re-scan with \`group_by: "rule"\` so that rule becomes ONE group spanning every file, then act on the single group whose \`rule_ids\` include \`<rule-id>\` — apply the SAME minimal fix at every site in its \`files\` and open ONE coherent PR (not one per file). This is the sweep path; still honour \`max_prs\` and never batch a \`needs-human\` group. Cite each fixed site in the PR body.
34
34
 
35
35
  4. **for the chosen group**:
36
- - **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
36
+ - **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
37
37
  - **idempotency**: the remediation branch is \`remediate/<group-id>\`. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
38
38
  - **branch**: create \`remediate/<group-id>\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git checkout -b remediate/<group-id>\`). Do NOT switch to a different base first — branching from the scanned checkout keeps the PR diff to exactly your fix.
39
39
  - **honest refusal (decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision — narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal — never a guessed, unverifiable PR.
@@ -29,9 +29,10 @@ This mode reviews the **Terraform** in a human PR and submits ONE review. It is
29
29
 
30
30
  4. **introduced findings — comment or dismiss each one**: for EVERY introduced finding the delta lists (the most severe first; past its limit it only counts the rest, which need nothing from you), read the cited code and decide:
31
31
  - **comment**: an inline comment on the changed line, with \`finding_id\` set to that finding's id and an \`audit\` filled from your verification pass. Explain the concrete consequence in this configuration, not the rule's generic text. The tool adds the scanner, rule and mapped controls itself — do not restate them.
32
- - **dismiss**: list it in \`dismissed\` with the specific reason it needs no comment here — a named sibling resource or module default already satisfies the control, the resource is not instantiated (\`count = 0\`), or the rule does not fit this configuration for a stated reason. "Not important" is not a reason. A finding marked \`possibly_preexisting\` is usually a rename, or a move into another module, without a \`moved\` block: say that (and whether a \`moved\` block is needed) rather than treating the misconfiguration as new.
32
+ - **dismiss**: list it in \`dismissed\` with the specific reason it needs no comment here — a named sibling resource or module default already satisfies the control, the resource is not instantiated (\`count = 0\`), or the rule does not fit this configuration for a stated reason. "Not important" is not a reason. A finding marked \`possibly_preexisting\` is usually a rename, or a move into another module, without a \`moved\` block (\`renamed_from\` names the resource it pairs with): the review's verdict already names each such resource once, so make ONE comment per renamed resource giving the \`moved\` block it needs, and dismiss its other findings as part of that rename rather than treating each misconfiguration as new.
33
33
  **One comment per root cause.** When several introduced findings are the same problem on the same lines (two scanners flagging one open ingress rule), comment once with the most specific \`finding_id\` and dismiss the others as duplicates of that comment.
34
34
  The review tool refuses once if any introduced finding is neither commented nor dismissed; on the retry it names the rest to the author as unaddressed.
35
+ **A comment limit is not yours to apply.** The repo may set \`review_comment_limit\`; the tool enforces it after you submit, keeping the most severe comments inline and listing the rest in the review body. Comment on or dismiss every introduced finding exactly as you would without one — never leave a finding unaddressed, or fold it into prose, because of a limit.
35
36
 
36
37
  5. **suggestions**: offer a \`suggestion\` only on a scanner-backed comment whose fix fits inside that comment's own line range [start_line, line], preserving exact indentation. The tool applies it to a scratch copy, re-runs fmt, validate and every scanner, and makes it committable only if the finding is gone and nothing new appeared ("Proven by re-scan"); otherwise it is shown as plain code marked "Unproven". A fix that needs another resource, a new variable or lines outside the range is described in prose instead — it cannot be proven from a line range. A judgement's suggestion is always shown as unproven code.
37
38
 
@@ -54,9 +55,11 @@ This mode reviews the **Terraform** in a human PR and submits ONE review. It is
54
55
 
55
56
  When the finding under test is a **cloud-misconfig** (public exposure, missing encryption, over-broad IAM, open ingress), the verification dispatch MUST include the Terraform-security refute lens above in addition to the generic charge — these IaC false-positive patterns (a separate hardening resource, a disabled resource, a call-site override) are exactly what a generic code lens misses. A scanner-backed finding the refute lens knocks down is DISMISSED with that reason, not silently dropped.
56
57
 
57
- Every 🚨/⚠️ comment — and every scanner-backed comment — carries an \`audit\`; one without is withheld. Then ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\` (never \`report_progress\` for review output), with \`comments\` (anchored to NEW line numbers) and \`dismissed\`. Non-anchorable concerns go in the body \`### \` sections. The tool opens the body with the verdict (merge-blocking or not, and the author's next steps) and closes it with the scan delta — counts, gate, controls, dismissals and suggestion proofs — so do not restate those yourself.
58
+ A \`betterleaks:\` finding is a secret the PR's commits add, in any file, Terraform or not: answer it like any scanner finding, and never quote, decode or guess its value — say which file and line holds it and that it must be removed from the history and rotated.
58
59
 
59
- Same opening-callout ladder + \`approved\` lever as Review — \`[!CAUTION]\` (a state-destroying or security-breaking change) → \`[!IMPORTANT]\` (a real misconfig to fix before merge; at least this tier whenever an introduced high/critical finding stands after verification) → \`> ℹ️ ...\` (minor HCL/style nits) → \`> ✅ No new issues found.\` (mergeable, \`approved: true\`, no inline comments). Pick the tier the author's actual next action justifies. note: the first \`create_pull_request_review\` submission may error with a one-time diff-coverage, audit or scan-delta nudge — fix what it names and retry.
60
+ Every 🚨/⚠️ comment — and every scanner-backed comment — carries an \`audit\`; one without is withheld. Then ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\` (never \`report_progress\` for review output), with \`comments\` (anchored to NEW line numbers) and \`dismissed\`. Non-anchorable concerns go in the body \`### \` sections. With more than about 10 comments and dismissals in all, stage them first with \`${t("stage_review")}\` in calls of up to 10 — one dismissal entry can carry several \`finding_ids\` that share a reason — and then submit \`${t("create_pull_request_review")}\` with the body alone: one oversized call can be cut off mid-way and cost the whole review. The tool opens the body with the verdict (merge-blocking or not, and the author's next steps) and closes it with the scan delta — counts, gate, controls, dismissals and suggestion proofs — so do not restate those yourself.
61
+
62
+ Same opening-callout ladder + \`approved\` lever as Review — \`[!CAUTION]\` (a state-destroying or security-breaking change) → \`[!IMPORTANT]\` (a real misconfig to fix before merge; at least this tier whenever an introduced high/critical finding stands after verification) → \`> ℹ️ ...\` (minor HCL/style nits) → \`> ✅ No new issues found.\` (mergeable, \`approved: true\`, no inline comments). Pick the tier the author's actual next action justifies. note: the first \`create_pull_request_review\` submission may error with a one-time diff-coverage, audit or scan-delta nudge — fix what it names (stage what it asks for) and retry.
60
63
 
61
64
  9. **guardrails**: never modify \`*.tf\`/\`*.tfvars\`, never commit/push, never open a PR or issue. Only Terraform files are in scope. The review is the only deliverable.
62
65