infraweaver 0.3.7 → 0.3.9

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 +31908 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31992 -18749
  8. package/dist/internal.js +133 -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 +41 -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 +7 -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,7 +12,7 @@ import {
12
12
  groupConcernsByRule,
13
13
  planBatches,
14
14
  } from "#app/mcp/terraform/decisions";
15
- import { scanSessionOf } from "#app/mcp/terraform/scanSession";
15
+ import { recordConcernKeys, scanSessionOf } from "#app/mcp/terraform/scanSession";
16
16
  import { suppressConcerns } from "#app/mcp/terraform/suppressions";
17
17
  import { dedupeCrossScanner } from "#app/mcp/terraform/taxonomy";
18
18
  import {
@@ -114,6 +114,7 @@ export function buildConcernResult(
114
114
  session.baselineConcernKeys,
115
115
  fullBaseline.map((c) => concernKeyOf(c)),
116
116
  );
117
+ recordConcernKeys(session, fullBaseline);
117
118
  // the files already unformatted at this (pre-fix) scan. terraform_validate
118
119
  // subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
119
120
  // whole-file reformat into a scoped fix. Captured from the raw set (fmt
@@ -40,11 +40,22 @@
40
40
  * the expression as text. Multiplicity is part of the key, so those dirs
41
41
  * simply never group.
42
42
  *
43
+ * MODULE CALLS IN THE SHAPE. A root's call of a LOCAL module that the
44
+ * cluster's resources read is part of the stack as much as they are, so it can
45
+ * join the cluster: the proposed module then wraps the call (`module.data` → `module.<new>.module.data`, one
46
+ * `moved {}` block) and a reference into it stays inside the module instead of
47
+ * crossing the boundary as an input. Only calls the proof can follow make it
48
+ * in — a local source, no `count`/`for_each`/`providers`/`depends_on` — and a
49
+ * cluster that wraps calls is only the WIDER offer: when it cannot be
50
+ * proposed, the same roots are still considered without their calls.
51
+ *
43
52
  * Pure: an already-read tree in, structure out. No filesystem, no subprocess.
44
53
  */
45
54
 
55
+ import { posix } from "node:path";
46
56
  import { classifyRootDirs, detectEnvironmentTwins, groupFilesByDir } from "#app/mcp/roots";
47
57
  import { parseBlocks, topLevelEntries } from "#app/mcp/terraform/hcl";
58
+ import { localSourceFrom } from "#app/mcp/terraform/localModules";
48
59
  import { generateMovedBlocks, type MovedBlock } from "#app/mcp/terraform/refactor/movedBlocks";
49
60
  import { type Multiplicity, parseResourcesWithArgs } from "#app/mcp/terraform/refactor/resources";
50
61
  import type { SourceFile } from "#app/mcp/terraform/tree";
@@ -75,8 +86,16 @@ const MODELLED_META: ReadonlySet<string> = new Set(["count", "for_each"]);
75
86
 
76
87
  /** expressions whose meaning is scoped to the ROOT that writes them. Identical
77
88
  * text in two roots does NOT mean an identical value — `var.bucket_name`
78
- * resolves per root — so these become module variables even when they match. */
79
- const ROOT_SCOPED = /\b(?:var|local|data|module|terraform)\./;
89
+ * resolves per root — so these become module variables even when they match.
90
+ * The lookbehind keeps `aws_s3_bucket.data.id` and `module.data.x` from reading
91
+ * as a data source. `module.` is judged per cluster — see [[isRootScoped]]. */
92
+ const ROOT_SCOPED = /(?<![\w.-])(?:var|local|data|terraform)\./;
93
+ const MODULE_REF = /(?<![\w.-])module\.([A-Za-z_][\w-]*)/g;
94
+
95
+ /** a module call that cannot join a cluster: the proof does not follow a
96
+ * relocated call's repetition or provider mapping, and `depends_on` is as
97
+ * invisible on a call as on a resource. */
98
+ const CALL_META: ReadonlySet<string> = new Set(["count", "for_each", "providers", "depends_on"]);
80
99
 
81
100
  /** expressions that cannot survive relocation into a module at all. `path.module`
82
101
  * resolves to the directory the code LIVES in, so moving the code changes its
@@ -120,6 +139,10 @@ export interface MemberResource {
120
139
  invisibleMeta: string[];
121
140
  /** attributes whose value did not parse to a close — a truncated file. */
122
141
  unreadable: string[];
142
+ /** set on a module CALL (`type` is `module`): the repo-relative directory its
143
+ * local source resolves to. Part of the shape key — two calls of different
144
+ * modules are not the same shape, whatever their inputs. */
145
+ call?: { dir: string };
123
146
  }
