infraweaver 0.3.12 → 0.3.13

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +554 -199
  5. package/dist/index.js +531 -176
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  13. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  14. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  15. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  16. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  17. package/dist/mcp/terraform/tree.d.ts +5 -0
  18. package/dist/mcp/terraform/types.d.ts +2 -1
  19. package/dist/mcp/terraform/verifySites.d.ts +11 -3
  20. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  21. package/package.json +2 -2
  22. package/src/agents/opencode.ts +8 -1
  23. package/src/agents/quotaTokens.ts +7 -1
  24. package/src/mcp/git.ts +64 -12
  25. package/src/mcp/localContext.ts +6 -1
  26. package/src/mcp/moduleExtraction.ts +5 -4
  27. package/src/mcp/review.ts +52 -3
  28. package/src/mcp/reviewCommentLimit.ts +169 -26
  29. package/src/mcp/reviewMarkers.ts +11 -0
  30. package/src/mcp/reviewProvenance.ts +1 -1
  31. package/src/mcp/staleFix.ts +73 -2
  32. package/src/mcp/terraform/concernResult.ts +17 -10
  33. package/src/mcp/terraform/deltaSummary.ts +4 -0
  34. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  35. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  36. package/src/mcp/terraform/nativeScan.ts +4 -0
  37. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  38. package/src/mcp/terraform/refactorScope.ts +90 -21
  39. package/src/mcp/terraform/ruleContext.ts +4 -0
  40. package/src/mcp/terraform/scanDelta.ts +67 -26
  41. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  42. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  43. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  44. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  45. package/src/mcp/terraform/tools/scan.ts +3 -0
  46. package/src/mcp/terraform/tree.ts +8 -0
  47. package/src/mcp/terraform/types.ts +2 -1
  48. package/src/mcp/terraform/verifySites.ts +23 -5
  49. package/src/modes/refactor.ts +1 -1
  50. package/src/modes/remediate-and-refactor.ts +1 -1
  51. package/src/modes/remediate.ts +1 -1
  52. package/src/phases/runAgentWithWatchdogs.ts +3 -2
@@ -42,7 +42,7 @@ import {
42
42
  readFetchedGitModules,
43
43
  readFetchedRegistryModuleDirs,
44
44
  } from "#app/mcp/terraform/refactor/workspace";
45
- import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
45
+ import { isExamplePath, libraryScope, reportOnly } from "#app/mcp/terraform/refactorScope";
46
46
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
47
47
  import { log } from "#app/utils/cli";
48
48
  import { pushInto } from "#app/utils/collect";
