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
@@ -1,13 +1,12 @@
1
1
  /**
2
- * The VALUE half of a refactor proof: did each relocated argument keep the value
3
- * it had?
2
+ * The VALUE half of a refactor proof: does every resource the change touches
3
+ * still say what it said?
4
4
  *
5
5
  * [[equivalence]] compares argument NAMES and never values, for a good reason —
6
- * the names are what survives being moved behind a module boundary, and a static
7
- * read cannot evaluate an expression. That reading is exactly right for a b.3
8
- * extraction whose arguments move verbatim. It is a HOLE the moment the refactor
9
- * PARAMETERISES those arguments, which is what consolidating N environments onto
10
- * one shared module does to every value that differs between them:
6
+ * the names are what survives being moved behind a module boundary. On its own
7
+ * that is a hole wherever the change PARAMETERISES a value, which is what
8
+ * consolidating N environments onto one shared module does to every value that
9
+ * differs between them:
11
10
  *
12
11
  * before env/dev/main.tf bucket = "acme-dev-logs"
13
12
  * env/prod/main.tf bucket = "acme-prod-logs"
@@ -15,114 +14,112 @@
15
14
  * env/dev/main.tf module "logging" { bucket = "acme-prod-logs" } ← swapped
16
15
  * env/prod/main.tf module "logging" { bucket = "acme-dev-logs" } ← swapped
17
16
  *
18
- * Every existing gate passes that tree. The resource set is identical, the
19
- * argument names are identical, the `moved {}` blocks are complete, `terraform
20
- * validate` is clean, and — because both roots still declare exactly one bucket
21
- * each — so is a plan. The apply destroys prod.
17
+ * Every name-level gate passes that tree, and so does a plan. The apply
18
+ * destroys prod. The same hole has other doors: a shared module whose body was
19
+ * edited on the way (every caller changes, moved or not), a nested block whose
20
+ * value now comes from a call input, a `dynamic` block fed one rule fewer, a
21
+ * data source the moved resource reads.
22
22
  *
23
- * WHAT THIS DOES, AND ONLY THIS. For an argument whose after-value is a single
24
- * bare `var.<X>`, it follows `X` back UP the call chain to the value written at
25
- * the call site, and compares that against the before-value as TEXT. Both sides
26
- * are then expressions in the SAME root's scope, so `var.bucket_name` on one
27
- * side and `var.bucket_name` on the other really are the same value — which is
28
- * why the walk stops the moment it reaches the root and never tries to resolve a
29
- * root variable further. Nothing is evaluated, here or anywhere else in the
30
- * proof.
23
+ * WHAT THIS DOES. Every resource present on both sides — matched through the
24
+ * relocations — is read into VALUES ([[expressions]]) and compared. A value is
25
+ * followed, never run: `var.x` back up the call chain to what the call site
26
+ * wrote (or the module's default), `local.x` to its definition, `module.m.o` to
27
+ * the output's expression, a `data` source to its body, a reference to another
28
+ * resource to that resource's identity (through the relocations, so
29
+ * `aws_s3_bucket.data.id` in the root and `aws_s3_bucket.this.id` in the module
30
+ * are the same value). A `dynamic` block is expanded when its `for_each`
31
+ * resolves to a literal collection; a conditional is folded when its condition
32
+ * does; a template whose parts are all literals is joined. Nothing else is
33
+ * evaluated — a function call or an operator is compared only as "the same
34
+ * code over the same values".
31
35
  *
32
- * AND WHAT IT REFUSES TO SAY. `null` is NOT CHECKED and is used freely: an
33
- * after-value that is not a bare `var.<X>`, an input the call site does not set
34
- * and the module does not default, a module whose directory does not resolve. A
35
- * mismatch is only ever reported when both sides were actually read — the trade
36
- * is missed instances, never false ones.
36
+ * AND WHAT IT REFUSES TO SAY. A comparison has three outcomes: equal, CHANGED,
37
+ * or NOT CHECKED. CHANGED is reported only when the difference is established
38
+ * — two literals that differ, a collection of a different length, a field one
39
+ * side lacks, a block that has no counterpart at all. Anything that would need
40
+ * evaluating is NOT CHECKED. The trade is missed instances, never false ones.
41
+ *
42
+ * Root-scoped values are never resolved further: `var.x` in a root means
43
+ * whatever tfvars says on both sides, so it is equal to itself and to nothing
44
+ * else.
37
45
  *
38
46
  * Pure: two already-read trees and the relocations in, findings out.
39
47
  */
40
48
 
41
49
  import { posix } from "node:path";
42
- import { isStringLiteral, parseBlocks, topLevelEntries } from "#app/mcp/terraform/hcl";
43
- import { addressKey, moduleHops, normaliseAddress } from "#app/mcp/terraform/refactor/addresses";
50
+ import { matchBraceBody, parseBlocks, topLevelEntries } from "#app/mcp/terraform/hcl";
51
+ import { moduleHops, normaliseAddress, stripIndices } from "#app/mcp/terraform/refactor/addresses";
52
+ import { type Expr, parseExpression, type Step } from "#app/mcp/terraform/refactor/expressions";
44
53
  import type { MovedBlock } from "#app/mcp/terraform/refactor/movedBlocks";
45
54
  import type { SourceFile } from "#app/mcp/terraform/tree";
46
55
 
47
56
  interface ValueSubstitution {
48
- /** the root the relocation happened in. */
57
+ /** the root module the resource belongs to. */
49
58
  root: string;
50
59
  /** the BEFORE address, as `expected_moves` spells it. */
51
60
  resource: string;
61
+ /** the argument, with a path into nested blocks: `versioning_configuration[0].status`. */
52
62
  argument: string;
53
- /** the value the argument had before the refactor, in the root's scope. */
63
+ /** the value as the before tree writes it. */
54
64
  before: string;
55
- /** the value written on the relocated resource — typically `var.<X>`. */
65
+ /** the value as the after tree writes it — often `var.<x>`. */
56
66
  after: string;
57
- /** what `after` resolves to AT THE CALL SITE, i.e. back in the root's scope.
58
- * `null` means NOT CHECKED — see `not_checked` for which case. */
67
+ /** what `after` resolves to, followed back to the root's scope. `null` when
68
+ * the comparison was NOT CHECKED. */
59
69
  after_resolved: string | null;
60
- /** `true`/`false` only when both sides were read; `null` = NOT CHECKED. */
70
+ /** `true`/`false` only when the comparison settled; `null` = NOT CHECKED. */
61
71
  match: boolean | null;
62
- /** why this argument was not checked, or null when it was. */
72
+ /** why this value was not checked, or null when it was. */
63
73
  not_checked: string | null;
64
74
  }
65
75
 
