infraweaver 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31903 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31987 -18749
  8. package/dist/internal.js +132 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +34 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +6 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -0,0 +1,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, writeFileSync } from "node:fs";
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
- "refactor b.4 only: set true when emitting evidence for a third-party-module adoption shipped WITHOUT a plan, 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.",
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
- writeFileSync(target, bytes);
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) writeFileSync(sigPath, `${signBytes(bytes, signingKey)}\n`);
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
- writeFileSync(keylessBundleFile, `${JSON.stringify(sigstoreBundle, null, 2)}\n`);
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, writeFileSync } from "node:fs";
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
- writeFileSync(target, `${JSON.stringify(doc, null, 2)}\n`);
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, writeFileSync } from "node:fs";
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
- writeFileSync(target, bytes);
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) writeFileSync(sigPath, `${signBytes(bytes, signingKey)}\n`);
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
- writeFileSync(keylessBundleFile, `${JSON.stringify(sigstoreBundle, null, 2)}\n`);
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 type { EquivalenceVerdict } from "#app/mcp/terraform/types";
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 fmtOutcome = scanFmt(cwd);
91
- const validateOutcome = scanValidate(cwd, terraformEnv);
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
- [...afterFiles, ...fetched.files.filter((f) => !afterFiles.some((a) => a.path === f.path))],
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 validatePassed =
155
+ const validateFailed =
143
156
  validateRan &&
144
- validateOutcome.concerns.length === 0 &&
145
- (validateOutcome.unvalidated ?? 0) === 0;
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
- const reasons = [...result.reasons, ...substitution.reasons];
159
- if (!validatePassed)
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
- validateRan
162
- ? "`terraform validate` did not pass"
163
- : "`terraform validate` did not run (terraform not installed) — the equivalence proof is incomplete",
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 = result.equivalent && validatePassed && fmtClean && valuesPreserved;
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
- (validateRan && !validatePassed) ||
254
+ stale.length > 0 ||
255
+ (validateFailed && !onlyInherited) ||
186
256
  (fmtOutcome.ran && !fmtClean)
187
257
  ? "declined"
188
- : result.verdict === "unverifiable" || !validateRan || !fmtOutcome.ran
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
- : valuesPreserved
253
- ? "NOT equivalent — the push will be blocked. Fix the issues in `reasons` (an uncovered move is the dangerous one — add the missing `moved {}` block)."
254
- : "NOT equivalent — a relocated argument's VALUE changed. See `value_mismatches`: each root must receive its OWN value at its OWN call site. Copy the values from the before tree verbatim; do not retype them.",
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);