infraweaver 0.3.11 → 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 (59) 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 +846 -412
  5. package/dist/index.js +806 -372
  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/awsPrices.d.ts +1 -1
  13. package/dist/mcp/terraform/azurePrices.d.ts +1 -1
  14. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  15. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  16. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  17. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  18. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  19. package/dist/mcp/terraform/scanSession.d.ts +2 -0
  20. package/dist/mcp/terraform/tree.d.ts +5 -0
  21. package/dist/mcp/terraform/types.d.ts +2 -1
  22. package/dist/mcp/terraform/verifySites.d.ts +56 -0
  23. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  24. package/package.json +2 -2
  25. package/src/agents/opencode.ts +8 -1
  26. package/src/agents/quotaTokens.ts +7 -1
  27. package/src/mcp/git.ts +64 -12
  28. package/src/mcp/localContext.ts +6 -1
  29. package/src/mcp/moduleExtraction.ts +5 -4
  30. package/src/mcp/review.ts +52 -3
  31. package/src/mcp/reviewCommentLimit.ts +169 -26
  32. package/src/mcp/reviewMarkers.ts +11 -0
  33. package/src/mcp/reviewProvenance.ts +1 -1
  34. package/src/mcp/staleFix.ts +73 -2
  35. package/src/mcp/terraform/awsPrices.ts +1 -1
  36. package/src/mcp/terraform/azurePrices.ts +23 -23
  37. package/src/mcp/terraform/concernResult.ts +18 -9
  38. package/src/mcp/terraform/deltaSummary.ts +4 -0
  39. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  40. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  41. package/src/mcp/terraform/nativeScan.ts +4 -0
  42. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  43. package/src/mcp/terraform/refactorScope.ts +90 -21
  44. package/src/mcp/terraform/ruleContext.ts +4 -0
  45. package/src/mcp/terraform/scanDelta.ts +67 -26
  46. package/src/mcp/terraform/scanSession.ts +6 -0
  47. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  48. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  49. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  50. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  51. package/src/mcp/terraform/tools/scan.ts +3 -0
  52. package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
  53. package/src/mcp/terraform/tree.ts +8 -0
  54. package/src/mcp/terraform/types.ts +2 -1
  55. package/src/mcp/terraform/verifySites.ts +124 -0
  56. package/src/modes/refactor.ts +1 -1
  57. package/src/modes/remediate-and-refactor.ts +1 -1
  58. package/src/modes/remediate.ts +1 -1
  59. package/src/phases/runAgentWithWatchdogs.ts +3 -2
package/src/mcp/git.ts CHANGED
@@ -948,19 +948,68 @@ export const Git = type({
948
948
  args: type.string.array().describe("Additional arguments for the git command").optional(),
949
949
  });
950
950
 