66
76
  export interface SubstitutionResult {
67
- /** every argument considered, checked or not. `null` means the question did
68
- * not arise: no relocation put a resource inside a module call, so there was
69
- * no parameter substitution to verify. Distinct from `[]`, which means
70
- * relocations were examined and none had a comparable argument. */
77
+ /** every value compared on a RELOCATED resource, plus every established
78
+ * change anywhere. `null` means the question did not arise: nothing moved
79
+ * and nothing changed. */
71
80
  value_substitutions: ValueSubstitution[] | null;
72
- /** the established mismatches — the subset with `match: false`. A non-empty
81
+ /** the established changes — the subset with `match: false`. A non-empty
73
82
  * list is a violation, not a warning. */
74
83
  value_mismatches: ValueSubstitution[];
84
+ /** calls of a module this tree does not contain — a registry module, a git
85
+ * module nothing fetched — whose `source` or `version` changed. The code the
86
+ * resources come from was swapped outside the tree, so no comparison here
87
+ * can say what changed: never a proof, in either direction. */
88
+ opaque_module_changes: { root: string; call: string; before: string; after: string }[];
75
89
  reasons: string[];
76
90
  }
77
91
 
78
- const NOT_APPLICABLE: SubstitutionResult = {
79
- value_substitutions: null,
80
- value_mismatches: [],
81
- reasons: [],
82
- };
83
-
84
- /** whitespace-normalised comparison text. Two spellings of one value read as
85
- * equal; two different values never do. Never evaluated. */
86
- function normaliseValue(value: string): string {
87
- return value.trim().replace(/\s+/g, " ");
88
- }
89
-
90
- /** a single bare `var.<name>` and nothing else — the one substitution shape this
91
- * check follows. `"${var.x}-suffix"` deliberately does not match: resolving it
92
- * would mean evaluating a template. */
93
- const BARE_VAR = /^var\.([A-Za-z_][A-Za-z0-9_-]*)$/;
94
-
95
92
  // --- the tree, indexed by directory ------------------------------------------
96
93
 
