infraweaver 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31903 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31987 -18749
  8. package/dist/internal.js +132 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +34 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +6 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { readFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { errorMessage } from "@infraweaver-io/core/errors";
4
5
  import { resolveEnv } from "@infraweaver-io/core/secrets";
5
6
  import { globToRegex, isPathAllowed } from "@infraweaver-io/sandbox/glob";
6
7
  import { $ } from "@infraweaver-io/sandbox/shell";
@@ -12,19 +13,25 @@ import {
12
13
  findDialectMarkers,
13
14
  foreignDialectIntroductions,
14
15
  } from "#app/mcp/terraform/dialect";
16
+ import { isUnchangedEmittedArtefact } from "#app/mcp/terraform/emittedArtefacts";
15
17
  import { addressKey, stripIndices } from "#app/mcp/terraform/refactor/addresses";
16
18
  import { type MovedBlock, parseMovedBlocks } from "#app/mcp/terraform/refactor/movedBlocks";
17
19
  import { parseResourcesWithArgs } from "#app/mcp/terraform/refactor/resources";
18
20
  import { readWorkingTree, workingTreeFingerprint } from "#app/mcp/terraform/refactor/workspace";
19
21
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
20
22
  import type { SourceFile } from "#app/mcp/terraform/tree";
21
- import { describeAfter, guardVersionConstraints } from "#app/mcp/terraform/versionGuard";
23
+ import {
24
+ type ConstraintChange,
25
+ describeAfter,
26
+ guardVersionConstraints,
27
+ } from "#app/mcp/terraform/versionGuard";
22
28
  import type { ToolState } from "#app/toolState";
23
29
  import { log } from "#app/utils/cli";
24
30
  import type { RunContextData } from "#app/utils/cloud/runContextData";
25
31
  import type { ResolvedPayload } from "#app/utils/config/payload";
26
32
  import type { OctokitWithPlugins } from "#app/utils/github/github";
27
33
  import { resolveToolSelection } from "#app/utils/prompt/toolSelection";
34
+ import { parseDiffLines } from "#app/utils/unifiedDiff";
28
35
 
29
36
  /**
30
37
  * The narrow context the push-time guardrails read: the payload (allow-lists,
@@ -265,6 +272,29 @@ export function changedFilesSinceRunStart(ctx: GuardrailContext): string[] {
265
272
  return files;
266
273
  }
267
274
 
275
+ /**
276
+ * A compliance artefact an emit tool wrote this run, committed with the bytes it
277
+ * wrote — the one non-Terraform file a guarded push carries (see
278
+ * emittedArtefacts.ts). Asks git only when some artefact was emitted.
279
+ */
280
+ function isCommittedAsEmitted(ctx: GuardrailContext, file: string): boolean {
281
+ if (!scanSessionOf(ctx.toolState).emittedArtefacts) return false;
282
+ let top: string;
283
+ let blob: string | null;
284
+ try {
285
+ top = $("git", ["rev-parse", "--show-toplevel"], { log: false });
286
+ } catch {
287
+ return false;
288
+ }
289
+ try {
290
+ blob = $("git", ["rev-parse", `HEAD:${file}`], { log: false });
291
+ } catch {
292
+ // deleted at HEAD: nothing an emit tool wrote
293
+ blob = null;
294
+ }
295
+ return isUnchangedEmittedArtefact(ctx.toolState, top, file, blob);
296
+ }
297
+
268
298
  /**
269
299
  * Enforce the path allow-list before a Remediate-mode push. Throws if the
270
300
  * branch changed any file outside the allowed globs — the choke point is
@@ -285,7 +315,14 @@ export function enforceRemediationPaths(ctx: GuardrailContext): void {
285
315
 
286
316
  const allowed = resolveAllowedPaths(ctx);
287
317
  const changed = changedFilesSinceRunStart(ctx);
288
- const violations = changed.filter((f) => !isPathAllowed(f, allowed));
318
+ const outside = changed.filter((f) => !isPathAllowed(f, allowed));
319
+ const emitted = outside.filter((f) => isCommittedAsEmitted(ctx, f));
320
+ const violations = outside.filter((f) => !emitted.includes(f));
321
+ if (emitted.length > 0) {
322
+ log.info(
323
+ `» Terraform-only path guardrail: ${emitted.join(", ")} committed as emitted this run`,
324
+ );
325
+ }
289
326
  if (violations.length > 0) {
290
327
  throw new Error(
291
328
  `push blocked (Terraform-only guardrail): this run changed files outside the allowed paths [${allowed.join(", ")}]. ` +
@@ -294,7 +331,8 @@ export function enforceRemediationPaths(ctx: GuardrailContext): void {
294
331
  );
295
332
  }
296
333
  log.info(
297
- `» Terraform-only path guardrail ok (${changed.length} file(s), all within [${allowed.join(", ")}])`,
334
+ `» Terraform-only path guardrail ok (${changed.length - emitted.length} file(s) within [${allowed.join(", ")}]` +
335
+ (emitted.length > 0 ? `, ${emitted.length} emitted artefact(s))` : ")"),
298
336
  );
299
337
  }
300
338
 
@@ -425,39 +463,23 @@ const SENSITIVE_ASSIGNMENT =
425
463
  export function scanDiffForSecrets(diff: string): SecretHit[] {
426
464
  const hits: SecretHit[] = [];
427
465
  let file = "(unknown)";
428
- let newLine = 0;
429
- for (const raw of diff.split("\n")) {
430
- if (raw.startsWith("+++ ")) {
431
- const path = raw.slice(4).trim().replace(/^b\//, "");
432
- file = path === "/dev/null" ? "(deleted)" : path;
466
+ for (const line of parseDiffLines(diff)) {
467
+ if (line.kind === "file") {
468
+ file = line.path === "/dev/null" ? "(deleted)" : line.path;
433
469
  continue;
434
470
  }
435
- if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("index "))
436
- continue;
437
- const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
438
- if (hunk) {
439
- newLine = Number(hunk[1]);
440
- continue;
471
+ if (line.kind !== "add") continue;
472
+ const content = line.text;
473
+ for (const [rule, re] of SECRET_VALUE_PATTERNS) {
474
+ if (re.test(content)) hits.push({ file, line: line.newLine, rule });
441
475
  }
442
- if (raw.startsWith("+")) {
443
- const content = raw.slice(1);
444
- for (const [rule, re] of SECRET_VALUE_PATTERNS) {
445
- if (re.test(content)) hits.push({ file, line: newLine, rule });
446
- }
447
- // only a real inlined literal trips it: skip empty strings and any value
448
- // carrying a `${...}` interpolation — including one with literal text before
449
- // the interpolation (e.g. `connection_string = "postgres://u:${var.pw}@h"`),
450
- // which the prior first-char-only exclusion wrongly flagged and dead-ended.
451
- const secretValue = content.match(SENSITIVE_ASSIGNMENT)?.[1];
452
- if (secretValue && !secretValue.includes("${")) {
453
- hits.push({ file, line: newLine, rule: "hardcoded-secret-assignment" });
454
- }
455
- newLine++;
456
- } else if (raw.startsWith("-")) {
457
- // removed line — does not advance the new-side counter
458
- } else {
459
- // context line (leading space) or blank — advances the new-side counter
460
- newLine++;
476
+ // only a real inlined literal trips it: skip empty strings and any value
477
+ // carrying a `${...}` interpolation — including one with literal text before
478
+ // the interpolation (e.g. `connection_string = "postgres://u:${var.pw}@h"`),
479
+ // which the prior first-char-only exclusion wrongly flagged and dead-ended.
480
+ const secretValue = content.match(SENSITIVE_ASSIGNMENT)?.[1];
481
+ if (secretValue && !secretValue.includes("${")) {
482
+ hits.push({ file, line: line.newLine, rule: "hardcoded-secret-assignment" });
461
483
  }
462
484
  }
463
485
  return hits;
@@ -796,7 +818,11 @@ export function assertNoUnreviewedMajorBump(ctx: GuardrailContext): void {
796
818
  }
797
819
 
798
820
  const allow = ctx.payload.allowMajorUpgrade ?? [];
799
- const blocked = verdict.crossing.filter((c) => !isReplaceAllowed(c.name, allow));
821
+ // an operator names a dependency by what it IS — `terraform-aws-modules/s3-bucket/aws`,
822
+ // `hashicorp/aws` — as readily as by the call or local name this repo gives it
823
+ const allowed = (c: ConstraintChange) =>
824
+ isReplaceAllowed(c.name, allow) || (c.source !== null && isReplaceAllowed(c.source, allow));
825
+ const blocked = verdict.crossing.filter((c) => !allowed(c));
800
826
  if (blocked.length === 0) {
801
827
  if (verdict.crossing.length > 0) {
802
828
  log.info(
@@ -810,8 +836,13 @@ export function assertNoUnreviewedMajorBump(ctx: GuardrailContext): void {
810
836
  `ACROSS A MAJOR. A provider/module major renames arguments and removes blocks, so the upgrade needs a ` +
811
837
  `human to review the interface change — it is real work, but it is not autonomous work. Either revert ` +
812
838
  `the constraint to its original major, or, if the upgrade is genuinely intended, set the ` +
813
- `\`allow_major_upgrade\` input to include the name(s):\n` +
814
- blocked.map((c) => ` - ${c.kind} ${c.name}: '${c.before}' → '${c.after}'`).join("\n"),
839
+ `\`allow_major_upgrade\` input to include the name or source:\n` +
840
+ blocked
841
+ .map(
842
+ (c) =>
843
+ ` - ${c.kind} ${c.name}${c.source ? ` (${c.source})` : ""}: '${c.before}' → '${c.after}'`,
844
+ )
845
+ .join("\n"),
815
846
  );
816
847
  }
817
848
 
@@ -1331,6 +1362,33 @@ export function assertUnderPrCap(ctx: GuardrailContext): void {
1331
1362
  }
1332
1363
  }
1333
1364
 
1365
+ /**
1366
+ * Refuse a guarded-mode PR whose head is not a commit this run pushed.
1367
+ *
1368
+ * Every content guardrail — the path allow-list, the secret scan, the rename
1369
+ * and equivalence gates — runs in `push_branch`, so a PR is only as safe as the
1370
+ * push behind it. A head this run never pushed went through none of them: a
1371
+ * branch another run (or a person) left on the remote, adopted after this
1372
+ * run's own push was rejected, opens a PR whose content nothing here checked,
1373
+ * under a body describing work it does not contain.
1374
+ */
1375
+ export function assertHeadPushedThisRun(
1376
+ ctx: GuardrailContext,
1377
+ branch: string,
1378
+ headSha: string,
1379
+ ): void {
1380
+ if (!isGuardedMode(ctx)) return;
1381
+ const pushed = ctx.toolState.pushedHeads?.get(branch);
1382
+ if (pushed === headSha) return;
1383
+ throw new Error(
1384
+ `PR blocked (pushed-head guardrail): '${branch}' at ${headSha.slice(0, 12)} is not a commit this run pushed ` +
1385
+ (pushed
1386
+ ? `(it pushed ${pushed.slice(0, 12)} there) — commit and push_branch what you want proposed first.`
1387
+ : "— a PR may only present a commit that went through push_branch, where every guardrail runs. " +
1388
+ "If that branch belongs to another change, do not reuse it: put this run's change on a NEW branch name and push that."),
1389
+ );
1390
+ }
1391
+
1334
1392
  /** record that a guarded-mode PR was opened (after create_pull_request succeeds). */
1335
1393
  export function recordRemediationPrOpened(ctx: GuardrailContext): void {
1336
1394
  if (!isGuardedMode(ctx)) return;
@@ -1412,3 +1470,97 @@ export async function assertUnderOpenPrCaps(ctx: OpenPrCapContext): Promise<void
1412
1470
  }
1413
1471
  }
