infraweaver 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claudePretoolGate.d.ts +0 -36
- package/dist/agents/toolPlaneWatch.d.ts +31 -0
- package/dist/cli.mjs +31908 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31992 -18749
- package/dist/internal.js +133 -52
- package/dist/mcp/baseBranch.d.ts +41 -0
- package/dist/mcp/changeSummary.d.ts +1 -1
- package/dist/mcp/findingAudit.d.ts +2 -0
- package/dist/mcp/guardrails.d.ts +38 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/moduleExtraction.d.ts +5 -0
- package/dist/mcp/pr.d.ts +0 -31
- package/dist/mcp/reproveSuggestions.d.ts +27 -0
- package/dist/mcp/review.d.ts +126 -2
- package/dist/mcp/reviewCommentLimit.d.ts +17 -0
- package/dist/mcp/reviewComments.d.ts +9 -5
- package/dist/mcp/reviewDedup.d.ts +1 -0
- package/dist/mcp/reviewMarkers.d.ts +10 -0
- package/dist/mcp/reviewProvenance.d.ts +20 -6
- package/dist/mcp/shared.d.ts +15 -0
- package/dist/mcp/suggestionProver.d.ts +3 -2
- package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
- package/dist/mcp/terraform/consolidation.d.ts +26 -0
- package/dist/mcp/terraform/cost.d.ts +2 -2
- package/dist/mcp/terraform/currency.d.ts +11 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
- package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
- package/dist/mcp/terraform/evidence.d.ts +10 -0
- package/dist/mcp/terraform/gateScan.d.ts +2 -16
- package/dist/mcp/terraform/hcl.d.ts +15 -0
- package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
- package/dist/mcp/terraform/localModules.d.ts +29 -0
- package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
- package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
- package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
- package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
- package/dist/mcp/terraform/refactor/references.d.ts +15 -0
- package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
- package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
- package/dist/mcp/terraform/scanDelta.d.ts +28 -0
- package/dist/mcp/terraform/scanSession.d.ts +13 -0
- package/dist/mcp/terraform/scanners.d.ts +67 -18
- package/dist/mcp/terraform/secretScan.d.ts +19 -0
- package/dist/mcp/terraform/standardsReport.d.ts +1 -1
- package/dist/mcp/terraform/tools/plan.d.ts +1 -1
- package/dist/mcp/terraform/tools/scan.d.ts +7 -7
- package/dist/mcp/terraform/tools/validate.d.ts +7 -2
- package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
- package/dist/mcp/terraform/treeLayout.d.ts +57 -0
- package/dist/mcp/terraform/types.d.ts +1 -1
- package/dist/phases/applyPlan.d.ts +0 -2
- package/dist/toolState.d.ts +3 -0
- package/dist/utils/agent/codexOAuth.d.ts +1 -1
- package/dist/utils/cli.d.ts +1 -7
- package/dist/utils/cloud/cloudReport.d.ts +1 -1
- package/dist/utils/config/baseRefConfig.d.ts +1 -1
- package/dist/utils/config/infraweaverConfig.d.ts +1 -1
- package/dist/utils/config/payload.d.ts +5 -0
- package/dist/utils/markdownTable.d.ts +6 -0
- package/dist/utils/setup/toolLicensing.d.ts +1 -1
- package/dist/utils/unifiedDiff.d.ts +69 -0
- package/package.json +34 -28
- package/src/agents/claude.ts +48 -13
- package/src/agents/claudePretoolGate.ts +9 -17
- package/src/agents/sessionLabeler.ts +3 -3
- package/src/agents/toolPlaneWatch.ts +71 -0
- package/src/external.ts +3 -1
- package/src/mcp/assess.ts +8 -8
- package/src/mcp/baseBranch.ts +96 -0
- package/src/mcp/changeSummary.ts +10 -11
- package/src/mcp/checkSuite.ts +7 -10
- package/src/mcp/checkout.ts +13 -40
- package/src/mcp/crosswalk.ts +5 -0
- package/src/mcp/findingAudit.ts +1 -1
- package/src/mcp/git.ts +44 -2
- package/src/mcp/guardrails.ts +188 -36
- package/src/mcp/localContext.ts +2 -0
- package/src/mcp/localServer.ts +1 -0
- package/src/mcp/moduleExtraction.ts +17 -5
- package/src/mcp/moduleTests.ts +9 -2
- package/src/mcp/modules.ts +10 -7
- package/src/mcp/pr.ts +17 -65
- package/src/mcp/reproveSuggestions.ts +86 -0
- package/src/mcp/review.ts +249 -75
- package/src/mcp/reviewAnchor.ts +11 -23
- package/src/mcp/reviewCommentLimit.ts +79 -0
- package/src/mcp/reviewComments.ts +34 -81
- package/src/mcp/reviewDedup.ts +1 -1
- package/src/mcp/reviewMarkers.ts +25 -2
- package/src/mcp/reviewProvenance.ts +56 -21
- package/src/mcp/roots.ts +3 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/shared.ts +40 -1
- package/src/mcp/shell.ts +27 -28
- package/src/mcp/suggestionProver.ts +8 -4
- package/src/mcp/terraform/checkovSeverities.ts +1156 -0
- package/src/mcp/terraform/concernResult.ts +2 -1
- package/src/mcp/terraform/consolidation.ts +232 -19
- package/src/mcp/terraform/cost.ts +16 -7
- package/src/mcp/terraform/cspm.ts +8 -4
- package/src/mcp/terraform/currency.ts +28 -12
- package/src/mcp/terraform/decisions.ts +1 -1
- package/src/mcp/terraform/deltaSummary.ts +50 -1
- package/src/mcp/terraform/emittedArtefacts.ts +63 -0
- package/src/mcp/terraform/evidence.ts +23 -3
- package/src/mcp/terraform/gateScan.ts +191 -128
- package/src/mcp/terraform/hcl.ts +27 -2
- package/src/mcp/terraform/idleFloor.ts +2 -1
- package/src/mcp/terraform/initArtefacts.ts +76 -0
- package/src/mcp/terraform/localModules.ts +112 -0
- package/src/mcp/terraform/moduleDocs.ts +2 -5
- package/src/mcp/terraform/moduleFindings.ts +102 -0
- package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
- package/src/mcp/terraform/planPairs.ts +2 -20
- package/src/mcp/terraform/policyGate.ts +0 -15
- package/src/mcp/terraform/refactor/attribution.ts +39 -5
- package/src/mcp/terraform/refactor/equivalence.ts +49 -10
- package/src/mcp/terraform/refactor/expressions.ts +323 -0
- package/src/mcp/terraform/refactor/references.ts +101 -0
- package/src/mcp/terraform/refactor/resources.ts +79 -7
- package/src/mcp/terraform/refactor/substitution.ts +852 -256
- package/src/mcp/terraform/scanDelta.ts +41 -0
- package/src/mcp/terraform/scanSession.ts +30 -0
- package/src/mcp/terraform/scannerCache.ts +4 -0
- package/src/mcp/terraform/scanners.ts +553 -233
- package/src/mcp/terraform/secretScan.ts +131 -0
- package/src/mcp/terraform/standardsReport.ts +14 -13
- package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
- package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
- package/src/mcp/terraform/tools/emitOscal.ts +3 -2
- package/src/mcp/terraform/tools/emitVex.ts +10 -4
- package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
- package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
- package/src/mcp/terraform/tools/plan.ts +30 -17
- package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
- package/src/mcp/terraform/tools/scan.ts +5 -3
- package/src/mcp/terraform/tools/standardsReport.ts +1 -1
- package/src/mcp/terraform/tools/validate.ts +20 -5
- package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
- package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
- package/src/mcp/terraform/treeLayout.ts +378 -0
- package/src/mcp/terraform/types.ts +4 -1
- package/src/mcp/terraform/versionRequirements.ts +11 -2
- package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
- package/src/modes/modernize-deprecated.ts +2 -2
- package/src/modes/refactor.ts +9 -9
- package/src/modes/remediate.ts +1 -1
- package/src/modes/terraform-code-review.ts +7 -3
- package/src/modes/update-dependencies.ts +6 -6
- package/src/phases/applyPlan.ts +33 -14
- package/src/phases/applySuggestions.ts +2 -2
- package/src/phases/runAgentWithWatchdogs.ts +2 -1
- package/src/toolState.ts +7 -0
- package/src/utils/agent/codexOAuth.ts +7 -7
- package/src/utils/agent/openCodeModels.ts +4 -6
- package/src/utils/cli.ts +1 -31
- package/src/utils/cloud/runContext.ts +1 -8
- package/src/utils/config/baseRefConfig.ts +6 -0
- package/src/utils/config/infraweaverConfig.ts +2 -0
- package/src/utils/config/payload.ts +41 -0
- package/src/utils/github/assets.ts +1 -6
- package/src/utils/github/github.ts +42 -150
- package/src/utils/github/token.ts +6 -11
- package/src/utils/log.ts +4 -5
- package/src/utils/markdownTable.ts +11 -0
- package/src/utils/prompt/changeImpact.ts +18 -26
- package/src/utils/prompt/instructions.ts +1 -1
- package/src/utils/prompt/promptDirectives.ts +1 -1
- package/src/utils/prompt/toolSelection.ts +11 -2
- package/src/utils/setup/toolLicensing.ts +7 -0
- package/src/utils/telemetry.ts +2 -1
- package/src/utils/unifiedDiff.ts +120 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The VALUE half of a refactor proof:
|
|
3
|
-
* it
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 {
|
|
43
|
-
import {
|
|
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
|
|
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
|
|
63
|
+
/** the value as the before tree writes it. */
|
|
54
64
|
before: string;
|
|
55
|
-
/** the value
|
|
65
|
+
/** the value as the after tree writes it — often `var.<x>`. */
|
|
56
66
|
after: string;
|
|
57
|
-
/** what `after` resolves to
|
|
58
|
-
*
|
|
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
|
|
70
|
+
/** `true`/`false` only when the comparison settled; `null` = NOT CHECKED. */
|
|
61
71
|
match: boolean | null;
|
|
62
|
-
/** why this
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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>` →
|
|
107
|
-
resources: Map<string,
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
):
|
|
152
|
-
const byDir = new Map
|
|
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 =
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
464
|
+
k: "code",
|
|
465
|
+
shape: `${HOLE} ? ${HOLE} : ${HOLE}`,
|
|
466
|
+
parts: [cond, evaluate(expr.whenTrue, scope), evaluate(expr.whenFalse, scope)],
|
|
252
467
|
};
|
|
253
468
|
}
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 =
|
|
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
|
|
377
|
-
"root's own value to its own call site
|
|
378
|
-
"
|
|
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 {
|
|
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
|
}
|