97
- interface ModuleCallIndex {
94
+ interface ModuleCall {
98
95
  /** repo-relative posix dir the source resolves to, or null when it does not
99
96
  * resolve (a registry module, or a git module nothing fetched). */
100
97
  dir: string | null;
101
- /** input name → raw value, whitespace-normalised. */
98
+ body: string;
99
+ /** input name → raw value. */
102
100
  inputs: Map<string, string>;
103
101
  }
104
102
 
105
103
  interface DirIndex {
106
- /** `<type>.<name>` → argument → raw value. */
107
- resources: Map<string, Map<string, string>>;
108
- /** call name → the call. */
109
- calls: Map<string, ModuleCallIndex>;
110
- /** variable name → its `default`, or null when it declares none. */
104
+ /** `<type>.<name>` → block body. */
105
+ resources: Map<string, string>;
106
+ /** `<type>.<name>` → block body. */
107
+ data: Map<string, string>;
108
+ calls: Map<string, ModuleCall>;
109
+ /** variable name → its raw `default`, or null when it declares none. */
111
110
  variables: Map<string, string | null>;
111
+ locals: Map<string, string>;
112
+ /** output name → its raw `value`. */
113
+ outputs: Map<string, string>;
112
114
  }
113
115
 
114
- function emptyDir(): DirIndex {
115
- return { resources: new Map(), calls: new Map(), variables: new Map() };
116
- }
117
-
118
- /** raw top-level attribute values of a block body, whitespace-normalised.
119
- * Nested blocks and `dynamic` blocks have no single value to compare and are
120
- * skipped; so is a value that did not parse to a close. */
121
- function attributeValues(body: string): Map<string, string> {
116
+ /** complete top-level attributes of a block body, raw. */
117
+ function attributes(body: string): Map<string, string> {
122
118
  const out = new Map<string, string>();
123
119
  for (const entry of topLevelEntries(body)) {
124
- if (entry.kind !== "attr" || entry.value === null || !entry.complete) continue;
125
- out.set(entry.name, normaliseValue(entry.value));
120
+ if (entry.kind === "attr" && entry.value !== null && entry.complete) {
121
+ out.set(entry.name, entry.value);
122
+ }
126
123
  }
127
124
  return out;
128
125
  }
@@ -136,130 +133,691 @@ function callTargetDir(
136
133
  source: string | undefined,
137
134
  fetchedBySource: Map<string, string> | undefined,
138
135
  ): string | null {
139
- if (source === undefined) return null;
140
- const literal = isStringLiteral(source) ? source.slice(1, -1) : null;
141
- if (literal === null) return null;
136
+ const parsed = source === undefined ? null : parseExpression(source);
137
+ if (parsed?.kind !== "literal" || typeof parsed.value !== "string") return null;
138
+ const literal = parsed.value;
142
139
  if (literal.startsWith("./") || literal.startsWith("../")) {
143
140
  return posix.normalize(posix.join(callerDir, literal)).replace(/\/+$/, "");
144
141
  }
145
142
  return fetchedBySource?.get(literal) ?? null;
146
143
  }
147
144
 
145
+ type Tree = Map<string, DirIndex>;
146
+
148
147
  function indexTree(
149
148
  files: readonly SourceFile[],
150
149
  fetchedBySource: Map<string, string> | undefined,
151
- ): Map<string, DirIndex> {
152
- const byDir = new Map<string, DirIndex>();
150
+ ): Tree {
151
+ const byDir: Tree = new Map();
153
152
  for (const file of files) {
154
153
  const dir = posix.dirname(file.path);
155
154
  let index = byDir.get(dir);
156
155
  if (!index) {
157
- index = emptyDir();
156
+ index = {
157
+ resources: new Map(),
158
+ data: new Map(),
159
+ calls: new Map(),
160
+ variables: new Map(),
161
+ locals: new Map(),
162
+ outputs: new Map(),
163
+ };
158
164
  byDir.set(dir, index);
159
165
  }
160
- for (const block of parseBlocks(file.hcl, ["resource", "module", "variable"])) {
161
- if (block.keyword === "resource") {
162
- const [type, name] = block.labels;
163
- if (!type || !name) continue;
164
- index.resources.set(`${type}.${name}`, attributeValues(block.body));
165
- continue;
166
- }
167
- if (block.keyword === "module") {
168
- const [name] = block.labels;
169
- if (!name) continue;
170
- const inputs = attributeValues(block.body);
171
- index.calls.set(name, {
172
- dir: callTargetDir(dir, inputs.get("source"), fetchedBySource),
173
- inputs,
174
- });
175
- continue;
166
+ const blocks = parseBlocks(file.hcl, [
167
+ "resource",
168
+ "data",
169
+ "module",
170
+ "variable",
171
+ "locals",
172
+ "output",
173
+ ]);
174
+ for (const block of blocks) {
175
+ const [first, second] = block.labels;
176
+ switch (block.keyword) {
177
+ case "resource":
178
+ case "data":
179
+ if (first && second) {
180
+ (block.keyword === "data" ? index.data : index.resources).set(
181
+ `${first}.${second}`,
182
+ block.body,
183
+ );
184
+ }
185
+ break;
186
+ case "module":
187
+ if (first) {
188
+ const inputs = attributes(block.body);
189
+ index.calls.set(first, {
190
+ dir: callTargetDir(dir, inputs.get("source"), fetchedBySource),
191
+ body: block.body,
192
+ inputs,
193
+ });
194
+ }
195
+ break;
196
+ case "variable":
197
+ if (first) index.variables.set(first, attributes(block.body).get("default") ?? null);
198
+ break;
199
+ case "locals":
200
+ for (const [name, value] of attributes(block.body)) index.locals.set(name, value);
201
+ break;
202
+ case "output": {
203
+ const value = first ? attributes(block.body).get("value") : undefined;
204
+ if (first && value !== undefined) index.outputs.set(first, value);
205
+ break;
206
+ }
176
207
  }
177
- const [name] = block.labels;
178
- if (!name) continue;
179
- index.variables.set(name, attributeValues(block.body).get("default") ?? null);
180
208
  }
181
209
  }
182
210
  return byDir;
183
211
  }
184
212
 
185
- /** walk a chain of module call names from `root`, returning the directory each
186
- * level lives in — index 0 is the root itself. Stops (returns null) at the
187
- * first hop that does not resolve. */
188
- function chainDirs(
189
- tree: ReadonlyMap<string, DirIndex>,
190
- root: string,
191
- hops: readonly string[],
192
- ): string[] | null {
193
- const dirs = [root];
194
- for (const hop of hops) {
195
- const call = tree.get(dirs[dirs.length - 1] as string)?.calls.get(hop);
196
- if (!call?.dir) return null;
197
- dirs.push(call.dir);
213
+ // --- values ------------------------------------------------------------------
214
+
215
+ type Value =
216
+ | { k: "const"; v: string | number | boolean | null }
217
+ | { k: "tuple"; items: Value[] }
218
+ | { k: "object"; entries: Map<string, Value> }
219
+ /** a value with an identity but no readable content — another resource's
220
+ * attribute, a root variable, `each.value`. Equal only to itself. */
221
+ | { k: "ref"; id: string; text: string }
222
+ /** a data source: its body, read like a resource, and the attribute taken.
223
+ * `label` is for reporting only — two documents with different names and the
224
+ * same body are one value. */
225
+ | { k: "data"; type: string; label: string; body: BlockValue; path: string }
226
+ /** code over values — a function call, an operator. Equal only to the same
227
+ * code over equal values. */
228
+ | { k: "code"; shape: string; parts: Value[] }
229
+ | { k: "unread"; why: string };
230
+
231
+ interface BlockValue {
232
+ attrs: Map<string, Value>;
233
+ /** the attributes as written, for reporting. */
234
+ raw: Map<string, string>;
235
+ /** block type → its blocks in order; null when a `dynamic` block of that
236
+ * type could not be expanded, so the set of blocks is not known. */
237
+ blocks: Map<string, BlockValue[] | null>;
238
+ }
239
+
240
+ /** where a `code` value's parts sit in its shape — a character no code holds. */
241
+ const HOLE = "";
242
+
243
+ const unread = (why: string): Value => ({ k: "unread", why });
244
+ const EVALUATING = "the value cannot be established without evaluating it";
245
+
246
+ /** a scope-dependent reference, or a splat, still sitting in code text after
247
+ * the traversals were lifted out of it. */
248
+ const UNLIFTED_REFERENCE = /(?<![\w.])(?:var|local|module|data|each|count)\.|\[\s*\*\s*\]|\.\*/;
249
+
250
+ interface Scope {
251
+ tree: Tree;
252
+ root: string;
253
+ /** the directory of each level, the root first. */
254
+ dirs: string[];
255
+ /** the call name of each module level. */
256
+ hops: string[];
257
+ /** BEFORE tree only: where a resource address went, so references to it name
258
+ * the same identity on both sides. */
259
+ rename?: (root: string, address: string) => string;
260
+ /** iterator names bound by an enclosing `dynamic` block. */
261
+ bindings: Map<string, Value>;
262
+ depth: number;
263
+ }
264
+
265
+ /** a chain this deep is a reference cycle, not a configuration. */
266
+ const MAX_DEPTH = 48;
267
+
268
+ function prefixOf(hops: readonly string[]): string {
269
+ return hops.map((h) => `module.${h}.`).join("");
270
+ }
271
+
272
+ function render(v: Value): string {
273
+ switch (v.k) {
274
+ case "const":
275
+ return typeof v.v === "string" ? `"${v.v}"` : String(v.v);
276
+ case "tuple":
277
+ return `[${v.items.map(render).join(", ")}]`;
278
+ case "object":
279
+ return `{ ${[...v.entries].map(([k, x]) => `${k} = ${render(x)}`).join(", ")} }`;
280
+ case "ref":
281
+ return v.text;
282
+ case "data":
283
+ return `data.${v.type}.${v.label}${v.path}`;
284
+ case "code": {
285
+ return v.shape
286
+ .split(HOLE)
287
+ .map((text, i) => (i === 0 ? text : `${render(v.parts[i - 1] ?? unread(""))}${text}`))
288
+ .join("");
289
+ }
290
+ case "unread":
291
+ return "(not read)";
198
292
  }
199
- return dirs;
200
293
  }
201
294
 
202
- /**
203
- * Resolve a module variable back to the expression written for it in the ROOT.
204
- *
205
- * Walks the call chain outward, one level per iteration. At each level the value
206
- * comes from the call site's own input; when the call site is the root, that
207
- * expression IS the root-scoped answer and the walk stops — resolving a root
208
- * `var.x` further would mean guessing at `terraform.tfvars`, a `-var` flag or an
209
- * environment variable, none of which the tree carries. An input the call site
210
- * omits falls back to the module's own `default`, which Terraform requires to be
211
- * a constant, so it needs no scope.
212
- *
213
- * Only a bare `var.<x>` continues the walk past an intermediate level: every
214
- * other expression there belongs to that module's scope and cannot be compared
215
- * with a root-scoped value at all.
216
- */
217
- function resolveToRootScope(
218
- tree: ReadonlyMap<string, DirIndex>,
219
- dirs: readonly string[],
220
- hops: readonly string[],
221
- variable: string,
222
- ): { value: string } | { reason: string } {
223
- let name = variable;
224
- for (let level = dirs.length - 1; level >= 1; level--) {
225
- // the call at this level is named by the hop that produced it — looked up by
226
- // NAME rather than by target directory, since one root calling the same
227
- // module twice has two calls with one dir and different inputs.
228
- const call = tree.get(dirs[level - 1] as string)?.calls.get(hops[level - 1] as string);
229
- const written = call?.inputs.get(name);
230
- if (written === undefined) {
231
- const fallback = tree.get(dirs[level] as string)?.variables.get(name);
232
- if (fallback === undefined || fallback === null) {
233
- return {
234
- reason: `the module input \`${name}\` is not set at the call site and the module declares no default`,
235
- };
295
+ function renderSteps(steps: readonly Step[], scope: Scope): string {
296
+ return steps
297
+ .map((s) => ("attr" in s ? `.${s.attr}` : `[${render(evaluate(s.index, scope))}]`))
298
+ .join("");
299
+ }
300
+
301
+ function applySteps(value: Value, steps: readonly Step[], scope: Scope): Value {
302
+ let v = value;
303
+ for (const step of steps) {
304
+ const key: Value = "attr" in step ? { k: "const", v: step.attr } : evaluate(step.index, scope);
305
+ const label = "attr" in step ? `.${step.attr}` : `[${render(key)}]`;
306
+ if (v.k === "object" && key.k === "const" && typeof key.v === "string") {
307
+ v = v.entries.get(key.v) ?? unread(`the field \`${key.v}\` is not set`);
308
+ } else if (v.k === "tuple" && key.k === "const" && typeof key.v === "number") {
309
+ v = v.items[key.v] ?? unread(`index ${key.v} is out of range`);
310
+ } else if (v.k === "ref") {
311
+ v = { k: "ref", id: `${v.id}${label}`, text: `${v.text}${label}` };
312
+ } else if (v.k === "data") {
313
+ v = { ...v, path: `${v.path}${label}` };
314
+ } else if (v.k === "unread") {
315
+ return v;
316
+ } else {
317
+ v = { k: "code", shape: `${HOLE}${label}`, parts: [v] };
318
+ }
319
+ }
320
+ return v;
321
+ }
322
+
323
+ function deeper(scope: Scope, patch: Partial<Scope> = {}): Scope {
324
+ return { ...scope, ...patch, depth: scope.depth + 1 };
325
+ }
326
+
327
+ /** `var.<name>` at the current level, followed up to the root's scope. */
328
+ function resolveVar(name: string, scope: Scope): Value {
329
+ const level = scope.dirs.length - 1;
330
+ if (level === 0) {
331
+ return { k: "ref", id: `${scope.root}|var.${name}`, text: `var.${name}` };
332
+ }
333
+ // looked up by call NAME, not target dir: one root calling the same module
334
+ // twice has two calls with one dir and different inputs.
335
+ const call = scope.tree
336
+ .get(scope.dirs[level - 1] as string)
337
+ ?.calls.get(scope.hops[level - 1] as string);
338
+ const written = call?.inputs.get(name);
339
+ if (written !== undefined) {
340
+ return evaluate(
341
+ parseExpression(written),
342
+ deeper(scope, {
343
+ dirs: scope.dirs.slice(0, -1),
344
+ hops: scope.hops.slice(0, -1),
345
+ bindings: new Map(),
346
+ }),
347
+ );
348
+ }
349
+ const fallback = scope.tree.get(scope.dirs[level] as string)?.variables.get(name);
350
+ if (fallback === undefined || fallback === null) {
351
+ return unread(
352
+ `the module input \`${name}\` is not set at the call site and the module declares no default`,
353
+ );
354
+ }
355
+ return evaluate(parseExpression(fallback), deeper(scope, { bindings: new Map() }));
356
+ }
357
+
358
+ function resolveTraversal(t: Expr & { kind: "traversal" }, scope: Scope): Value {
359
+ const bound = scope.bindings.get(t.root);
360
+ if (bound) return applySteps(bound, t.steps, scope);
361
+ const dir = scope.dirs[scope.dirs.length - 1] as string;
362
+ const index = scope.tree.get(dir);
363
+ const [first, second] = t.steps;
364
+ const name = first && "attr" in first ? first.attr : undefined;
365
+ const leaf = second && "attr" in second ? second.attr : undefined;
366
+ switch (t.root) {
367
+ case "var":
368
+ return name
369
+ ? applySteps(resolveVar(name, scope), t.steps.slice(1), scope)
370
+ : unread(EVALUATING);
371
+ case "local": {
372
+ const raw = name ? index?.locals.get(name) : undefined;
373
+ if (raw === undefined) return unread(`\`local.${name}\` is not defined here`);
374
+ return applySteps(evaluate(parseExpression(raw), deeper(scope)), t.steps.slice(1), scope);
375
+ }
376
+ case "data": {
377
+ const body = name && leaf ? index?.data.get(`${name}.${leaf}`) : undefined;
378
+ if (body === undefined) return unread(`\`data.${name}.${leaf}\` is not declared here`);
379
+ return {
380
+ k: "data",
381
+ type: name as string,
382
+ label: leaf as string,
383
+ body: evaluateBlock(body, deeper(scope)),
384
+ path: renderSteps(t.steps.slice(2), scope),
385
+ };
386
+ }
387
+ case "module": {
388
+ const call = name ? index?.calls.get(name) : undefined;
389
+ const output = call?.dir && leaf ? scope.tree.get(call.dir)?.outputs.get(leaf) : undefined;
390
+ if (call?.dir && output !== undefined) {
391
+ const inner = evaluate(
392
+ parseExpression(output),
393
+ deeper(scope, {
394
+ dirs: [...scope.dirs, call.dir],
395
+ hops: [...scope.hops, name as string],
396
+ bindings: new Map(),
397
+ }),
398
+ );
399
+ return applySteps(inner, t.steps.slice(2), scope);
400
+ }
401
+ const address = `${prefixOf(scope.hops)}module.${name}`;
402
+ const mapped = scope.rename ? scope.rename(scope.root, address) : address;
403
+ const rest = renderSteps(t.steps.slice(1), scope);
404
+ return { k: "ref", id: `${scope.root}|${mapped}${rest}`, text: `module.${name}${rest}` };
405
+ }
406
+ case "path":
407
+ return name === "module"
408
+ ? { k: "ref", id: `${dir}|path.module`, text: t.text }
409
+ : { k: "ref", id: t.text, text: t.text };
410
+ default: {
411
+ if (name && index?.resources.has(`${t.root}.${name}`)) {
412
+ const address = `${prefixOf(scope.hops)}${t.root}.${name}`;
413
+ const mapped = scope.rename ? scope.rename(scope.root, address) : address;
414
+ const rest = renderSteps(t.steps.slice(1), scope);
415
+ return { k: "ref", id: `${scope.root}|${mapped}${rest}`, text: t.text };
416
+ }
417
+ // `each`, `count`, `self`, a `for` expression's own symbol: the same text
418
+ // names the same thing on both sides
419
+ return { k: "ref", id: t.text, text: t.text };
420
+ }
421
+ }
422
+ }
423
+
424
+ function evaluate(expr: Expr, scope: Scope): Value {
425
+ if (scope.depth > MAX_DEPTH) return unread("the reference chain is too deep to follow");
426
+ switch (expr.kind) {
427
+ case "literal":
428
+ return { k: "const", v: expr.value };
429
+ case "template": {
430
+ const parts = expr.parts.map((p) =>
431
+ typeof p === "string" ? ({ k: "const", v: p } as Value) : evaluate(p, scope),
432
+ );
433
+ // `"${x}"` alone is x itself, whatever its type
434
+ if (expr.parts.length === 1 && typeof expr.parts[0] !== "string") return parts[0] as Value;
435
+ if (parts.every((p) => p.k === "const" && p.v !== null)) {
436
+ return { k: "const", v: parts.map((p) => String((p as { v: unknown }).v)).join("") };
236
437
  }
237
- return { value: fallback };
438
+ // literal text goes into the shape as written and each interpolation is a
439
+ // `${…}` hole, so a template still compares part by part and renders as
440
+ // the template it is — `"${var.env}-boot"`, not `"var.env"-boot""`
441
+ const holes: Value[] = [];
442
+ const shape = expr.parts
443
+ .map((p, i) => {
444
+ if (typeof p === "string") return p;
445
+ holes.push(parts[i] as Value);
446
+ return `\${${HOLE}}`;
447
+ })
448
+ .join("");
449
+ return { k: "code", shape: `"${shape}"`, parts: holes };
238
450
  }
239
- if (level === 1) return { value: written };
240
- // MID-CHAIN, the walk may only continue through another bare `var.<x>`.
241
- // Anything else — `"${var.x}"`, `local.y`, a literal-with-suffix — is written
242
- // in the scope of an INTERMEDIATE module, not the root's, so returning it
243
- // would compare two expressions from different scopes: textually equal ones
244
- // would report a false match, and textually different ones a false mismatch.
245
- // Both contradict this module's contract, so the chain stops NOT CHECKED.
246
- const inner = BARE_VAR.exec(written);
247
- if (inner?.[1] === undefined) {
451
+ case "tuple":
452
+ return { k: "tuple", items: expr.items.map((x) => evaluate(x, scope)) };
453
+ case "object":
454
+ return {
455
+ k: "object",
456
+ entries: new Map(expr.entries.map((e) => [e.key, evaluate(e.value, scope)])),
457
+ };
458
+ case "conditional": {
459
+ const cond = evaluate(expr.cond, scope);
460
+ if (cond.k === "const" && typeof cond.v === "boolean") {
461
+ return evaluate(cond.v ? expr.whenTrue : expr.whenFalse, scope);
462
+ }
248
463
  return {
249
- reason:
250
- `the input \`${name}\` is set to \`${written}\` at an intermediate module call, whose scope ` +
251
- "is not the root's — resolving it further would mean evaluating it",
464
+ k: "code",
465
+ shape: `${HOLE} ? ${HOLE} : ${HOLE}`,
466
+ parts: [cond, evaluate(expr.whenTrue, scope), evaluate(expr.whenFalse, scope)],
252
467
  };
253
468
  }
254
- name = inner[1];
469
+ case "traversal":
470
+ return resolveTraversal(expr, scope);
471
+ case "raw": {
472
+ // a reference left in the code text — under a splat or a unary minus,
473
+ // which the lifting does not follow — names something different in a
474
+ // different module, so identical text proves nothing about its value
475
+ if (expr.parts.some((p) => typeof p === "string" && UNLIFTED_REFERENCE.test(p))) {
476
+ return unread(
477
+ "the expression reads a value through a splat or an operator this check does not follow",
478
+ );
479
+ }
480
+ const parts: Value[] = [];
481
+ const shape = expr.parts
482
+ .map((p) => {
483
+ if (typeof p === "string") return p;
484
+ parts.push(evaluate(p, scope));
485
+ return HOLE;
486
+ })
487
+ .join("")
488
+ .replace(/\s+/g, " ")
489
+ .trim();
490
+ return { k: "code", shape, parts };
491
+ }
492
+ case "opaque":
493
+ return unread(EVALUATING);
494
+ }
495
+ }
496
+
497
+ /** a `dynamic` block's generated blocks, or null when `for_each` is not a
498
+ * literal collection once followed. */
499
+ function expandDynamic(label: string, body: string, scope: Scope): BlockValue[] | null {
500
+ const entries = topLevelEntries(body);
501
+ const forEach = entries.find((e) => e.kind === "attr" && e.name === "for_each")?.value;
502
+ const iterator = entries.find((e) => e.kind === "attr" && e.name === "iterator")?.value?.trim();
503
+ const content = entries.find((e) => e.kind === "block" && e.name === "content")?.value;
504
+ if (!forEach || content === null || content === undefined) return null;
505
+ const collection = evaluate(parseExpression(forEach), scope);
506
+ let elements: [Value, Value][];
507
+ if (collection.k === "tuple") {
508
+ elements = collection.items.map((v, i) => [{ k: "const", v: i }, v]);
509
+ } else if (collection.k === "object") {
510
+ // a map is iterated in key order
511
+ elements = [...collection.entries]
512
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
513
+ .map(([key, v]) => [{ k: "const", v: key }, v]);
514
+ } else {
515
+ return null;
516
+ }
517
+ const name = iterator && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(iterator) ? iterator : label;
518
+ return elements.map(([key, value]) =>
519
+ evaluateBlock(
520
+ content,
521
+ deeper(scope, {
522
+ bindings: new Map([
523
+ ...scope.bindings,
524
+ [
525
+ name,
526
+ {
527
+ k: "object",
528
+ entries: new Map([
529
+ ["key", key],
530
+ ["value", value],
531
+ ]),
532
+ },
533
+ ],
534
+ ]),
535
+ }),
536
+ ),
537
+ );
538
+ }
539
+
540
+ /** the body of the `dynamic "<label>"` block whose header is on `bodyLine`. */
541
+ function dynamicBody(body: string, label: string, bodyLine: number): string | null {
542
+ let at = 0;
543
+ for (let line = 1; line < bodyLine && at !== -1; line++) at = body.indexOf("\n", at) + 1;
544
+ const header = body.indexOf(`"${label}"`, at);
545
+ const open = header === -1 ? -1 : body.indexOf("{", header);
546
+ return matchBraceBody(body, open)?.text ?? null;
547
+ }
548
+
549
+ function evaluateBlock(body: string, scope: Scope): BlockValue {
550
+ const block: BlockValue = { attrs: new Map(), raw: new Map(), blocks: new Map() };
551
+ const add = (type: string, generated: BlockValue[] | null) => {
552
+ const have = block.blocks.get(type);
553
+ if (have === null) return;
554
+ block.blocks.set(type, generated === null ? null : [...(have ?? []), ...generated]);
555
+ };
556
+ for (const entry of topLevelEntries(body)) {
557
+ if (entry.kind === "attr") {
558
+ block.raw.set(entry.name, (entry.value ?? "").replace(/\s+/g, " "));
559
+ block.attrs.set(
560
+ entry.name,
561
+ entry.value !== null && entry.complete
562
+ ? evaluate(parseExpression(entry.value), scope)
563
+ : unread("the value did not parse"),
564
+ );
565
+ } else if (entry.kind === "block") {
566
+ add(entry.name, [evaluateBlock(entry.value ?? "", scope)]);
567
+ } else {
568
+ const inner = dynamicBody(body, entry.name, entry.bodyLine);
569
+ add(entry.name, inner === null ? null : expandDynamic(entry.name, inner, scope));
570
+ }
571
+ }
572
+ return block;
573
+ }
574
+
575
+ // --- comparison --------------------------------------------------------------
576
+
577
+ type Verdict = "equal" | "changed" | "unknown";
578
+
579
+ function combine(verdicts: Iterable<Verdict>): Verdict {
580
+ let all: Verdict = "equal";
581
+ for (const v of verdicts) {
582
+ if (v === "changed") return "changed";
583
+ if (v === "unknown") all = "unknown";
584
+ }
585
+ return all;
586
+ }
587
+
588
+ const STRUCTURED = new Set(["const", "tuple", "object"]);
589
+
590
+ function compareValues(a: Value, b: Value): Verdict {
591
+ if (a.k === "const" && b.k === "const") {
592
+ if (a.v === b.v) return "equal";
593
+ if (typeof a.v === typeof b.v || a.v === null || b.v === null) return "changed";
594
+ // `443` and `"443"` may convert to one another
595
+ return "unknown";
596
+ }
597
+ if (a.k === "tuple" && b.k === "tuple") {
598
+ if (a.items.length !== b.items.length) return "changed";
599
+ return combine(a.items.map((x, i) => compareValues(x, b.items[i] as Value)));
600
+ }
601
+ if (a.k === "object" && b.k === "object") {
602
+ const keys = new Set([...a.entries.keys(), ...b.entries.keys()]);
603
+ if ([...keys].some((k) => !a.entries.has(k) || !b.entries.has(k))) return "changed";
604
+ return combine(
605
+ [...keys].map((k) => compareValues(a.entries.get(k) as Value, b.entries.get(k) as Value)),
606
+ );
607
+ }
608
+ if (a.k === "ref" && b.k === "ref") return a.id === b.id ? "equal" : "unknown";
609
+ if (a.k === "data" && b.k === "data") {
610
+ if (a.type !== b.type || a.path !== b.path) return "unknown";
611
+ return compareBlocks(a.body, b.body, "", null);
612
+ }
613
+ if (a.k === "code" && b.k === "code") {
614
+ if (a.shape !== b.shape || a.parts.length !== b.parts.length) return "unknown";
615
+ return combine(a.parts.map((x, i) => compareValues(x, b.parts[i] as Value))) === "equal"
616
+ ? "equal"
617
+ : "unknown";
618
+ }
619
+ // a string where there was a list: no conversion makes those one value
620
+ if (STRUCTURED.has(a.k) && STRUCTURED.has(b.k)) return "changed";
621
+ return "unknown";
622
+ }
623
+
624
+ interface Finding {
625
+ argument: string;
626
+ before: string;
627
+ after: string;
628
+ after_resolved: string;
629
+ verdict: Verdict;
630
+ why: string | null;
631
+ }
632
+
633
+ /** the meta-arguments of a resource: compared by the multiplicity, provider and
634
+ * dependency checks, not here. */
635
+ const META = new Set(["count", "for_each", "provider", "depends_on"]);
636
+ /** a module call's own: its identity, not its inputs. A version bump is the
637
+ * version guard's question. */
638
+ const CALL_META = new Set([...META, "source", "version", "providers"]);
639
+
640
+ function whyNot(a: Value | undefined, b: Value | undefined): string {
641
+ if (a?.k === "unread") return a.why;
642
+ if (b?.k === "unread") return b.why;
643
+ return EVALUATING;
644
+ }
645
+
646
+ function compareBlocks(
647
+ before: BlockValue,
648
+ after: BlockValue,
649
+ path: string,
650
+ out: Finding[] | null,
651
+ meta: ReadonlySet<string> | null = null,
652
+ ): Verdict {
653
+ const verdicts: Verdict[] = [];
654
+ const record = (f: Finding) => {
655
+ verdicts.push(f.verdict);
656
+ out?.push(f);
657
+ };
658
+ const names = [...new Set([...before.attrs.keys(), ...after.attrs.keys()])].sort();
659
+ for (const name of names) {
660
+ if (meta?.has(name)) continue;
661
+ const b = before.attrs.get(name);
662
+ const a = after.attrs.get(name);
663
+ if (!b || !a) {
664
+ // at the top level a one-sided argument is argument DRIFT, which
665
+ // `arg_names_equivalent` already reports; nested, nothing else sees it,
666
+ // but the provider's default may equal what was written
667
+ if (meta) continue;
668
+ record({
669
+ argument: `${path}${name}`,
670
+ before: before.raw.get(name) ?? "(not set)",
671
+ after: after.raw.get(name) ?? "(not set)",
672
+ after_resolved: a ? render(a) : "(not set)",
673
+ verdict: "unknown",
674
+ why: "set on one side only, and the provider's default is not known here",
675
+ });
676
+ continue;
677
+ }
678
+ const verdict = compareValues(b, a);
679
+ record({
680
+ argument: `${path}${name}`,
681
+ before: before.raw.get(name) ?? render(b),
682
+ after: after.raw.get(name) ?? render(a),
683
+ after_resolved: render(a),
684
+ verdict,
685
+ why: verdict === "unknown" ? whyNot(b, a) : null,
686
+ });
687
+ }
688
+ const types = [...new Set([...before.blocks.keys(), ...after.blocks.keys()])].sort();
689
+ for (const type of types) {
690
+ const bs = before.blocks.has(type) ? (before.blocks.get(type) ?? null) : [];
691
+ const as = after.blocks.has(type) ? (after.blocks.get(type) ?? null) : [];
692
+ const argument = `${path}${type}`;
693
+ if (bs === null || as === null) {
694
+ record({
695
+ argument,
696
+ before: bs === null ? "dynamic" : `${bs.length} block(s)`,
697
+ after: as === null ? "dynamic" : `${as.length} block(s)`,
698
+ after_resolved: as === null ? "dynamic" : `${as.length} block(s)`,
699
+ verdict: "unknown",
700
+ why: "a `dynamic` block whose `for_each` is not a literal collection once followed",
701
+ });
702
+ continue;
703
+ }
704
+ if (bs.length !== as.length) {
705
+ record({
706
+ argument,
707
+ before: `${bs.length} block(s)`,
708
+ after: `${as.length} block(s)`,
709
+ after_resolved: `${as.length} block(s)`,
710
+ verdict: "changed",
711
+ why: null,
712
+ });
713
+ continue;
714
+ }
715
+ const inOrder = bs.map((b, i) => compareBlocks(b, as[i] as BlockValue, "", null));
716
+ if (inOrder.every((v) => v === "equal")) {
717
+ bs.forEach((b, i) => {
718
+ compareBlocks(b, as[i] as BlockValue, `${argument}[${i}].`, out);
719
+ });
720
+ verdicts.push("equal");
721
+ continue;
722
+ }
723
+ // one block with no counterpart that could be equal is an established
724
+ // change, whatever the order means for this block type
725
+ const orphan = bs.some((b) => as.every((a) => compareBlocks(b, a, "", null) === "changed"));
726
+ if (orphan) {
727
+ bs.forEach((b, i) => {
728
+ if (inOrder[i] !== "equal")
729
+ compareBlocks(b, as[i] as BlockValue, `${argument}[${i}].`, out);
730
+ });
731
+ verdicts.push("changed");
732
+ continue;
733
+ }
734
+ record({
735
+ argument,
736
+ before: `${bs.length} block(s)`,
737
+ after: `${as.length} block(s)`,
738
+ after_resolved: `${as.length} block(s)`,
739
+ verdict: "unknown",
740
+ why: inOrder.includes("changed")
741
+ ? "the same blocks in a different order, and whether order matters for this block type is the provider's to say"
742
+ : EVALUATING,
743
+ });
255
744
  }
256
- return { reason: "the relocation crosses no module call" };
745
+ return combine(verdicts);
746
+ }
747
+
748
+ // --- what is compared --------------------------------------------------------
749
+
750
+ interface Instance {
751
+ root: string;
752
+ address: string;
753
+ dirs: string[];
754
+ hops: string[];
755
+ body: string;
756
+ /** a module call nothing in the tree resolves: its inputs are its values. */
757
+ call: boolean;
257
758
  }
258
759
 
259
- // --- the check ----------------------------------------------------------------
760
+ /** a call graph deeper than this is a cycle or past what the proof attributes. */
761
+ const MAX_MODULE_DEPTH = 12;
762
+
763
+ function instancesOf(tree: Tree, roots: readonly string[]): Map<string, Instance> {
764
+ const out = new Map<string, Instance>();
765
+ const visit = (dirs: string[], hops: string[], root: string) => {
766
+ const dir = dirs[dirs.length - 1] as string;
767
+ const index = tree.get(dir);
768
+ if (!index) return;
769
+ const prefix = prefixOf(hops);
770
+ for (const [key, body] of index.resources) {
771
+ const address = `${prefix}${key}`;
772
+ out.set(`${root}|${address}`, { root, address, dirs, hops, body, call: false });
773
+ }
774
+ for (const [name, call] of index.calls) {
775
+ if (call.dir && tree.has(call.dir)) {
776
+ if (dirs.includes(call.dir) || dirs.length > MAX_MODULE_DEPTH) continue;
777
+ visit([...dirs, call.dir], [...hops, name], root);
778
+ continue;
779
+ }
780
+ const address = `${prefix}module.${name}`;
781
+ out.set(`${root}|${address}`, { root, address, dirs, hops, body: call.body, call: true });
782
+ }
783
+ };
784
+ for (const root of roots) visit([root], [], root);
785
+ return out;
786
+ }
787
+
788
+ function rootsOf(tree: Tree, moduleDirs: ReadonlySet<string>): string[] {
789
+ return [...tree.keys()].filter((d) => !moduleDirs.has(d)).sort();
790
+ }
791
+
792
+ /** where each before address went, per root — a move of a whole module call
793
+ * relocates everything under it. */
794
+ function renamer(moves: readonly MovedBlock[]): (root: string, address: string) => string {
795
+ const exact = new Map<string, string>();
796
+ const prefixes: { root: string; from: string; to: string }[] = [];
797
+ for (const mv of moves) {
798
+ const root = mv.root === undefined || mv.root === "" ? "." : mv.root;
799
+ const from = normaliseAddress(mv.from);
800
+ const to = normaliseAddress(mv.to);
801
+ exact.set(`${root}|${from}`, to);
802
+ const base = `${root}|${stripIndices(from)}`;
803
+ if (!exact.has(base)) exact.set(base, stripIndices(to));
804
+ if (/^module\.[^.]+$/.test(stripIndices(from)))
805
+ prefixes.push({ root, from: stripIndices(from), to: stripIndices(to) });
806
+ }
807
+ return (root, address) => {
808
+ const hit = exact.get(`${root}|${address}`);
809
+ if (hit !== undefined) return hit;
810
+ for (const p of prefixes) {
811
+ if (p.root === root && address.startsWith(`${p.from}.`)) {
812
+ return `${p.to}${address.slice(p.from.length)}`;
813
+ }
814
+ }
815
+ return address;
816
+ };
817
+ }
260
818
 
261
819
  /**
262
- * Verify that every relocated argument's value survived being parameterised.
820
+ * Compare the values of every resource present on both sides.
263
821
  *
264
822
  * `moves` is `computeEquivalence`'s `expected_moves` — the authoritative set of
265
823
  * relocations, so this check and the proof it augments always speak about the
@@ -271,111 +829,149 @@ export function verifyParameterSubstitution(
271
829
  moves: readonly MovedBlock[],
272
830
  opts: { fetchedBySource?: Map<string, string> } = {},
273
831
  ): SubstitutionResult {
274
- // only relocations that put a resource INSIDE a module call parameterise
275
- // anything; a rename within one directory moves values verbatim or not at all.
276
- const relevant = moves.filter((mv) => moduleHops(mv.to).length > moduleHops(mv.from).length);
277
- if (relevant.length === 0) return NOT_APPLICABLE;
278
-
279
832
  const before = indexTree(beforeFiles, opts.fetchedBySource);
280
833
  const after = indexTree(afterFiles, opts.fetchedBySource);
834
+ // a directory either tree instantiates is a module in both — see [[attributePair]]
835
+ const moduleDirs = new Set<string>();
836
+ for (const tree of [before, after])
837
+ for (const index of tree.values())
838
+ for (const call of index.calls.values()) if (call.dir) moduleDirs.add(call.dir);
839
+ const rename = renamer(moves);
840
+ const beforeInstances = instancesOf(before, rootsOf(before, moduleDirs));
841
+ const afterInstances = instancesOf(after, rootsOf(after, moduleDirs));
281
842
 
282
- const substitutions: ValueSubstitution[] = [];
283
- for (const mv of relevant) {
284
- // an unqualified block (a single-root tree, or a hand-built one) acts in the
285
- // repo root, which is how `posix.dirname` spells a top-level file's dir.
286
- const root = mv.root === undefined || mv.root === "" ? "." : mv.root;
287
- const fromAddress = normaliseAddress(mv.from);
288
-
289
- const afterHops = moduleHops(mv.to);
290
- const beforeDirs = chainDirs(before, root, moduleHops(fromAddress));
291
- const afterDirs = chainDirs(after, root, afterHops);
292
- const beforeValues = beforeDirs
293
- ? before.get(beforeDirs[beforeDirs.length - 1] as string)?.resources.get(addressKey(mv.from))
294
- : undefined;
295
- const afterValues = afterDirs
296
- ? after.get(afterDirs[afterDirs.length - 1] as string)?.resources.get(addressKey(mv.to))
297
- : undefined;
298
- if (!beforeValues || !afterValues || !afterDirs) {
299
- substitutions.push({
300
- root,
301
- resource: fromAddress,
302
- argument: "*",
303
- before: "",
304
- after: "",
305
- after_resolved: null,
306
- match: null,
307
- not_checked:
308
- "the resource could not be read on one side — its module source did not resolve (a " +
309
- "registry module, or a git module nothing fetched), or the block itself was not found there",
310
- });
311
- continue;
843
+ const rows: ValueSubstitution[] = [];
844
+ const opaque: SubstitutionResult["opaque_module_changes"] = [];
845
+ const compared = new Set<string>();
846
+ const origin = (i: Instance) => {
847
+ const inputs = attributes(i.body);
848
+ const at = inputs.get("version");
849
+ return `${(inputs.get("source") ?? "").trim()}${at ? ` @ ${at.trim()}` : ""}`;
850
+ };
851
+ // a call whose code this tree cannot read that goes away, appears, or is
852
+ // renamed without a `moved` block destroys or creates everything inside it,
853
+ // and nothing here can see what that is
854
+ const reached = new Set<string>();
855
+ for (const b of beforeInstances.values()) {
856
+ const target = `${b.root}|${rename(b.root, b.address)}`;
857
+ reached.add(target);
858
+ if (b.call && afterInstances.get(target)?.call !== true) {
859
+ opaque.push({ root: b.root, call: b.address, before: origin(b), after: "(removed)" });
312
860
  }
313
-
314
- for (const [argument, afterRaw] of [...afterValues].sort(([a], [b]) =>
315
- a < b ? -1 : a > b ? 1 : 0,
316
- )) {
317
- const beforeRaw = beforeValues.get(argument);
318
- // an argument present on only one side is argument DRIFT, which
319
- // `arg_names_equivalent` already establishes. Saying it twice, in
320
- // different words, would only make the reason list harder to act on.
321
- if (beforeRaw === undefined) continue;
322
-
323
- const row = (
324
- after_resolved: string | null,
325
- match: boolean | null,
326
- not_checked: string | null,
327
- ): ValueSubstitution => ({
328
- root,
329
- resource: fromAddress,
330
- argument,
331
- before: beforeRaw,
332
- after: afterRaw,
333
- after_resolved,
334
- match,
335
- not_checked,
336
- });
337
-
338
- const bare = BARE_VAR.exec(afterRaw);
339
- if (bare?.[1] !== undefined) {
340
- const resolved = resolveToRootScope(after, afterDirs, afterHops, bare[1]);
341
- if ("reason" in resolved) {
342
- substitutions.push(row(null, null, resolved.reason));
343
- continue;
344
- }
345
- substitutions.push(
346
- row(resolved.value, normaliseValue(resolved.value) === normaliseValue(beforeRaw), null),
347
- );
348
- continue;
349
- }
350
- if (afterRaw === beforeRaw) {
351
- // copied verbatim into the module body — no substitution to follow, and
352
- // the value demonstrably did not change.
353
- substitutions.push(row(afterRaw, true, null));
354
- continue;
861
+ }
862
+ for (const [key, a] of afterInstances) {
863
+ if (a.call && !reached.has(key)) {
864
+ opaque.push({ root: a.root, call: a.address, before: "(absent)", after: origin(a) });
865
+ }
866
+ }
867
+ let relocated = 0;
868
+ const keys = [...beforeInstances.keys()].sort();
869
+ for (const key of keys) {
870
+ const b = beforeInstances.get(key) as Instance;
871
+ const target = rename(b.root, b.address);
872
+ const a = afterInstances.get(`${b.root}|${target}`);
873
+ if (!a || a.call !== b.call) continue;
874
+ compared.add(`${b.root}|${b.address}`);
875
+ if (b.call) {
876
+ if (origin(b) !== origin(a)) {
877
+ opaque.push({ root: b.root, call: b.address, before: origin(b), after: origin(a) });
355
878
  }
356
- if (isStringLiteral(beforeRaw) && isStringLiteral(afterRaw)) {
357
- // two bare literals that differ is a value change, established without
358
- // evaluating anything.
359
- substitutions.push(row(afterRaw, false, null));
360
- continue;
879
+ }
880
+ const scopeOf = (i: Instance, tree: Tree): Scope => ({
881
+ tree,
882
+ root: i.root,
883
+ dirs: i.dirs,
884
+ hops: i.hops,
885
+ bindings: new Map(),
886
+ depth: 0,
887
+ ...(tree === before ? { rename } : {}),
888
+ });
889
+ const findings: Finding[] = [];
890
+ const beforeBlock = evaluateBlock(b.body, scopeOf(b, before));
891
+ const afterBlock = evaluateBlock(a.body, scopeOf(a, after));
892
+ compareBlocks(beforeBlock, afterBlock, "", findings, b.call ? CALL_META : META);
893
+ // `each.value` and `count.index` read the resource's own repetition, and
894
+ // the same text means the same value only if that repetition is the same
895
+ // collection on both sides — `for_each = var.names` names a different one
896
+ // inside a module than in the root
897
+ const repetition = combine(
898
+ ["for_each", "count"].map((name) => {
899
+ const x = beforeBlock.attrs.get(name);
900
+ const y = afterBlock.attrs.get(name);
901
+ if (!x && !y) return "equal";
902
+ return x && y ? compareValues(x, y) : "unknown";
903
+ }),
904
+ );
905
+ if (repetition !== "equal") {
906
+ for (const f of findings) {
907
+ if (f.verdict === "equal" && /(?<![\w.])(?:each|count)\./.test(`${f.before} ${f.after}`)) {
908
+ f.verdict = "unknown";
909
+ f.why =
910
+ "it reads the resource's repetition, which could not be shown to be the same collection on both sides";
911
+ }
361
912
  }
362
- substitutions.push(
363
- row(
364
- null,
365
- null,
366
- "the relocated value is neither a bare `var.<name>` nor a literal, so what it resolves to cannot be established without evaluating it",
367
- ),
368
- );
369
913
  }
914
+ const moved = target !== b.address;
915
+ if (moved) relocated++;
916
+ for (const f of findings) {
917
+ // an unmoved resource reports only what CHANGED: listing every value of
918
+ // every untouched resource would bury the one that matters
919
+ if (!moved && f.verdict !== "changed") continue;
920
+ rows.push({
921
+ root: b.root,
922
+ resource: b.address,
923
+ argument: f.argument,
924
+ before: f.before,
925
+ after: f.after,
926
+ after_resolved: f.verdict === "unknown" ? null : f.after_resolved,
927
+ match: f.verdict === "equal" ? true : f.verdict === "changed" ? false : null,
928
+ not_checked: f.verdict === "unknown" ? f.why : null,
929
+ });
930
+ }
931
+ }
932
+
933
+ // a relocation INTO a module whose content this tree cannot read (a registry
934
+ // module, a git module nothing fetched) is not compared at all — and says so
935
+ for (const mv of moves) {
936
+ if (moduleHops(mv.to).length <= moduleHops(mv.from).length) continue;
937
+ const root = mv.root === undefined || mv.root === "" ? "." : mv.root;
938
+ const from = normaliseAddress(mv.from);
939
+ if (compared.has(`${root}|${stripIndices(from)}`) || compared.has(`${root}|${from}`)) continue;
940
+ compared.add(`${root}|${from}`);
941
+ rows.push({
942
+ root,
943
+ resource: from,
944
+ argument: "*",
945
+ before: "",
946
+ after: "",
947
+ after_resolved: null,
948
+ match: null,
949
+ not_checked:
950
+ "the resource could not be read on one side — its module source did not resolve (a " +
951
+ "registry module, or a git module nothing fetched), or the block itself was not found there",
952
+ });
370
953
  }
371
954
 
372
- const mismatches = substitutions.filter((s) => s.match === false);
955
+ const mismatches = rows.filter((s) => s.match === false);
373
956
  const reasons = mismatches.map(
374
957
  (s) =>
375
958
  `${s.resource}.${s.argument}${s.root === "." ? "" : ` in ${s.root}`}: the value CHANGED — was ` +
376
- `${s.before}, the refactor resolves it to ${s.after_resolved}. A relocation must carry each ` +
377
- "root's own value to its own call site; consolidating several roots, that means the value " +
378
- "written at one member's call site is another member's.",
959
+ `${s.before}, the refactor resolves it to ${s.after_resolved}. A refactor must keep every ` +
960
+ "value; relocating a resource, each root's own value goes to its own call site, and a shared " +
961
+ "module's body changes every caller.",
379
962
  );
380
- return { value_substitutions: substitutions, value_mismatches: mismatches, reasons };
963
+ return {
964
+ value_substitutions: rows.length === 0 && relocated === 0 ? null : rows,
965
+ value_mismatches: mismatches,
966
+ opaque_module_changes: opaque,
967
+ reasons: [
968
+ ...reasons,
969
+ ...opaque.map(
970
+ (o) =>
971
+ `${o.call}${o.root === "." ? "" : ` in ${o.root}`}: the module changed from ${o.before} to ` +
972
+ `${o.after}. Its resources come from code outside this tree, so this check cannot see what ` +
973
+ "the change does to them — it is not proven, and only a plan can prove it.",
974
+ ),
975
+ ],
976
+ };
381
977
  }