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
@@ -13,6 +13,7 @@ import { getTempDir } from "#app/utils/env";
13
13
  import { resolveBodyAssets } from "#app/utils/github/body";
14
14
  import { stripExistingFooter } from "#app/utils/github/buildInfraweaverFooter";
15
15
  import { log } from "#app/utils/log";
16
+ import { type HunkHeader, parseDiffLines } from "#app/utils/unifiedDiff";
16
17
 
17
18
  /**
18
19
  * A GraphQL review-thread fetch worth retrying: an HTTP 5xx / network blip
@@ -24,7 +25,7 @@ import { log } from "#app/utils/log";
24
25
  */
25
26
  export function isTransientGraphqlError(error: unknown): boolean {
26
27
  if (isTransientServerError(error)) return true;
27
- const msg = error instanceof Error ? error.message : String(error);
28
+ const msg = errorMessage(error);
28
29
  return /something went wrong while executing your query/i.test(msg);
29
30
  }
30
31
 
@@ -100,7 +101,7 @@ query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
100
101
  line
101
102
  startLine
102
103
  diffSide
103
- comments(first: 1) { nodes { databaseId body author { login __typename } } }
104
+ comments(first: 1) { nodes { databaseId body originalCommit { oid } author { login __typename } } }
104
105
  }
105
106
  }
106
107
  }