1414
1472
  }
1473
+
1474
+ /** the head branches this engine opens its own PRs from. */
1475
+ const ENGINE_PR_BRANCH = /^(?:remediate\/|remediate-refactor\/|infraweaver\/)/;
1476
+
1477
+ /** the modes that cut a fresh branch, and so a fresh PR, for each change.
1478
+ * Remediate is not one: its branch is named by the concern group, so a re-run
1479
+ * for the same group updates the same PR. */
1480
+ const BRANCH_PER_CHANGE_MODES: ReadonlySet<string> = new Set([
1481
+ REFACTOR_MODE,
1482
+ REMEDIATE_AND_REFACTOR_MODE,
1483
+ UPDATE_DEPENDENCIES_MODE,
1484
+ MODERNIZE_DEPRECATED_MODE,
1485
+ COST_OPTIMIZATION_MODE,
1486
+ ]);
1487
+
1488
+ /** whether a mode names a fresh branch for every change, so that a remote
1489
+ * branch of the same name which this run did not push is another change. */
1490
+ export function isBranchPerChangeMode(mode: string | undefined): boolean {
1491
+ return mode !== undefined && BRANCH_PER_CHANGE_MODES.has(mode);
1492
+ }
1493
+
1494
+ /** how many open PRs a new one is compared with — bounds the API calls. */
1495
+ const MAX_OPEN_PRS_COMPARED = 30;
1496
+
1497
+ /**
1498
+ * Refuse to open a PR for a change to files an open PR of this engine already
1499
+ * changes against the same base.
1500
+ *
1501
+ * Checked when the PR is opened, because that is where a duplicate comes into
1502
+ * being and where the GitHub API is reachable — the push tools deliberately
1503
+ * shell out without it. The pushed branch is left for the agent to delete.
1504
+ *
1505
+ * Every run of a branch-per-change mode names its own branch, so nothing else
1506
+ * notices that a scheduled run is re-proposing what last week's run proposed
1507
+ * and nobody has reviewed yet: each run opened another PR over the same code,
1508
+ * duplicating the review and conflicting with the first on merge. Overlapping
1509
+ * FILES is the test rather than an identical diff: two open PRs editing one
1510
+ * file from one base conflict whatever they say, and a run that finds its
1511
+ * files taken can still pick work elsewhere.
1512
+ *
1513
+ * Fails open on an API error — this guards review noise, not infrastructure,
1514
+ * and a GitHub hiccup must not throw away a proven change.
1515
+ */
1516
+ export async function assertNotAlreadyProposed(
1517
+ ctx: OpenPrCapContext,
1518
+ opts: { branch: string; base: string; changed: readonly string[] },
1519
+ ): Promise<void> {
1520
+ if (!isBranchPerChangeMode(ctx.toolState.selectedMode)) return;
1521
+ const changed = new Set(opts.changed);
1522
+ if (changed.size === 0) return;
1523
+ const owner = ctx.repo.owner;
1524
+ const repo = ctx.repo.name;
1525
+ try {
1526
+ const open = await ctx.octokit.paginate(ctx.octokit.rest.pulls.list, {
1527
+ owner,
1528
+ repo,
1529
+ state: "open",
1530
+ base: opts.base,
1531
+ per_page: 100,
1532
+ });
1533
+ const engine = open.filter(
1534
+ (pr) => ENGINE_PR_BRANCH.test(pr.head.ref) && pr.head.ref !== opts.branch,
1535
+ );
1536
+ if (engine.length > MAX_OPEN_PRS_COMPARED) {
1537
+ log.warning(
1538
+ `open PR guardrail: ${engine.length} open ${PRODUCT_NAME} PRs against \`${opts.base}\`; ` +
1539
+ `only the newest ${MAX_OPEN_PRS_COMPARED} are compared for overlapping files`,
1540
+ );
1541
+ }
1542
+ const ours = engine.slice(0, MAX_OPEN_PRS_COMPARED);
1543
+ for (const pr of ours) {
1544
+ const files = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
1545
+ owner,
1546
+ repo,
1547
+ pull_number: pr.number,
1548
+ per_page: 100,
1549
+ });
1550
+ const shared = files.map((f) => f.filename).filter((f) => changed.has(f));
1551
+ if (shared.length === 0) continue;
1552
+ throw new OpenPrOverlapError(
1553
+ `PR blocked (open PR guardrail): #${pr.number} (\`${pr.head.ref}\`) is already open against ` +
1554
+ `\`${opts.base}\` and changes ${shared.join(", ")}. A second PR over the same code duplicates ` +
1555
+ `that review and conflicts with it on merge. Do not retry under another branch name: delete the ` +
1556
+ `branch you pushed (\`delete_branch\`), stop, and report that #${pr.number} is waiting for ` +
1557
+ "review — a run after it is merged or closed picks the work up again.",
1558
+ );
1559
+ }
1560
+ } catch (err) {
1561
+ if (err instanceof OpenPrOverlapError) throw err;
1562
+ log.warning(`» open PR guardrail skipped — could not list open PRs: ${errorMessage(err)}`);
1563
+ }
1564
+ }
1565
+
1566
+ class OpenPrOverlapError extends Error {}
@@ -29,6 +29,8 @@ export interface LocalToolContext {
29
29
  // (the native `repeated-literal` rule). Honoured on the local stdio MCP
30
30
  // server too, so a CLI session scans on the same threshold as CI.
31
31
  | "repeatedLiteralThreshold"
32
+ // `-var-file` files the repo assigns to a root, loaded by trivy and checkov
33
+ | "varFiles"
32
34
  | "costIncreaseBlockUsd"
33
35
  | "moduleCatalogue"
34
36
  // resource addresses the operator allows to be destroyed/replaced. Read by
@@ -104,6 +104,7 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
104
104
  autonomyThreshold: undefined,
105
105
  // the local server has no action inputs; the rule uses its own default.
106
106
  repeatedLiteralThreshold: undefined,
107
+ varFiles: undefined,
107
108
  costIncreaseBlockUsd: undefined,
108
109
  // a local `infraweaver mcp` run has no PR/push surface, so the
109
110
  // allow_replace gate never engages — terraform_plan reports
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { readFileSync } from "node:fs";
16
- import { join } from "node:path";
16
+ import { join, posix } from "node:path";
17
17
  import { type } from "arktype";
18
18
  import type { LocalToolContext } from "#app/mcp/localContext";
19
19
  import {
@@ -28,6 +28,8 @@ import {
28
28
  } from "#app/mcp/modules";
29
29
  import { execute, tool, toolOk } from "#app/mcp/shared";
30
30
  import { PUBLIC_REGISTRY_HOST, splitModuleAddress } from "#app/mcp/terraform/currency";
31
+ import { RESOURCE_HEADER, unquoteLabel } from "#app/mcp/terraform/hcl";
32
+ import { localSourceFrom } from "#app/mcp/terraform/localModules";
31
33
  import {
32
34
  findPartialInterfaces,
33
35
  MAX_PARTIAL_INTERFACE_FINDINGS,
@@ -55,14 +57,14 @@ export interface ParsedResource {
55
57
  * enough here — clustering and signature matching only need type + name. */
56
58
  export function parseResourceBlocks(hcl: string): ParsedResource[] {
57
59
  const out: ParsedResource[] = [];
58
- const re = /(?:^|\n)\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
60
+ const re = new RegExp(RESOURCE_HEADER, "g");
59
61
  let m: RegExpExecArray | null;
60
62
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
61
63
  while ((m = re.exec(hcl)) !== null) {
62
64
  const resourceType = m[1];
63
65
  const name = m[2];
64
66
  if (resourceType !== undefined && name !== undefined) {
65
- out.push({ type: resourceType, name });
67
+ out.push({ type: unquoteLabel(resourceType), name: unquoteLabel(name) });
66
68
  }
67
69
  }
68
70
  return out;
@@ -172,7 +174,12 @@ export function clusterResources(file: string, resources: ParsedResource[]): Ext
172
174
 
173
175
  export interface CandidateModule {
174
176
  name: string;
177
+ /** the module's identity: repo-relative (`./modules/s3`) for a house module. */
175
178
  source: string;
179
+ /** a house module only: the `source` the cluster's root writes to call it —
180
+ * relative to that root, so `../../modules/s3` from `env/dev`. `source` is
181
+ * not a valid module source anywhere but the repo root. */
182
+ call_source?: string;
176
183
  version: string | null;
177
184
  kind: ModuleSourceKind;
178
185
  /** how the match was made: a house module's actual resource types, or a
@@ -501,7 +508,7 @@ function selectionReason(
501
508
  return (
502
509
  `${basis}. INDISTINGUISHABLE from ${runnerUp.source} on every signal available — same ` +
503
510
  `overlap, same kind of match — so the pick is source order, which is stable but not ` +
504
- `meaningful. Say so in the PR and let the reviewer choose.`
511
+ `meaningful: which of the two to adopt is the reviewer's call.`
505
512
  );
506
513
  }
507
514
 
@@ -631,7 +638,12 @@ export function matchCluster(
631
638
  { name, source: `./${house.dir}`, version: null, kind: "local", publicRegistry: false },
632
639
  clusterArgs,
633
640
  );
634
- if (candidate && exactEnough(house, candidate)) out.push(candidate);
641
+ if (candidate && exactEnough(house, candidate)) {
642
+ out.push({
643
+ ...candidate,
644
+ call_source: localSourceFrom(posix.dirname(cluster.file), house.dir),
645
+ });
646
+ }
635
647
  }
636
648
  for (const entry of catalogue) {
637
649
  // local catalogue entries are house modules — already covered by signature.
@@ -5,6 +5,7 @@ import { type } from "arktype";
5
5
  import type { LocalToolContext } from "#app/mcp/localContext";
6
6
  import { collectModuleInterface } from "#app/mcp/modules";
7
7
  import { execute, tool, toolOk } from "#app/mcp/shared";
8
+ import { BLOCK_LABEL } from "#app/mcp/terraform/hcl";
8
9
  import { log } from "#app/utils/cli";
9
10
 
10
11
  /**
@@ -139,7 +140,10 @@ export function parseExampleModuleVariables(
139
140
  sourceMatches: (source: string) => boolean,
140
141
  ): string[] {
141
142
  const set = new Set<string>();
142
- for (const body of eachBlockBody(hcl, /module\s+"[^"]+"\s*\{/g)) {
143
+ for (const body of eachBlockBody(
144
+ hcl,
145
+ new RegExp(String.raw`module\s+${BLOCK_LABEL}\s*\{`, "g"),
146
+ )) {
143
147
  const source = moduleBlockSource(body);
144
148
  if (!source || !sourceMatches(source)) continue;
145
149
  for (const name of topLevelAttributeNames(body)) {
@@ -298,7 +302,10 @@ export function discoverModuleTestAssets(cwd: string, moduleDir: string): Module
298
302
  const vars = parseExampleModuleVariables(hcl, (s) => matchesModule(s, exDir));
299
303
  // only count it as THIS module's example when a module block actually
300
304
  // targets it (an `examples/` tree can hold fixtures for sibling modules).
301
- const targets = eachBlockBody(hcl, /module\s+"[^"]+"\s*\{/g).some((b) => {
305
+ const targets = eachBlockBody(
306
+ hcl,
307
+ new RegExp(String.raw`module\s+${BLOCK_LABEL}\s*\{`, "g"),
308
+ ).some((b) => {
302
309
  const src = moduleBlockSource(b);
303
310
  return src !== null && matchesModule(src, exDir);
304
311
  });
@@ -4,6 +4,7 @@ import { resolveWithinCwd } from "@infraweaver-io/sandbox/pathSafety";
4
4
  import { type } from "arktype";
5
5
  import type { LocalToolContext } from "#app/mcp/localContext";
6
6
  import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import { MODULE_HEADER, unquoteLabel } from "#app/mcp/terraform/hcl";
7
8
  import { log } from "#app/utils/cli";
8
9
 
9
10
  /**
@@ -466,11 +467,11 @@ export function isExactVersion(version: string | null | undefined): boolean {
466
467
  */
467
468
  export function parseModuleBlocks(hcl: string): ModuleBlock[] {
468
469
  const out: ModuleBlock[] = [];
469
- const re = /module\s+"([^"]+)"\s*\{/g;
470
+ const re = new RegExp(MODULE_HEADER, "g");
470
471
  let m: RegExpExecArray | null;
471
472
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
472
473
  while ((m = re.exec(hcl)) !== null) {
473
- const name = m[1]!;
474
+ const name = unquoteLabel(m[1]!);
474
475
  const braceStart = re.lastIndex - 1;
475
476
  let depth = 0;
476
477
  let end = -1;
@@ -564,9 +565,9 @@ export interface ModuleGraph {
564
565
  * whole tree; in the second it proposes extracting a module out of a module.
565
566
  */
566
567
  export function isModuleDir(hcl: string): boolean {
567
- if (/(?:^|\n)\s*provider\s+"/.test(hcl)) return false;
568
- if (!/(?:^|\n)\s*variable\s+"/.test(hcl)) return false;
569
- return /(?:^|\n)\s*resource\s+"/.test(hcl);
568
+ if (/(?:^|\n)\s*provider\s+(?:"|[A-Za-z_])/.test(hcl)) return false;
569
+ if (!/(?:^|\n)\s*variable\s+(?:"|[A-Za-z_])/.test(hcl)) return false;
570
+ return /(?:^|\n)\s*resource\s+(?:"|[A-Za-z_])/.test(hcl);
570
571
  }
571
572
 
572
573
  // per-file byte cap for HCL reads. real .tf files are a few KB; anything this
@@ -616,9 +617,11 @@ const SKIP_DIRS = new Set([
616
617
  ".git",
617
618
  ".svn",
618
619
  ".hg",
619
- // terraform's own working state and vendored copies
620
+ // terraform's own working state and vendored copies, and checkov's default
621
+ // download directory for the modules a scan fetches
620
622
  ".terraform",
621
623
  ".terragrunt-cache",
624
+ ".external_modules",
622
625
  // language dependency trees / virtualenvs
623
626
  ".venv",
624
627
  "venv",
@@ -1002,7 +1005,7 @@ function blockBody(
1002
1005
  let m: RegExpExecArray | null;
1003
1006
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
1004
1007
  while ((m = re.exec(hcl)) !== null) {
1005
- const name = m[1]!;
1008
+ const name = unquoteLabel(m[1]!);
1006
1009
  const braceStart = re.lastIndex - 1;
1007
1010
  let depth = 0;
1008
1011
  let end = -1;
package/src/mcp/pr.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { $ } from "@infraweaver-io/sandbox/shell";
2
2
  import { type } from "arktype";
3
+ import { resolveBaseBranch } from "#app/mcp/baseBranch";
3
4
  import type { GitHubWriteToolContext } from "#app/mcp/capabilityContext";
5
+ import { assertWritableMode } from "#app/mcp/git";
4
6
  import {
7
+ assertHeadPushedThisRun,
8
+ assertNotAlreadyProposed,
5
9
  assertUnderOpenPrCaps,
6
10
  assertUnderPrCap,
7
11
  changedFilesSinceRunStart,
@@ -10,14 +14,11 @@ import {
10
14
  recordRemediationPrOpened,
11
15
  runStartSha,
12
16
  } from "#app/mcp/guardrails";
13
- import { assertWritableMode } from "#app/mcp/git";
14
17
  import { parseModuleBlocks } from "#app/mcp/modules";
15
18
  import { assertTargetInScope, recordCreatedTarget } from "#app/mcp/scope";
16
19
  import { execute, tool } from "#app/mcp/shared";
17
20
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
18
21
  import { log } from "#app/utils/cli";
19
- import type { RunContextData } from "#app/utils/cloud/runContextData";
20
- import type { ResolvedPayload } from "#app/utils/config/payload";
21
22
  import { fixDoubleEscapedString } from "#app/utils/fixDoubleEscapedString";
22
23
  import {
23
24
  buildInfraweaverFooter,
@@ -26,78 +27,17 @@ import {
26
27
  import { patchWorkflowRunFields } from "#app/utils/github/patchWorkflowRunFields";
27
28
  import { redactSecrets } from "#app/utils/redact";
28
29
 
29
- /**
30
- * The slice `resolveBaseBranch` reads: the declared base-branch input and the
31
- * repository's default branch. Shared with `terraform_change_summary`, which
32
- * resolves the same base without needing the rest of the GitHub surface.
33
- */
34
- export interface BaseBranchContext {
35
- payload: ResolvedPayload;
36
- repo: RunContextData["repo"];
37
- }
38
-
39
30
  export const PullRequest = type({
40
31
  title: type.string.describe("the title of the pull request"),
41
32
  body: type.string.describe("the body content of the pull request"),
42
33
  "base?": type.string.describe(
43
- "the base branch to merge into (e.g. 'main'). Omit to use the run's resolved base branch: the `base_branch` input, else the repository's default branch (main, or master).",
34
+ "the base branch to merge into (e.g. 'main'). Omit to use the run's resolved base branch: the `base_branch` input, else the branch the run started on, else the repository's default branch (main, or master).",
44
35
  ),
45
36
  "draft?": type.boolean.describe(
46
37
  "if true, create the pull request as a draft. use when the user explicitly asks for a draft PR.",
47
38
  ),
48
39
  });
49
40
 
50
- /**
51
- * Pure base-branch picker. Precedence: an explicit `base_branch` declaration
52
- * always wins; otherwise the repository's default branch (GitHub reports it —
53
- * normally `main`, sometimes `master`); otherwise prefer `main`, then `master`;
54
- * final fallback `main`. Split out from git/ctx so it's exhaustively testable.
55
- */
56
- export function pickBaseBranch(opts: {
57
- declared?: string | undefined;
58
- defaultBranch?: string | undefined;
59
- mainExists: boolean;
60
- masterExists: boolean;
61
- }): string {
62
- if (opts.declared) return opts.declared;
63
- if (opts.defaultBranch) return opts.defaultBranch;
64
- if (opts.mainExists) return "main";
65
- if (opts.masterExists) return "master";
66
- return "main";
67
- }
68
-
69
- /** true when `branch` exists as a remote-tracking or local ref. */
70
- function branchExists(branch: string): boolean {
71
- for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
72
- try {
73
- $("git", ["rev-parse", "--verify", "--quiet", ref], { log: false });
74
- return true;
75
- } catch {
76
- // ref absent — try the next form
77
- }
78
- }
79
- return false;
80
- }
81
-
82
- /**
83
- * Deterministically resolve the branch a PR targets, so the base is never left
84
- * to the agent's guess. Resolves to the repository's default branch (`main`, or
85
- * `master`), overridable by the `base_branch` input. Git is only probed for
86
- * main/master in the last-resort case where neither an explicit declaration nor
87
- * a GitHub default branch is available (essentially never).
88
- */
89
- export function resolveBaseBranch(ctx: BaseBranchContext): string {
90
- const declared = ctx.payload.baseBranch?.trim() || undefined;
91
- const defaultBranch = ctx.repo.data.default_branch?.trim() || undefined;
92
- const needsProbe = !declared && !defaultBranch;
93
- return pickBaseBranch({
94
- declared,
95
- defaultBranch,
96
- mainExists: needsProbe && branchExists("main"),
97
- masterExists: needsProbe && branchExists("master"),
98
- });
99
- }
100
-
101
41
  /**
102
42
  * Append the extraction detector's `selection_reason` for each adopted module
103
43
  * the body does not already state — pure half, so the rule is testable without
@@ -235,8 +175,20 @@ export function CreatePullRequestTool(ctx: GitHubWriteToolContext) {
235
175
  const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
236
176
  log: false,
237
177
  });
178
+ // the PR presents what this run pushed, or nothing
179
+ assertHeadPushedThisRun(
180
+ ctx,
181
+ currentBranch,
182
+ $("git", ["rev-parse", "HEAD"], { log: false }).trim(),
183
+ );
238
184
  const base = params.base ?? resolveBaseBranch(ctx);
239
185
  log.debug(`Current branch: ${currentBranch}; PR base: ${base}`);
186
+ // one change, one PR: not another over files an open one already changes
187
+ await assertNotAlreadyProposed(ctx, {
188
+ branch: currentBranch,
189
+ base,
190
+ changed: changedFilesSinceRunStart(ctx),
191
+ });
240
192
 
241
193
  const bodyWithFooter = buildPrBodyWithFooter(ctx, params.body);
242
194