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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets a pull request adds, found by betterleaks — opt-in, by naming
|
|
3
|
+
* `betterleaks` in `tools_enabled`.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IS SCANNED. Only the lines the PR's commits add (`git` mode over
|
|
6
|
+
* `<merge base>..HEAD`), so every hit is introduced by the PR by construction.
|
|
7
|
+
* A secret added and then deleted in a later commit is still reported: it
|
|
8
|
+
* stays in the branch history, readable by anyone who can read the repository.
|
|
9
|
+
*
|
|
10
|
+
* NOTHING LEAVES THE RUNNER. betterleaks v2 can check a found credential
|
|
11
|
+
* against its provider; that sends the secret over the network, so it is
|
|
12
|
+
* switched off explicitly (flags and environment both), and the report is
|
|
13
|
+
* written redacted, so the secret is never in a file, a log or a comment.
|
|
14
|
+
*
|
|
15
|
+
* v2 ONLY. v2 changed the command line and the report format. A v1 binary is
|
|
16
|
+
* reported as not run rather than parsed wrongly.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { run, runAsync } from "#app/mcp/terraform/subprocess";
|
|
21
|
+
import { buildConcern, type Concern } from "#app/mcp/terraform/types";
|
|
22
|
+
|
|
23
|
+
export interface SecretScan {
|
|
24
|
+
ran: boolean;
|
|
25
|
+
/** why it did not run */
|
|
26
|
+
reason?: string;
|
|
27
|
+
concerns: Concern[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BetterleaksFinding {
|
|
31
|
+
rule_id?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
confidence?: string;
|
|
34
|
+
location?: { path?: string; start_line?: number };
|
|
35
|
+
attributes?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a betterleaks v2 JSON report into concerns, paths made relative to the
|
|
40
|
+
* scan directory (`prefix` is its path in the repository), and whether the
|
|
41
|
+
* scan completed: an incomplete one keeps the findings it made before it
|
|
42
|
+
* stopped. Throws on a report that is not v2's.
|
|
43
|
+
*/
|
|
44
|
+
export function parseBetterleaksReport(
|
|
45
|
+
json: string,
|
|
46
|
+
prefix = "",
|
|
47
|
+
): { concerns: Concern[]; complete: boolean } {
|
|
48
|
+
const doc = JSON.parse(json) as unknown;
|
|
49
|
+
if (Array.isArray(doc) || !doc || typeof doc !== "object" || !("findings" in doc)) {
|
|
50
|
+
throw new Error("not a betterleaks v2 report — betterleaks v2 is required");
|
|
51
|
+
}
|
|
52
|
+
const { findings = [], scan } = doc as {
|
|
53
|
+
findings?: BetterleaksFinding[];
|
|
54
|
+
scan?: { state?: string };
|
|
55
|
+
};
|
|
56
|
+
const concerns: Concern[] = [];
|
|
57
|
+
for (const f of findings) {
|
|
58
|
+
const path = (f.location?.path ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
59
|
+
if (!path.startsWith(prefix)) continue;
|
|
60
|
+
const rule = f.rule_id ?? "secret";
|
|
61
|
+
const commit = f.attributes?.["git.sha"]?.slice(0, 7);
|
|
62
|
+
concerns.push(
|
|
63
|
+
buildConcern({
|
|
64
|
+
source: "betterleaks",
|
|
65
|
+
rule,
|
|
66
|
+
file: path.slice(prefix.length),
|
|
67
|
+
line: f.location?.start_line ?? null,
|
|
68
|
+
// a secret in the repository is exposed to everyone who can read it; a
|
|
69
|
+
// match betterleaks itself rates low-confidence is reported, not gated
|
|
70
|
+
severity: f.confidence === "low" ? "medium" : "critical",
|
|
71
|
+
category: "security",
|
|
72
|
+
evidence:
|
|
73
|
+
`${f.description ?? rule}: a secret is added` +
|
|
74
|
+
`${commit ? ` in commit ${commit}` : ""} (value redacted)`,
|
|
75
|
+
remediation_hint:
|
|
76
|
+
"Remove the secret from the change and from the branch history, and rotate it — " +
|
|
77
|
+
"it is readable by everyone who can read the repository. Read it at run time from a " +
|
|
78
|
+
"secret store or a sensitive variable instead.",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return { concerns, complete: scan?.state === "complete" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Scan the lines the commits after `mergeBase` add, from the scan directory `cwd`. */
|
|
86
|
+
export async function scanAddedSecrets(
|
|
87
|
+
cwd: string,
|
|
88
|
+
mergeBase: string,
|
|
89
|
+
tmp: string,
|
|
90
|
+
): Promise<SecretScan> {
|
|
91
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
|
|
92
|
+
const prefix = run("git", ["rev-parse", "--show-prefix"], cwd).stdout.trim();
|
|
93
|
+
if (!top) return { ran: false, reason: "not a git checkout", concerns: [] };
|
|
94
|
+
const report = join(tmp, `betterleaks-${process.pid}.json`);
|
|
95
|
+
try {
|
|
96
|
+
const r = await runAsync(
|
|
97
|
+
"betterleaks",
|
|
98
|
+
[
|
|
99
|
+
"git",
|
|
100
|
+
top,
|
|
101
|
+
`--log-opts=${mergeBase}..HEAD`,
|
|
102
|
+
"--validate=false",
|
|
103
|
+
"--analyze=false",
|
|
104
|
+
"--redact",
|
|
105
|
+
"--silent",
|
|
106
|
+
"--exit-code=0",
|
|
107
|
+
"-o",
|
|
108
|
+
report,
|
|
109
|
+
],
|
|
110
|
+
top,
|
|
111
|
+
{ BETTERLEAKS_VALIDATE: "false", BETTERLEAKS_ANALYZE: "false" },
|
|
112
|
+
);
|
|
113
|
+
if (r.missing) return { ran: false, reason: "betterleaks not installed", concerns: [] };
|
|
114
|
+
// an incomplete scan exits 1 even with --exit-code=0, and still writes the
|
|
115
|
+
// findings it made: keep those, and say the scan did not finish
|
|
116
|
+
const why = (r.stderr || r.stdout).trim().split("\n").pop() ?? "";
|
|
117
|
+
let parsed: ReturnType<typeof parseBetterleaksReport>;
|
|
118
|
+
try {
|
|
119
|
+
parsed = parseBetterleaksReport(readFileSync(report, "utf-8"), prefix);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const reason = r.status !== 0 ? `betterleaks exited ${r.status}: ${why}` : `${error}`;
|
|
122
|
+
return { ran: false, reason, concerns: [] };
|
|
123
|
+
}
|
|
124
|
+
if (!parsed.complete || r.status !== 0) {
|
|
125
|
+
return { ran: false, reason: `the scan did not complete: ${why}`, concerns: parsed.concerns };
|
|
126
|
+
}
|
|
127
|
+
return { ran: true, concerns: parsed.concerns };
|
|
128
|
+
} finally {
|
|
129
|
+
rmSync(report, { force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
import type { Concern, ScannerOutcome } from "#app/mcp/terraform/types";
|
|
67
67
|
import { spansMajors } from "#app/mcp/terraform/versionGuard";
|
|
68
68
|
import { pushInto } from "#app/utils/collect";
|
|
69
|
+
import { tableCell } from "#app/utils/markdownTable";
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* How consistently the repository follows one convention.
|
|
@@ -351,8 +352,8 @@ export function conformingRules(
|
|
|
351
352
|
const ANY_BLOCK = /(?:^|\n)\s*(?:resource|module|data|variable|output|locals|provider)\s*[">{]/;
|
|
352
353
|
|
|
353
354
|
const SUBJECT: Record<string, RegExp> = {
|
|
354
|
-
"secret-persisted-to-state": /(?:^|\n)\s*resource\s+"/,
|
|
355
|
-
"imperative-action-trigger": /(?:^|\n)\s*resource\s+"/,
|
|
355
|
+
"secret-persisted-to-state": /(?:^|\n)\s*resource\s+(?:"|[A-Za-z_])/,
|
|
356
|
+
"imperative-action-trigger": /(?:^|\n)\s*resource\s+(?:"|[A-Za-z_])/,
|
|
356
357
|
"dynamic-destroy-protection": /(?:^|\n)\s*lifecycle\s*\{/,
|
|
357
358
|
"deprecated-state-locking": /(?:^|\n)\s*backend\s+"s3"/,
|
|
358
359
|
"cross-dialect-syntax": ANY_BLOCK,
|
|
@@ -363,22 +364,22 @@ const SUBJECT: Record<string, RegExp> = {
|
|
|
363
364
|
// rule's silence a real statement. Whether git could answer is handled one
|
|
364
365
|
// level up by the blind set: a run that could not ask is never conforming.
|
|
365
366
|
"committed-state-file": ANY_BLOCK,
|
|
366
|
-
"deprecated-s3-bucket-argument": /(?:^|\n)\s*resource\s+"aws_s3_bucket"/,
|
|
367
|
+
"deprecated-s3-bucket-argument": /(?:^|\n)\s*resource\s+"?aws_s3_bucket(?:"|\s)/,
|
|
367
368
|
"superseded-resource-type":
|
|
368
|
-
/(?:^|\n)\s*(?:resource\s+"(?:aws_launch_configuration|aws_elb)"|data\s+"template_file")/,
|
|
369
|
+
/(?:^|\n)\s*(?:resource\s+"?(?:aws_launch_configuration|aws_elb)(?:"|\s)|data\s+"?template_file(?:"|\s))/,
|
|
369
370
|
"missing-required-version": /(?:^|\n)\s*(?:resource|module|provider)\s+"/,
|
|
370
371
|
"missing-required-providers": /(?:^|\n)\s*(?:resource|module|provider)\s+"/,
|
|
371
372
|
"unpinned-provider-version": /required_providers/,
|
|
372
373
|
"unpinned-required-version": /required_version/,
|
|
373
374
|
// a tree with no `module` call has no module constraint to pin, so the rule
|
|
374
375
|
// is not applicable rather than passing.
|
|
375
|
-
"unpinned-module-version": /(?:^|\n)\s*module\s+"/,
|
|
376
|
+
"unpinned-module-version": /(?:^|\n)\s*module\s+(?:"|[A-Za-z_])/,
|
|
376
377
|
// both read every string in the tree, so any block is subject enough.
|
|
377
378
|
"legacy-interpolation": ANY_BLOCK,
|
|
378
379
|
"legacy-block-assignment": ANY_BLOCK,
|
|
379
|
-
"untyped-variable": /(?:^|\n)\s*variable\s+"/,
|
|
380
|
-
"undocumented-variable": /(?:^|\n)\s*variable\s+"/,
|
|
381
|
-
"undocumented-output": /(?:^|\n)\s*output\s+"/,
|
|
380
|
+
"untyped-variable": /(?:^|\n)\s*variable\s+(?:"|[A-Za-z_])/,
|
|
381
|
+
"undocumented-variable": /(?:^|\n)\s*variable\s+(?:"|[A-Za-z_])/,
|
|
382
|
+
"undocumented-output": /(?:^|\n)\s*output\s+(?:"|[A-Za-z_])/,
|
|
382
383
|
"repeated-literal": ANY_BLOCK,
|
|
383
384
|
};
|
|
384
385
|
|
|
@@ -568,7 +569,7 @@ export function buildStandardsReport(
|
|
|
568
569
|
* offered — `buildStandardsReport` is the pure seam for a caller that already
|
|
569
570
|
* holds both.
|
|
570
571
|
*/
|
|
571
|
-
export function standardsReport(cwd: string): StandardsReport {
|
|
572
|
+
export async function standardsReport(cwd: string): Promise<StandardsReport> {
|
|
572
573
|
const tree = readTfTree(cwd);
|
|
573
574
|
// The scan re-derives its findings from the SAME tree this report counts its
|
|
574
575
|
// conventions off, so it is handed the one already in memory. Reading it
|
|
@@ -590,7 +591,7 @@ export function standardsReport(cwd: string): StandardsReport {
|
|
|
590
591
|
// the only convention here the CLI adjudicates rather than the native pack.
|
|
591
592
|
// It degrades to `ran: false` (→ `inconclusive`) with no terraform or tofu
|
|
592
593
|
// installed, which is the ordinary case on a plain CI runner.
|
|
593
|
-
formatting: scanFmt(cwd),
|
|
594
|
+
formatting: await scanFmt(cwd),
|
|
594
595
|
// The tree the conventions were counted off is the same one the scan saw,
|
|
595
596
|
// so if it was cut short BOTH halves of this report describe a fraction of
|
|
596
597
|
// the estate. Saying so is the whole point of the file's discipline.
|
|
@@ -641,7 +642,7 @@ export function renderStandardsReportMarkdown(report: StandardsReport): string {
|
|
|
641
642
|
lines.push("### Conventions detected", "");
|
|
642
643
|
lines.push("| Convention | State | Observed |", "| --- | --- | --- |");
|
|
643
644
|
for (const c of report.conventions) {
|
|
644
|
-
lines.push(`| ${c.title} | ${VERDICT_MARK[c.verdict]} | ${c.evidence} |`);
|
|
645
|
+
lines.push(`| ${c.title} | ${VERDICT_MARK[c.verdict]} | ${tableCell(c.evidence)} |`);
|
|
645
646
|
}
|
|
646
647
|
lines.push("");
|
|
647
648
|
lines.push(
|
|
@@ -655,7 +656,7 @@ export function renderStandardsReportMarkdown(report: StandardsReport): string {
|
|
|
655
656
|
lines.push("| Rule | Count | First | Claim |", "| --- | --- | --- | --- |");
|
|
656
657
|
for (const v of report.violations) {
|
|
657
658
|
lines.push(
|
|
658
|
-
`| \`${v.rule}\` | ${v.count} | ${v.first.file}${v.first.line ? `:${v.first.line}` : ""} | ${BAND_NOTE[v.band]} |`,
|
|
659
|
+
`| \`${v.rule}\` | ${v.count} | ${tableCell(`${v.first.file}${v.first.line ? `:${v.first.line}` : ""}`)} | ${BAND_NOTE[v.band]} |`,
|
|
659
660
|
);
|
|
660
661
|
}
|
|
661
662
|
lines.push("");
|
|
@@ -666,7 +667,7 @@ export function renderStandardsReportMarkdown(report: StandardsReport): string {
|
|
|
666
667
|
lines.push("| Rule | Count | First |", "| --- | --- | --- |");
|
|
667
668
|
for (const d of report.convention_departures) {
|
|
668
669
|
lines.push(
|
|
669
|
-
`| \`${d.rule}\` | ${d.count} | ${d.first.file}${d.first.line ? `:${d.first.line}` : ""} |`,
|
|
670
|
+
`| \`${d.rule}\` | ${d.count} | ${tableCell(`${d.first.file}${d.first.line ? `:${d.first.line}` : ""}`)} |`,
|
|
670
671
|
);
|
|
671
672
|
}
|
|
672
673
|
lines.push("");
|
|
@@ -18,6 +18,9 @@ export function ConsolidationCandidatesTool(ctx: LocalToolContext) {
|
|
|
18
18
|
"member dirs, the shared resource set, `module_body_values` (attributes IDENTICAL in every " +
|
|
19
19
|
"member — these go in the module verbatim), `variables` (attributes that DIFFER, each with " +
|
|
20
20
|
"`per_member` values), and `expected_moves_by_member` (the `moved {}` blocks each root needs). " +
|
|
21
|
+
"A root's call of a LOCAL module can be part of the shape: it is listed in `resources` as " +
|
|
22
|
+
"`module.<name>`, moves into the new module keeping its name (one `moved {}` block for the " +
|
|
23
|
+
"call), and writes the `source` given in `wrapped_calls` — relative to the new module. " +
|
|
21
24
|
"ONE module, N call sites — never merge the roots themselves: two roots are two state files. " +
|
|
22
25
|
"COPY EACH `per_member.value` INTO THAT MEMBER'S CALL VERBATIM — swapping two environments' " +
|
|
23
26
|
"values produces a tree that still proves `equivalent: true` and destroys the wrong " +
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { basename, dirname } from "node:path";
|
|
3
3
|
import { errorMessage } from "@infraweaver-io/core/errors";
|
|
4
4
|
import { resolveWithinCwd } from "@infraweaver-io/sandbox/pathSafety";
|
|
@@ -7,12 +7,14 @@ import { PRODUCT_NAME } from "#app/brand";
|
|
|
7
7
|
import { runAssessmentPipeline } from "#app/mcp/assess";
|
|
8
8
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
9
9
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
10
|
+
import { writeEmittedArtefact } from "#app/mcp/terraform/emittedArtefacts";
|
|
10
11
|
import {
|
|
11
12
|
buildCostStatement,
|
|
12
13
|
buildEgressStatement,
|
|
13
14
|
buildEvidenceBundle,
|
|
14
15
|
buildRefactorStatement,
|
|
15
16
|
DEFAULT_EVIDENCE_PATH,
|
|
17
|
+
proposedChangeOf,
|
|
16
18
|
} from "#app/mcp/terraform/evidence";
|
|
17
19
|
import {
|
|
18
20
|
buildInTotoStatement,
|
|
@@ -36,7 +38,7 @@ const TerraformEmitEvidenceParams = type({
|
|
|
36
38
|
"minimum severity to include (default: the run's configured threshold, else low).",
|
|
37
39
|
),
|
|
38
40
|
"proposed_unproven?": type("boolean").describe(
|
|
39
|
-
"
|
|
41
|
+
"set true when emitting evidence for a behaviour-altering change shipped WITHOUT a plan (a refactor b.4 third-party-module adoption, a module or provider-major bump, a deprecated-pattern migration), so the bundle records the unmissable `proposed — unproven` marker (it asserts nothing about runtime behaviour). Ignored when a keys-free equivalence proof was already recorded this run.",
|
|
40
42
|
),
|
|
41
43
|
"reviewed_plan?": type("boolean").describe(
|
|
42
44
|
"refactor b.4 with credentials only: set true when the adoption ships with a REVIEWED PLAN DELTA — terraform_plan must have run this session (refused otherwise), and the bundle records that the claim rests on a human reviewing that plan. This is the marker the push gate accepts in place of an equivalence proof for a behaviour-altering adoption.",
|
|
@@ -83,6 +85,7 @@ export function TerraformEmitEvidenceTool(ctx: LocalToolContext) {
|
|
|
83
85
|
proposedUnproven: proposed_unproven ?? false,
|
|
84
86
|
planTotals: session.lastPlanTotals,
|
|
85
87
|
reviewedPlan: reviewed_plan ?? false,
|
|
88
|
+
change: proposedChangeOf(ctx.toolState.selectedMode),
|
|
86
89
|
});
|
|
87
90
|
// surface the proposed-unproven verdict to the Cloud report (cloudReport.ts):
|
|
88
91
|
// an equivalence proof, when present, takes precedence, so only record the
|
|
@@ -147,10 +150,11 @@ export function TerraformEmitEvidenceTool(ctx: LocalToolContext) {
|
|
|
147
150
|
const bytes = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
148
151
|
// the default compliance/ dir may not exist yet on a first run.
|
|
149
152
|
mkdirSync(dirname(target), { recursive: true });
|
|
150
|
-
|
|
153
|
+
writeEmittedArtefact(ctx.toolState, target, bytes);
|
|
151
154
|
// detached signature over the EXACT bytes written (builder is deterministic,
|
|
152
155
|
// so no canonicaliser) — verify with `infraweaver verify-evidence`.
|
|
153
|
-
if (signingKey)
|
|
156
|
+
if (signingKey)
|
|
157
|
+
writeEmittedArtefact(ctx.toolState, sigPath, `${signBytes(bytes, signingKey)}\n`);
|
|
154
158
|
|
|
155
159
|
// Second tier: keyless Sigstore attestation (opt-in via INFRAWEAVER_KEYLESS_SIGNING,
|
|
156
160
|
// CI-only). Best-effort — a Fulcio/Rekor failure logs and leaves the evidence
|
|
@@ -174,7 +178,11 @@ export function TerraformEmitEvidenceTool(ctx: LocalToolContext) {
|
|
|
174
178
|
});
|
|
175
179
|
const sigstoreBundle = await keylessSignStatement(statement);
|
|
176
180
|
keylessBundleFile = keylessBundlePath(target);
|
|
177
|
-
|
|
181
|
+
writeEmittedArtefact(
|
|
182
|
+
ctx.toolState,
|
|
183
|
+
keylessBundleFile,
|
|
184
|
+
`${JSON.stringify(sigstoreBundle, null, 2)}\n`,
|
|
185
|
+
);
|
|
178
186
|
log.info(
|
|
179
187
|
`» terraform_emit_evidence: keyless Sigstore attestation → ${keylessBundleFile}`,
|
|
180
188
|
);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { mkdirSync
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { resolveWithinCwd } from "@infraweaver-io/sandbox/pathSafety";
|
|
4
4
|
import { type } from "arktype";
|
|
5
5
|
import { runAssessmentPipeline } from "#app/mcp/assess";
|
|
6
6
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
7
7
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
8
|
+
import { writeEmittedArtefact } from "#app/mcp/terraform/emittedArtefacts";
|
|
8
9
|
import {
|
|
9
10
|
buildOscalAssessmentResults,
|
|
10
11
|
DEFAULT_OSCAL_PATH,
|
|
@@ -58,7 +59,7 @@ export function TerraformEmitOscalTool(ctx: LocalToolContext) {
|
|
|
58
59
|
});
|
|
59
60
|
// the default compliance/ dir may not exist yet on a first run.
|
|
60
61
|
mkdirSync(dirname(target), { recursive: true });
|
|
61
|
-
|
|
62
|
+
writeEmittedArtefact(ctx.toolState, target, `${JSON.stringify(doc, null, 2)}\n`);
|
|
62
63
|
const result = doc["assessment-results"].results[0];
|
|
63
64
|
const findings = result?.findings.length ?? 0;
|
|
64
65
|
log.info(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { basename, dirname } from "node:path";
|
|
3
3
|
import { errorMessage } from "@infraweaver-io/core/errors";
|
|
4
4
|
import { resolveWithinCwd } from "@infraweaver-io/sandbox/pathSafety";
|
|
@@ -7,6 +7,7 @@ import { PRODUCT_NAME } from "#app/brand";
|
|
|
7
7
|
import { runAssessmentPipeline } from "#app/mcp/assess";
|
|
8
8
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
9
9
|
import { execute, tool, toolOk } from "#app/mcp/shared";
|
|
10
|
+
import { writeEmittedArtefact } from "#app/mcp/terraform/emittedArtefacts";
|
|
10
11
|
import {
|
|
11
12
|
buildInTotoStatement,
|
|
12
13
|
keylessBundlePath,
|
|
@@ -77,10 +78,11 @@ export function TerraformEmitVexTool(ctx: LocalToolContext) {
|
|
|
77
78
|
const bytes = `${JSON.stringify(doc, null, 2)}\n`;
|
|
78
79
|
// the default compliance/ dir may not exist yet on a first run.
|
|
79
80
|
mkdirSync(dirname(target), { recursive: true });
|
|
80
|
-
|
|
81
|
+
writeEmittedArtefact(ctx.toolState, target, bytes);
|
|
81
82
|
// same signing contract as the evidence bundle: detached signature over
|
|
82
83
|
// the exact bytes the artefact holds on disk.
|
|
83
|
-
if (signingKey)
|
|
84
|
+
if (signingKey)
|
|
85
|
+
writeEmittedArtefact(ctx.toolState, sigPath, `${signBytes(bytes, signingKey)}\n`);
|
|
84
86
|
|
|
85
87
|
// Second tier: keyless Sigstore attestation of the VEX (opt-in, CI-only,
|
|
86
88
|
// best-effort — same contract as terraform_emit_evidence). Binds the exact
|
|
@@ -100,7 +102,11 @@ export function TerraformEmitVexTool(ctx: LocalToolContext) {
|
|
|
100
102
|
});
|
|
101
103
|
const sigstoreBundle = await keylessSignStatement(statement);
|
|
102
104
|
keylessBundleFile = keylessBundlePath(target);
|
|
103
|
-
|
|
105
|
+
writeEmittedArtefact(
|
|
106
|
+
ctx.toolState,
|
|
107
|
+
keylessBundleFile,
|
|
108
|
+
`${JSON.stringify(sigstoreBundle, null, 2)}\n`,
|
|
109
|
+
);
|
|
104
110
|
log.info(`» terraform_emit_vex: keyless Sigstore attestation → ${keylessBundleFile}`);
|
|
105
111
|
} catch (e) {
|
|
106
112
|
log.warning(
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import { type } from "arktype";
|
|
2
5
|
import { REMEDIATE_AND_REFACTOR_MODE } from "#app/mcp/guardrails";
|
|
3
6
|
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
4
7
|
import { execute, tool, toolOk, toolSkip } from "#app/mcp/shared";
|
|
8
|
+
import { initArtefacts, removeNewInitArtefacts } from "#app/mcp/terraform/initArtefacts";
|
|
5
9
|
import { attributePair } from "#app/mcp/terraform/refactor/attribution";
|
|
6
10
|
import { computeEquivalence } from "#app/mcp/terraform/refactor/equivalence";
|
|
11
|
+
import { staleReferenceReason, staleReferences } from "#app/mcp/terraform/refactor/references";
|
|
7
12
|
import { verifyParameterSubstitution } from "#app/mcp/terraform/refactor/substitution";
|
|
8
13
|
import {
|
|
9
14
|
changedFilesSinceBase,
|
|
@@ -13,9 +18,10 @@ import {
|
|
|
13
18
|
resolveBaseSha,
|
|
14
19
|
workingTreeFingerprint,
|
|
15
20
|
} from "#app/mcp/terraform/refactor/workspace";
|
|
16
|
-
import { scanFmt, scanValidate } from "#app/mcp/terraform/scanners";
|
|
21
|
+
import { initFailedRoots, initFailures, scanFmt, scanValidate } from "#app/mcp/terraform/scanners";
|
|
17
22
|
import { scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
18
|
-
import
|
|
23
|
+
import { run } from "#app/mcp/terraform/subprocess";
|
|
24
|
+
import type { Concern, EquivalenceVerdict } from "#app/mcp/terraform/types";
|
|
19
25
|
import { log } from "#app/utils/cli";
|
|
20
26
|
import { resolveModuleCredentialEnv } from "#app/utils/setup/registryAuth";
|
|
21
27
|
|
|
@@ -87,8 +93,9 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
87
93
|
const terraformEnv = resolveModuleCredentialEnv(ctx.payload, {
|
|
88
94
|
sshKeyDir: ctx.tmpdir,
|
|
89
95
|
});
|
|
90
|
-
const
|
|
91
|
-
const
|
|
96
|
+
const artefactsBefore = initArtefacts(cwd);
|
|
97
|
+
const fmtOutcome = await scanFmt(cwd);
|
|
98
|
+
const validateOutcome = await scanValidate(cwd, terraformEnv);
|
|
92
99
|
|
|
93
100
|
// Attributed as a PAIR: whether a directory is a module or a root is a
|
|
94
101
|
// property of the change, not of one snapshot. See [[attributePair]].
|
|
@@ -128,10 +135,16 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
128
135
|
// compares argument NAMES, so a consolidation that hands prod's bucket
|
|
129
136
|
// name to dev passes every check above — and the plan too, since each root
|
|
130
137
|
// still declares exactly one bucket. Driven by `expected_moves` so it
|
|
131
|
-
// speaks about exactly the relocations the proof established.
|
|
138
|
+
// speaks about exactly the relocations the proof established. The fetched
|
|
139
|
+
// module code goes into BOTH trees, as [[attributePair]] does, so a git
|
|
140
|
+
// module called before and after is compared rather than skipped.
|
|
141
|
+
const withFetched = (files: typeof afterFiles) => [
|
|
142
|
+
...files,
|
|
143
|
+
...fetched.files.filter((f) => !files.some((a) => a.path === f.path)),
|
|
144
|
+
];
|
|
132
145
|
const substitution = verifyParameterSubstitution(
|
|
133
|
-
beforeFiles,
|
|
134
|
-
|
|
146
|
+
withFetched(beforeFiles),
|
|
147
|
+
withFetched(afterFiles),
|
|
135
148
|
result.expected_moves,
|
|
136
149
|
{ fetchedBySource: fetched.bySource },
|
|
137
150
|
);
|
|
@@ -139,10 +152,28 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
139
152
|
substitution.value_substitutions?.filter((s) => s.match === true).length ?? 0;
|
|
140
153
|
|
|
141
154
|
const validateRan = validateOutcome.ran;
|
|
142
|
-
const
|
|
155
|
+
const validateFailed =
|
|
143
156
|
validateRan &&
|
|
144
|
-
validateOutcome.concerns.length
|
|
145
|
-
|
|
157
|
+
(validateOutcome.concerns.length > 0 || (validateOutcome.unvalidated ?? 0) > 0);
|
|
158
|
+
// a root whose init failed validated without its modules or providers, so
|
|
159
|
+
// its clean result checked almost nothing — it is not a pass
|
|
160
|
+
const shallowRoots = validateRan ? initFailedRoots(cwd) : [];
|
|
161
|
+
// the fetched module code is read and the init outcome known, so what
|
|
162
|
+
// validate's init wrote can go: left behind, it blocks the push
|
|
163
|
+
removeNewInitArtefacts(cwd, artefactsBefore, "terraform_equivalence_check");
|
|
164
|
+
const validatePassed = validateRan && !validateFailed && shallowRoots.length === 0;
|
|
165
|
+
// A repository that does not validate at the run-start commit cannot be
|
|
166
|
+
// proven equivalent by any change made to it — but saying "declined" for
|
|
167
|
+
// errors the change did not introduce sends the editor re-proving forever
|
|
168
|
+
// (a real repository ran its whole token budget that way). Every error
|
|
169
|
+
// inherited is `unverifiable` and a stop; one the change added is its own.
|
|
170
|
+
const inherited =
|
|
171
|
+
validateFailed && !validateOutcome.unvalidated && validateOutcome.concerns.length > 0
|
|
172
|
+
? await baselineValidateErrors(cwd, base, terraformEnv, scanSessionOf(ctx.toolState))
|
|
173
|
+
: null;
|
|
174
|
+
const onlyInherited =
|
|
175
|
+
inherited !== null &&
|
|
176
|
+
validateOutcome.concerns.every((c) => inherited.has(validateErrorKey(c)));
|
|
146
177
|
// fmt-clean is scoped to the files this run changed — pre-existing drift in
|
|
147
178
|
// untouched files is not the refactor's to fix and must not fail its proof.
|
|
148
179
|
const changed = new Set(changedFilesSinceBase(cwd, base));
|
|
@@ -155,12 +186,44 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
155
186
|
];
|
|
156
187
|
const fmtClean = fmtOutcome.ran && fmtDriftFiles.length === 0;
|
|
157
188
|
|
|
158
|
-
|
|
159
|
-
|
|
189
|
+
// references the change left naming an address it relocated — validate
|
|
190
|
+
// reports them only after an init that installed every module
|
|
191
|
+
const stale = staleReferences(afterFiles, result.expected_moves);
|
|
192
|
+
const staleReason = staleReferenceReason(stale);
|
|
193
|
+
const reasons = [
|
|
194
|
+
...result.reasons,
|
|
195
|
+
...substitution.reasons,
|
|
196
|
+
...(staleReason ? [staleReason] : []),
|
|
197
|
+
];
|
|
198
|
+
if (onlyInherited)
|
|
199
|
+
reasons.push(
|
|
200
|
+
`\`terraform validate\` fails at the run-start commit too, with the same ` +
|
|
201
|
+
`${validateOutcome.concerns.length} error(s) — they predate this change, but a tree that does not ` +
|
|
202
|
+
"validate cannot be proven equivalent. STOP: report them as the blocker; re-running this check " +
|
|
203
|
+
"cannot change its answer until they are fixed.\n- " +
|
|
204
|
+
validateOutcome.concerns.slice(0, 5).map(validateErrorKey).join("\n- "),
|
|
205
|
+
);
|
|
206
|
+
else if (!validatePassed)
|
|
160
207
|
reasons.push(
|
|
161
|
-
|
|
162
|
-
?
|
|
163
|
-
|
|
208
|
+
!validateFailed && shallowRoots.length > 0
|
|
209
|
+
? `\`terraform init\` did not complete in ${shallowRoots.join(", ")}, so \`terraform validate\` ` +
|
|
210
|
+
"could not check the configuration there — the equivalence proof is incomplete" +
|
|
211
|
+
initFailures(cwd)
|
|
212
|
+
.map((f) => `\n- ${f.root}: ${f.error}`)
|
|
213
|
+
.join("")
|
|
214
|
+
: validateRan
|
|
215
|
+
? // the errors themselves: "did not pass" alone sends the editor
|
|
216
|
+
// hunting for a fault in the proof rather than in its tree
|
|
217
|
+
[
|
|
218
|
+
"`terraform validate` did not pass",
|
|
219
|
+
...validateOutcome.concerns
|
|
220
|
+
.slice(0, 5)
|
|
221
|
+
.map(
|
|
222
|
+
(c) =>
|
|
223
|
+
`${c.location.file}${c.location.line ? `:${c.location.line}` : ""}: ${c.evidence}`,
|
|
224
|
+
),
|
|
225
|
+
].join("\n- ")
|
|
226
|
+
: "`terraform validate` did not run (terraform not installed) — the equivalence proof is incomplete",
|
|
164
227
|
);
|
|
165
228
|
if (!fmtClean && fmtOutcome.ran && fmtDriftFiles.length > 0)
|
|
166
229
|
reasons.push(
|
|
@@ -168,7 +231,13 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
168
231
|
);
|
|
169
232
|
|
|
170
233
|
const valuesPreserved = substitution.value_mismatches.length === 0;
|
|
171
|
-
const equivalent =
|
|
234
|
+
const equivalent =
|
|
235
|
+
result.equivalent &&
|
|
236
|
+
validatePassed &&
|
|
237
|
+
fmtClean &&
|
|
238
|
+
valuesPreserved &&
|
|
239
|
+
stale.length === 0 &&
|
|
240
|
+
substitution.opaque_module_changes.length === 0;
|
|
172
241
|
|
|
173
242
|
// The wrapper's own verdict, which the static core cannot reach: a
|
|
174
243
|
// validate that RAN and failed is an established defect (`declined`), but a
|
|
@@ -182,10 +251,16 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
182
251
|
const toolVerdict: EquivalenceVerdict =
|
|
183
252
|
result.verdict === "declined" ||
|
|
184
253
|
!valuesPreserved ||
|
|
185
|
-
|
|
254
|
+
stale.length > 0 ||
|
|
255
|
+
(validateFailed && !onlyInherited) ||
|
|
186
256
|
(fmtOutcome.ran && !fmtClean)
|
|
187
257
|
? "declined"
|
|
188
|
-
: result.verdict === "unverifiable" ||
|
|
258
|
+
: result.verdict === "unverifiable" ||
|
|
259
|
+
onlyInherited ||
|
|
260
|
+
substitution.opaque_module_changes.length > 0 ||
|
|
261
|
+
!validateRan ||
|
|
262
|
+
shallowRoots.length > 0 ||
|
|
263
|
+
!fmtOutcome.ran
|
|
189
264
|
? "unverifiable"
|
|
190
265
|
: "proven";
|
|
191
266
|
|
|
@@ -216,6 +291,7 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
216
291
|
result.unsettled_only_keyed_relevel &&
|
|
217
292
|
toolVerdict === "unverifiable" &&
|
|
218
293
|
validateRan &&
|
|
294
|
+
shallowRoots.length === 0 &&
|
|
219
295
|
fmtOutcome.ran,
|
|
220
296
|
};
|
|
221
297
|
|
|
@@ -232,6 +308,7 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
232
308
|
values_preserved: valuesPreserved,
|
|
233
309
|
value_substitutions: substitution.value_substitutions,
|
|
234
310
|
value_mismatches: substitution.value_mismatches,
|
|
311
|
+
stale_references: stale,
|
|
235
312
|
validate_passed: validatePassed,
|
|
236
313
|
validate_ran: validateRan,
|
|
237
314
|
fmt_clean: fmtClean,
|
|
@@ -249,10 +326,51 @@ export function TerraformEquivalenceCheckTool(ctx: LocalToolContext) {
|
|
|
249
326
|
reasons,
|
|
250
327
|
note: equivalent
|
|
251
328
|
? "behaviour-preserving refactor PROVEN (keys-free). For the stronger proof, run terraform_plan — refactor_safe:true corroborates it."
|
|
252
|
-
:
|
|
253
|
-
? "NOT
|
|
254
|
-
:
|
|
329
|
+
: onlyInherited
|
|
330
|
+
? "NOT provable here — the repository already fails `terraform validate` at the run-start commit. Do not re-run this check or edit around it: stop, and report the inherited errors in `reasons` as what blocks any refactor of this tree."
|
|
331
|
+
: substitution.opaque_module_changes.length > 0 && valuesPreserved
|
|
332
|
+
? "NOT a proof — a module whose code is outside this tree was added, removed, renamed without a `moved` block, or changed source or version, so what it now creates or destroys is unknown here. Prove it with terraform_plan, or ship it proposed-unproven; never as an equivalence claim."
|
|
333
|
+
: valuesPreserved
|
|
334
|
+
? "NOT equivalent — the push will be blocked. Fix the issues in `reasons` (an uncovered move is the dangerous one — add the missing `moved {}` block)."
|
|
335
|
+
: "NOT equivalent — a VALUE changed. See `value_mismatches`: each root must receive its OWN value at its OWN call site, and a shared module's body must keep what every caller had. Copy the values from the before tree verbatim; do not retype them.",
|
|
255
336
|
});
|
|
256
337
|
}),
|
|
257
338
|
});
|
|
258
339
|
}
|
|
340
|
+
|
|
341
|
+
/** `file: message` — what an error is, not where: an edit above it moves its line. */
|
|
342
|
+
function validateErrorKey(c: Concern): string {
|
|
343
|
+
return `${c.location.file}: ${c.evidence}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The validate errors the run-start commit already has, from a throwaway
|
|
348
|
+
* worktree at `base`. Null when that could not be done — every error then reads
|
|
349
|
+
* as the change's own, the safe direction.
|
|
350
|
+
*/
|
|
351
|
+
async function baselineValidateErrors(
|
|
352
|
+
cwd: string,
|
|
353
|
+
base: string,
|
|
354
|
+
env: Record<string, string> | undefined,
|
|
355
|
+
session: ReturnType<typeof scanSessionOf>,
|
|
356
|
+
): Promise<Set<string> | null> {
|
|
357
|
+
if (session.baselineValidateErrors?.base === base) {
|
|
358
|
+
return new Set(session.baselineValidateErrors.errors);
|
|
359
|
+
}
|
|
360
|
+
const prefix = run("git", ["rev-parse", "--show-prefix"], cwd);
|
|
361
|
+
if (prefix.status !== 0) return null;
|
|
362
|
+
const tree = mkdtempSync(join(tmpdir(), "iw-baseline-"));
|
|
363
|
+
try {
|
|
364
|
+
if (run("git", ["worktree", "add", "--detach", "--quiet", tree, base], cwd).status !== 0) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const outcome = await scanValidate(join(tree, prefix.stdout.trim()), env);
|
|
368
|
+
if (!outcome.ran || outcome.unvalidated) return null;
|
|
369
|
+
const errors = outcome.concerns.map(validateErrorKey);
|
|
370
|
+
session.baselineValidateErrors = { base, errors };
|
|
371
|
+
return new Set(errors);
|
|
372
|
+
} finally {
|
|
373
|
+
run("git", ["worktree", "remove", "--force", tree], cwd);
|
|
374
|
+
rmSync(tree, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -32,7 +32,7 @@ export async function runInfracostDiff(
|
|
|
32
32
|
if (!key) {
|
|
33
33
|
return skipResult("infracost_key_unset", "INFRACOST_API_KEY not set — cost analysis is opt-in");
|
|
34
34
|
}
|
|
35
|
-
const cur = runners.runInfracostBreakdown(cwd, key);
|
|
35
|
+
const cur = await runners.runInfracostBreakdown(cwd, key);
|
|
36
36
|
if (cur.missing) return skipResult("infracost_not_installed", "infracost not installed");
|
|
37
37
|
if (cur.status !== 0) {
|
|
38
38
|
return skipResult(
|
|
@@ -46,7 +46,7 @@ export async function runInfracostDiff(
|
|
|
46
46
|
} catch {
|
|
47
47
|
return skipResult("infracost_parse_error", "could not parse infracost json output");
|
|
48
48
|
}
|
|
49
|
-
const baseline = runners.infracostBaseline(cwd, key, ctx.tmpdir);
|
|
49
|
+
const baseline = await runners.infracostBaseline(cwd, key, ctx.tmpdir);
|
|
50
50
|
const delta = computeCostDelta(baseline, current);
|
|
51
51
|
// per-resource breakdown (top drivers) for a collapsed <details> in the PR.
|
|
52
52
|
const topResources = parseInfracostResources(cur.stdout).slice(0, 10);
|