951
- /** a command line split into words, honouring single and double quotes. */
951
+ /**
952
+ * A command line split into words the way a POSIX shell splits it, with no
953
+ * expansion: a quote may open mid-word and is removed (`--message="a b"` is
954
+ * the one word `--message=a b`), single quotes are literal, a backslash
955
+ * escapes the next character outside them (inside double quotes only `"`,
956
+ * `\`, `$` and `` ` ``), and adjacent parts join into one word. What a shell
957
+ * would read differently — an unterminated quote, a trailing backslash, an
958
+ * unquoted newline (a second command) — throws instead of being guessed at.
959
+ */
952
960
  function splitWords(line: string): string[] {
953
- return [...line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
961
+ const words: string[] = [];
962
+ let word = "";
963
+ let inWord = false;
964
+ let quote: "'" | '"' | undefined;
965
+ for (let i = 0; i < line.length; i++) {
966
+ const ch = line.charAt(i);
967
+ if (quote === "'") {
968
+ if (ch === "'") quote = undefined;
969
+ else word += ch;
970
+ } else if (quote === '"') {
971
+ if (ch === '"') quote = undefined;
972
+ else if (ch === "\\" && i + 1 < line.length && '"\\$`\n'.includes(line.charAt(i + 1))) {
973
+ i += 1;
974
+ if (line.charAt(i) !== "\n") word += line.charAt(i);
975
+ } else word += ch;
976
+ } else if (ch === " " || ch === "\t") {
977
+ if (inWord) words.push(word);
978
+ word = "";
979
+ inWord = false;
980
+ } else if (ch === "\n") {
981
+ throw new Error(
982
+ "command holds an unquoted newline — pass one git command per call, and quote a multi-line message",
983
+ );
984
+ } else if (ch === "\\") {
985
+ if (i + 1 >= line.length) throw new Error("command ends in a backslash that escapes nothing");
986
+ i += 1;
987
+ if (line.charAt(i) === "\n") continue;
988
+ word += line.charAt(i);
989
+ inWord = true;
990
+ } else if (ch === "'" || ch === '"') {
991
+ quote = ch;
992
+ inWord = true;
993
+ } else {
994
+ word += ch;
995
+ inWord = true;
996
+ }
997
+ }
998
+ if (quote) throw new Error(`command has an unterminated ${quote} quote`);
999
+ if (inWord) words.push(word);
1000
+ return words;
954
1001
  }
955
1002
 
956
1003
  /**
957
1004
  * The subcommand and arguments of a call, however the model wrote it: the
958
1005
  * subcommand alone (the documented form), a whole `git …` command line, or the
959
1006
  * subcommand as the first of `args`. Models wrote the other two often enough
960
- * to cost a turn per run, and each is unambiguous. The subcommand is checked
961
- * against the same pattern the schema uses, so a flag (`-c`, `-C`,
962
- * `--exec-path`) can no more arrive through `args[0]` or a command line than
963
- * through `command`.
1007
+ * to cost a turn per run, and each is unambiguous. Leading `git` words are
1008
+ * dropped, so `{ command: "git", args: ["status"] }` is not `git git status`.
1009
+ * The subcommand is checked against the same pattern the schema uses — in
1010
+ * every form, the documented one included, so the check does not rest on the
1011
+ * schema alone — and a flag (`-c`, `-C`, `--exec-path`) can no more arrive
1012
+ * through `args[0]` or a command line than through `command`.
964
1013
  */
965
1014
  export function normalizeGitCall(params: {
966
1015
  command?: string | undefined;
@@ -969,12 +1018,15 @@ export function normalizeGitCall(params: {
969
1018
  command: string;
970
1019
  args: string[];
971
1020
  } {
972
- let words: string[];
973
- if (params.command === undefined) words = [...(params.args ?? [])];
974
- else if (/^git\s/.test(params.command))
975
- words = [...splitWords(params.command).slice(1), ...(params.args ?? [])];
976
- else return { command: params.command, args: params.args ?? [] };
977
- const [command, ...args] = words[0] === "git" ? words.slice(1) : words;
1021
+ const words =
1022
+ params.command === undefined
1023
+ ? []
1024
+ : /^git\s/.test(params.command)
1025
+ ? splitWords(params.command)
1026
+ : [params.command];
1027
+ words.push(...(params.args ?? []));
1028
+ while (words[0] === "git") words.shift();
1029
+ const [command, ...args] = words;
978
1030
  if (command === undefined || !/^[a-z][a-z0-9-]*$/.test(command)) {
979
1031
  throw new Error(
980
1032
  `command must be a Git subcommand such as 'status' or 'checkout' (was "${command ?? ""}")`,
@@ -1,5 +1,6 @@
1
1
  import type { ToolState } from "#app/toolState";
2
2
  import type { ResolvedPayload } from "#app/utils/config/payload";
3
+ import type { OctokitWithPlugins } from "#app/utils/github/github";
3
4
 
4
5
  /**
5
6
  * The cwd-scoped, GitHub-free subset of `ToolContext` that the read-only
@@ -79,5 +80,9 @@ export interface LocalToolContext {
79
80
  /** the repository, for its default branch. The Action's full `ToolContext`
80
81
  * carries it; a local session has none, so its remediation branches keep the
81
82
  * plain `remediate/<id>` name. */
82
- repo?: { data: { default_branch?: string | null } } | undefined;
83
+ repo?: { owner?: string; name?: string; data: { default_branch?: string | null } } | undefined;
84
+ /** the Action's GitHub client, read only to find the remediation PRs already
85
+ * open, so a group that has one keeps that PR's branch. A local session has
86
+ * none and names its branches from the group and base alone. */
87
+ octokit?: OctokitWithPlugins | undefined;
83
88
  }
@@ -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
@@ -13,7 +13,7 @@ export const AWS_PRICE_REGION = "eu-west-2";
13
13
  /** Currency of every price below. The bulk API quotes USD for every region. */
14
14
  export const AWS_PRICE_CURRENCY = "USD";
15
15
  /** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
16
- export const AWS_PRICE_FETCHED_AT = "2026-09-23";
16
+ export const AWS_PRICE_FETCHED_AT = "2026-10-03";
17
17
 
18
18
  /** meter key → list price per HOUR. */
19
19
  export const AWS_HOURLY_PRICES: Record<string, number> = {