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,323 @@
1
+ /**
2
+ * A Terraform expression read into its SHAPE, for the refactor proof's value
3
+ * comparison ([[substitution]]).
4
+ *
5
+ * Not an evaluator and not a full HCL grammar. It recognises the forms whose
6
+ * value can be followed without running anything — literals, quoted templates,
7
+ * tuples, objects, traversals (`var.x`, `aws_s3_bucket.b.arn`, `data.t.n.json`)
8
+ * and a top-level conditional — and keeps everything else (function calls,
9
+ * operators, `for` expressions) as `raw`: the code text, with the traversals
10
+ * and strings inside it lifted out so they can still be resolved. A raw
11
+ * expression is only ever compared as "the same code over the same values",
12
+ * which can say equal, never different.
13
+ *
14
+ * Pure.
15
+ */
16
+
17
+ import { skipNonCode } from "#app/mcp/terraform/hcl";
18
+
19
+ export type Step = { attr: string } | { index: Expr };
20
+
21
+ export type Expr =
22
+ | { kind: "literal"; value: string | number | boolean | null }
23
+ /** a quoted string; `parts` alternate text and `${…}` interpolations. */
24
+ | { kind: "template"; parts: (string | Expr)[] }
25
+ | { kind: "tuple"; items: Expr[] }
26
+ | { kind: "object"; entries: { key: string; value: Expr }[] }
27
+ /** `root.step.step[index]…` — `root` is `var`, `local`, a resource type, … */
28
+ | { kind: "traversal"; root: string; steps: Step[]; text: string }
29
+ | { kind: "conditional"; cond: Expr; whenTrue: Expr; whenFalse: Expr }
30
+ /** anything else: code text interleaved with the expressions found inside it. */
31
+ | { kind: "raw"; parts: (string | Expr)[] }
32
+ /** text whose inside cannot be read safely — a heredoc, a template directive. */
33
+ | { kind: "opaque"; text: string };
34
+
35
+ const IDENT = /[A-Za-z_][A-Za-z0-9_-]*/y;
36
+ const NUMBER = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
37
+
38
+ /** the last index of a string, comment or heredoc starting at `i`, or -1.
39
+ * Strings are read here rather than by [[skipNonCode]], which stops at the
40
+ * first inner quote of `"${lookup(m, "k")}"`. */
41
+ function skipSpan(s: string, i: number): number {
42
+ if (s[i] === '"') return stringEnd(s, i) - 1;
43
+ return skipNonCode(s, i)?.end ?? -1;
44
+ }
45
+
46
+ /** the index of the bracket closing the one at `open`, string/comment aware. */
47
+ function closingBracket(s: string, open: number): number {
48
+ let depth = 0;
49
+ for (let i = open; i < s.length; i++) {
50
+ const end = skipSpan(s, i);
51
+ if (end !== -1) {
52
+ i = end;
53
+ continue;
54
+ }
55
+ const ch = s[i];
56
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
57
+ else if (ch === "}" || ch === "]" || ch === ")") {
58
+ depth--;
59
+ if (depth === 0) return i;
60
+ }
61
+ }
62
+ return -1;
63
+ }
64
+
65
+ /** the end (exclusive) of the string literal opening at `open`, `${…}`-aware. */
66
+ function stringEnd(s: string, open: number): number {
67
+ for (let i = open + 1; i < s.length; i++) {
68
+ const ch = s[i];
69
+ if (ch === "\\") {
70
+ i++;
71
+ continue;
72
+ }
73
+ if (ch === '"') return i + 1;
74
+ if ((ch === "$" || ch === "%") && s[i + 1] === "{") {
75
+ const close = closingBracket(s, i + 1);
76
+ if (close === -1) return s.length;
77
+ i = close;
78
+ }
79
+ }
80
+ return s.length;
81
+ }
82
+
83
+ function parseTemplate(text: string): Expr {
84
+ const parts: (string | Expr)[] = [];
85
+ let buf = "";
86
+ for (let i = 1; i < text.length - 1; i++) {
87
+ const ch = text[i] as string;
88
+ if (ch === "\\") {
89
+ buf += text.slice(i, i + 2);
90
+ i++;
91
+ continue;
92
+ }
93
+ if ((ch === "$" || ch === "%") && text[i + 1] === "{") {
94
+ // `$${` / `%%{` are escapes for the literal text
95
+ if (text[i - 1] === ch && buf.endsWith(ch)) {
96
+ buf += "{";
97
+ i++;
98
+ continue;
99
+ }
100
+ if (ch === "%") return { kind: "opaque", text };
101
+ const close = closingBracket(text, i + 1);
102
+ if (close === -1) return { kind: "opaque", text };
103
+ if (buf) parts.push(buf);
104
+ buf = "";
105
+ // `${~ x ~}` strip markers do not change the interpolated value
106
+ parts.push(parseExpression(text.slice(i + 2, close).replace(/^~|~$/g, "")));
107
+ i = close;
108
+ continue;
109
+ }
110
+ buf += ch;
111
+ }
112
+ if (buf) parts.push(buf);
113
+ if (parts.every((p) => typeof p === "string")) {
114
+ return { kind: "literal", value: parts.join("") };
115
+ }
116
+ return { kind: "template", parts };
117
+ }
118
+
119
+ /** split `s` at depth-0 occurrences of any char in `seps`. */
120
+ function splitTopLevel(s: string, seps: string): string[] {
121
+ const out: string[] = [];
122
+ let depth = 0;
123
+ let from = 0;
124
+ for (let i = 0; i < s.length; i++) {
125
+ const end = skipSpan(s, i);
126
+ if (end !== -1) {
127
+ // a line comment swallows its newline, which separates entries
128
+ const comment = s[i] === "#" || (s[i] === "/" && s[i + 1] === "/");
129
+ if (comment && depth === 0 && seps.includes("\n")) {
130
+ out.push(s.slice(from, i));
131
+ from = end + 1;
132
+ }
133
+ i = end;
134
+ continue;
135
+ }
136
+ const ch = s[i] as string;
137
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
138
+ else if (ch === "}" || ch === "]" || ch === ")") depth--;
139
+ else if (depth === 0 && seps.includes(ch)) {
140
+ out.push(s.slice(from, i));
141
+ from = i + 1;
142
+ }
143
+ }
144
+ out.push(s.slice(from));
145
+ return out.map((p) => p.trim()).filter((p) => p !== "");
146
+ }
147
+
148
+ function parseObject(inner: string): Expr {
149
+ const entries: { key: string; value: Expr }[] = [];
150
+ for (const item of splitTopLevel(inner, "\n,")) {
151
+ const m =
152
+ /^("(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_-]*|\([^)]*\))\s*(?:=|:)(?!=)\s*([\s\S]*)$/.exec(
153
+ item,
154
+ );
155
+ if (!m) return { kind: "raw", parts: [`{${inner}}`] };
156
+ const rawKey = m[1] as string;
157
+ const key = rawKey.startsWith('"') ? rawKey.slice(1, -1) : rawKey;
158
+ // a computed key `(var.x)` names a value, not a field: keep the object raw.
159
+ // So does a quoted key that is a template (`"${local.env}-owner"`): the same
160
+ // text resolves to a different key on each side of a module boundary, and
161
+ // comparing it as a literal called a changed key unchanged. `$${` is an
162
+ // escaped, literal `${`.
163
+ if (rawKey.startsWith("(") || /(?:^|[^$])\$\{|(?:^|[^%])%\{/.test(key)) {
164
+ return rawExpression(`{${inner}}`);
165
+ }
166
+ entries.push({ key, value: parseExpression(m[2] as string) });
167
+ }
168
+ return { kind: "object", entries };
169
+ }
170
+
171
+ /** read a traversal starting at `i`: `root(.attr|[index])*`. */
172
+ function readTraversal(
173
+ s: string,
174
+ i: number,
175
+ ): { expr: Expr & { kind: "traversal" }; end: number } | null {
176
+ IDENT.lastIndex = i;
177
+ const head = IDENT.exec(s);
178
+ if (!head) return null;
179
+ let j = i + head[0].length;
180
+ const steps: Step[] = [];
181
+ for (;;) {
182
+ if (s[j] === "." && s[j + 1] === "*") return null; // splat: leave it to `raw`
183
+ if (s[j] === ".") {
184
+ IDENT.lastIndex = j + 1;
185
+ const attr = IDENT.exec(s);
186
+ const num = /^\d+/.exec(s.slice(j + 1));
187
+ if (attr) {
188
+ steps.push({ attr: attr[0] });
189
+ j += 1 + attr[0].length;
190
+ continue;
191
+ }
192
+ if (num) {
193
+ steps.push({ index: { kind: "literal", value: Number(num[0]) } });
194
+ j += 1 + num[0].length;
195
+ continue;
196
+ }
197
+ break;
198
+ }
199
+ if (s[j] === "[") {
200
+ const close = closingBracket(s, j);
201
+ if (close === -1) return null;
202
+ const inner = s.slice(j + 1, close).trim();
203
+ if (inner === "*") return null;
204
+ steps.push({ index: parseExpression(inner) });
205
+ j = close + 1;
206
+ continue;
207
+ }
208
+ break;
209
+ }
210
+ return { expr: { kind: "traversal", root: head[0], steps, text: s.slice(i, j) }, end: j };
211
+ }
212
+
213
+ const KEYWORDS = new Set(["for", "in", "if", "true", "false", "null"]);
214
+
215
+ /** code text with the traversals and strings inside it lifted out. */
216
+ function rawExpression(s: string): Expr {
217
+ const parts: (string | Expr)[] = [];
218
+ let buf = "";
219
+ const flush = () => {
220
+ if (buf) parts.push(buf);
221
+ buf = "";
222
+ };
223
+ for (let i = 0; i < s.length; i++) {
224
+ const ch = s[i] as string;
225
+ if (ch === '"') {
226
+ const end = stringEnd(s, i);
227
+ flush();
228
+ parts.push(parseTemplate(s.slice(i, end)));
229
+ i = end - 1;
230
+ continue;
231
+ }
232
+ const span = skipNonCode(s, i);
233
+ if (span) {
234
+ if (ch === "<") {
235
+ flush();
236
+ parts.push({ kind: "opaque", text: s.slice(i, span.end + 1) });
237
+ }
238
+ i = span.end;
239
+ continue;
240
+ }
241
+ const prev = s[i - 1] ?? "";
242
+ if (/[A-Za-z_]/.test(ch) && !/[A-Za-z0-9_.-]/.test(prev)) {
243
+ const t = readTraversal(s, i);
244
+ if (t) {
245
+ const next = s.slice(t.end).trimStart()[0];
246
+ // a function name, an object key or a keyword is code text, not a value
247
+ const isCode =
248
+ (t.expr.steps.length === 0 && (next === "(" || KEYWORDS.has(t.expr.root))) ||
249
+ (next === "=" && s[s.indexOf("=", t.end) + 1] !== "=");
250
+ if (!isCode) {
251
+ flush();
252
+ parts.push(t.expr);
253
+ i = t.end - 1;
254
+ continue;
255
+ }
256
+ buf += t.expr.text;
257
+ i = t.end - 1;
258
+ continue;
259
+ }
260
+ }
261
+ buf += ch;
262
+ }
263
+ flush();
264
+ return { kind: "raw", parts };
265
+ }
266
+
267
+ /** the depth-0 `?` and its matching `:` of a conditional, or null. */
268
+ function conditionalSplit(s: string): { q: number; c: number } | null {
269
+ let depth = 0;
270
+ let q = -1;
271
+ let pending = 0;
272
+ for (let i = 0; i < s.length; i++) {
273
+ const end = skipSpan(s, i);
274
+ if (end !== -1) {
275
+ i = end;
276
+ continue;
277
+ }
278
+ const ch = s[i];
279
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
280
+ else if (ch === "}" || ch === "]" || ch === ")") depth--;
281
+ else if (depth === 0 && ch === "?") {
282
+ if (q === -1) q = i;
283
+ else pending++;
284
+ } else if (depth === 0 && ch === ":" && q !== -1) {
285
+ if (pending === 0) return { q, c: i };
286
+ pending--;
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+
292
+ /** read one expression's text into its shape. */
293
+ export function parseExpression(text: string): Expr {
294
+ const s = text.trim();
295
+ if (s === "") return { kind: "raw", parts: [] };
296
+ const cond = conditionalSplit(s);
297
+ if (cond) {
298
+ return {
299
+ kind: "conditional",
300
+ cond: parseExpression(s.slice(0, cond.q)),
301
+ whenTrue: parseExpression(s.slice(cond.q + 1, cond.c)),
302
+ whenFalse: parseExpression(s.slice(cond.c + 1)),
303
+ };
304
+ }
305
+ if (s === "true" || s === "false") return { kind: "literal", value: s === "true" };
306
+ if (s === "null") return { kind: "literal", value: null };
307
+ if (NUMBER.test(s)) return { kind: "literal", value: Number(s) };
308
+ if (s.startsWith('"') && stringEnd(s, 0) === s.length) return parseTemplate(s);
309
+ if ((s[0] === "[" || s[0] === "{" || s[0] === "(") && closingBracket(s, 0) === s.length - 1) {
310
+ const inner = s.slice(1, -1);
311
+ if (s[0] === "(") return parseExpression(inner);
312
+ // `[for …]` / `{for …}` build a value by iteration: code, not a literal
313
+ if (/^\s*for\s/.test(inner)) return rawExpression(s);
314
+ return s[0] === "["
315
+ ? { kind: "tuple", items: splitTopLevel(inner, ",").map(parseExpression) }
316
+ : parseObject(inner);
317
+ }
318
+ if (/^[A-Za-z_]/.test(s)) {
319
+ const t = readTraversal(s, 0);
320
+ if (t && t.end === s.length && !KEYWORDS.has(t.expr.root)) return t.expr;
321
+ }
322
+ return rawExpression(s);
323
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * References a refactor left pointing at an address it relocated.
3
+ *
4
+ * The equivalence proof compares the relocated resources themselves — the
5
+ * resource set, their arguments, their `moved {}` coverage — and not who points
6
+ * at them. A policy or output that read `aws_s3_bucket.data.arn` before the
7
+ * bucket moved into `module.data` must now read one of the module's outputs;
8
+ * left as it was, it names a resource that no longer exists. `terraform
9
+ * validate` reports that, but only after an `init` that installed every module:
10
+ * when init fails, validate's "Module not installed" is all it says, and that is
11
+ * dropped as noise. This check reads the tree and needs neither.
12
+ */
13
+ import { posix } from "node:path";
14
+ import { lineOf, skipNonCode } from "#app/mcp/terraform/hcl";
15
+ import type { MovedBlock } from "#app/mcp/terraform/refactor/movedBlocks";
16
+ import type { SourceFile } from "#app/mcp/terraform/tree";
17
+
18
+ export interface StaleReference {
19
+ file: string;
20
+ line: number;
21
+ /** the relocated address the reference still names, without instance key. */
22
+ address: string;
23
+ }
24
+
25
+ /** `hcl` with comments and `moved {}` blocks blanked — newlines kept, so line
26
+ * numbers hold. Strings and heredocs stay: an interpolation is a reference. */
27
+ function referenceText(hcl: string): string {
28
+ const out = hcl.split("");
29
+ for (let i = 0; i < hcl.length; i++) {
30
+ const ch = hcl[i];
31
+ const comment = ch === "#" || (ch === "/" && (hcl[i + 1] === "/" || hcl[i + 1] === "*"));
32
+ const span = ch === '"' || ch === "<" || comment ? skipNonCode(hcl, i) : null;
33
+ if (!span) continue;
34
+ if (comment) for (let j = i; j <= span.end; j++) if (out[j] !== "\n") out[j] = " ";
35
+ i = span.end;
36
+ }
37
+ // a moved block names the old address by design
38
+ return out.join("").replace(/\bmoved\s*\{[^}]*\}/g, (block) => block.replace(/[^\n]/g, " "));
39
+ }
40
+
41
+ /** The root-level address a relocation moved away from: `aws_s3_bucket.data`
42
+ * for a resource (instance key dropped), `module.app` for a renamed call.
43
+ * null for an address inside a module — that module's own code refers to it
44
+ * by its local name, which the relocation does not change. */
45
+ function relocatedFrom(from: string): string | null {
46
+ const base = from.replace(/\[[^\]]*\]/g, "");
47
+ if (/^module\.[^.]+$/.test(base)) return base;
48
+ if (base.startsWith("module.") || base.startsWith("data.")) return null;
49
+ return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(base) ? base : null;
50
+ }
51
+
52
+ /** Whether `address` is still declared in `files` (the relocation was not one). */
53
+ function declared(files: readonly SourceFile[], address: string): boolean {
54
+ const [kind, name] = address.startsWith("module.")
55
+ ? ["module", address.slice("module.".length)]
56
+ : [`resource\\s+"${address.split(".")[0]}"`, address.split(".")[1]];
57
+ const re = new RegExp(`(?:^|\\n)\\s*${kind}\\s+"${name}"\\s*\\{`);
58
+ return files.some((f) => re.test(f.hcl));
59
+ }
60
+
61
+ /**
62
+ * Every place in a relocation's root that still names the address it moved
63
+ * away from. Each reference found is a defect the proof must refuse.
64
+ */
65
+ export function staleReferences(
66
+ after: readonly SourceFile[],
67
+ relocations: readonly MovedBlock[],
68
+ ): StaleReference[] {
69
+ const out: StaleReference[] = [];
70
+ const seen = new Set<string>();
71
+ for (const move of relocations) {
72
+ const address = relocatedFrom(move.from);
73
+ if (!address) continue;
74
+ const dir = !move.root || move.root === "." ? "" : move.root;
75
+ const files = after.filter(
76
+ (f) => (posix.dirname(f.path) === "." ? "" : posix.dirname(f.path)) === dir,
77
+ );
78
+ if (declared(files, address)) continue;
79
+ const pattern = new RegExp(`(?<![\\w.-])${address.replace(/\./g, "\\.")}(?![\\w-])`, "g");
80
+ for (const file of files) {
81
+ const text = referenceText(file.hcl);
82
+ for (const m of text.matchAll(pattern)) {
83
+ const key = `${file.path}:${m.index}`;
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ out.push({ file: file.path, line: lineOf(text, m.index), address });
87
+ }
88
+ }
89
+ }
90
+ return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
91
+ }
92
+
93
+ /** The proof's reason for a set of stale references; null when there are none. */
94
+ export function staleReferenceReason(stale: readonly StaleReference[]): string | null {
95
+ if (stale.length === 0) return null;
96
+ const sites = stale.map((s) => `${s.file}:${s.line} (${s.address})`).join(", ");
97
+ return (
98
+ `${stale.length} reference(s) still name a relocated address: ${sites}. ` +
99
+ "The resource now lives in a module — reach it through one of the module's outputs."
100
+ );
101
+ }
@@ -19,10 +19,13 @@
19
19
  */
20
20
 
21
21
  import {
22
+ MODULE_HEADER,
22
23
  matchBraceBody,
24
+ RESOURCE_HEADER,
23
25
  stripTrailingComment,
24
26
  topLevelAssignments,
25
27
  topLevelEntries,
28
+ unquoteLabel,
26
29
  } from "#app/mcp/terraform/hcl";
27
30
 
28
31
  export interface ResourceRef {
@@ -47,6 +50,68 @@ export interface ResourceRef {
47
50
  * single instance. See [[Multiplicity]] — this is deliberately NOT folded into
48
51
  * `argNames`, because a correct extraction MOVES it onto the module call. */
49
52
  multiplicity: Multiplicity[];
53
+ /** the provider configuration that manages the resource, as the ROOT names it
54
+ * (`aws`, `aws.replica`): the resource's own `provider` meta-argument, or the
55
+ * default for its type, resolved through every module call's `providers`
56
+ * map. Absent on a hand-built ref, which is not compared. See
57
+ * [[moduleCallProviders]]. */
58
+ provider?: string;
59
+ }
60
+
61
+ // --- provider configuration (G16) -------------------------------------------
62
+
63
+ /**
64
+ * Which provider configuration a resource is managed through is not an
65
+ * argument, and a refactor can change it without touching one. A resource
66
+ * pinned to `provider = aws.replica` (another region) moved into a module whose
67
+ * call passes no `providers` map is managed by the module's DEFAULT `aws` after
68
+ * the move: same arguments, same moves, same configuration — and a different
69
+ * region. Validate accepts it, and a plan without refresh shows a no-op.
70
+ *
71
+ * So, like multiplicity, the invariant is the EFFECTIVE value: the root's name
72
+ * for the configuration, resolved through each module call on the way down.
73
+ */
74
+ function parseProvider(body: string): string | null {
75
+ const found = topLevelAssignments(body).find((a) => a.name === "provider");
76
+ return found ? stripTrailingComment(found.value).trim() : null;
77
+ }
78
+
79
+ /** `module "<name>" { … }` call name → its `providers` map (the module's name
80
+ * for a configuration → the caller's), or null when the call passes none and
81
+ * the module inherits the caller's default configurations. */
82
+ export function moduleCallProviders(hcl: string): Map<string, Map<string, string> | null> {
83
+ const out = new Map<string, Map<string, string> | null>();
84
+ const re = new RegExp(MODULE_HEADER, "g");
85
+ let m: RegExpExecArray | null;
86
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
87
+ while ((m = re.exec(hcl)) !== null) {
88
+ const name = m[1] === undefined ? undefined : unquoteLabel(m[1]);
89
+ if (name === undefined) continue;
90
+ const braceStart = hcl.indexOf("{", m.index);
91
+ if (braceStart === -1) break;
92
+ const body = matchBraceBody(hcl, braceStart);
93
+ if (!body) break;
94
+ re.lastIndex = body.end + 1;
95
+ // the whole value: a `providers` map usually spans lines, and
96
+ // `topLevelAssignments` keeps only the first
97
+ const providers = topLevelEntries(body.text).find(
98
+ (e) => e.kind === "attr" && e.name === "providers",
99
+ );
100
+ if (!providers?.value) {
101
+ out.set(name, null);
102
+ continue;
103
+ }
104
+ const map = new Map<string, string>();
105
+ for (const e of providers.value
106
+ .replace(/(?:#|\/\/)[^\n]*/g, "")
107
+ .matchAll(
108
+ /([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?)\s*=\s*([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?)/g,
109
+ )) {
110
+ if (e[1] && e[2]) map.set(e[1], e[2]);
111
+ }
112
+ out.set(name, map);
113
+ }
114
+ return out;
50
115
  }
51
116
 
52
117
  // --- instance multiplicity (G4) ----------------------------------------------
@@ -115,11 +180,11 @@ export function parseMultiplicity(body: string): Multiplicity | null {
115
180
  * `for_each`, so this is half of the effective-multiplicity calculation. */
116
181
  export function moduleCallMultiplicity(hcl: string): Map<string, Multiplicity | null> {
117
182
  const out = new Map<string, Multiplicity | null>();
118
- const re = /(?:^|\n)\s*module\s+"([^"]+)"\s*\{/g;
183
+ const re = new RegExp(MODULE_HEADER, "g");
119
184
  let m: RegExpExecArray | null;
120
185
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
121
186
  while ((m = re.exec(hcl)) !== null) {
122
- const name = m[1];
187
+ const name = m[1] === undefined ? undefined : unquoteLabel(m[1]);
123
188
  if (name === undefined) continue;
124
189
  const braceStart = hcl.indexOf("{", m.index);
125
190
  if (braceStart === -1) break;
@@ -389,20 +454,26 @@ function topLevelArgNames(body: string): string[] {
389
454
  * brace matching here is string/comment/heredoc-aware, which is the whole
390
455
  * difference between reading a `}` in a comment as a block end and not.
391
456
  */
392
- export function parseResourcesWithArgs(
393
- hcl: string,
394
- ): { type: string; name: string; argNames: string[]; multiplicity: Multiplicity | null }[] {
457
+ export function parseResourcesWithArgs(hcl: string): {
458
+ type: string;
459
+ name: string;
460
+ argNames: string[];
461
+ multiplicity: Multiplicity | null;
462
+ provider: string | null;
463
+ }[] {
395
464
  const out: {
396
465
  type: string;
397
466
  name: string;
398
467
  argNames: string[];
399
468
  multiplicity: Multiplicity | null;
469
+ provider: string | null;
400
470
  }[] = [];
401
- const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
471
+ const re = new RegExp(RESOURCE_HEADER, "g");
402
472
  let m: RegExpExecArray | null;
403
473
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
404
474
  while ((m = re.exec(hcl)) !== null) {
405
- const [, resourceType = "", name = ""] = m;
475
+ const resourceType = unquoteLabel(m[1] ?? "");
476
+ const name = unquoteLabel(m[2] ?? "");
406
477
  const braceStart = hcl.indexOf("{", m.index);
407
478
  if (braceStart === -1) break;
408
479
  const body = matchBraceBody(hcl, braceStart);
@@ -413,6 +484,7 @@ export function parseResourcesWithArgs(
413
484
  name,
414
485
  argNames: topLevelArgNames(body.text),
415
486
  multiplicity: parseMultiplicity(body.text),
487
+ provider: parseProvider(body.text),
416
488
  });
417
489
  }
418
490
  return out;