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.
- 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 +31903 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31987 -18749
- package/dist/internal.js +132 -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 +34 -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 +6 -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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AZURE_PRICE_REGION,
|
|
13
13
|
} from "#app/mcp/terraform/azurePrices";
|
|
14
14
|
import type { TfResourceEntry } from "#app/mcp/terraform/cspm";
|
|
15
|
+
import { tableCell } from "#app/utils/markdownTable";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Idle-floor detection — the resources that
|
|
@@ -464,7 +465,7 @@ export function renderCostMarkdown(report: IdleFloorReport, toolVersion: string)
|
|
|
464
465
|
"| --- | --- | --- | --- |",
|
|
465
466
|
...provider.items.map(
|
|
466
467
|
(i) =>
|
|
467
|
-
`| \`${i.address}\` | ${i.resourceType} | ${i.file}:${i.line} | ${money(i.currency, i.monthly_cost)} |`,
|
|
468
|
+
`| \`${i.address}\` | ${i.resourceType} | ${tableCell(`${i.file}:${i.line}`)} | ${money(i.currency, i.monthly_cost)} |`,
|
|
468
469
|
),
|
|
469
470
|
"",
|
|
470
471
|
);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `terraform init` output a tool leaves in the checkout, and undoing it.
|
|
3
|
+
*
|
|
4
|
+
* Validate inits every root in place. In a review that dirties the checkout the
|
|
5
|
+
* agent then works in; in a write mode it is worse — the push refuses a dirty
|
|
6
|
+
* tree, and an editor that deletes the files by hand spends turns on it, or
|
|
7
|
+
* commits a lock file the repository never had.
|
|
8
|
+
*/
|
|
9
|
+
import { rmSync } from "node:fs";
|
|
10
|
+
import { join, posix } from "node:path";
|
|
11
|
+
import { resetTerraformInitCache } from "#app/mcp/terraform/scanners";
|
|
12
|
+
import { run } from "#app/mcp/terraform/subprocess";
|
|
13
|
+
import { log } from "#app/utils/cli";
|
|
14
|
+
|
|
15
|
+
const INIT_ARTEFACTS = new Set([".terraform", ".terraform.lock.hcl"]);
|
|
16
|
+
|
|
17
|
+
export interface InitArtefacts {
|
|
18
|
+
/** init output git neither tracks nor ignores. */
|
|
19
|
+
untracked: Set<string>;
|
|
20
|
+
/** committed lock files with uncommitted changes. */
|
|
21
|
+
modifiedLocks: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `terraform init` output in the checkout, as repo-relative paths; null
|
|
26
|
+
* outside a git checkout. A committed lock file counts once it is modified:
|
|
27
|
+
* init adds the runner's platform hash to one made on another platform (a
|
|
28
|
+
* lock file from a Mac lacks the linux hash).
|
|
29
|
+
*/
|
|
30
|
+
export function initArtefacts(cwd: string): InitArtefacts | null {
|
|
31
|
+
const r = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], cwd);
|
|
32
|
+
if (r.status !== 0) return null;
|
|
33
|
+
const entries = r.stdout.split("\0");
|
|
34
|
+
const isArtefact = (p: string) => INIT_ARTEFACTS.has(posix.basename(p.replace(/\/$/, "")));
|
|
35
|
+
return {
|
|
36
|
+
untracked: new Set(
|
|
37
|
+
entries
|
|
38
|
+
.filter((e) => e.startsWith("?? "))
|
|
39
|
+
.map((e) => e.slice(3))
|
|
40
|
+
.filter(isArtefact),
|
|
41
|
+
),
|
|
42
|
+
modifiedLocks: new Set(
|
|
43
|
+
entries
|
|
44
|
+
.filter((e) => e[1] === "M" && e[2] === " ")
|
|
45
|
+
.map((e) => e.slice(3))
|
|
46
|
+
.filter((p) => posix.basename(p) === ".terraform.lock.hcl"),
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Undo what the gate scan's init did to the checkout: remove the artefacts it
|
|
53
|
+
* created and restore the committed lock files it changed. Validate inits every
|
|
54
|
+
* root in place, and a checkout that is dirty before the agent starts makes
|
|
55
|
+
* `checkout_pr` refuse — or, on a committing run, ships the change in the fix.
|
|
56
|
+
* What was there before the scan is left alone.
|
|
57
|
+
*/
|
|
58
|
+
export function removeNewInitArtefacts(
|
|
59
|
+
cwd: string,
|
|
60
|
+
before: InitArtefacts | null,
|
|
61
|
+
by: string,
|
|
62
|
+
): void {
|
|
63
|
+
if (!before) return;
|
|
64
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
|
|
65
|
+
const after = initArtefacts(cwd);
|
|
66
|
+
const created = [...(after?.untracked ?? [])].filter((p) => !before.untracked.has(p));
|
|
67
|
+
const changed = [...(after?.modifiedLocks ?? [])].filter((p) => !before.modifiedLocks.has(p));
|
|
68
|
+
if (!top || created.length + changed.length === 0) return;
|
|
69
|
+
for (const p of created) rmSync(join(top, p), { recursive: true, force: true });
|
|
70
|
+
if (changed.length > 0) run("git", ["checkout", "--", ...changed], top);
|
|
71
|
+
resetTerraformInitCache();
|
|
72
|
+
log.info(
|
|
73
|
+
`» ${by}: removed ${created.length} terraform init artefact(s) from the checkout` +
|
|
74
|
+
(changed.length ? `, restored ${changed.length} committed lock file(s)` : ""),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local `module` calls a Terraform root reaches, followed through every
|
|
3
|
+
* local module it calls in turn, each resolved against the disk.
|
|
4
|
+
*
|
|
5
|
+
* `terraform validate` cannot report a local source that points nowhere: init
|
|
6
|
+
* fails to install the module, and validate's "Module not installed" is
|
|
7
|
+
* indistinguishable from an init that failed for an environmental reason (no
|
|
8
|
+
* network, a private registry), so it is dropped as noise. A local source needs
|
|
9
|
+
* neither — whether `./modules/s3` exists is a fact about the tree — so it is
|
|
10
|
+
* read here instead, and a missing one is reported as the defect it is.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join, posix, resolve } from "node:path";
|
|
14
|
+
import { parseModuleBlocks } from "#app/mcp/modules";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `source` a module block in `fromDir` writes to call the module at
|
|
18
|
+
* `moduleDir`, both repo-relative POSIX paths. A local source is relative to the
|
|
19
|
+
* CALLER's directory, so `./modules/s3` is right only from the repo root; from
|
|
20
|
+
* `env/dev` the same module is `../../modules/s3`.
|
|
21
|
+
*/
|
|
22
|
+
export function localSourceFrom(fromDir: string, moduleDir: string): string {
|
|
23
|
+
const rel = posix.relative(fromDir || ".", moduleDir || ".") || ".";
|
|
24
|
+
return rel === "." || rel.startsWith("../") || rel === ".." ? rel : `./${rel}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONFIG_SUFFIXES = [".tf", ".tf.json", ".tofu"] as const;
|
|
28
|
+
|
|
29
|
+
interface LocalModuleCall {
|
|
30
|
+
/** absolute path of the file declaring the call. */
|
|
31
|
+
file: string;
|
|
32
|
+
/** 1-based line of the `module` header; null in JSON configuration. */
|
|
33
|
+
line: number | null;
|
|
34
|
+
name: string;
|
|
35
|
+
source: string;
|
|
36
|
+
/** absolute directory the source resolves to. */
|
|
37
|
+
dir: string;
|
|
38
|
+
/** whether that directory holds any Terraform configuration. */
|
|
39
|
+
exists: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LocalModuleClosure {
|
|
43
|
+
/** every local call reached from the root, missing ones included. */
|
|
44
|
+
calls: LocalModuleCall[];
|
|
45
|
+
/** the root and every existing local module directory it reaches. */
|
|
46
|
+
dirs: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** the configuration files directly in `dir` (not beneath it). */
|
|
50
|
+
export function configFilesIn(dir: string): string[] {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync(dir)
|
|
53
|
+
.filter((f) => CONFIG_SUFFIXES.some((s) => f.endsWith(s)))
|
|
54
|
+
.map((f) => join(dir, f))
|
|
55
|
+
.filter((f) => statSync(f).isFile())
|
|
56
|
+
.sort();
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function callsIn(file: string): Omit<LocalModuleCall, "dir" | "exists">[] {
|
|
63
|
+
let text: string;
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(file, "utf-8");
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
if (file.endsWith(".json")) {
|
|
70
|
+
try {
|
|
71
|
+
const doc = JSON.parse(text) as { module?: Record<string, { source?: unknown }> };
|
|
72
|
+
return Object.entries(doc.module ?? {}).flatMap(([name, block]) =>
|
|
73
|
+
typeof block?.source === "string" && /^\.\.?[\\/]/.test(block.source)
|
|
74
|
+
? [{ file, line: null, name, source: block.source }]
|
|
75
|
+
: [],
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parseModuleBlocks(text)
|
|
82
|
+
.filter((b) => b.kind === "local")
|
|
83
|
+
.map((b) => {
|
|
84
|
+
const at = text.search(new RegExp(`module\\s+"${RegExp.escape(b.name)}"`));
|
|
85
|
+
return {
|
|
86
|
+
file,
|
|
87
|
+
line: at < 0 ? null : text.slice(0, at).split("\n").length,
|
|
88
|
+
name: b.name,
|
|
89
|
+
source: b.source,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function localModuleClosure(rootDir: string): LocalModuleClosure {
|
|
95
|
+
const calls: LocalModuleCall[] = [];
|
|
96
|
+
const seen = new Set<string>([resolve(rootDir)]);
|
|
97
|
+
const queue = [resolve(rootDir)];
|
|
98
|
+
for (let dir = queue.shift(); dir !== undefined; dir = queue.shift()) {
|
|
99
|
+
for (const file of configFilesIn(dir)) {
|
|
100
|
+
for (const call of callsIn(file)) {
|
|
101
|
+
const target = resolve(dir, call.source);
|
|
102
|
+
const exists = existsSync(target) && configFilesIn(target).length > 0;
|
|
103
|
+
calls.push({ ...call, dir: target, exists });
|
|
104
|
+
if (exists && !seen.has(target)) {
|
|
105
|
+
seen.add(target);
|
|
106
|
+
queue.push(target);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { calls, dirs: [...seen] };
|
|
112
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PRODUCT_NAME } from "#app/brand";
|
|
2
2
|
import type { ModuleInterface } from "#app/mcp/modules";
|
|
3
|
+
import { tableCell } from "#app/utils/markdownTable";
|
|
3
4
|
import packageJson from "#package.json" with { type: "json" };
|
|
4
5
|
|
|
5
6
|
export type ModuleDocsVerdict =
|
|
@@ -41,11 +42,7 @@ const VERDICT_CLAIM: Record<ModuleDocsVerdict, string> = {
|
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
function escapeCell(text: string | null): string {
|
|
44
|
-
|
|
45
|
-
return text
|
|
46
|
-
.replaceAll("|", "\\|")
|
|
47
|
-
.replaceAll(/\s*\n\s*/g, " ")
|
|
48
|
-
.trim();
|
|
45
|
+
return text ? tableCell(text) : "";
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
function moduleName(moduleDir: string): string {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings inside a module the repository does not hold — a registry or git
|
|
3
|
+
* module that `terraform init` or checkov fetched — moved to the module call
|
|
4
|
+
* that instantiates it.
|
|
5
|
+
*
|
|
6
|
+
* Both scanners evaluate such a module with the caller's inputs, which is how an
|
|
7
|
+
* insecure input (`block_public_acls = false`) shows up at all. But they report
|
|
8
|
+
* the finding against the fetched copy: a path under `.terraform/modules`, under
|
|
9
|
+
* checkov's download directory, or one trivy synthesises from the module source.
|
|
10
|
+
* None of those is a file in the repository, so a review scoped to the roots a
|
|
11
|
+
* change reaches drops the finding, and a fix loop points the agent at code it
|
|
12
|
+
* must not edit. The call is where the finding is answered — through the
|
|
13
|
+
* module's inputs — and where a reviewer reads the change.
|
|
14
|
+
*/
|
|
15
|
+
import { isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
16
|
+
import { readTextCapped } from "#app/mcp/modules";
|
|
17
|
+
import { buildConcern, type Concern } from "#app/mcp/terraform/types";
|
|
18
|
+
|
|
19
|
+
/** Where a scanner was told to put the modules it fetched, outside the tree. */
|
|
20
|
+
export interface FetchDir {
|
|
21
|
+
/** the directory the scanner's paths are relative to. */
|
|
22
|
+
cwd: string;
|
|
23
|
+
dir: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when a scanner-reported path is a fetched module's copy, not repository
|
|
28
|
+
* code: under terraform's or checkov's module directory in the tree, or under
|
|
29
|
+
* the directory a scan had checkov download into. A path that merely leaves the
|
|
30
|
+
* scanned directory is not enough — a scan of one root reports the repository's
|
|
31
|
+
* own local modules as `../../modules/x`.
|
|
32
|
+
*/
|
|
33
|
+
export function isFetchedModulePath(file: string, fetchedInto?: FetchDir): boolean {
|
|
34
|
+
const segments = file.replace(/\\/g, "/").split("/");
|
|
35
|
+
if (segments.some((s) => s === ".terraform" || s === ".external_modules")) return true;
|
|
36
|
+
if (!fetchedInto) return false;
|
|
37
|
+
const rel = relative(fetchedInto.dir, resolve(fetchedInto.cwd, file));
|
|
38
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 1-based line of `module "<name>"` in `file`, or null when it is not there. */
|
|
42
|
+
export function moduleCallLine(cwd: string, file: string, name: string): number | null {
|
|
43
|
+
const text = readTextCapped(join(cwd, file));
|
|
44
|
+
if (text === null) return null;
|
|
45
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
const at = text.search(new RegExp(`^[ \\t]*module[ \\t]+"${escaped}"[ \\t]*\\{`, "m"));
|
|
47
|
+
return at < 0 ? null : text.slice(0, at).split("\n").length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `module.<name>…` → `<name>`, the call in the root that instantiates it. */
|
|
51
|
+
export function topModuleName(address: string): string | null {
|
|
52
|
+
return /^module\.([^.[]+)/.exec(address)?.[1] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ModuleFinding {
|
|
56
|
+
/** the concern as the scanner reported it, against the fetched copy. */
|
|
57
|
+
concern: Concern;
|
|
58
|
+
/** the call that instantiates the module, in the repository. */
|
|
59
|
+
call: { file: string; line: number | null };
|
|
60
|
+
/** the module resource the finding is about, e.g. `module.data.aws_s3_bucket.this`. */
|
|
61
|
+
address: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Re-locate each finding to its call, saying in the evidence which module
|
|
66
|
+
* resource it is about and where in the module. Findings that land on the same
|
|
67
|
+
* call under the same rule are one concern — a concern's identity is its rule
|
|
68
|
+
* and location — so they are merged, every resource named: one concern per
|
|
69
|
+
* resource would be collapsed by the dedupe anyway, and silently.
|
|
70
|
+
*/
|
|
71
|
+
export function relocateToCalls(findings: ModuleFinding[]): Concern[] {
|
|
72
|
+
const merged = new Map<string, { concern: Concern; addresses: string[]; where: string[] }>();
|
|
73
|
+
for (const { concern, call, address } of findings) {
|
|
74
|
+
const bareRule = concern.rule_id.slice(concern.source.length + 1);
|
|
75
|
+
const at = buildConcern({
|
|
76
|
+
source: concern.source,
|
|
77
|
+
rule: bareRule,
|
|
78
|
+
file: call.file,
|
|
79
|
+
line: call.line,
|
|
80
|
+
severity: concern.severity,
|
|
81
|
+
category: concern.category,
|
|
82
|
+
evidence: concern.evidence,
|
|
83
|
+
remediation_hint: concern.remediation_hint,
|
|
84
|
+
// the scanner's snippet is the module's code; the location is now the call
|
|
85
|
+
code_snippet: null,
|
|
86
|
+
...(concern.language ? { language: concern.language } : {}),
|
|
87
|
+
});
|
|
88
|
+
const where = `${posix.basename(concern.location.file)}:${concern.location.line ?? "?"}`;
|
|
89
|
+
const prior = merged.get(at.id);
|
|
90
|
+
if (!prior) merged.set(at.id, { concern: at, addresses: [address], where: [where] });
|
|
91
|
+
else if (!prior.addresses.includes(address)) {
|
|
92
|
+
prior.addresses.push(address);
|
|
93
|
+
prior.where.push(where);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...merged.values()].map(({ concern, addresses, where }) => ({
|
|
97
|
+
...concern,
|
|
98
|
+
evidence:
|
|
99
|
+
`${concern.evidence} — inside the module: ` +
|
|
100
|
+
addresses.map((a, i) => `${a} (${where[i]})`).join(", "),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
@@ -46,7 +46,7 @@ export interface UnpinnedModule {
|
|
|
46
46
|
|
|
47
47
|
/** the 1-based line of a `module "<name>"` header in a file. */
|
|
48
48
|
function moduleLine(hcl: string, name: string): number {
|
|
49
|
-
const re = new RegExp(`module\\s+"${
|
|
49
|
+
const re = new RegExp(`module\\s+"${RegExp.escape(name)}"\\s*\\{`);
|
|
50
50
|
const at = re.exec(hcl)?.index;
|
|
51
51
|
return at === undefined ? 1 : hcl.slice(0, at).split("\n").length;
|
|
52
52
|
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* network. The plan file is supplied by the caller.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { isDeepStrictEqual } from "node:util";
|
|
36
37
|
import type { PlanResourceChange } from "#app/mcp/terraform/plan";
|
|
37
38
|
|
|
38
39
|
// --- ignore rules ------------------------------------------------------------
|
|
@@ -172,25 +173,6 @@ export function isUnknownPath(unknown: Set<string>, path: string): boolean {
|
|
|
172
173
|
return false;
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
/** structural equality over JSON values (the only shapes a plan contains). */
|
|
176
|
-
function deepEqual(a: unknown, b: unknown): boolean {
|
|
177
|
-
if (a === b) return true;
|
|
178
|
-
if (typeof a !== typeof b) return false;
|
|
179
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
180
|
-
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
181
|
-
}
|
|
182
|
-
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
183
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
184
|
-
const ka = Object.keys(a);
|
|
185
|
-
const kb = Object.keys(b);
|
|
186
|
-
if (ka.length !== kb.length) return false;
|
|
187
|
-
return ka.every((k) =>
|
|
188
|
-
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
176
|
// --- pair comparison ---------------------------------------------------------
|
|
195
177
|
|
|
196
178
|
export interface PairComparison {
|
|
@@ -239,7 +221,7 @@ export function comparePair(
|
|
|
239
221
|
}
|
|
240
222
|
const a = before.get(path);
|
|
241
223
|
const b = after.get(path);
|
|
242
|
-
if (
|
|
224
|
+
if (isDeepStrictEqual(a, b)) {
|
|
243
225
|
equal++;
|
|
244
226
|
continue;
|
|
245
227
|
}
|
|
@@ -404,11 +404,6 @@ function blockKey(block: { keyword: string; labels: string[] }): string {
|
|
|
404
404
|
return [block.keyword, ...block.labels].join(".");
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
-
/** A `resource` or `module` header whose first label is NOT quoted. Valid HCL,
|
|
408
|
-
* but `parseBlocks` reads quoted labels only, so such a block would be invisible
|
|
409
|
-
* — and an invisible violation is a pass. */
|
|
410
|
-
const UNQUOTED_BLOCK = /(?:^|\n)[ \t]*(?:resource|module)[ \t]+[A-Za-z_][A-Za-z0-9_-]*[ \t{]/;
|
|
411
|
-
|
|
412
407
|
/** Why the walk below never reads a module block's code, or null when it does.
|
|
413
408
|
* A local source is walked where it lives — unless its path leaves `cwd` or
|
|
414
409
|
* lands in a directory the walk skips; then it is as unseen as a registry
|
|
@@ -506,16 +501,6 @@ function evaluateRulesStatically(
|
|
|
506
501
|
const types = new Set(rules.map((r) => r.resource_type));
|
|
507
502
|
for (const { rel, text } of files) {
|
|
508
503
|
const file = posix(rel);
|
|
509
|
-
if (blindSpotMatters && UNQUOTED_BLOCK.test(text)) {
|
|
510
|
-
blind(
|
|
511
|
-
file,
|
|
512
|
-
"a resource or module block with an unquoted label is not read by the static path",
|
|
513
|
-
{
|
|
514
|
-
file,
|
|
515
|
-
},
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
504
|
for (const { block, unread } of modules.get(rel) ?? []) {
|
|
520
505
|
if (!unread) continue; // its resources are walked where they live
|
|
521
506
|
const changed = scope.kind === "all" || scope.isChanged(rel, blockKey(block), block.body);
|
|
@@ -23,14 +23,15 @@
|
|
|
23
23
|
|
|
24
24
|
import { posix } from "node:path";
|
|
25
25
|
import { parseModuleBlocks } from "#app/mcp/modules";
|
|
26
|
-
import type { FetchedModules } from "#app/mcp/terraform/refactor/workspace";
|
|
27
26
|
import { type MovedBlock, parseMovedBlocks } from "#app/mcp/terraform/refactor/movedBlocks";
|
|
28
27
|
import {
|
|
29
28
|
type Multiplicity,
|
|
30
29
|
moduleCallMultiplicity,
|
|
30
|
+
moduleCallProviders,
|
|
31
31
|
parseResourcesWithArgs,
|
|
32
32
|
type ResourceRef,
|
|
33
33
|
} from "#app/mcp/terraform/refactor/resources";
|
|
34
|
+
import type { FetchedModules } from "#app/mcp/terraform/refactor/workspace";
|
|
34
35
|
import type { SourceFile } from "#app/mcp/terraform/tree";
|
|
35
36
|
import { pushInto } from "#app/utils/collect";
|
|
36
37
|
|
|
@@ -52,6 +53,35 @@ interface ModuleCall {
|
|
|
52
53
|
/** the CALL's own `count`/`for_each` — the outer half of the effective
|
|
53
54
|
* multiplicity chain. See [[Multiplicity]]. */
|
|
54
55
|
multiplicity: Multiplicity | null;
|
|
56
|
+
/** the CALL's `providers` map, or null when it passes none — the module then
|
|
57
|
+
* inherits the caller's default configurations. See [[parseProvider]]. */
|
|
58
|
+
providers: Map<string, string> | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** a module's name for a provider configuration → the root's, or null when the
|
|
62
|
+
* module cannot reach that configuration at all. */
|
|
63
|
+
type ProviderScope = (local: string) => string | null;
|
|
64
|
+
|
|
65
|
+
/** the scope inside a module called with `providers` (or without it). Without
|
|
66
|
+
* the map a module inherits only the caller's DEFAULT configurations; an
|
|
67
|
+
* aliased one must be passed explicitly. */
|
|
68
|
+
function childScope(parent: ProviderScope, providers: Map<string, string> | null): ProviderScope {
|
|
69
|
+
if (providers === null) return (local) => (local.includes(".") ? null : parent(local));
|
|
70
|
+
return (local) => {
|
|
71
|
+
const passed = providers.get(local);
|
|
72
|
+
return passed === undefined ? null : parent(passed);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** the configuration a resource is managed through, as the root names it. */
|
|
77
|
+
function effectiveProvider(
|
|
78
|
+
r: { type: string; provider: string | null },
|
|
79
|
+
scope: ProviderScope,
|
|
80
|
+
): string {
|
|
81
|
+
// a resource with no `provider` uses the default configuration of the
|
|
82
|
+
// provider its type names: `aws_s3_bucket` → `aws`
|
|
83
|
+
const local = r.provider ?? r.type.split("_")[0] ?? r.type;
|
|
84
|
+
return scope(local) ?? `${local} (not passed to this module)`;
|
|
55
85
|
}
|
|
56
86
|
|
|
57
87
|
/** one `.tf` file, parsed once — everything the attribution walk needs. */
|
|
@@ -73,6 +103,7 @@ function parseTree(
|
|
|
73
103
|
for (const f of files) {
|
|
74
104
|
const dir = posix.dirname(f.path);
|
|
75
105
|
const callMultiplicity = moduleCallMultiplicity(f.hcl);
|
|
106
|
+
const callProviders = moduleCallProviders(f.hcl);
|
|
76
107
|
const calls: ModuleCall[] = [];
|
|
77
108
|
for (const block of parseModuleBlocks(f.hcl)) {
|
|
78
109
|
const targetDir =
|
|
@@ -86,6 +117,7 @@ function parseTree(
|
|
|
86
117
|
dir: targetDir,
|
|
87
118
|
callName: block.name,
|
|
88
119
|
multiplicity: callMultiplicity.get(block.name) ?? null,
|
|
120
|
+
providers: callProviders.get(block.name) ?? null,
|
|
89
121
|
});
|
|
90
122
|
}
|
|
91
123
|
const parsed: ParsedFile = {
|
|
@@ -189,6 +221,7 @@ export function attributeAddresses(
|
|
|
189
221
|
prefix: string,
|
|
190
222
|
chain: Multiplicity[],
|
|
191
223
|
path: string[],
|
|
224
|
+
scope: ProviderScope,
|
|
192
225
|
): void => {
|
|
193
226
|
if (path.includes(dir)) {
|
|
194
227
|
ambiguous.add(`${root}: module call cycle ${[...path, dir].join(" -> ")}`);
|
|
@@ -216,6 +249,7 @@ export function attributeAddresses(
|
|
|
216
249
|
multiplicity: r.multiplicity
|
|
217
250
|
? [...chain, { ...r.multiplicity, at: "resource" as const }]
|
|
218
251
|
: chain,
|
|
252
|
+
provider: effectiveProvider(r, scope),
|
|
219
253
|
});
|
|
220
254
|
}
|
|
221
255
|
for (const mv of p.moved) {
|
|
@@ -232,12 +266,14 @@ export function attributeAddresses(
|
|
|
232
266
|
`${prefix}module.${c.callName}.`,
|
|
233
267
|
c.multiplicity ? [...chain, { ...c.multiplicity, at: "module" as const }] : chain,
|
|
234
268
|
[...path, dir],
|
|
269
|
+
childScope(scope, c.providers),
|
|
235
270
|
);
|
|
236
271
|
}
|
|
237
272
|
}
|
|
238
273
|
};
|
|
239
274
|
|
|
240
|
-
|
|
275
|
+
// at a root every configuration is its own name
|
|
276
|
+
for (const root of roots) visit(root, root, "", [], [], (local) => local);
|
|
241
277
|
|
|
242
278
|
const uninstantiated = [...byDir.keys()].filter((d) => !reached.has(d)).sort();
|
|
243
279
|
return { resources, ambiguous: [...ambiguous].sort(), roots, moved, uninstantiated };
|
|
@@ -279,9 +315,7 @@ export function attributePair(
|
|
|
279
315
|
const present = new Set(files.map((f) => f.path));
|
|
280
316
|
return [...files, ...fetched.files.filter((f) => !present.has(f.path))];
|
|
281
317
|
};
|
|
282
|
-
const opts = fetched
|
|
283
|
-
? { moduleDirs, fetchedBySource: fetched.bySource }
|
|
284
|
-
: { moduleDirs };
|
|
318
|
+
const opts = fetched ? { moduleDirs, fetchedBySource: fetched.bySource } : { moduleDirs };
|
|
285
319
|
return {
|
|
286
320
|
before: attributeAddresses(withFetched(beforeFiles), opts),
|
|
287
321
|
after: attributeAddresses(withFetched(afterFiles), opts),
|
|
@@ -97,6 +97,9 @@ export interface EquivalenceResult {
|
|
|
97
97
|
multiplicity_preserved: boolean;
|
|
98
98
|
/** per-resource multiplicity drift, human-readable on both sides. */
|
|
99
99
|
multiplicity_diffs: { resource: string; before: string; after: string }[];
|
|
100
|
+
/** relocations that changed the provider configuration managing the resource
|
|
101
|
+
* (G16) — another region or account, with every argument unchanged. */
|
|
102
|
+
provider_diffs: { resource: string; before: string; after: string }[];
|
|
100
103
|
/** relocations where the repetition changed LEVEL — it sat on the resource and
|
|
101
104
|
* now sits on a module call, or the reverse (G15). The instance key moves to a
|
|
102
105
|
* different position in the address, so the same instances live at entirely
|
|
@@ -399,12 +402,19 @@ function uncoveredInstanceKeys(p: Pairing): string[] | null {
|
|
|
399
402
|
const written = (k: string) => (kind === "count" ? k : `"${k}"`);
|
|
400
403
|
const declared = new Set<string>();
|
|
401
404
|
for (const blk of p.blocks) {
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
405
|
+
// A relevelling repeats BOTH sides, at different levels: the instance is
|
|
406
|
+
// `aws_s3_bucket.data["logs"]` before and `module.data["logs"].…` after, so a
|
|
407
|
+
// block covers it only when each side names that same key. A key on one side
|
|
408
|
+
// alone (`aws_s3_bucket.data["logs"]` → `module.data.aws_s3_bucket.this`)
|
|
409
|
+
// points at an address the repetition no longer has, and Terraform plans it
|
|
410
|
+
// as a destroy and a create.
|
|
411
|
+
const from = instanceKeysOf(blk.from);
|
|
412
|
+
const to = instanceKeysOf(blk.to);
|
|
413
|
+
// more than one key per side is a chain this proof does not read; no key on
|
|
414
|
+
// either side names no single instance
|
|
415
|
+
if (from.length > 1 || to.length > 1) return null;
|
|
416
|
+
if (from.length === 0 && to.length === 0) return null;
|
|
417
|
+
if (from.length === 1 && to.length === 1 && from[0] === to[0]) declared.add(from[0] as string);
|
|
408
418
|
}
|
|
409
419
|
return expected.filter((k) => !declared.has(written(k)));
|
|
410
420
|
}
|
|
@@ -492,6 +502,23 @@ export function computeEquivalence(
|
|
|
492
502
|
}
|
|
493
503
|
const multiplicityPreserved = multiplicityDiffs.length === 0;
|
|
494
504
|
|
|
505
|
+
// 2d. the provider configuration per pair (G16). A resource on
|
|
506
|
+
// `provider = aws.replica` moved into a module called without a `providers`
|
|
507
|
+
// map is managed by the module's default `aws` afterwards: every argument,
|
|
508
|
+
// move and planned value unchanged, and the resource in another region.
|
|
509
|
+
// Compared only where both sides were attributed (a hand-built ref has none).
|
|
510
|
+
const providerDiffs: EquivalenceResult["provider_diffs"] = [];
|
|
511
|
+
const seenProviderDiff = new Set<string>();
|
|
512
|
+
for (const p of corr.pairs) {
|
|
513
|
+
const before = p.before.provider;
|
|
514
|
+
const after = p.after.provider;
|
|
515
|
+
if (before === undefined || after === undefined || before === after) continue;
|
|
516
|
+
const id = `${p.before.key}|${before}|${after}`;
|
|
517
|
+
if (seenProviderDiff.has(id)) continue;
|
|
518
|
+
seenProviderDiff.add(id);
|
|
519
|
+
providerDiffs.push({ resource: p.before.address, before, after });
|
|
520
|
+
}
|
|
521
|
+
|
|
495
522
|
// 2c. WHERE the repetition sits, not just how much of it there is (G15).
|
|
496
523
|
//
|
|
497
524
|
// Moving a `for_each` from the resource onto the module call that now wraps it
|
|
@@ -514,7 +541,9 @@ export function computeEquivalence(
|
|
|
514
541
|
if (seenRelevel.has(id)) continue;
|
|
515
542
|
seenRelevel.add(id);
|
|
516
543
|
const missing = p.keyed ? uncoveredInstanceKeys(p) : null;
|
|
517
|
-
|
|
544
|
+
// per relocation, not per logical key: `aws_s3_bucket.data` at the root and
|
|
545
|
+
// inside a module share one key and are missing different instances
|
|
546
|
+
if (missing !== null && missing.length > 0) missingKeys.set(id, missing);
|
|
518
547
|
relevelled.push({
|
|
519
548
|
resource: p.before.key,
|
|
520
549
|
from: p.before.address,
|
|
@@ -536,14 +565,16 @@ export function computeEquivalence(
|
|
|
536
565
|
`instance key. Terraform would DESTROY and RECREATE every instance. Write one per-key ` +
|
|
537
566
|
`\`moved {}\` block instead.`,
|
|
538
567
|
);
|
|
539
|
-
for (const r of relevelledKeyedIncomplete)
|
|
568
|
+
for (const r of relevelledKeyedIncomplete) {
|
|
569
|
+
const missing = missingKeys.get(`${r.from}=>${r.to}`) ?? [];
|
|
540
570
|
reasons.push(
|
|
541
571
|
`${r.resource}: the repetition moved between levels (${r.from} → ${r.to}) and the collection ` +
|
|
542
572
|
`is written out in the configuration, so the instance keys are known — and ` +
|
|
543
|
-
`${
|
|
544
|
-
`${
|
|
573
|
+
`${missing.length} of them have NO \`moved {}\` block: ` +
|
|
574
|
+
`${missing.map((k) => `"${k}"`).join(", ")}. Terraform would ` +
|
|
545
575
|
`DESTROY and RECREATE those instances. Write one per-key block for each.`,
|
|
546
576
|
);
|
|
577
|
+
}
|
|
547
578
|
for (const r of relevelledKeyedUnread)
|
|
548
579
|
reasons.push(
|
|
549
580
|
`${r.resource}: the repetition moved between levels (${r.from} → ${r.to}) and the per-key ` +
|
|
@@ -556,6 +587,12 @@ export function computeEquivalence(
|
|
|
556
587
|
`instance multiplicity CHANGED for ${d.resource}: ${d.before} → ${d.after} — this adds or ` +
|
|
557
588
|
`destroys instances, whatever the argument names say`,
|
|
558
589
|
);
|
|
590
|
+
for (const d of providerDiffs)
|
|
591
|
+
reasons.push(
|
|
592
|
+
`provider configuration CHANGED for ${d.resource}: ${d.before} → ${d.after} — the resource ` +
|
|
593
|
+
`would be managed through another configuration (region or account) with every argument ` +
|
|
594
|
+
`unchanged. Pass it to the module call: \`providers = { ${d.after.split(" ")[0]?.split(".")[0]} = ${d.before} }\`.`,
|
|
595
|
+
);
|
|
559
596
|
|
|
560
597
|
// 3. moved-block exhaustiveness. A pair whose address changed is a relocation;
|
|
561
598
|
// it is COVERED exactly when a declared block established the pairing, and
|
|
@@ -639,6 +676,7 @@ export function computeEquivalence(
|
|
|
639
676
|
!resourceSetEquivalent ||
|
|
640
677
|
!argNamesEquivalent ||
|
|
641
678
|
!multiplicityPreserved ||
|
|
679
|
+
providerDiffs.length > 0 ||
|
|
642
680
|
!movesExhaustive ||
|
|
643
681
|
illegalMoves.length > 0 ||
|
|
644
682
|
relevelledUnkeyed.length > 0 ||
|
|
@@ -671,6 +709,7 @@ export function computeEquivalence(
|
|
|
671
709
|
arg_diffs: argDiffs,
|
|
672
710
|
multiplicity_preserved: multiplicityPreserved,
|
|
673
711
|
multiplicity_diffs: multiplicityDiffs,
|
|
712
|
+
provider_diffs: providerDiffs,
|
|
674
713
|
relevelled_repetitions: relevelled,
|
|
675
714
|
unsettled_only_keyed_relevel:
|
|
676
715
|
verdict === "unverifiable" && relevelledKeyedUnread.length > 0 && !otherDoubt,
|