@@ -127,6 +128,7 @@ export type OutstandingThreadsResponse = {
127
128
  | ({
128
129
  databaseId?: number | null;
129
130
  body?: string | null;
131
+ originalCommit?: { oid: string } | null;
130
132
  author: { login: string; __typename?: string } | null;
131
133
  } | null)[]
132
134
  | null;
@@ -148,6 +150,10 @@ export type OpenInfraweaverThread = {
148
150
  rootCommentId?: number;
149
151
  /** the marker the root comment carries, when the engine wrote one */
150
152
  marker?: CommentMarker;
153
+ /** the root comment's body */
154
+ rootBody?: string;
155
+ /** the commit the root comment was made on */
156
+ rootCommit?: string;
151
157
  };
152
158
 
153
159
  /**
@@ -202,6 +208,8 @@ export async function listOpenInfraweaverThreads(input: {
202
208
  if (typeof root.databaseId === "number") entry.rootCommentId = root.databaseId;
203
209
  const marker = parseCommentMarker(root.body);
204
210
  if (marker) entry.marker = marker;
211
+ if (root.body) entry.rootBody = root.body;
212
+ if (root.originalCommit?.oid) entry.rootCommit = root.originalCommit.oid;
205
213
  // a thread with no current `line` has no comparable span — outdated, or a
206
214
  // file-level comment. counted, never matched against.
207
215
  if (!entry.isOutdated && thread.path && thread.line != null) {
@@ -289,62 +297,26 @@ function extractCommentedLines(
289
297
  endLine: number | null,
290
298
  side: "LEFT" | "RIGHT",
291
299
  ): string {
292
- const lines = diffHunk.split("\n");
293
- if (lines.length <= 1) return diffHunk;
294
-
295
- const header = lines[0] ?? "";
296
- const contentLines = lines.slice(1);
297
-
298
- // parse header: @@ -old_start,old_count +new_start,new_count @@
299
- const headerMatch = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
300
- if (!headerMatch) return diffHunk;
301
-
302
- // destructure with defaults instead of `!` — the two `(\d+)` groups are required,
303
- // so the defaults never apply; this carries the non-null in the binding, not an assertion.
304
- const [, oldStr = "", newStr = ""] = headerMatch;
305
- const hunkOldStart = parseInt(oldStr, 10);
306
- const hunkNewStart = parseInt(newStr, 10);
300
+ const [first, ...rest] = parseDiffLines(diffHunk);
301
+ if (rest.length === 0 || first?.kind !== "hunk") return diffHunk;
302
+ const header = first.raw;
307
303
 
308
304
  // LEFT = old file (deletions), RIGHT = new file (additions)
309
- const hunkStart = side === "LEFT" ? hunkOldStart : hunkNewStart;
305
+ const hunkStart = side === "LEFT" ? first.header.oldStart : first.header.newStart;
310
306
  const commentStart = startLine ?? endLine ?? hunkStart;
311
307
  const commentEnd = endLine ?? commentStart;
312
308
 
313
- // walk through diff lines, tracking line numbers for both old and new files
309
+ // number each diff line on the commented side only
314
310
  // - lines: old file only (LEFT)
315
311
  // + lines: new file only (RIGHT)
316
312
  // context lines: both files
317
- type DiffLine = { text: string; lineNum: number | null };
318
- const diffLines: DiffLine[] = [];
319
- let oldLineNum = hunkOldStart;
320
- let newLineNum = hunkNewStart;
321
-
322
- for (const line of contentLines) {
323
- const prefix = line[0];
324
- if (prefix === "-") {
325
- // deletion - only has old line number
326
- diffLines.push({
327
- text: line,
328
- lineNum: side === "LEFT" ? oldLineNum : null,
329
- });
330
- oldLineNum++;
331
- } else if (prefix === "+") {
332
- // addition - only has new line number
333
- diffLines.push({
334
- text: line,
335
- lineNum: side === "RIGHT" ? newLineNum : null,
336
- });
337
- newLineNum++;
338
- } else {
339
- // context - has both line numbers
340
- diffLines.push({
341
- text: line,
342
- lineNum: side === "LEFT" ? oldLineNum : newLineNum,
343
- });
344
- oldLineNum++;
345
- newLineNum++;
346
- }
347
- }
313
+ const diffLines = rest.map((line) => {
314
+ let lineNum: number | null = null;
315
+ if (line.kind === "remove") lineNum = side === "LEFT" ? line.oldLine : null;
316
+ else if (line.kind === "add") lineNum = side === "RIGHT" ? line.newLine : null;
317
+ else if (line.kind === "context") lineNum = side === "LEFT" ? line.oldLine : line.newLine;
318
+ return { text: line.raw, lineNum };
319
+ });
348
320
 
349
321
  // find lines for comment range with context
350
322
  const targetStart = commentStart - CONTEXT_PADDING;
@@ -373,44 +345,26 @@ function extractCommentedLines(
373
345
  }
374
346
 
375
347
  // parsed hunk from a unified diff
376
- export type ParsedHunk = {
348
+ export type ParsedHunk = HunkHeader & {
377
349
  header: string;
378
- oldStart: number;
379
- oldCount: number;
380
- newStart: number;
381
- newCount: number;
382
350
  content: string[];
383
351
  };
384
352
 
385
353
  // parse a full file patch into individual hunks
386
354
  export function parseFilePatches(patch: string): ParsedHunk[] {
387
355
  const hunks: ParsedHunk[] = [];
388
- const lines = patch.split("\n");
389
-
390
356
  let currentHunk: ParsedHunk | null = null;
391
357
 
392
- for (const line of lines) {
393
- // anchor to line start (mirrors commentableLinesForFile's `^@@`): an added,
394
- // removed, or context line whose TEXT contains a hunk-header-shaped substring
395
- // (e.g. `+ log.info("@@ -1,2 +3,4 @@")`) must not be mistaken for a real hunk
396
- // boundary — that would mis-key downstream overlap math and show wrong context.
397
- const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
398
- if (hunkMatch) {
358
+ // the hunk-header match is anchored to line start: an added, removed, or context
359
+ // line whose TEXT contains a hunk-header-shaped substring must not be mistaken for
360
+ // a real hunk boundary — that would mis-key downstream overlap math and show wrong
361
+ // context.
362
+ for (const line of parseDiffLines(patch)) {
363
+ if (line.kind === "hunk") {
399
364
  if (currentHunk) hunks.push(currentHunk);
400
- // destructure with defaults — `oldS`/`newS` are required `(\d+)` groups (the
401
- // "" default never applies); `oldC`/`newC` are the optional counts, defaulting to
402
- // "1" exactly as the prior `?? "1"` did.
403
- const [, oldS = "", oldC = "1", newS = "", newC = "1"] = hunkMatch;
404
- currentHunk = {
405
- header: line,
406
- oldStart: parseInt(oldS, 10),
407
- oldCount: parseInt(oldC, 10),
408
- newStart: parseInt(newS, 10),
409
- newCount: parseInt(newC, 10),
410
- content: [],
411
- };
365
+ currentHunk = { ...line.header, header: line.raw, content: [] };
412
366
  } else if (currentHunk) {
413
- currentHunk.content.push(line);
367
+ currentHunk.content.push(line.raw);
414
368
  }
415
369
  }
416
370
  if (currentHunk) hunks.push(currentHunk);
@@ -1092,13 +1046,12 @@ export function ResolveReviewThreadTool(ctx: GitHubToolContext) {
1092
1046
  };
1093
1047
  } catch (error) {
1094
1048
  // handle common error cases gracefully
1095
- const errorMessage = error instanceof Error ? error.message : String(error);
1096
- const isResolved =
1097
- errorMessage.includes("already resolved") || errorMessage.includes("isResolved");
1049
+ const msg = errorMessage(error);
1050
+ const isResolved = msg.includes("already resolved") || msg.includes("isResolved");
1098
1051
 
1099
1052
  const message = isResolved
1100
1053
  ? `thread ${params.thread_id} was already resolved`
1101
- : `failed to resolve thread ${params.thread_id}: ${errorMessage}`;
1054
+ : `failed to resolve thread ${params.thread_id}: ${msg}`;
1102
1055
  log.info(message);
1103
1056
 
1104
1057
  return {
@@ -75,7 +75,7 @@ export type DuplicateComment = {
75
75
  /** how much of a withheld comment the note quotes back */
76
76
  const GIST_MAX_CHARS = 120;
77
77
 
78
- function gistOf(body: unknown): string | undefined {
78
+ export function gistOf(body: unknown): string | undefined {
79
79
  if (typeof body !== "string") return undefined;
80
80
  // The claim is prose. Skip fenced blocks WHOLE — a suggestion's contents are
81
81
  // code, and `x = 1` as the summary of a withheld finding tells a reader
@@ -16,16 +16,22 @@ import { isInfraweaver } from "#app/utils/config/payload";
16
16
 
17
17
  export type CommentMarker =
18
18
  | { kind: "finding"; key: string; proven: boolean }
19
- | { kind: "judgement" };
19
+ | { kind: "judgement" }
20
+ /** a reply re-proving its thread's suggestion on a later head, at the
21
+ * thread's current lines — what lets the apply command take it there */
22
+ | { kind: "reproof"; head: string; startLine: number; line: number };
20
23
 
21
24
  const PREFIX = `${PRODUCT_SLUG}:`;
22
- const MARKER = new RegExp(`<!-- ${PREFIX}(finding|judgement)((?: [a-z]+=[^\\s]*)*) -->`);
25
+ const MARKER = new RegExp(`<!-- ${PREFIX}(finding|judgement|reproof)((?: [a-z]+=[^\\s]*)*) -->`);
23
26
 
24
27
  // `-` is encoded too: `--` inside an HTML comment is not allowed to appear.
25
28
  const enc = (s: string): string => encodeURIComponent(s).replace(/-/g, "%2D");
26
29
 
27
30
  export function renderCommentMarker(marker: CommentMarker): string {
28
31
  if (marker.kind === "judgement") return `<!-- ${PREFIX}judgement -->`;
32
+ if (marker.kind === "reproof") {
33
+ return `<!-- ${PREFIX}reproof head=${enc(marker.head)} start=${marker.startLine} line=${marker.line} -->`;
34
+ }
29
35
  return `<!-- ${PREFIX}finding key=${enc(marker.key)}${marker.proven ? " proven=1" : ""} -->`;
30
36
  }
31
37
 
@@ -43,6 +49,15 @@ export function parseCommentMarker(body: string | null | undefined): CommentMark
43
49
  return [pair.slice(0, eq), pair.slice(eq + 1)] as const;
44
50
  }),
45
51
  );