@@ -912,12 +912,14 @@ export function ModuleExtractionCandidatesTool(ctx: LocalToolContext) {
912
912
  parameters: ModuleExtractionCandidatesParams,
913
913
  execute: execute(async () => {
914
914
  const cwd = ctx.payload.cwd ?? process.cwd();
915
+ const library = libraryScope(cwd);
915
916
  const candidates = findExtractionCandidates(
916
917
  cwd,
917
918
  ctx.payload.moduleCatalogue,
918
919
  ctx.toolState.orgModuleHcl,
919
- ).filter((c) => ctx.payload.refactorExamples || !isExamplePath(c.cluster.file));
920
- const library = libraryRepoReason(cwd);
920
+ )
921
+ .filter((c) => ctx.payload.refactorExamples || !isExamplePath(c.cluster.file))
922
+ .map((c) => ({ ...reportOnly(library(c.cluster.file)), ...c }));
921
923
  const matched = candidates.filter((c) => c.candidates.length > 0);
922
924
  // Record every candidate's reason, keyed by source, for create_pull_request:
923
925
  // when a refactor PR's diff adopts one of these modules and the agent left
@@ -945,7 +947,6 @@ export function ModuleExtractionCandidatesTool(ctx: LocalToolContext) {
945
947
  : ""),
946
948
  );
947
949
  return toolOk({
948
- ...reportOnly(library),
949
950
  cluster_count: candidates.length,
950
951
  matched_count: matched.length,
951
952
  candidates,
package/src/mcp/review.ts CHANGED
@@ -25,7 +25,11 @@ import {
25
25
  } from "#app/mcp/findingAudit";
26
26
  import { reproveStaleSuggestions } from "#app/mcp/reproveSuggestions";
27
27
  import { formatReanchoredNote, patchLinesBySide, reanchorComments } from "#app/mcp/reviewAnchor";
28
- import { formatOverflowNote, limitInlineComments } from "#app/mcp/reviewCommentLimit";
28
+ import {
29
+ foldedFindingsStillIntroduced,
30
+ formatOverflowNote,
31
+ limitInlineComments,
32
+ } from "#app/mcp/reviewCommentLimit";
29
33
  import { listOpenInfraweaverThreads, type OpenInfraweaverThread } from "#app/mcp/reviewComments";
30
34
  import {
31
35
  type DuplicateComment,
@@ -56,6 +60,7 @@ import { deltaVerdict } from "#app/mcp/terraform/deltaSummary";
56
60
  import { scanForProof } from "#app/mcp/terraform/gateScan";
57
61
  import { findingKey, type GateDelta } from "#app/mcp/terraform/scanDelta";
58
62
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
63
+ import type { Severity } from "#app/mcp/terraform/types";
59
64
  import type { CommentableLines } from "#app/toolState";
60
65
  import { log } from "#app/utils/cli";
61
66
  import { getApiUrl, isBackendConfigured } from "#app/utils/cloud/apiUrl";
@@ -730,6 +735,33 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
730
735
  `if any thread is a real outstanding issue, do NOT approve — submit a non-approving review (omit \`approved\`, or set \`request_changes\` for a blocking one) that covers it instead.`,
731
736
  );
732
737
  }
738
+ // a comment the limit folded into an earlier review's body never became
739
+ // a thread, so the count above cannot see it. A folded scanner finding
740
+ // left its marker in that body: it holds an approval back the same way
741
+ // while this head still introduces it. Fatal on a failed fetch, like
742
+ // the thread check — this is the same invariant.
743
+ if (delta) {
744
+ const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
745
+ owner: ctx.repo.owner,
746
+ repo: ctx.repo.name,
747
+ pull_number,
748
+ per_page: 100,
749
+ });
750
+ const folded = foldedFindingsStillIntroduced({ reviews, delta, dismissed });
751
+ if (folded.length > 0) {
752
+ throw new Error(
753
+ `cannot approve: ${folded.length} introduced finding(s) an earlier ${PRODUCT_NAME} review listed in its body (past \`review_comment_limit\`, so with no thread to resolve) are still introduced by this head: ` +
754
+ folded
755
+ .slice(0, 20)
756
+ .map(
757
+ (f) =>
758
+ `${f.concern.id} (${f.concern.rule_id} at ${f.concern.location.file}:${f.concern.location.line ?? "?"})`,
759
+ )
760
+ .join(", ") +
761
+ `. approval requires every prior ${PRODUCT_NAME} finding to be addressed. if one genuinely needs no change, list it in \`dismissed\` with the reason and retry; otherwise do NOT approve — submit a non-approving review that covers it.`,
762
+ );
763
+ }
764
+ }
733
765
  }
734
766
 
735
767
  // skip empty COMMENT reviews before any GitHub call. see reviewSkipDecision
@@ -893,6 +925,9 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
893
925
  : new Map<number, SuggestionProof>();
894
926
 
895
927
  type ReviewComment = NonNullable<typeof params.comments>[number];
928
+ // the scanner severity of the finding a comment answers: what the comment
929
+ // limit ranks it by, whatever marker the reviewer gave it
930
+ const findingSeverity = new Map<ReviewComment, Severity>();
896
931
  const reviewComments = auditedComments.map((comment, index) => {
897
932
  let commentBody = fixDoubleEscapedString(comment.body || "");
898
933
  if (comment.suggestion !== undefined) {
@@ -931,6 +966,9 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
931
966
  reviewComment.start_line = comment.start_line;
932
967
  reviewComment.start_side = side;
933
968
  }
969
+ if (comment.provenance?.kind === "scanner") {
970
+ findingSeverity.set(reviewComment, comment.provenance.finding.concern.severity);
971
+ }
934
972
  return reviewComment;
935
973
  });
936
974
 
@@ -1003,7 +1041,9 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
1003
1041
  // then the per-review limit: the most important comments stay inline,
