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.
- package/README.md +1 -1
- package/dist/agents/opencode.d.ts +6 -0
- package/dist/agents/quotaTokens.d.ts +6 -0
- package/dist/cli.mjs +846 -412
- package/dist/index.js +806 -372
- package/dist/internal.js +4 -4
- package/dist/mcp/git.d.ts +6 -4
- package/dist/mcp/localContext.d.ts +7 -0
- package/dist/mcp/reviewCommentLimit.d.ts +55 -5
- package/dist/mcp/reviewMarkers.d.ts +5 -0
- package/dist/mcp/staleFix.d.ts +36 -1
- package/dist/mcp/terraform/awsPrices.d.ts +1 -1
- package/dist/mcp/terraform/azurePrices.d.ts +1 -1
- package/dist/mcp/terraform/concernResult.d.ts +9 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
- package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
- package/dist/mcp/terraform/refactorScope.d.ts +14 -2
- package/dist/mcp/terraform/ruleContext.d.ts +3 -0
- package/dist/mcp/terraform/scanSession.d.ts +2 -0
- package/dist/mcp/terraform/tree.d.ts +5 -0
- package/dist/mcp/terraform/types.d.ts +2 -1
- package/dist/mcp/terraform/verifySites.d.ts +56 -0
- package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
- package/package.json +2 -2
- package/src/agents/opencode.ts +8 -1
- package/src/agents/quotaTokens.ts +7 -1
- package/src/mcp/git.ts +64 -12
- package/src/mcp/localContext.ts +6 -1
- package/src/mcp/moduleExtraction.ts +5 -4
- package/src/mcp/review.ts +52 -3
- package/src/mcp/reviewCommentLimit.ts +169 -26
- package/src/mcp/reviewMarkers.ts +11 -0
- package/src/mcp/reviewProvenance.ts +1 -1
- package/src/mcp/staleFix.ts +73 -2
- package/src/mcp/terraform/awsPrices.ts +1 -1
- package/src/mcp/terraform/azurePrices.ts +23 -23
- package/src/mcp/terraform/concernResult.ts +18 -9
- package/src/mcp/terraform/deltaSummary.ts +4 -0
- package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
- package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
- package/src/mcp/terraform/nativeScan.ts +4 -0
- package/src/mcp/terraform/refactor/equivalence.ts +42 -9
- package/src/mcp/terraform/refactorScope.ts +90 -21
- package/src/mcp/terraform/ruleContext.ts +4 -0
- package/src/mcp/terraform/scanDelta.ts +67 -26
- package/src/mcp/terraform/scanSession.ts +6 -0
- package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
- package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
- package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
- package/src/mcp/terraform/tools/readFindings.ts +2 -0
- package/src/mcp/terraform/tools/scan.ts +3 -0
- package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
- package/src/mcp/terraform/tree.ts +8 -0
- package/src/mcp/terraform/types.ts +2 -1
- package/src/mcp/terraform/verifySites.ts +124 -0
- package/src/modes/refactor.ts +1 -1
- package/src/modes/remediate-and-refactor.ts +1 -1
- package/src/modes/remediate.ts +1 -1
- 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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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 ?? ""}")`,
|
package/src/mcp/localContext.ts
CHANGED
|
@@ -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,
|
|
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
|
-
)
|
|
920
|
-
|
|
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 {
|
|
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
|
|
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 —
|
|
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
|
|
9
|
-
* the review found is lost, only folded. The gate is not involved: it
|
|
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
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
43
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
const escapeHtml = (s: string): string =>
|
|
95
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
77
|
-
|
|
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
|
}
|
package/src/mcp/reviewMarkers.ts
CHANGED
|
@@ -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
|
|
178
|
+
const { blocking } = verdict;
|
|
179
179
|
const renamedBlocking = delta.introduced.filter(
|
|
180
180
|
(f) => f.possiblyPreexisting && failsGate(f, [...(input.requiredControls ?? [])]),
|
|
181
181
|
).length;
|
package/src/mcp/staleFix.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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> = {
|