52
+ if (m[1] === "reproof") {
53
+ const head = fields.get("head");
54
+ const startLine = Number(fields.get("start"));
55
+ const line = Number(fields.get("line"));
56
+ const valid = (n: number) => Number.isInteger(n) && n > 0;
57
+ if (!head || !/^[0-9a-f]{7,64}$/.test(head) || !valid(startLine) || !valid(line)) return null;
58
+ if (startLine > line) return null;
59
+ return { kind: "reproof", head, startLine, line };
60
+ }
46
61
  const raw = fields.get("key");
47
62
  if (!raw) return null;
48
63
  try {
@@ -52,6 +67,14 @@ export function parseCommentMarker(body: string | null | undefined): CommentMark
52
67
  }
53
68
  }
54
69
 
70
+ const SUGGESTION = /```suggestion\r?\n([\s\S]*?)```/;
71
+
72
+ /** The text of a comment's `suggestion` block, without its closing newline. */
73
+ export function suggestionText(body: string): string | undefined {
74
+ const m = SUGGESTION.exec(body);
75
+ return m ? (m[1] ?? "").replace(/\r?\n$/, "") : undefined;
76
+ }
77
+
55
78
  /**
56
79
  * A review comment the engine wrote: by the Infraweaver app, or by any Bot
57
80
  * account when it carries the marker (the workflow-token path).
@@ -13,24 +13,24 @@
13
13
  * one that is neither is named in the summary as not addressed, so a silently
14
14
  * skipped finding is visible to the author rather than absent.
15
15
  */