124
147
 
125
148
  /** Read one directory's resources, values included. */
@@ -146,7 +169,18 @@ function readResources(files: readonly SourceFile[]): MemberResource[] {
146
169
  continue;
147
170
  }
148
171
  if (MODELLED_META.has(entry.name)) continue;
149
- if (entry.kind === "dynamic" || entry.value === null) continue;
172
+ // a `dynamic` block generates nested blocks, so it is judged like one:
173
+ // carried verbatim when every member writes it alike and it reads
174
+ // nothing the root keeps, refused otherwise. Skipping it let two roots
175
+ // whose blocks differed — tcp in one, udp in the other — share one body.
176
+ if (entry.kind === "dynamic") {
177
+ const bodies = parseBlocks(block.body, ["dynamic"])
178
+ .filter((d) => d.labels[0] === entry.name)
179
+ .map((d) => normaliseValue(d.body));
180
+ values.set(`dynamic "${entry.name}"`, { kind: "block", text: bodies.join("\n") });
181
+ continue;
182
+ }
183
+ if (entry.value === null) continue;
150
184
  if (!entry.complete) {
151
185
  unreadable.push(entry.name);
152
186
  continue;
@@ -170,6 +204,72 @@ function readResources(files: readonly SourceFile[]): MemberResource[] {
170
204
  return out.sort((a, b) => byText(`${a.type}.${a.name}`, `${b.type}.${b.name}`));
171
205
  }
172
206
 
207
+ /**
208
+ * Read one root directory's module calls that may join a cluster: a local
209
+ * source inside the repository and none of [[CALL_META]]. Any other call stays
210
+ * where it is, exactly as before calls could join — a reference into it crosses
211
+ * the new module's boundary as an input.
212
+ */
213
+ function readModuleCalls(dir: string, files: readonly SourceFile[]): MemberResource[] {
214
+ const out: MemberResource[] = [];
215
+ for (const file of files) {
216
+ for (const block of parseBlocks(file.hcl, ["module"])) {
217
+ const [name] = block.labels;
218
+ if (!name) continue;
219
+ const entries = topLevelEntries(block.body);
220
+ if (entries.some((e) => CALL_META.has(e.name))) continue;
221
+ const source = entries.find((e) => e.name === "source")?.value?.trim() ?? "";
222
+ const local = /^"(\.\.?\/[^"$]*)"$/.exec(source)?.[1];
223
+ if (!local) continue;
224
+ const target = posix.normalize(posix.join(dir || ".", local)).replace(/\/$/, "");
225
+ if (target === ".." || target.startsWith("../")) continue;
226
+ const values = new Map<string, { kind: "attribute" | "block"; text: string }>();
227
+ const unreadable: string[] = [];
228
+ for (const entry of entries) {
229
+ if (entry.name === "source" || entry.value === null) continue;
230
+ if (!entry.complete) {
231
+ unreadable.push(entry.name);
232
+ continue;
233
+ }
234
+ values.set(entry.name, {
235
+ kind: entry.kind === "block" ? "block" : "attribute",
236
+ text: normaliseValue(entry.value),
237
+ });
238
+ }
239
+ out.push({
240
+ type: "module",
241
+ name,
242
+ argNames: [...values.keys()].sort(),
243
+ multiplicity: null,
244
+ values,
245
+ invisibleMeta: [],
246
+ unreadable: unreadable.sort(),
247
+ call: { dir: target },
248
+ });
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /** whether a value reaches something that stays in the ROOT when `cluster`
255
+ * moves into a module: a variable, local, data source or terraform setting; a
256
+ * module call the cluster does not wrap; or a resource the cluster leaves
257
+ * behind. Its text may match in every member while its value differs. */
258
+ function isRootScoped(
259
+ text: string,
260
+ cluster: readonly MemberResource[],
261
+ rootAddresses: readonly string[],
262
+ ): boolean {
263
+ if (ROOT_SCOPED.test(text)) return true;
264
+ const inside = new Set(cluster.map((r) => `${r.type}.${r.name}`));
265
+ for (const m of text.matchAll(MODULE_REF)) if (!inside.has(`module.${m[1]}`)) return true;
266
+ return rootAddresses.some(
267
+ (address) =>
268
+ !inside.has(address) &&
269
+ new RegExp(`(?<![\\w.-])${address.replace(/[.]/g, "\\.")}(?![\\w-])`).test(text),
270
+ );
271
+ }
272
+
173
273
  // --- the shape key ------------------------------------------------------------
174
274
 
175
275
  /** the multiplicity half of the key — `count`/`for_each` with the expression as
@@ -188,7 +288,8 @@ export function shapeKey(resources: readonly MemberResource[]): string {
188
288
  return resources
189
289
  .map(
190
290
  (r) =>
191
- `${r.type}.${r.name}|${[...r.argNames].sort().join(",")}|${multiplicitySignature(r.multiplicity)}`,
291
+ `${r.type}.${r.name}|${[...r.argNames].sort().join(",")}|${multiplicitySignature(r.multiplicity)}` +
292
+ (r.call ? `|source=${r.call.dir}` : ""),
192
293
  )
193
294
  .join("\n");
194
295
  }
@@ -216,23 +317,87 @@ interface Cluster {
216
317
  * unrelated resources, so requiring whole-directory equality would fire on
217
318
  * almost no real repository — while prefix cohesion alone would miss a root
218
319
  * whose every resource belongs to the one stack.
320
+ *
321
+ * Each comes twice when one of the cluster's resources READS a wrappable module
322
+ * call: with that call, and without it. Only a call the resources read is
323
+ * worth wrapping — leaving it behind is what would force the reference across
324
+ * the new module's boundary. A call that merely takes a cluster resource as an
325
+ * input (a resource group's name, say) stays in the root: wrapping it with
326
+ * that resource adds a layer and consolidates nothing.
219
327
  */
220
- function clustersOf(dir: string, resources: MemberResource[]): Cluster[] {
328
+ function clustersOf(
329
+ dir: string,
330
+ resources: MemberResource[],
331
+ calls: MemberResource[] = [],
332
+ ): Cluster[] {
221
333
  if (resources.length === 0) return [];
222
- const out: Cluster[] = [{ dir, prefix: null, resources }];
334
+ const out: Cluster[] = [];
335
+ const seen = new Set<string>();
336
+ const add = (prefix: string | null, members: MemberResource[]) => {
337
+ // a prefix group equal to the whole directory is the same cluster twice.
338
+ const key = members.map((m) => `${m.type}.${m.name}`).join("\n");
339
+ if (seen.has(key)) return;
340
+ seen.add(key);
341
+ if (readByLeftBehind(members, [...resources, ...calls])) return;
342
+ out.push({ dir, prefix, resources: members });
343
+ };
344
+ const withReadCalls = (members: MemberResource[]) =>
345
+ [...members, ...calls.filter((c) => readsCall(members, c.name))].sort((a, b) =>
346
+ byText(`${a.type}.${a.name}`, `${b.type}.${b.name}`),
347
+ );
348
+ add(null, withReadCalls(resources));
349
+ add(null, resources);
223
350
  const byPrefix = new Map<string, MemberResource[]>();
224
351
  for (const r of resources) {
225
352
  const p = namePrefix(r.name);
226
353
  byPrefix.set(p, [...(byPrefix.get(p) ?? []), r]);
227
354
  }
228
355
  for (const [prefix, members] of [...byPrefix].sort(([a], [b]) => byText(a, b))) {
229
- // a prefix group equal to the whole directory is the same cluster twice.
230
- if (members.length === resources.length) continue;
231
- out.push({ dir, prefix, resources: members });
356
+ add(prefix, withReadCalls(members));
357
+ add(prefix, members);
232
358
  }
233
359
  return out;
234
360
  }
235
361
 
362
+ /** a reference to `address` (`type.name` or `module.name`) in HCL text. Only
363
+ * the `${…}` interpolations of a string literal count: a description that
364
+ * mentions an address reads nothing. Hyphens bound a name, as they do in HCL
365
+ * identifiers. */
366
+ function readsAddress(text: string, address: string): boolean {
367
+ const ref = new RegExp(`(?<![\\w.-])${address.replace(/[.-]/g, "\\$&")}\\.`);
368
+ const code = text.replace(
369
+ /"((?:[^"\\]|\\.)*)"/g,
370
+ (_, inner: string) => ` ${[...inner.matchAll(/\$\{([^}]*)\}/g)].map((m) => m[1]).join(" ")} `,
371
+ );
372
+ return ref.test(code);
373
+ }
374
+
375
+ /** whether any of `resources` reads an output of module call `name`. */
376
+ function readsCall(resources: readonly MemberResource[], name: string): boolean {
377
+ return resources.some((r) =>
378
+ [...r.values.values()].some((v) => readsAddress(v.text, `module.${name}`)),
379
+ );
380
+ }
381
+
382
+ /** whether something the cluster leaves in the root reads one of its members.
383
+ * Moved into a module, that member is reachable only through an output, and
384
+ * the proposal carries none — so the reference would go stale. */
385
+ function readByLeftBehind(
386
+ members: readonly MemberResource[],
387
+ all: readonly MemberResource[],
388
+ ): boolean {
389
+ const addressOf = (m: MemberResource) =>
390
+ m.type === "module" ? `module.${m.name}` : `${m.type}.${m.name}`;
391
+ const inside = new Set(members.map(addressOf));
392
+ return all.some(
393
+ (other) =>
394
+ !inside.has(addressOf(other)) &&
395
+ [...other.values.values()].some((v) =>
396
+ [...inside].some((address) => readsAddress(v.text, address)),
397
+ ),
398
+ );
399
+ }
400
+
236
401
  // --- the group ----------------------------------------------------------------