1004
1042
  // the rest are listed in the body (see reviewCommentLimit.ts). After
1005
1043
  // dedup, so a withheld duplicate never takes a slot.
1006
- const limited = limitInlineComments(deduped.kept, ctx.payload.reviewCommentLimit);
1044
+ const limited = limitInlineComments(deduped.kept, ctx.payload.reviewCommentLimit, (c) =>
1045
+ findingSeverity.get(c),
1046
+ );
1007
1047
  overflowComments = limited.overflow;
1008
1048
  if (overflowComments.length > 0) {
1009
1049
  log.info(
@@ -1063,7 +1103,16 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
1063
1103
  body = body ? body + note : note.replace(/^\n\n/, "");
1064
1104
  }
1065
1105
  if (overflowComments.length > 0) {
1066
- const note = formatOverflowNote(overflowComments, ctx.payload.reviewCommentLimit);
1106
+ const server = process.env.GITHUB_SERVER_URL || "https://github.com";
1107
+ const commit = params.commit_id;
1108
+ const note = formatOverflowNote(
1109
+ overflowComments,
1110
+ ctx.payload.reviewCommentLimit,
1111
+ commit
1112
+ ? (c) =>
1113
+ `${server}/${ctx.repo.owner}/${ctx.repo.name}/blob/${commit}/${c.path.split("/").map(encodeURIComponent).join("/")}${c.line && c.side !== "LEFT" ? `#L${c.line}` : ""}`
1114
+ : undefined,
1115
+ );
1067
1116
  body = body ? body + note : note.replace(/^\n\n/, "");
1068
1117
  }
1069
1118
 
@@ -3,23 +3,39 @@
3
3
  *
4
4
  * A review of forty inline comments is read by no one, and every one of them is
5
5
  * a thread the author must resolve. Past the limit the most important comments
6
- * still post inline — 🚨 before ⚠️ before the rest, a proven (one-click
6
+ * still post inline — ranked by the scanner severity of the finding a comment
7
+ * answers, or else by its own 🚨/⚠️/ℹ️ marker (the marker is optional, so an
8
+ * unmarked comment ranks level with ⚠️, not last), a proven (one-click
7
9
  * committable) fix ahead of an equal one without, otherwise in the order the
8
- * reviewer wrote them — and the rest are listed in the review body, so nothing
9
- * the review found is lost, only folded. The gate is not involved: it judges the
10
- * scan delta, never the comments.
10
+ * reviewer wrote them — and the rest are listed in the review body in full, so
11
+ * nothing the review found is lost, only folded. The gate is not involved: it
12
+ * judges the scan delta, never the comments.
13
+ *
14
+ * A folded comment opens no thread, so the approval check that counts open
15
+ * threads cannot see it. A folded scanner finding keeps its hidden marker in
16
+ * the review body instead, and a later approval is refused while the head
17
+ * still introduces it (`foldedFindingsStillIntroduced`).
11
18
  *
12
19
  * The limit is a repo setting (`review_comment_limit`), read from the base
13
20
  * branch on a pull request: lowering it moves findings out of the diff view,
14
21
  * which is a way to make them easier to miss, so a PR does not get to set it for
15
22
  * itself.
16
23
  */
24
+
17
25
  import { leadingMarker } from "#app/mcp/findingAudit";
18
26
  import { gistOf } from "#app/mcp/reviewDedup";
27
+ import { findingKeysIn, isOwnReviewComment, renderCommentMarker } from "#app/mcp/reviewMarkers";
28
+ import type { Dismissal } from "#app/mcp/reviewProvenance";
29
+ import { type DeltaFinding, findingKey, type GateDelta } from "#app/mcp/terraform/scanDelta";
30
+ import type { Severity } from "#app/mcp/terraform/types";
19
31
 
20
- /** Lines the overflow note lists before counting the rest; keeps the body
21
- * clear of GitHub's ~65 KB limit, as the other review notes do. */
22
- const MAX_OVERFLOW_LINES = 50;
32
+ /** The note's share of GitHub's 65,536-character review body; the verdict, the
33
+ * reviewer's own text, the other notes, the delta summary and the footer
34
+ * share the rest. */
35
+ const MAX_NOTE_CHARS = 30_000;
36
+
37
+ /** Room kept for the closing count and size note. */
38
+ const TRAILER_RESERVE = 400;
23
39
 
24
40
  /** what `renderSuggestion` writes under a suggestion a re-scan proved. */
25
41
  const PROVEN_MARK = "✅ **Proven by re-scan**";
@@ -30,26 +46,41 @@ interface LimitableComment {
30
46
  body?: string | undefined;
31
47
  }
32
48
 
33
- function rank(c: LimitableComment): number {
49
+ /** Importance by level, most important first. */
50
+ const SEVERITY_LEVEL: Record<Severity, number> = {
51
+ critical: 0,
52
+ high: 1,
53
+ medium: 2,
54
+ low: 3,
55
+ info: 4,
56
+ };
57
+ const MARKER_LEVEL: Record<string, number> = { "🚨": 1, "⚠️": 2, "⚠": 2, ℹ️: 3, ℹ: 3 };
58
+ /** no marker, or one not on the ladder: the marker is optional, so mid-rank */
59
+ const UNMARKED_LEVEL = 2;
60
+
61
+ function rank(c: LimitableComment, severity: Severity | undefined): number {
34
62
  const body = c.body ?? "";
35
- const marker = leadingMarker(body);
36
- const severity = marker === "🚨" ? 0 : marker === "⚠️" || marker === "⚠" ? 2 : 4;
37
- return severity + (body.includes(PROVEN_MARK) ? 0 : 1);
63
+ const level = severity
64
+ ? SEVERITY_LEVEL[severity]
65
+ : (MARKER_LEVEL[leadingMarker(body) ?? ""] ?? UNMARKED_LEVEL);
66
+ return level * 2 + (body.includes(PROVEN_MARK) ? 0 : 1);
38
67
  }
39
68
 
40
69
  /**
41
70
  * Split `comments` into those posted inline and those folded into the body.
42
- * `limit` 0 or undefined means no limit. Both halves keep the input order, so
43
- * the posted review reads in the order the reviewer wrote it.
71
+ * `limit` 0 or undefined means no limit. `severityOf` names the severity of the
72
+ * finding a comment answers, when it answers one. Both halves keep the input
73
+ * order, so the posted review reads in the order the reviewer wrote it.
44
74
  */
45
75
  export function limitInlineComments<T extends LimitableComment>(
46
76
  comments: readonly T[],
47
77
  limit: number | undefined,
78
+ severityOf?: (c: T) => Severity | undefined,
48
79
  ): { kept: T[]; overflow: T[] } {
49
80
  if (!limit || comments.length <= limit) return { kept: [...comments], overflow: [] };
50
81
  const keep = new Set(
51
82
  comments
52
- .map((c, i) => ({ i, r: rank(c) }))
83
+ .map((c, i) => ({ i, r: rank(c, severityOf?.(c)) }))
53
84
  .sort((a, b) => a.r - b.r || a.i - b.i)
54
85
  .slice(0, limit)
55
86
  .map(({ i }) => i),
@@ -60,20 +91,132 @@ export function limitInlineComments<T extends LimitableComment>(
60
91
  };
61
92
  }
62
93
 
63
- /** The review-body note listing the comments the limit folded, or "" for none. */
64
- export function formatOverflowNote(overflow: readonly LimitableComment[], limit: number): string {
65
- if (overflow.length === 0) return "";
66
- const shown = overflow.slice(0, MAX_OVERFLOW_LINES).map((c) => {
67
- const where = c.line ? `${c.path}:${c.line}` : c.path;
68
- const gist = gistOf(c.body);
69
- return `- \`${where.replace(/`/g, "'")}\`${gist ? ` — ${gist}` : ""}`;
70
- });
71
- const rest = overflow.length - shown.length;
72
- if (rest > 0) shown.push(`- …and ${rest} more not shown`);
94
+ const escapeHtml = (s: string): string =>
95
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
96
+
97
+ const whereOf = (c: LimitableComment): string => (c.line ? `${c.path}:${c.line}` : c.path);
98
+
99
+ /**
100
+ * Whether a comment's markup closes inside it. An inline comment is its own
101
+ * document; lifted into the shared review body, an unclosed fence, `<details>`
102
+ * or `<!--` would swallow everything after it — the other folded comments and
103
+ * the footer.
104
+ */
105
+ function closesItself(body: string): boolean {
106
+ const occurrences = (re: RegExp) => body.match(re)?.length ?? 0;
107
+ const fences = body.split("\n").filter((l) => /^\s*(```|~~~)/.test(l)).length;
73
108
  return (
109
+ fences % 2 === 0 &&
110
+ occurrences(/<details\b/gi) === occurrences(/<\/details\s*>/gi) &&
111
+ occurrences(/<!--/g) === occurrences(/-->/g)
112
+ );
113
+ }
114
+
115
+ /** `body` as a fenced text block no run of backticks inside it can close. */
116
+ function asText(body: string): string {
117
+ const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((run) => run.length));
118
+ const fence = "`".repeat(Math.max(3, longest + 1));
119
+ return `${fence}text\n${body}\n${fence}`;
120
+ }
121
+
122
+ /** One folded comment in full: collapsed under its place and first line. */
123
+ function renderWhole<T extends LimitableComment>(
124
+ c: T,
125
+ linkOf: ((c: T) => string | undefined) | undefined,
126
+ ): string {
127
+ const where = escapeHtml(whereOf(c));
128
+ const gist = gistOf(c.body);
129
+ const href = linkOf?.(c);
130
+ const body = c.body ?? "";
131
+ return [
132
+ `<details><summary><code>${where}</code>${gist ? ` — ${gist}` : ""}</summary>`,
133
+ ...(href ? [`<a href="${escapeHtml(href)}">${where}</a>`] : []),
134
+ ...(body ? [closesItself(body) ? body : asText(body)] : []),
135
+ "</details>",
136
+ ].join("\n\n");
137
+ }
138
+
139
+ /** One folded comment by place and first line, keeping its finding marker. */
140
+ function renderBrief(c: LimitableComment): string {
141
+ const gist = gistOf(c.body);
142
+ const markers = findingKeysIn(c.body ?? "").map((key) =>
143
+ renderCommentMarker({ kind: "finding", key, proven: false }),
144
+ );
145
+ return `- \`${whereOf(c).replace(/`/g, "'")}\`${gist ? ` — ${gist}` : ""}${markers.map((m) => ` ${m}`).join("")}`;
146
+ }
147
+
148
+ /**
149
+ * The review-body note carrying the comments the limit folded, or "" for none.
150
+ * Each is kept whole while the note stays within its share of GitHub's body
151
+ * limit; past that, by place and first line (and its finding marker), and the
152
+ * note says so.
153
+ */
154
+ export function formatOverflowNote<T extends LimitableComment>(
155
+ overflow: readonly T[],
156
+ limit: number,
157
+ linkOf?: (c: T) => string | undefined,
158
+ ): string {
159
+ if (overflow.length === 0) return "";
160
+ const header =
74
161
  "\n\n---\n\n" +
75
162
  `**Note:** this review posts at most ${limit} inline comments (\`review_comment_limit\`), ` +
76
- `so ${overflow.length} more ${overflow.length === 1 ? "is" : "are"} listed here instead:\n` +
77
- shown.join("\n")
163
+ `so ${overflow.length} more ${overflow.length === 1 ? "is" : "are"} listed here instead:`;
164
+ const SEP = 2;
165
+ let room = MAX_NOTE_CHARS - TRAILER_RESERVE - header.length;
166
+ // every comment's place first, then as many in full as the rest allows
167
+ const items: string[] = [];
168
+ for (const c of overflow) {
169
+ const brief = renderBrief(c);
170
+ if (brief.length + SEP > room) break;
171
+ items.push(brief);
172
+ room -= brief.length + SEP;
173
+ }
174
+ let brief = items.length;
175
+ for (const [i, c] of overflow.slice(0, items.length).entries()) {
176
+ const whole = renderWhole(c, linkOf);
177
+ const extra = whole.length - (items[i] as string).length;
178
+ if (extra > room) continue;
179
+ items[i] = whole;
180
+ room -= extra;
181
+ brief--;
182
+ }
183
+ const omitted = overflow.length - items.length;
184
+ if (omitted > 0) items.push(`- …and ${omitted} more not shown`);
185
+ const cut = [
186
+ brief > 0
187
+ ? `${brief} of them ${brief === 1 ? "is" : "are"} shown by place and first line only`
188
+ : "",
189
+ omitted > 0 ? `${omitted} ${omitted === 1 ? "is" : "are"} not shown at all` : "",
190
+ ].filter(Boolean);
191
+ if (cut.length > 0) {
192
+ items.push(`${cut.join(" and ")}, to keep this review within GitHub's size limit.`);
193
+ }
194
+ return `${header}\n\n${items.join("\n\n")}`;
195
+ }
196
+
197
+ /**
198
+ * The introduced findings an earlier review of this PR folded into its body —
199
+ * so they never became threads — that this head still introduces and this
200
+ * review does not dismiss. An approval past one would pass a finding the
201
+ * open-thread check cannot see. Markers count only in the engine's own reviews;
202
+ * one pasted elsewhere can only add a finding to this list, never remove one.
203
+ */
204
+ export function foldedFindingsStillIntroduced(input: {
205
+ reviews: readonly {
206
+ user?: { login?: string | null; type?: string | null } | null;
207
+ body?: string | null;
208
+ }[];
209
+ delta: GateDelta;
210
+ dismissed: readonly Dismissal[];
211
+ }): DeltaFinding[] {
212
+ const folded = new Set(
213
+ input.reviews.flatMap((r) =>
214
+ r.body && isOwnReviewComment(r.user, r.body) ? findingKeysIn(r.body) : [],
215
+ ),
216
+ );
217
+ if (folded.size === 0) return [];
218
+ const dismissed = new Set(input.dismissed.map((d) => d.finding_id));
219
+ return input.delta.introduced.filter(
220
+ (f) => folded.has(findingKey(f)) && !dismissed.has(f.concern.id),
78
221
  );
79
222
  }
@@ -67,6 +67,17 @@ export function parseCommentMarker(body: string | null | undefined): CommentMark
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * The key of every finding marker in `body`. A review BODY carries one per
72
+ * comment the comment limit folded into it, where a comment carries one.
73
+ */
74
+ export function findingKeysIn(body: string): string[] {
75
+ return [...body.matchAll(new RegExp(MARKER.source, "g"))].flatMap((m) => {
76
+ const marker = parseCommentMarker(m[0]);
77
+ return marker?.kind === "finding" ? [marker.key] : [];
78
+ });
79
+ }
80
+
70
81
  const SUGGESTION = /```suggestion\r?\n([\s\S]*?)```/;
71
82
 
72
83
  /** The text of a comment's `suggestion` block, without its closing newline. */
@@ -175,7 +175,7 @@ export function renderReviewVerdict(input: {
175
175
  }): string {
176
176
  const { delta, verdict } = input;
177
177
  const introduced = delta.introduced.length;
178
- const blocking = verdict.gating + verdict.required;
178
+ const { blocking } = verdict;
179
179
  const renamedBlocking = delta.introduced.filter(
180
180
  (f) => f.possiblyPreexisting && failsGate(f, [...(input.requiredControls ?? [])]),
181
181
  ).length;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { errorMessage } from "@infraweaver-io/core/errors";
2
3
  import { type } from "arktype";
3
4
  import { PRODUCT_NAME, PRODUCT_SLUG } from "#app/brand";
@@ -7,6 +8,7 @@ import { assertTargetInScope } from "#app/mcp/scope";
7
8
  import { execute, tool, toolOk } from "#app/mcp/shared";
8
9
  import { log } from "#app/utils/cli";
9
10
  import { mapWithConcurrency } from "#app/utils/concurrency";
11
+ import type { OctokitWithPlugins } from "#app/utils/github/github";
10
12
 
11
13
  /**
12
14
  * Stale-fix self-healing. A Infraweaver remediation PR is "base + a minimal,
@@ -46,7 +48,8 @@ export function groupIdFromBranch(branch: string): string | null {
46
48
 
47
49
  /**
48
50
  * What a remediation branch name carries after its group id: nothing when the
49
- * PR targets the repository default branch, `--<base>` otherwise.
51
+ * PR targets the repository default branch, `--<base>` otherwise — slugged for
52
+ * a ref, with a hash of the raw name when the slug changed it.
50
53
  *
51
54
  * The group id is a hash of the file (or rule) alone, so the same fix aimed at
52
55
  * two base branches — or at two repos whose roots are both `main.tf` — named
@@ -59,7 +62,75 @@ export function remediationBranchSuffix(
59
62
  defaultBranch: string | undefined,
60
63
  ): string {
61
64
  if (!base || !defaultBranch || base === defaultBranch) return "";
62
- return `--${base.replace(/[^A-Za-z0-9._-]+/g, "-")}`;
65
+ const slug = base.replace(/[^A-Za-z0-9._-]+/g, "-");
66
+ if (slug === base) return `--${slug}`;
67
+ // a lossy slug is shared — `release/1.0` and `release-1.0` — so a fix built
68
+ // on one base would be force-pushed into the PR targeting the other
69
+ return `--${slug}-${createHash("sha1").update(base).digest("hex").slice(0, 8)}`;
70
+ }
71
+
72
+ /** an open remediation PR, as far as naming a group's branch needs it. */
73
+ export interface OpenRemediationPr {
74
+ head: string;
75
+ base: string;
76
+ }
77
+
78
+ /**
79
+ * The branch a remediation group's fix is pushed to: the head of the PR already
80
+ * open for this group into this exact base, when there is one, else
81
+ * `remediate/<id>` plus [[remediationBranchSuffix]].
82
+ *
83
+ * The open PR wins because its name may predate the current naming — opened
84
+ * before the base suffix existed, or under an unhashed suffix — and Remediate
85
+ * keys idempotency on the name, so a newly computed one opened a duplicate PR.
86
+ * A head that another open PR also proposes into a different base is never
87
+ * reused: pushing to it would rewrite that PR too.
88
+ */
89
+ export function remediationBranchFor(
90
+ id: string,
91
+ base: string | undefined,
92
+ defaultBranch: string | undefined,
93
+ open: readonly OpenRemediationPr[] | undefined,
94
+ ): string {
95
+ const computed = `remediate/${id}${remediationBranchSuffix(base, defaultBranch)}`;
96
+ const heads = (open ?? [])
97
+ .filter((pr) => pr.base === base && groupIdFromBranch(pr.head) === id)
98
+ .map((pr) => pr.head)
99
+ .filter((head) => !(open ?? []).some((pr) => pr.head === head && pr.base !== base));
100
+ return heads.includes(computed) ? computed : (heads[0] ?? computed);
101
+ }
102
+
103
+ /**
104
+ * This repository's open `remediate/*` PRs, for [[remediationBranchFor]]. Empty
105
+ * without a GitHub client (a local session) or when the API call fails: the
106
+ * group then gets the computed name, as it did before open PRs were consulted.
107
+ * Only Remediate pushes to a group's branch, so no other mode pays for the call.
108
+ */
109
+ export async function listOpenRemediationPrs(ctx: {
110
+ octokit?: OctokitWithPlugins | undefined;
111
+ repo?: { owner?: string; name?: string } | undefined;
112
+ toolState?: { selectedMode?: string | undefined } | undefined;
113
+ }): Promise<OpenRemediationPr[]> {
114
+ const { octokit, repo } = ctx;
115
+ if (ctx.toolState?.selectedMode !== "Remediate") return [];
116
+ if (!octokit || !repo?.owner || !repo.name) return [];
117
+ try {
118
+ const open = await octokit.paginate(octokit.rest.pulls.list, {
119
+ owner: repo.owner,
120
+ repo: repo.name,
121
+ state: "open",
122
+ per_page: 100,
123
+ });
124
+ return (
125
+ open
126
+ // a fork's branch of the same name is not one this run can push to
127
+ .filter((pr) => pr.head.repo?.id === pr.base.repo.id && groupIdFromBranch(pr.head.ref))
128
+ .map((pr) => ({ head: pr.head.ref, base: pr.base.ref }))
129
+ );
130
+ } catch (e) {
131
+ log.warning(`» could not list open remediation PRs: ${errorMessage(e)}`);
132
+ return [];
133
+ }
63
134
  }
64
135
 
65
136
  /** core matcher: a NON-EMPTY identity string (a GitHub login or a git author
@@ -1,5 +1,5 @@
1
1
  import type { LocalToolContext } from "#app/mcp/localContext";
2
- import { remediationBranchSuffix } from "#app/mcp/staleFix";
2
+ import { groupIdFromBranch, type OpenRemediationPr, remediationBranchFor } from "#app/mcp/staleFix";
3
3
  import {
4
4
  capSeededConcerns,
5
5
  type PrioritySignals,
@@ -113,6 +113,14 @@ export function buildConcernResult(
113
113
  * the full raw set — so a capped concern the re-scan still sees can never
114
114
  * read as a regression. */
115
115
  seedCaps?: SeedCaps;
116
+ /** The repository's open remediation PRs (see `listOpenRemediationPrs`), so
117
+ * a group that already has one keeps its branch. */
118
+ openRemediationPrs?: readonly OpenRemediationPr[];
119
+ /** The concerns were scanned off the tree in `cwd` as it is now, so their
120
+ * lines name today's resources (see `recordConcernSites`). Only
121
+ * `terraform_scan` sets it: a findings file or external report may
122
+ * describe another commit. */
123
+ linesMatchTree?: boolean;
116
124
  },
117
125
  ): ConcernResult {
118
126
  const fullBaseline = dedupe(rawConcerns);
@@ -129,7 +137,7 @@ export function buildConcernResult(
129
137
  fullBaseline.map((c) => concernKeyOf(c)),
130
138
  );
131
139
  recordConcernKeys(session, fullBaseline);
132
- recordConcernSites(session, fullBaseline, opts.cwd);
140
+ recordConcernSites(session, fullBaseline, opts.cwd, opts.linesMatchTree ?? false);
133
141
  // the files already unformatted at this (pre-fix) scan. terraform_validate
134
142
  // subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
135
143
  // whole-file reformat into a scoped fix. Captured from the raw set (fmt
@@ -156,17 +164,16 @@ export function buildConcernResult(
156
164
  active,
157
165
  opts.autonomyThreshold,
158
166
  );
159
- const suffix = remediationBranchSuffix(
160
- remediationBaseOf(ctx),
161
- ctx.repo?.data.default_branch?.trim() || undefined,
162
- );
167
+ const base = remediationBaseOf(ctx);
168
+ const defaultBranch = ctx.repo?.data.default_branch?.trim() || undefined;
169
+ const branchFor = (id: string) =>
170
+ remediationBranchFor(id, base, defaultBranch, opts.openRemediationPrs);
163
171
  const groups = (
164
172
  opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
165
- ).map((g) => ({ ...g, branch: `remediate/${g.id}${suffix}` }));
173
+ ).map((g) => ({ ...g, branch: branchFor(g.id) }));
166
174
  const planned = planBatches(groups);
167
- const batchPlan = planned.batch_branch
168
- ? { ...planned, batch_branch: `${planned.batch_branch}${suffix}` }
169
- : planned;
175
+ const batchId = planned.batch_branch ? groupIdFromBranch(planned.batch_branch) : null;
176
+ const batchPlan = batchId ? { ...planned, batch_branch: branchFor(batchId) } : planned;
170
177
 
171
178
  const bySeverity: Record<string, number> = {};
172
179
  for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;
@@ -42,6 +42,9 @@ export interface DeltaVerdict {
42
42
  gating: number;
43
43
  /** introduced findings on a required control, any severity */
44
44
  required: number;
45
+ /** introduced findings that fail either half, each counted once: a high
46
+ * finding on a required control is in both `gating` and `required`. */
47
+ blocking: number;
45
48
  }
46
49
 
47
50
  /** The gate's verdict on a delta: the same two halves `findings-gate` applies. */
@@ -53,6 +56,7 @@ export function deltaVerdict(delta: GateDelta, requiredControls: string[]): Delt
53
56
  pass: gate.pass && required.length === 0,
54
57
  gating: gate.gating.length,
55
58
  required: required.length,
59
+ blocking: new Set([...gate.gating, ...required]).size,
56
60
  };
57
61
  }
58
62