16
- import { type } from "arktype";
17
16
  import { packControlsForConcern } from "#app/mcp/crosswalk";
18
17
  import { claimsBlockingSeverity, type WithheldFinding } from "#app/mcp/findingAudit";
19
18
  import {
20
19
  controlsTouched,
21
20
  type DeltaVerdict,
21
+ failsGate,
22
+ renderUndeclaredRenames,
22
23
  reviewedFindings,
23
24
  } from "#app/mcp/terraform/deltaSummary";
24
- import type { DeltaFinding, GateDelta } from "#app/mcp/terraform/scanDelta";
25
+ import { count } from "#app/mcp/terraform/prose";
26
+ import { type DeltaFinding, type GateDelta, undeclaredRenames } from "#app/mcp/terraform/scanDelta";
25
27
  import { neutralizeMarkup } from "#app/mcp/untrustedContent";
26
28
 
27
- export const Dismissal = type({
28
- finding_id: type.string.describe("the `finding_id` from review_scan_delta"),
29
- reason: type.string.describe(
30
- "why this introduced finding needs no comment — e.g. mitigated by a named sibling resource, or a false positive for a stated reason. Shown to the author in the review summary.",
31
- ),
32
- });
33
- export type Dismissal = typeof Dismissal.infer;
29
+ /** An introduced finding the reviewer decided needs no comment, and why. */
30
+ export interface Dismissal {
31
+ finding_id: string;
32
+ reason: string;
33
+ }
34
34
 
35
35
  interface ProvenanceComment {
36
36
  path: string;
@@ -103,15 +103,35 @@ const code = (text: string): string =>
103
103
  .trim() || "—"
104
104
  }\``;
105
105
 