237
402
 
238
403
  type VariableClass = "differing" | "reference";
@@ -257,8 +422,20 @@ interface ConsolidationGroup {
257
422
  * path, else null. A LABEL on a group established from content — never the
258
423
  * thing that formed it. */
259
424
  pattern: string | null;
260
- members: { dir: string; environment: string | null; name_prefix: string | null }[];
425
+ members: {
426
+ dir: string;
427
+ environment: string | null;
428
+ name_prefix: string | null;
429
+ /** the `source` this member's call writes: `proposed_module.path` is
430
+ * repo-relative, and a local source is relative to the caller. */
431
+ call_source: string;
432
+ }[];
433
+ /** a module CALL the shape includes is here too, as type `module`. */
261
434
  resources: { type: string; name: string; arg_names: string[]; multiplicity: string | null }[];
435
+ /** the module calls the new module wraps: each moves into it keeping its
436
+ * name, and `source` is what it writes THERE — relative to the new module's
437
+ * directory, not to the roots it came from. */
438
+ wrapped_calls: { name: string; source: string }[];
262
439
  proposed_module: { name: string; path: string; call_name: string };
263
440
  /** arguments identical across every member: these go in the module body
264
441
  * verbatim, and are NOT variables. `kind` says whether the text is an
@@ -321,13 +498,24 @@ const MIN_MEMBERS_FOR_SINGLE_RESOURCE = 3;
321
498
  * stack, drifted"; below it they are simply different stacks. */
322
499
  const NEAR_OVERLAP = 0.5;
323
500
 
324
- function suggestModuleName(resources: readonly MemberResource[], prefix: string | null): string {
325
- if (prefix) return prefix;
501
+ function suggestModuleName(
502
+ members: readonly MemberResource[],
503
+ prefix: string | null,
504
+ taken: ReadonlySet<string>,
505
+ ): string {
506
+ const resources = members.filter((r) => !r.call);
326
507
  const names = new Set(resources.map((r) => r.name));
327
- if (names.size === 1) return [...names][0] as string;
328
508
  // the commonest type's service words, e.g. `aws_s3_bucket` → `s3_bucket`.
329
- const type = resources[0]?.type ?? "shared";
330
- return type.replace(/^[a-z]+_/, "");
509
+ const base =
510
+ prefix ??
511
+ (names.size === 1
512
+ ? ([...names][0] as string)
513
+ : (resources[0]?.type ?? "shared").replace(/^[a-z]+_/, ""));
514
+ // never a directory the tree already has: the proposal would author into an
515
+ // existing module — the very one it wraps, when the names coincide.
516
+ let name = base;
517
+ for (let i = 2; taken.has(`modules/${name}`); i++) name = `${base}_${i}`;
518
+ return name;
331
519
  }
332
520
 
333
521
  /**
@@ -342,6 +530,7 @@ function suggestModuleName(resources: readonly MemberResource[], prefix: string
342
530
  function classifyEntry(
343
531
  kind: "attribute" | "block",
344
532
  values: ({ kind: "attribute" | "block"; text: string } | undefined)[],
533
+ rootScopedIn: (text: string, member: number) => boolean,
345
534
  ): { kind: "identical" | "differing" | "reference" } | { kind: "refused"; reason: string } {
346
535
  if (values.some((v) => v === undefined)) {
347
536
  return { kind: "refused", reason: "not declared by every member" };
@@ -362,7 +551,7 @@ function classifyEntry(
362
551
  };
363
552
  }
364
553
  const identical = new Set(texts).size === 1;
365
- const rootScoped = texts.some((t) => ROOT_SCOPED.test(t));
554
+ const rootScoped = texts.some((t, i) => rootScopedIn(t, i));
366
555
  // A NESTED BLOCK can only be carried into the module body VERBATIM. There is
367
556
  // no variable that stands in for a block: `versioning_configuration = var.x`
368
557
  // is not the block, and parameterising the one attribute inside it that
@@ -374,7 +563,7 @@ function classifyEntry(
374
563
  kind: "refused",
375
564
  reason: !identical
376
565
  ? "it is a nested block whose contents differ between members, and a block cannot be replaced by a variable — parameterise the attribute inside it by hand"
377
- : "it is a nested block containing a root-scoped reference (`var.`/`local.`/`data.`/`module.`), which resolves per root and so cannot move into a shared module verbatim",
566
+ : "it is a nested block containing a root-scoped reference (`var.`/`local.`/`data.`/`module.`, or a resource the module would leave behind), which resolves per root and so cannot move into a shared module verbatim",
378
567
  };
379
568
  }
380
569
  if (rootScoped) return { kind: "reference" };
@@ -398,17 +587,20 @@ export function findConsolidationGroups(
398
587
 
399
588
  // resources per ROOT directory. A non-root dir has no state of its own, so a
400
589
  // `moved {}` block there would name an address in nobody's state.
590
+ const existingDirs = new Set(byDir.map(([dir]) => dir));
401
591
  const resourcesByDir = new Map<string, MemberResource[]>();
592
+ const callsByDir = new Map<string, MemberResource[]>();
402
593
  for (const [dir, dirFiles] of byDir) {
403
594
  if (!roots.has(dir)) continue;
404
595
  const resources = readResources(dirFiles);
405
596
  if (resources.length > 0) resourcesByDir.set(dir, resources);
597
+ callsByDir.set(dir, readModuleCalls(dir, dirFiles));
406
598
  }
407
599
 
408
600
  // every candidate cluster, keyed by exact shape.
409
601
  const byShape = new Map<string, Cluster[]>();
410
602
  for (const [dir, resources] of resourcesByDir) {
411
- for (const cluster of clustersOf(dir, resources)) {
603
+ for (const cluster of clustersOf(dir, resources, callsByDir.get(dir))) {
412
604
  const key = shapeKey(cluster.resources);
413
605
  byShape.set(key, [...(byShape.get(key) ?? []), cluster]);
414
606
  }
@@ -441,11 +633,16 @@ export function findConsolidationGroups(
441
633
  break;
442
634
  }
443
635
  const dirs = clusters.map((c) => c.dir).sort(byText);
636
+ // a cluster wrapping module calls is the wider of two offers for the same
637
+ // roots. When it cannot be proposed it neither declines nor claims them:
638
+ // the cluster without the calls comes next and speaks for those roots.
639
+ const wraps = first.resources.some((r) => r.call);
444
640
 
445
641
  const meta = [
446
642
  ...new Set(clusters.flatMap((c) => c.resources.flatMap((r) => r.invisibleMeta))),
447
643
  ].sort();
448
644
  if (meta.length > 0) {
645
+ if (wraps) continue;
449
646
  declined.push({
450
647
  dirs,
451
648
  reason:
@@ -461,6 +658,7 @@ export function findConsolidationGroups(
461
658
  ...new Set(clusters.flatMap((c) => c.resources.flatMap((r) => r.unreadable))),
462
659
  ].sort();
463
660
  if (unreadable.length > 0) {
661
+ if (wraps) continue;
464
662
  declined.push({
465
663
  dirs,
466
664
  reason: `the value of \`${unreadable.join("`, `")}\` could not be read to a close (a truncated or malformed file), and a value nobody read cannot be compared`,
@@ -490,6 +688,12 @@ export function findConsolidationGroups(
490
688
  const verdict = classifyEntry(
491
689
  entry.kind,
492
690
  perMember.map((p) => p.value),
691
+ (text, i) =>
692
+ isRootScoped(
693
+ text,
694
+ ordered[i]?.resources ?? [],
695
+ (resourcesByDir.get(ordered[i]?.dir ?? "") ?? []).map((r) => `${r.type}.${r.name}`),
696
+ ),
493
697
  );
494
698
  if (verdict.kind === "refused") {
495
699
  refusals.push(`${address}.${argument}: ${verdict.reason}`);
@@ -517,6 +721,7 @@ export function findConsolidationGroups(
517
721
  }
518
722
 
519
723
  if (refusals.length > 0) {
724
+ if (wraps) continue;
520
725
  declined.push({ dirs, reason: refusals.join("; ") });
521
726
  for (const c of clusters) claimed.add(c.dir);
522
727
  continue;
@@ -532,8 +737,10 @@ export function findConsolidationGroups(
532
737
  // name, so the type qualifier alone still collides and the agent would author
533
738
  // two `variable` blocks with one name — invalid HCL. Escalate those to the
534
739
  // resource's own name, which is unique within a directory by definition.
740
+ // A module call has no type worth naming, so its own name stands in.
535
741
  const qualify = (v: ConsolidationVariable, by: "type" | "address") => {
536
742
  const [type = "", name = ""] = v.resource.split(".");
743
+ if (type === "module") return `${name}_${v.argument}`;
537
744
  const short = type.replace(/^[a-z]+_/, "");
538
745
  const prefix = by === "type" ? short : `${short}_${name}`;
539
746
  return `${prefix}_${v.argument}`;
@@ -554,7 +761,7 @@ export function findConsolidationGroups(
554
761
  v.name = qualify(v, "address");
555
762
  }
556
763
 
557
- const moduleName = suggestModuleName(first.resources, first.prefix);
764
+ const moduleName = suggestModuleName(first.resources, first.prefix, existingDirs);
558
765
  const callName = moduleName;
559
766
  groups.push({
560
767
  pattern: twin?.pattern ?? null,
@@ -562,6 +769,7 @@ export function findConsolidationGroups(
562
769
  dir: c.dir,
563
770
  environment: twin?.members.find((m) => m.dir === c.dir)?.environment ?? null,
564
771
  name_prefix: c.prefix,
772
+ call_source: localSourceFrom(c.dir, `modules/${moduleName}`),
565
773
  })),
566
774
  resources: first.resources.map((r) => ({
567
775
  type: r.type,
@@ -569,6 +777,11 @@ export function findConsolidationGroups(
569
777
  arg_names: r.argNames,
570
778
  multiplicity: r.multiplicity ? multiplicitySignature(r.multiplicity) : null,
571
779
  })),
780
+ wrapped_calls: first.resources.flatMap((r) =>
781
+ r.call
782
+ ? [{ name: r.name, source: localSourceFrom(`modules/${moduleName}`, r.call.dir) }]
783
+ : [],
784
+ ),
572
785
  proposed_module: {
573
786
  name: moduleName,
574
787
  path: `./modules/${moduleName}`,
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { type } from "arktype";
4
4
  import { resolveBaseRef } from "#app/mcp/terraform/paths";
5
5
  import { parseScannerJson } from "#app/mcp/terraform/scannerJson";
6
- import { type RunResult, run } from "#app/mcp/terraform/subprocess";
6
+ import { type RunResult, run, runAsync } from "#app/mcp/terraform/subprocess";
7
7
  import { assertToolchainIntegrity } from "#app/mcp/terraform/toolchainIntegrity";
8
8
 
9
9
  // --- infracost (cost lens) ------------------------------------------------
@@ -132,16 +132,21 @@ export function classifyCostEscalation(
132
132
  return { escalate: false };
133
133
  }
134
134
 
135
- export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
135
+ export function runInfracostBreakdown(scanCwd: string, key: string): Promise<RunResult> {
136
136
  // Same supply-chain posture as trivy/tflint/checkov: probe the version and
137
137
  // refuse a denylisted build BEFORE handing the binary a credential. There is
138
138
  // no infracost advisory today, so this is a no-op gate — but it records the
139
139
  // observed version for evidence provenance, and a future advisory needs only
140
140
  // a denylist row (the stated rationale for probing checkov).
141
141
  assertToolchainIntegrity("infracost", scanCwd);
142
- return run("infracost", ["breakdown", "--path", ".", "--format", "json", "--no-color"], scanCwd, {
143
- INFRACOST_API_KEY: key,
144
- });
142
+ return runAsync(
143
+ "infracost",
144
+ ["breakdown", "--path", ".", "--format", "json", "--no-color"],
145
+ scanCwd,
146
+ {
147
+ INFRACOST_API_KEY: key,
148
+ },
149
+ );
145
150
  }
146
151
 
147
152
  /**
@@ -150,7 +155,11 @@ export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
150
155
  * any failure (no base ref, worktree add fails, infracost errors) returns null
151
156
  * and the caller falls back to reporting current cost only.
152
157
  */
153
- export function infracostBaseline(cwd: string, key: string, tmpdir: string): CostBreakdown | null {
158
+ export async function infracostBaseline(
159
+ cwd: string,
160
+ key: string,
161
+ tmpdir: string,
162
+ ): Promise<CostBreakdown | null> {
154
163
  const baseRef = resolveBaseRef(cwd);
155
164
  if (!baseRef) return null;
156
165
  const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
@@ -170,7 +179,7 @@ export function infracostBaseline(cwd: string, key: string, tmpdir: string): Cos
170
179
  }
171
180
  try {
172
181
  const scanCwd = prefix ? join(worktree, prefix) : worktree;
173
- const r = runInfracostBreakdown(scanCwd, key);
182
+ const r = await runInfracostBreakdown(scanCwd, key);
174
183
  if (r.missing || r.status !== 0) return null;
175
184
  return parseInfracostBreakdown(r.stdout);
176
185
  } catch {
@@ -1,4 +1,5 @@
1
1
  import { type } from "arktype";
2
+ import { BLOCK_LABEL, unquoteLabel } from "#app/mcp/terraform/hcl";
2
3
  import { buildConcern, type Concern, type Severity } from "#app/mcp/terraform/types";
3
4
  import { neutralizeOptional } from "#app/mcp/untrustedContent";
4
5
 
@@ -89,7 +90,10 @@ export interface TfResourceEntry {
89
90
  identifiers: string[];
90
91
  }
91
92
 
92
- const RESOURCE_HEADER = /(^|\n)([ \t]*)resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
93
+ const RESOURCE_HEADER = new RegExp(
94
+ String.raw`(^|\n)([ \t]*)resource\s+(${BLOCK_LABEL})\s+(${BLOCK_LABEL})\s*\{`,
95
+ "g",
96
+ );
93
97
 
94
98
  /** Collect the string literals inside the block that opens at `openBraceIdx`, scanning
95
99
  * to the matching close brace (approximate — good enough to correlate an id/ARN). */
@@ -127,9 +131,9 @@ export function indexTfResources(
127
131
  let m: RegExpExecArray | null;
128
132
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
129
133
  while ((m = RESOURCE_HEADER.exec(hcl)) !== null) {
130
- const type = m[3];
131
- const name = m[4];
132
- if (type === undefined || name === undefined) continue;
134
+ if (m[3] === undefined || m[4] === undefined) continue;
135
+ const type = unquoteLabel(m[3]);
136
+ const name = unquoteLabel(m[4]);
133
137
  const headerStart = m.index + (m[1] ? m[1].length : 0);
134
138
  const line = hcl.slice(0, headerStart).split("\n").length;
135
139
  const openBraceIdx = hcl.indexOf("{", m.index);
@@ -26,7 +26,7 @@ import {
26
26
  getMajor,
27
27
  getMinor,
28
28
  getPrerelease,
29
- isLess,
29
+ isLessThan,
30
30
  normalize,
31
31
  sortReversed,
32
32
  } from "verkit";
@@ -103,6 +103,12 @@ export interface CurrencyVerdict {
103
103
  /** how many MAJORs the constraint's best version trails the latest by —
104
104
  * >0 signals an interface-risk upgrade that must be `needs-human`. */
105
105
  majorsBehind: number;
106
+ /** the newest stable version in the major the constraint already admits,
107
+ * when the constraint does not admit it — the upgrade that crosses no major.
108
+ * An exact pin `3.15.1` with `3.15.2` published is outdated even when the
109
+ * latest release is two majors on; reporting only `latest` hides the one
110
+ * bump that needs nobody's sign-off. Null when there is none. */
111
+ latestInMajor: string | null;
106
112
  }
107
113
 
108
114
  /** drop non-semver tokens and prereleases; registries publish plain versions
@@ -135,22 +141,22 @@ export function classifyCurrency(params: {
135
141
  }): CurrencyVerdict {
136
142
  const versions = stableVersions(params.available);
137
143
  const latest = versions.length > 0 ? (sortReversed(versions)[0] ?? null) : null;
138
- if (latest === null) {
139
- return { latest: null, newestSatisfying: null, outdated: false, majorsBehind: 0 };
140
- }
141
- if (params.constraint === null) {
142
- return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
143
- }
144
+ const unknown = { newestSatisfying: null, outdated: false, majorsBehind: 0, latestInMajor: null };
145
+ if (latest === null) return { latest: null, ...unknown };
146
+ if (params.constraint === null) return { latest, ...unknown };
144
147
  const range = terraformConstraintToRange(params.constraint);
145
- if (range === null) {
146
- return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
147
- }
148
+ if (range === null) return { latest, ...unknown };
148
149
  const newestSatisfying = findMaxSatisfying(versions, range);
149
- const outdated = newestSatisfying === null || isLess(newestSatisfying, latest);
150
+ const outdated = newestSatisfying === null || isLessThan(newestSatisfying, latest);
150
151
  const majorsBehind = newestSatisfying
151
152
  ? Math.max(0, getMajor(latest) - getMajor(newestSatisfying))
152
153
  : Math.max(0, getMajor(latest));
153
- return { latest, newestSatisfying, outdated, majorsBehind };
154
+ const sameMajor = newestSatisfying
155
+ ? sortReversed(versions.filter((v) => getMajor(v) === getMajor(newestSatisfying)))[0]
156
+ : undefined;
157
+ const latestInMajor =
158
+ newestSatisfying && sameMajor && isLessThan(newestSatisfying, sameMajor) ? sameMajor : null;
159
+ return { latest, newestSatisfying, outdated, majorsBehind, latestInMajor };
154
160
  }
155
161
 
156
162
  // --- registry lookups (I/O) --------------------------------------------------
@@ -386,6 +392,8 @@ interface ProviderCurrencyRow {
386
392
  constraint: string | null;
387
393
  latest: string | null;
388
394
  newest_satisfying: string | null;
395
+ /** see {@link CurrencyVerdict.latestInMajor}. */
396
+ latest_in_major: string | null;
389
397
  outdated: boolean;
390
398
  majors_behind: number;
391
399
  lookup: LookupStatus;
@@ -397,7 +405,10 @@ interface ModuleCurrencyRow {
397
405
  version: string | null;
398
406
  latest: string | null;
399
407
  newest_satisfying: string | null;
408
+ /** see {@link CurrencyVerdict.latestInMajor}. */
409
+ latest_in_major: string | null;
400
410
  outdated: boolean;
411
+ majors_behind: number;
401
412
  /** registry module with no `version` attribute — pin it (to `latest`). */
402
413
  unpinned: boolean;
403
414
  lookup: ModuleLookupStatus;
@@ -474,6 +485,7 @@ export async function checkVersionCurrency(
474
485
  constraint: req.version,
475
486
  latest: verdict.latest,
476
487
  newest_satisfying: verdict.newestSatisfying,
488
+ latest_in_major: verdict.latestInMajor,
477
489
  outdated: result.status === "ok" && verdict.outdated,
478
490
  majors_behind: result.status === "ok" ? verdict.majorsBehind : 0,
479
491
  lookup: result.status,
@@ -491,7 +503,9 @@ export async function checkVersionCurrency(
491
503
  version: mod.version,
492
504
  latest: verdict.latest,
493
505
  newest_satisfying: verdict.newestSatisfying,
506
+ latest_in_major: verdict.latestInMajor,
494
507
  outdated: result.status === "ok" && verdict.outdated,
508
+ majors_behind: result.status === "ok" ? verdict.majorsBehind : 0,
495
509
  unpinned: mod.version === null,
496
510
  lookup: result.status,
497
511
  declared_in: mod.declaredIn,
@@ -513,7 +527,9 @@ export async function checkVersionCurrency(
513
527
  version: mod.version,
514
528
  latest: null,
515
529
  newest_satisfying: null,
530
+ latest_in_major: null,
516
531
  outdated: false,
532
+ majors_behind: 0,
517
533
  // `unpinned` means "pin it to latest" — advice that cannot apply to a
518
534
  // source that resolves to nothing; the row's lookup status carries why.
519
535
  unpinned: false,
@@ -546,7 +546,7 @@ export function preventiveControlFor(
546
546
  snippet: [
547
547
  "# .github/workflows/infraweaver-pr-gate.yml",
548
548
  "- id: assess",
549
- " uses: infraweaver-io/infraweaver-action@main",
549
+ " uses: infraweaver-io/infraweaver-action@v0.3",
550
550
  " with:",
551
551
  " mode: assess",
552
552
  "- if: steps.assess.outputs.findings-gate == 'fail'",