106
- /** The provenance line closing an inline comment. */
107
- export function provenanceLine(kind: CommentKind): string {
106
+ /**
107
+ * The provenance line closing an inline comment.
108
+ *
109
+ * A scanner-backed comment carries two severities, and the line says which one
110
+ * decides what. The marker it opens with (🚨 ⚠️ ℹ️) is the reviewer's reading of
111
+ * the risk in this configuration; the gate counts the scanner's severity and
112
+ * the required controls. When the two disagree, the line says so, so an ℹ️ that
113
+ * still blocks the merge — or a 🚨 that does not — reads as intended.
114
+ */
115
+ export function provenanceLine(
116
+ kind: CommentKind,
117
+ opts: { requiredControls?: string[]; reviewerMarker?: string | null } = {},
118
+ ): string {
108
119
  if (kind.kind === "judgement") {
109
120
  return "<sub>**Review judgement** — the scanners do not flag this; it is the reviewer's own reading.</sub>";
110
121
  }
111
122
  const c = kind.finding.concern;
112
123
  const controls = packControlsForConcern(c);
124
+ const blocks = failsGate(kind.finding, opts.requiredControls ?? []);
125
+ const reviewerBlocks = opts.reviewerMarker === "🚨";
126
+ const disagreement = !opts.reviewerMarker
127
+ ? ""
128
+ : blocks && !reviewerBlocks
129
+ ? " — the reviewer rates the risk here lower, but the gate counts the scanner's severity"
130
+ : !blocks && reviewerBlocks
131
+ ? " — the reviewer rates the risk here higher; the gate does not count it"
132
+ : "";
113
133
  return (
114
- `<sub>**Scanner:** ${code(c.rule_id)} (${c.severity})` +
134
+ `<sub>**Scanner:** ${code(c.rule_id)} (${c.severity} · ${blocks ? "fails the gate" : "below the gate"}${disagreement})` +
115
135
  (controls.length ? ` · **Controls:** ${controls.map(code).join(", ")}` : "") +
116
136
  "</sub>"
117
137
  );
@@ -135,8 +155,6 @@ function where(f: DeltaFinding): string {
135
155
  return code(c.location.line ? `${c.location.file}:${c.location.line}` : c.location.file);
136
156
  }
137
157
 
138
- const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
139
-
140
158
  /**
141
159
  * The verdict opening a customer-PR review: whether the PR can merge as far as
142
160
  * the gate goes, why, and what the author can do next — in plain words, from
@@ -151,24 +169,41 @@ export function renderReviewVerdict(input: {
151
169
  proven: number;
152
170
  applyCommand: string;
153
171
  fixedThreads?: { resolved: number; unresolved: number } | undefined;
172
+ /** the run's `required_controls`, so findings on renamed resources can be told
173
+ * apart among the ones the gate fails on. */
174
+ requiredControls?: readonly string[];
154
175
  }): string {
155
176
  const { delta, verdict } = input;
156
177
  const introduced = delta.introduced.length;
157
178
  const blocking = verdict.gating + verdict.required;
179
+ const renamedBlocking = delta.introduced.filter(
180
+ (f) => f.possiblyPreexisting && failsGate(f, [...(input.requiredControls ?? [])]),
181
+ ).length;
158
182
  let lead: string;
159
- if (!verdict.pass) {
183
+ if (!verdict.pass && renamedBlocking > 0 && renamedBlocking === blocking) {
184
+ // every blocker rides on a rename: one thing to do, not N findings to read
185
+ lead =
186
+ `**❌ Blocks merge** — the gate fails on ${count(blocking, "finding")}, all on resources ` +
187
+ "that look renamed without a `moved` block. Adding the blocks named below should clear " +
188
+ `${blocking === 1 ? "it" : "them"}.`;
189
+ } else if (!verdict.pass) {
160
190
  lead =
161
- `**❌ Blocks merge** — this pull request introduces ${plural(blocking, "finding")} ` +
162
- `the gate fails on${introduced > blocking ? `, and ${introduced - blocking} lesser` : ""}.`;
191
+ `**❌ Blocks merge** — this pull request introduces ${count(blocking, "finding")} ` +
192
+ `the gate fails on${introduced > blocking ? `, and ${introduced - blocking} lesser` : ""}` +
193
+ `${renamedBlocking > 0 ? `; ${renamedBlocking} of the blocking ones ${renamedBlocking === 1 ? "sits" : "sit"} on resources that look renamed without a \`moved\` block` : ""}.`;
163
194
  } else if (introduced > 0) {
164
- lead = `**✅ Passes the gate** — ${plural(introduced, "new finding")}, none the gate fails on.`;
195
+ lead = `**✅ Passes the gate** — ${count(introduced, "new finding")}, none the gate fails on.`;
165
196
  } else {
166
197
  lead = "**✅ Passes the gate** — this pull request introduces no scanner findings.";
167
198
  }
168
199
  const lines = [lead];
200
+ // named whether or not the gate fails: a rename without `moved` is a destroy
201
+ // and re-create at apply, which no severity threshold speaks to.
202
+ const renames = renderUndeclaredRenames(undeclaredRenames(delta.introduced), code);
203
+ if (renames) lines.push(renames);
169
204
  if (input.concerns > 0) {
170
205
  lines.push(
171
- `The review also raises ${plural(input.concerns, "concern")} the scanners cannot see — the gate does not count ${input.concerns === 1 ? "it" : "them"}, so read before merging.`,
206
+ `The review also raises ${count(input.concerns, "concern")} the scanners cannot see — the gate does not count ${input.concerns === 1 ? "it" : "them"}, so read before merging.`,
172
207
  );
173
208
  }
174
209
  if (input.proven > 0) {
@@ -179,12 +214,12 @@ export function renderReviewVerdict(input: {
179
214
  const fixed = input.fixedThreads;
180
215
  if (fixed?.resolved) {
181
216
  lines.push(
182
- `Resolved ${plural(fixed.resolved, "earlier thread")} whose finding this push fixed.`,
217
+ `Resolved ${count(fixed.resolved, "earlier thread")} whose finding this push fixed.`,
183
218
  );
184
219
  }
185
220
  if (fixed?.unresolved) {
186
221
  lines.push(
187
- `${plural(fixed.unresolved, "earlier thread")} ${fixed.unresolved === 1 ? "is" : "are"} fixed but still open — this workflow's token may not resolve threads (\`contents: write\`).`,
222
+ `${count(fixed.unresolved, "earlier thread")} ${fixed.unresolved === 1 ? "is" : "are"} fixed but still open — this workflow's token may not resolve threads (\`contents: write\`).`,
188
223
  );
189
224
  }
190
225
  return `${lines.join("\n\n")}\n\n`;
package/src/mcp/roots.ts CHANGED
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import type { LocalToolContext } from "#app/mcp/localContext";
5
5
  import { parseModuleBlocks, walkTfFiles } from "#app/mcp/modules";
6
6
  import { execute, tool } from "#app/mcp/shared";
7
+ import { BLOCK_LABEL } from "#app/mcp/terraform/hcl";
7
8
  import type { SourceFile } from "#app/mcp/terraform/tree";
8
9
  import { log } from "#app/utils/cli";
9
10
  import { pushInto } from "#app/utils/collect";
@@ -33,7 +34,7 @@ export interface TerraformRoot {
33
34
  // a provider CONFIGURATION block — `provider "aws" {`. Distinct from a
34
35
  // `required_providers` block (which child modules also have). The negative
35
36
  // lookbehind avoids matching `required_providers`.
36
- const PROVIDER_CONFIG = /(?:^|\n)\s*provider\s+"[^"]+"\s*\{/;
37
+ const PROVIDER_CONFIG = new RegExp(String.raw`(?:^|\n)\s*provider\s+${BLOCK_LABEL}\s*\{`);
37
38
  const BACKEND_BLOCK = /(?:^|\n)\s*backend\s+"[^"]+"\s*\{/;
38
39
 
39
40
  /** detect whether some concatenated HCL marks a root module. */
@@ -112,7 +113,7 @@ export interface RootClassification {
112
113
  /** a dir that declares infrastructure — resources or module calls. Used only by
113
114
  * the fallback, where the question is "is this a place someone runs terraform",
114
115
  * not "what kind of module is it". */
115
- const DECLARES_INFRA = /(?:^|\n)\s*(?:resource|module)\s+"/;
116
+ const DECLARES_INFRA = /(?:^|\n)\s*(?:resource|module)\s+(?:"|[A-Za-z_])/;
116
117
 
117
118
  /** normalize a POSIX path, resolving `.`/`..` segments. */
118
119
  function normalizeRel(path: string): string {
package/src/mcp/server.ts CHANGED
@@ -47,7 +47,7 @@ import { PolicyCheckTool } from "#app/mcp/policy";
47
47
  import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "#app/mcp/pr";
48
48
  import { PullRequestInfoTool } from "#app/mcp/prInfo";
49
49
  import { TerraformProviderSchemaTool } from "#app/mcp/providerSchema";
50
- import { CreatePullRequestReviewTool } from "#app/mcp/review";
50
+ import { CreatePullRequestReviewTool, StageReviewTool } from "#app/mcp/review";
51
51
  import {
52
52
  GetReviewCommentsTool,
53
53
  ListPullRequestReviewsTool,
@@ -186,6 +186,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Infrawea
186
186
  GetIssueCommentsTool(ctx),
187
187
  GetIssueEventsTool(ctx),
188
188
  CreatePullRequestReviewTool(ctx),
189
+ StageReviewTool(ctx),
189
190
  PullRequestInfoTool(ctx),
190
191
  CommitInfoTool(ctx),
191
192
  CheckoutPrTool(ctx),
package/src/mcp/shared.ts CHANGED
@@ -246,10 +246,49 @@ function instrument(tool: InfraweaverTool): InfraweaverTool {
246
246
  } as InfraweaverTool;
247
247
  }
248
248
 
249
+ /**
250
+ * How often a running tool tells the client it is still working. Claude Code
251
+ * aborts a call that sends neither a response nor a progress notification for
252
+ * five minutes, and a scan that downloads external modules, or a plan, can run
253
+ * longer than that while working normally.
254
+ */
255
+ export const PROGRESS_HEARTBEAT_MS = 30_000;
256
+
257
+ /**
258
+ * Send a progress notification every {@link PROGRESS_HEARTBEAT_MS} while the
259
+ * tool runs, carrying the seconds elapsed (the spec requires `progress` to
260
+ * increase). It only reaches the client while the event loop is free, so a tool
261
+ * that blocks on a synchronous subprocess still looks idle — which is why the
262
+ * Terraform and scanner paths spawn asynchronously.
263
+ */
264
+ export function withProgressHeartbeat(tool: InfraweaverTool): InfraweaverTool {
265
+ if (typeof tool.execute !== "function") return tool;
266
+ const inner = tool.execute.bind(tool);
267
+ return {
268
+ ...tool,
269
+ execute: async (...args: Parameters<typeof inner>) => {
270
+ const [, context] = args;
271
+ const started = Date.now();
272
+ const timer = setInterval(() => {
273
+ const seconds = Math.round((Date.now() - started) / 1000);
274
+ void context.reportProgress({ progress: seconds, message: `${tool.name}: ${seconds}s` });
275
+ }, PROGRESS_HEARTBEAT_MS);
276
+ timer.unref?.();
277
+ try {
278
+ return await inner(...args);
279
+ } finally {
280
+ clearInterval(timer);
281
+ }
282
+ },
283
+ } as InfraweaverTool;
284
+ }
285
+
249
286
  export const addTools = (ctx: ToolContext, server: FastMCP<any>, tools: InfraweaverTool[]) => {
250
287
  const shouldSanitize = isGeminiRouted(ctx);
251
288
  for (const tool of tools) {
252
- server.addTool(instrument(shouldSanitize ? sanitizeToolForGemini(tool) : tool));
289
+ server.addTool(
290
+ withProgressHeartbeat(instrument(shouldSanitize ? sanitizeToolForGemini(tool) : tool)),
291
+ );
253
292
  }
254
293
  return server;
255
294
  };
package/src/mcp/shell.ts CHANGED
@@ -185,16 +185,15 @@ const SOCKET_CLEANUP = [
185
185
  // let the agent bypass the .git/* binds. resolveRepoRoot pins the actual
186
186
  // repo root once at startup via $GITHUB_WORKSPACE or `git rev-parse`.
187
187
  function buildFsMounts(repoDir: string): string {
188
- // shell-escape via single-quote wrap; bash interprets \' as the escape
189
- // for a single quote inside a single-quoted string by closing-and-reopening.
190
- // repoDir paths in practice are GHA workspace paths (no quotes), but the
191
- // escape keeps us correct against arbitrary user-configured workspaces.
192
- const escaped = repoDir.replace(/'/g, "'\\''");
188
+ // shell-escape via single-quote wrap. repoDir paths in practice are GHA
189
+ // workspace paths (no quotes), but the escape keeps us correct against
190
+ // arbitrary user-configured workspaces.
191
+ const gitDir = shSingleQuote(`${repoDir}/.git`);
193
192
  return [
194
193
  `mkdir -p /var/lib/infraweaver 2>/dev/null;`,
195
194
  `mount -t tmpfs tmpfs /var/lib/infraweaver 2>/dev/null;`,
196
195
  `[ -n "$RUNNER_TEMP" ] && [ -d "$RUNNER_TEMP/_runner_file_commands" ] && mount -t tmpfs tmpfs "$RUNNER_TEMP/_runner_file_commands" 2>/dev/null;`,
197
- `[ -e '${escaped}/.git' ] && mount --bind '${escaped}/.git' '${escaped}/.git' 2>/dev/null && mount -o remount,bind,ro '${escaped}/.git' 2>/dev/null;`,
196
+ `[ -e ${gitDir} ] && mount --bind ${gitDir} ${gitDir} 2>/dev/null && mount -o remount,bind,ro ${gitDir} 2>/dev/null;`,
198
197
  // the corepack shim dir sits FIRST on PATH (utils/setup/packageManager.ts)
199
198
  // and PATH survives filterEnv, so a writable pm-bin lets sandboxed code
200
199
  // plant a `git`/`pnpm` that a later UNSANDBOXED spawn resolves with the
@@ -215,8 +214,8 @@ function buildFsMounts(repoDir: string): string {
215
214
  function shimMount(): string {
216
215
  const tempDir = readTempDir();
217
216
  if (!tempDir) return "";
218
- const dir = packageManagerBinDir(tempDir).replace(/'/g, "'\\''");
219
- return `[ -d '${dir}' ] && mount --bind '${dir}' '${dir}' 2>/dev/null && mount -o remount,bind,ro '${dir}' 2>/dev/null;`;
217
+ const dir = shSingleQuote(packageManagerBinDir(tempDir));
218
+ return `[ -d ${dir} ] && mount --bind ${dir} ${dir} 2>/dev/null && mount -o remount,bind,ro ${dir} 2>/dev/null;`;
220
219
  }
221
220
 
222
221
  /** locate the repo root once at action startup. process.cwd() is unreliable
@@ -313,7 +312,6 @@ function spawnShell(params: SpawnParams): ChildProcess {
313
312
  params.timeoutMs && params.timeoutMs > 0
314
313
  ? `timeout --kill-after=5 --signal=TERM ${Math.ceil(params.timeoutMs / 1000)}s bash -c ${shSingleQuote(params.command)}`
315
314
  : params.command;
316
- const escaped = (pathRestore + userCommand).replace(/'/g, "'\\''");
317
315
  envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
318
316
  return spawn(
319
317
  "sudo",
@@ -326,7 +324,7 @@ function spawnShell(params: SpawnParams): ChildProcess {
326
324
  "--mount-proc",
327
325
  "bash",
328
326
  "-c",
329
- `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
327
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec su -p -s /bin/bash ${username} -c ${shSingleQuote(pathRestore + userCommand)}`,
330
328
  ],
331
329
  { ...spawnOpts, env: {} },
332
330
  );
@@ -352,13 +350,20 @@ function sudoKillGroup(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
352
350
  }
353
351
  }
354
352
 
353
+ /** SIGTERM a process group, give it 200ms to exit cleanly, then SIGKILL it.
354
+ * `signal` delivers one signal to the group; a throw from it propagates. */
355
+ async function termThenKill(signal: (sig: "SIGTERM" | "SIGKILL") => void): Promise<void> {
356
+ signal("SIGTERM");
357
+ await sleep(200);
358
+ signal("SIGKILL");
359
+ }
360
+
355
361
  /** kill process and its entire process group */
356
362
  async function killProcessGroup(proc: ChildProcess): Promise<void> {
357
- if (!proc.pid) return;
363
+ const pid = proc.pid;
364
+ if (!pid) return;
358
365
  try {
359
- process.kill(-proc.pid, "SIGTERM");
360
- await new Promise((r) => setTimeout(r, 200));
361
- process.kill(-proc.pid, "SIGKILL");
366
+ await termThenKill((sig) => process.kill(-pid, sig));
362
367
  } catch {
363
368
  try {
364
369
  proc.kill("SIGKILL");
@@ -652,21 +657,15 @@ export function KillBackgroundTool(ctx: ToolContext) {
652
657
  // under sudo-unshare the group is root-owned — process.kill(-pid) EPERMs,
653
658
  // so escalate straight through sudo. otherwise the unprivileged kill works.
654
659
  if (getSandboxMethod() === "sudo-unshare") {
655
- sudoKillGroup(proc.pid, "SIGTERM");
656
- await sleep(200);
657
- sudoKillGroup(proc.pid, "SIGKILL");
660
+ await termThenKill((sig) => sudoKillGroup(proc.pid, sig));
658
661
  } else {
659
- try {
660
- process.kill(-proc.pid, "SIGTERM");
661
- } catch {
662
- // already dead
663
- }
664
- await sleep(200);
665
- try {
666
- process.kill(-proc.pid, "SIGKILL");
667
- } catch {
668
- // already dead
669
- }
662
+ await termThenKill((sig) => {
663
+ try {
664
+ process.kill(-proc.pid, sig);
665
+ } catch {
666
+ // already dead
667
+ }
668
+ });
670
669
  }
671
670
 
672
671
  ctx.toolState.backgroundProcesses.delete(params.handle);
@@ -50,8 +50,9 @@ export interface SuggestionProof {
50
50
  const MAX_SINGLE_PROOFS = 4;
51
51
 
52
52
  export interface ProverDeps {
53
- /** a gate-configured scan of the tree at `cwd`, ACTIVE concerns only */
54
- scan: (cwd: string) => Promise<Concern[]>;
53
+ /** a gate-configured scan of the tree at `cwd`, ACTIVE concerns only, covering
54
+ * at least the root modules that `files` (relative to `cwd`) reach */
55
+ scan: (cwd: string, files: string[]) => Promise<Concern[]>;
55
56
  }
56
57
 
57
58
  /**
@@ -87,14 +88,17 @@ export async function proveSuggestions(
87
88
  return input.candidates.map((c) => out.get(c.index) as SuggestionProof);
88
89
  }
89
90
  const scanCwd = prefix ? join(worktree, prefix) : worktree;
91
+ // a suggestion can change findings only in the roots its file reaches, so
92
+ // both sides of each comparison scan those
93
+ const files = [...new Set(ready.map((c) => c.path.slice(prefix.length)))];
90
94
  try {
91
- const before = await deps.scan(scanCwd);
95
+ const before = await deps.scan(scanCwd, files);
92
96
  // review paths are repo-relative; concern paths are relative to the scan dir
93
97
  const pristine = snapshot(worktree, prefix, ready);
94
98
  const judge = async (subset: SuggestionCandidate[]) => {
95
99
  restore(worktree, prefix, pristine);
96
100
  for (const [path, group] of groupByPath(subset)) apply(worktree, path, group);
97
- const after = await deps.scan(scanCwd);
101
+ const after = await deps.scan(scanCwd, files);
98
102
  return {
99
103
  after,
100
104
  delta: computeScanDelta({