infraweaver 0.3.7 → 0.3.9
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 +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claudePretoolGate.d.ts +0 -36
- package/dist/agents/toolPlaneWatch.d.ts +31 -0
- package/dist/cli.mjs +31908 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31992 -18749
- package/dist/internal.js +133 -52
- package/dist/mcp/baseBranch.d.ts +41 -0
- package/dist/mcp/changeSummary.d.ts +1 -1
- package/dist/mcp/findingAudit.d.ts +2 -0
- package/dist/mcp/guardrails.d.ts +38 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/moduleExtraction.d.ts +5 -0
- package/dist/mcp/pr.d.ts +0 -31
- package/dist/mcp/reproveSuggestions.d.ts +27 -0
- package/dist/mcp/review.d.ts +126 -2
- package/dist/mcp/reviewCommentLimit.d.ts +17 -0
- package/dist/mcp/reviewComments.d.ts +9 -5
- package/dist/mcp/reviewDedup.d.ts +1 -0
- package/dist/mcp/reviewMarkers.d.ts +10 -0
- package/dist/mcp/reviewProvenance.d.ts +20 -6
- package/dist/mcp/shared.d.ts +15 -0
- package/dist/mcp/suggestionProver.d.ts +3 -2
- package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
- package/dist/mcp/terraform/consolidation.d.ts +26 -0
- package/dist/mcp/terraform/cost.d.ts +2 -2
- package/dist/mcp/terraform/currency.d.ts +11 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
- package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
- package/dist/mcp/terraform/evidence.d.ts +10 -0
- package/dist/mcp/terraform/gateScan.d.ts +2 -16
- package/dist/mcp/terraform/hcl.d.ts +15 -0
- package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
- package/dist/mcp/terraform/localModules.d.ts +29 -0
- package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
- package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
- package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
- package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
- package/dist/mcp/terraform/refactor/references.d.ts +15 -0
- package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
- package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
- package/dist/mcp/terraform/scanDelta.d.ts +28 -0
- package/dist/mcp/terraform/scanSession.d.ts +13 -0
- package/dist/mcp/terraform/scanners.d.ts +67 -18
- package/dist/mcp/terraform/secretScan.d.ts +19 -0
- package/dist/mcp/terraform/standardsReport.d.ts +1 -1
- package/dist/mcp/terraform/tools/plan.d.ts +1 -1
- package/dist/mcp/terraform/tools/scan.d.ts +7 -7
- package/dist/mcp/terraform/tools/validate.d.ts +7 -2
- package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
- package/dist/mcp/terraform/treeLayout.d.ts +57 -0
- package/dist/mcp/terraform/types.d.ts +1 -1
- package/dist/phases/applyPlan.d.ts +0 -2
- package/dist/toolState.d.ts +3 -0
- package/dist/utils/agent/codexOAuth.d.ts +1 -1
- package/dist/utils/cli.d.ts +1 -7
- package/dist/utils/cloud/cloudReport.d.ts +1 -1
- package/dist/utils/config/baseRefConfig.d.ts +1 -1
- package/dist/utils/config/infraweaverConfig.d.ts +1 -1
- package/dist/utils/config/payload.d.ts +5 -0
- package/dist/utils/markdownTable.d.ts +6 -0
- package/dist/utils/setup/toolLicensing.d.ts +1 -1
- package/dist/utils/unifiedDiff.d.ts +69 -0
- package/package.json +34 -28
- package/src/agents/claude.ts +48 -13
- package/src/agents/claudePretoolGate.ts +9 -17
- package/src/agents/sessionLabeler.ts +3 -3
- package/src/agents/toolPlaneWatch.ts +71 -0
- package/src/external.ts +3 -1
- package/src/mcp/assess.ts +8 -8
- package/src/mcp/baseBranch.ts +96 -0
- package/src/mcp/changeSummary.ts +10 -11
- package/src/mcp/checkSuite.ts +7 -10
- package/src/mcp/checkout.ts +13 -40
- package/src/mcp/crosswalk.ts +5 -0
- package/src/mcp/findingAudit.ts +1 -1
- package/src/mcp/git.ts +44 -2
- package/src/mcp/guardrails.ts +188 -36
- package/src/mcp/localContext.ts +2 -0
- package/src/mcp/localServer.ts +1 -0
- package/src/mcp/moduleExtraction.ts +17 -5
- package/src/mcp/moduleTests.ts +9 -2
- package/src/mcp/modules.ts +10 -7
- package/src/mcp/pr.ts +17 -65
- package/src/mcp/reproveSuggestions.ts +86 -0
- package/src/mcp/review.ts +249 -75
- package/src/mcp/reviewAnchor.ts +11 -23
- package/src/mcp/reviewCommentLimit.ts +79 -0
- package/src/mcp/reviewComments.ts +34 -81
- package/src/mcp/reviewDedup.ts +1 -1
- package/src/mcp/reviewMarkers.ts +25 -2
- package/src/mcp/reviewProvenance.ts +56 -21
- package/src/mcp/roots.ts +3 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/shared.ts +40 -1
- package/src/mcp/shell.ts +27 -28
- package/src/mcp/suggestionProver.ts +8 -4
- package/src/mcp/terraform/checkovSeverities.ts +1156 -0
- package/src/mcp/terraform/concernResult.ts +2 -1
- package/src/mcp/terraform/consolidation.ts +232 -19
- package/src/mcp/terraform/cost.ts +16 -7
- package/src/mcp/terraform/cspm.ts +8 -4
- package/src/mcp/terraform/currency.ts +28 -12
- package/src/mcp/terraform/decisions.ts +1 -1
- package/src/mcp/terraform/deltaSummary.ts +50 -1
- package/src/mcp/terraform/emittedArtefacts.ts +63 -0
- package/src/mcp/terraform/evidence.ts +23 -3
- package/src/mcp/terraform/gateScan.ts +191 -128
- package/src/mcp/terraform/hcl.ts +27 -2
- package/src/mcp/terraform/idleFloor.ts +2 -1
- package/src/mcp/terraform/initArtefacts.ts +76 -0
- package/src/mcp/terraform/localModules.ts +112 -0
- package/src/mcp/terraform/moduleDocs.ts +2 -5
- package/src/mcp/terraform/moduleFindings.ts +102 -0
- package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
- package/src/mcp/terraform/planPairs.ts +2 -20
- package/src/mcp/terraform/policyGate.ts +0 -15
- package/src/mcp/terraform/refactor/attribution.ts +39 -5
- package/src/mcp/terraform/refactor/equivalence.ts +49 -10
- package/src/mcp/terraform/refactor/expressions.ts +323 -0
- package/src/mcp/terraform/refactor/references.ts +101 -0
- package/src/mcp/terraform/refactor/resources.ts +79 -7
- package/src/mcp/terraform/refactor/substitution.ts +852 -256
- package/src/mcp/terraform/scanDelta.ts +41 -0
- package/src/mcp/terraform/scanSession.ts +30 -0
- package/src/mcp/terraform/scannerCache.ts +4 -0
- package/src/mcp/terraform/scanners.ts +553 -233
- package/src/mcp/terraform/secretScan.ts +131 -0
- package/src/mcp/terraform/standardsReport.ts +14 -13
- package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
- package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
- package/src/mcp/terraform/tools/emitOscal.ts +3 -2
- package/src/mcp/terraform/tools/emitVex.ts +10 -4
- package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
- package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
- package/src/mcp/terraform/tools/plan.ts +30 -17
- package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
- package/src/mcp/terraform/tools/scan.ts +5 -3
- package/src/mcp/terraform/tools/standardsReport.ts +1 -1
- package/src/mcp/terraform/tools/validate.ts +20 -5
- package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
- package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
- package/src/mcp/terraform/treeLayout.ts +378 -0
- package/src/mcp/terraform/types.ts +4 -1
- package/src/mcp/terraform/versionRequirements.ts +11 -2
- package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
- package/src/modes/modernize-deprecated.ts +2 -2
- package/src/modes/refactor.ts +9 -9
- package/src/modes/remediate.ts +1 -1
- package/src/modes/terraform-code-review.ts +7 -3
- package/src/modes/update-dependencies.ts +6 -6
- package/src/phases/applyPlan.ts +33 -14
- package/src/phases/applySuggestions.ts +2 -2
- package/src/phases/runAgentWithWatchdogs.ts +2 -1
- package/src/toolState.ts +7 -0
- package/src/utils/agent/codexOAuth.ts +7 -7
- package/src/utils/agent/openCodeModels.ts +4 -6
- package/src/utils/cli.ts +1 -31
- package/src/utils/cloud/runContext.ts +1 -8
- package/src/utils/config/baseRefConfig.ts +6 -0
- package/src/utils/config/infraweaverConfig.ts +2 -0
- package/src/utils/config/payload.ts +41 -0
- package/src/utils/github/assets.ts +1 -6
- package/src/utils/github/github.ts +42 -150
- package/src/utils/github/token.ts +6 -11
- package/src/utils/log.ts +4 -5
- package/src/utils/markdownTable.ts +11 -0
- package/src/utils/prompt/changeImpact.ts +18 -26
- package/src/utils/prompt/instructions.ts +1 -1
- package/src/utils/prompt/promptDirectives.ts +1 -1
- package/src/utils/prompt/toolSelection.ts +11 -2
- package/src/utils/setup/toolLicensing.ts +7 -0
- package/src/utils/telemetry.ts +2 -1
- package/src/utils/unifiedDiff.ts +120 -0
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
groupConcernsByRule,
|
|
13
13
|
planBatches,
|
|
14
14
|
} from "#app/mcp/terraform/decisions";
|
|
15
|
-
import { scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
15
|
+
import { recordConcernKeys, scanSessionOf } from "#app/mcp/terraform/scanSession";
|
|
16
16
|
import { suppressConcerns } from "#app/mcp/terraform/suppressions";
|
|
17
17
|
import { dedupeCrossScanner } from "#app/mcp/terraform/taxonomy";
|
|
18
18
|
import {
|
|
@@ -114,6 +114,7 @@ export function buildConcernResult(
|
|
|
114
114
|
session.baselineConcernKeys,
|
|
115
115
|
fullBaseline.map((c) => concernKeyOf(c)),
|
|
116
116
|
);
|
|
117
|
+
recordConcernKeys(session, fullBaseline);
|
|
117
118
|
// the files already unformatted at this (pre-fix) scan. terraform_validate
|
|
118
119
|
// subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
|
|
119
120
|
// whole-file reformat into a scoped fix. Captured from the raw set (fmt
|
|
@@ -40,11 +40,22 @@
|
|
|
40
40
|
* the expression as text. Multiplicity is part of the key, so those dirs
|
|
41
41
|
* simply never group.
|
|
42
42
|
*
|
|
43
|
+
* MODULE CALLS IN THE SHAPE. A root's call of a LOCAL module that the
|
|
44
|
+
* cluster's resources read is part of the stack as much as they are, so it can
|
|
45
|
+
* join the cluster: the proposed module then wraps the call (`module.data` → `module.<new>.module.data`, one
|
|
46
|
+
* `moved {}` block) and a reference into it stays inside the module instead of
|
|
47
|
+
* crossing the boundary as an input. Only calls the proof can follow make it
|
|
48
|
+
* in — a local source, no `count`/`for_each`/`providers`/`depends_on` — and a
|
|
49
|
+
* cluster that wraps calls is only the WIDER offer: when it cannot be
|
|
50
|
+
* proposed, the same roots are still considered without their calls.
|
|
51
|
+
*
|
|
43
52
|
* Pure: an already-read tree in, structure out. No filesystem, no subprocess.
|
|
44
53
|
*/
|
|
45
54
|
|
|
55
|
+
import { posix } from "node:path";
|
|
46
56
|
import { classifyRootDirs, detectEnvironmentTwins, groupFilesByDir } from "#app/mcp/roots";
|
|
47
57
|
import { parseBlocks, topLevelEntries } from "#app/mcp/terraform/hcl";
|
|
58
|
+
import { localSourceFrom } from "#app/mcp/terraform/localModules";
|
|
48
59
|
import { generateMovedBlocks, type MovedBlock } from "#app/mcp/terraform/refactor/movedBlocks";
|
|
49
60
|
import { type Multiplicity, parseResourcesWithArgs } from "#app/mcp/terraform/refactor/resources";
|
|
50
61
|
import type { SourceFile } from "#app/mcp/terraform/tree";
|
|
@@ -75,8 +86,16 @@ const MODELLED_META: ReadonlySet<string> = new Set(["count", "for_each"]);
|
|
|
75
86
|
|
|
76
87
|
/** expressions whose meaning is scoped to the ROOT that writes them. Identical
|
|
77
88
|
* text in two roots does NOT mean an identical value — `var.bucket_name`
|
|
78
|
-
* resolves per root — so these become module variables even when they match.
|
|
79
|
-
|
|
89
|
+
* resolves per root — so these become module variables even when they match.
|
|
90
|
+
* The lookbehind keeps `aws_s3_bucket.data.id` and `module.data.x` from reading
|
|
91
|
+
* as a data source. `module.` is judged per cluster — see [[isRootScoped]]. */
|
|
92
|
+
const ROOT_SCOPED = /(?<![\w.-])(?:var|local|data|terraform)\./;
|
|
93
|
+
const MODULE_REF = /(?<![\w.-])module\.([A-Za-z_][\w-]*)/g;
|
|
94
|
+
|
|
95
|
+
/** a module call that cannot join a cluster: the proof does not follow a
|
|
96
|
+
* relocated call's repetition or provider mapping, and `depends_on` is as
|
|
97
|
+
* invisible on a call as on a resource. */
|
|
98
|
+
const CALL_META: ReadonlySet<string> = new Set(["count", "for_each", "providers", "depends_on"]);
|
|
80
99
|
|
|
81
100
|
/** expressions that cannot survive relocation into a module at all. `path.module`
|
|
82
101
|
* resolves to the directory the code LIVES in, so moving the code changes its
|
|
@@ -120,6 +139,10 @@ export interface MemberResource {
|
|
|
120
139
|
invisibleMeta: string[];
|
|
121
140
|
/** attributes whose value did not parse to a close — a truncated file. */
|
|
122
141
|
unreadable: string[];
|
|
142
|
+
/** set on a module CALL (`type` is `module`): the repo-relative directory its
|
|
143
|
+
* local source resolves to. Part of the shape key — two calls of different
|
|
144
|
+
* modules are not the same shape, whatever their inputs. */
|
|
145
|
+
call?: { dir: string };
|
|
123
146
|
}
|
|
124
147
|
|
|
125
148
|
/** Read one directory's resources, values included. */
|
|
@@ -146,7 +169,18 @@ function readResources(files: readonly SourceFile[]): MemberResource[] {
|
|
|
146
169
|
continue;
|
|
147
170
|
}
|
|
148
171
|
if (MODELLED_META.has(entry.name)) continue;
|
|
149
|
-
|
|
172
|
+
// a `dynamic` block generates nested blocks, so it is judged like one:
|
|
173
|
+
// carried verbatim when every member writes it alike and it reads
|
|
174
|
+
// nothing the root keeps, refused otherwise. Skipping it let two roots
|
|
175
|
+
// whose blocks differed — tcp in one, udp in the other — share one body.
|
|
176
|
+
if (entry.kind === "dynamic") {
|
|
177
|
+
const bodies = parseBlocks(block.body, ["dynamic"])
|
|
178
|
+
.filter((d) => d.labels[0] === entry.name)
|
|
179
|
+
.map((d) => normaliseValue(d.body));
|
|
180
|
+
values.set(`dynamic "${entry.name}"`, { kind: "block", text: bodies.join("\n") });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (entry.value === null) continue;
|
|
150
184
|
if (!entry.complete) {
|
|
151
185
|
unreadable.push(entry.name);
|
|
152
186
|
continue;
|
|
@@ -170,6 +204,72 @@ function readResources(files: readonly SourceFile[]): MemberResource[] {
|
|
|
170
204
|
return out.sort((a, b) => byText(`${a.type}.${a.name}`, `${b.type}.${b.name}`));
|
|
171
205
|
}
|
|
172
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Read one root directory's module calls that may join a cluster: a local
|
|
209
|
+
* source inside the repository and none of [[CALL_META]]. Any other call stays
|
|
210
|
+
* where it is, exactly as before calls could join — a reference into it crosses
|
|
211
|
+
* the new module's boundary as an input.
|
|
212
|
+
*/
|
|
213
|
+
function readModuleCalls(dir: string, files: readonly SourceFile[]): MemberResource[] {
|
|
214
|
+
const out: MemberResource[] = [];
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
for (const block of parseBlocks(file.hcl, ["module"])) {
|
|
217
|
+
const [name] = block.labels;
|
|
218
|
+
if (!name) continue;
|
|
219
|
+
const entries = topLevelEntries(block.body);
|
|
220
|
+
if (entries.some((e) => CALL_META.has(e.name))) continue;
|
|
221
|
+
const source = entries.find((e) => e.name === "source")?.value?.trim() ?? "";
|
|
222
|
+
const local = /^"(\.\.?\/[^"$]*)"$/.exec(source)?.[1];
|
|
223
|
+
if (!local) continue;
|
|
224
|
+
const target = posix.normalize(posix.join(dir || ".", local)).replace(/\/$/, "");
|
|
225
|
+
if (target === ".." || target.startsWith("../")) continue;
|
|
226
|
+
const values = new Map<string, { kind: "attribute" | "block"; text: string }>();
|
|
227
|
+
const unreadable: string[] = [];
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (entry.name === "source" || entry.value === null) continue;
|
|
230
|
+
if (!entry.complete) {
|
|
231
|
+
unreadable.push(entry.name);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
values.set(entry.name, {
|
|
235
|
+
kind: entry.kind === "block" ? "block" : "attribute",
|
|
236
|
+
text: normaliseValue(entry.value),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
out.push({
|
|
240
|
+
type: "module",
|
|
241
|
+
name,
|
|
242
|
+
argNames: [...values.keys()].sort(),
|
|
243
|
+
multiplicity: null,
|
|
244
|
+
values,
|
|
245
|
+
invisibleMeta: [],
|
|
246
|
+
unreadable: unreadable.sort(),
|
|
247
|
+
call: { dir: target },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** whether a value reaches something that stays in the ROOT when `cluster`
|
|
255
|
+
* moves into a module: a variable, local, data source or terraform setting; a
|
|
256
|
+
* module call the cluster does not wrap; or a resource the cluster leaves
|
|
257
|
+
* behind. Its text may match in every member while its value differs. */
|
|
258
|
+
function isRootScoped(
|
|
259
|
+
text: string,
|
|
260
|
+
cluster: readonly MemberResource[],
|
|
261
|
+
rootAddresses: readonly string[],
|
|
262
|
+
): boolean {
|
|
263
|
+
if (ROOT_SCOPED.test(text)) return true;
|
|
264
|
+
const inside = new Set(cluster.map((r) => `${r.type}.${r.name}`));
|
|
265
|
+
for (const m of text.matchAll(MODULE_REF)) if (!inside.has(`module.${m[1]}`)) return true;
|
|
266
|
+
return rootAddresses.some(
|
|
267
|
+
(address) =>
|
|
268
|
+
!inside.has(address) &&
|
|
269
|
+
new RegExp(`(?<![\\w.-])${address.replace(/[.]/g, "\\.")}(?![\\w-])`).test(text),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
173
273
|
// --- the shape key ------------------------------------------------------------
|
|
174
274
|
|
|
175
275
|
/** the multiplicity half of the key — `count`/`for_each` with the expression as
|
|
@@ -188,7 +288,8 @@ export function shapeKey(resources: readonly MemberResource[]): string {
|
|
|
188
288
|
return resources
|
|
189
289
|
.map(
|
|
190
290
|
(r) =>
|
|
191
|
-
`${r.type}.${r.name}|${[...r.argNames].sort().join(",")}|${multiplicitySignature(r.multiplicity)}
|
|
291
|
+
`${r.type}.${r.name}|${[...r.argNames].sort().join(",")}|${multiplicitySignature(r.multiplicity)}` +
|
|
292
|
+
(r.call ? `|source=${r.call.dir}` : ""),
|
|
192
293
|
)
|
|
193
294
|
.join("\n");
|
|
194
295
|
}
|
|
@@ -216,23 +317,87 @@ interface Cluster {
|
|
|
216
317
|
* unrelated resources, so requiring whole-directory equality would fire on
|
|
217
318
|
* almost no real repository — while prefix cohesion alone would miss a root
|
|
218
319
|
* whose every resource belongs to the one stack.
|
|
320
|
+
*
|
|
321
|
+
* Each comes twice when one of the cluster's resources READS a wrappable module
|
|
322
|
+
* call: with that call, and without it. Only a call the resources read is
|
|
323
|
+
* worth wrapping — leaving it behind is what would force the reference across
|
|
324
|
+
* the new module's boundary. A call that merely takes a cluster resource as an
|
|
325
|
+
* input (a resource group's name, say) stays in the root: wrapping it with
|
|
326
|
+
* that resource adds a layer and consolidates nothing.
|
|
219
327
|
*/
|
|
220
|
-
function clustersOf(
|
|
328
|
+
function clustersOf(
|
|
329
|
+
dir: string,
|
|
330
|
+
resources: MemberResource[],
|
|
331
|
+
calls: MemberResource[] = [],
|
|
332
|
+
): Cluster[] {
|
|
221
333
|
if (resources.length === 0) return [];
|
|
222
|
-
const out: Cluster[] = [
|
|
334
|
+
const out: Cluster[] = [];
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const add = (prefix: string | null, members: MemberResource[]) => {
|
|
337
|
+
// a prefix group equal to the whole directory is the same cluster twice.
|
|
338
|
+
const key = members.map((m) => `${m.type}.${m.name}`).join("\n");
|
|
339
|
+
if (seen.has(key)) return;
|
|
340
|
+
seen.add(key);
|
|
341
|
+
if (readByLeftBehind(members, [...resources, ...calls])) return;
|
|
342
|
+
out.push({ dir, prefix, resources: members });
|
|
343
|
+
};
|
|
344
|
+
const withReadCalls = (members: MemberResource[]) =>
|
|
345
|
+
[...members, ...calls.filter((c) => readsCall(members, c.name))].sort((a, b) =>
|
|
346
|
+
byText(`${a.type}.${a.name}`, `${b.type}.${b.name}`),
|
|
347
|
+
);
|
|
348
|
+
add(null, withReadCalls(resources));
|
|
349
|
+
add(null, resources);
|
|
223
350
|
const byPrefix = new Map<string, MemberResource[]>();
|
|
224
351
|
for (const r of resources) {
|
|
225
352
|
const p = namePrefix(r.name);
|
|
226
353
|
byPrefix.set(p, [...(byPrefix.get(p) ?? []), r]);
|
|
227
354
|
}
|
|
228
355
|
for (const [prefix, members] of [...byPrefix].sort(([a], [b]) => byText(a, b))) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
out.push({ dir, prefix, resources: members });
|
|
356
|
+
add(prefix, withReadCalls(members));
|
|
357
|
+
add(prefix, members);
|
|
232
358
|
}
|
|
233
359
|
return out;
|
|
234
360
|
}
|
|
235
361
|
|
|
362
|
+
/** a reference to `address` (`type.name` or `module.name`) in HCL text. Only
|
|
363
|
+
* the `${…}` interpolations of a string literal count: a description that
|
|
364
|
+
* mentions an address reads nothing. Hyphens bound a name, as they do in HCL
|
|
365
|
+
* identifiers. */
|
|
366
|
+
function readsAddress(text: string, address: string): boolean {
|
|
367
|
+
const ref = new RegExp(`(?<![\\w.-])${address.replace(/[.-]/g, "\\$&")}\\.`);
|
|
368
|
+
const code = text.replace(
|
|
369
|
+
/"((?:[^"\\]|\\.)*)"/g,
|
|
370
|
+
(_, inner: string) => ` ${[...inner.matchAll(/\$\{([^}]*)\}/g)].map((m) => m[1]).join(" ")} `,
|
|
371
|
+
);
|
|
372
|
+
return ref.test(code);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** whether any of `resources` reads an output of module call `name`. */
|
|
376
|
+
function readsCall(resources: readonly MemberResource[], name: string): boolean {
|
|
377
|
+
return resources.some((r) =>
|
|
378
|
+
[...r.values.values()].some((v) => readsAddress(v.text, `module.${name}`)),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** whether something the cluster leaves in the root reads one of its members.
|
|
383
|
+
* Moved into a module, that member is reachable only through an output, and
|
|
384
|
+
* the proposal carries none — so the reference would go stale. */
|
|
385
|
+
function readByLeftBehind(
|
|
386
|
+
members: readonly MemberResource[],
|
|
387
|
+
all: readonly MemberResource[],
|
|
388
|
+
): boolean {
|
|
389
|
+
const addressOf = (m: MemberResource) =>
|
|
390
|
+
m.type === "module" ? `module.${m.name}` : `${m.type}.${m.name}`;
|
|
391
|
+
const inside = new Set(members.map(addressOf));
|
|
392
|
+
return all.some(
|
|
393
|
+
(other) =>
|
|
394
|
+
!inside.has(addressOf(other)) &&
|
|
395
|
+
[...other.values.values()].some((v) =>
|
|
396
|
+
[...inside].some((address) => readsAddress(v.text, address)),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
236
401
|
// --- the group ----------------------------------------------------------------
|
|
237
402
|
|
|
238
403
|
type VariableClass = "differing" | "reference";
|
|
@@ -257,8 +422,20 @@ interface ConsolidationGroup {
|
|
|
257
422
|
* path, else null. A LABEL on a group established from content — never the
|
|
258
423
|
* thing that formed it. */
|
|
259
424
|
pattern: string | null;
|
|
260
|
-
members: {
|
|
425
|
+
members: {
|
|
426
|
+
dir: string;
|
|
427
|
+
environment: string | null;
|
|
428
|
+
name_prefix: string | null;
|
|
429
|
+
/** the `source` this member's call writes: `proposed_module.path` is
|
|
430
|
+
* repo-relative, and a local source is relative to the caller. */
|
|
431
|
+
call_source: string;
|
|
432
|
+
}[];
|
|
433
|
+
/** a module CALL the shape includes is here too, as type `module`. */
|
|
261
434
|
resources: { type: string; name: string; arg_names: string[]; multiplicity: string | null }[];
|
|
435
|
+
/** the module calls the new module wraps: each moves into it keeping its
|
|
436
|
+
* name, and `source` is what it writes THERE — relative to the new module's
|
|
437
|
+
* directory, not to the roots it came from. */
|
|
438
|
+
wrapped_calls: { name: string; source: string }[];
|
|
262
439
|
proposed_module: { name: string; path: string; call_name: string };
|
|
263
440
|
/** arguments identical across every member: these go in the module body
|
|
264
441
|
* verbatim, and are NOT variables. `kind` says whether the text is an
|
|
@@ -321,13 +498,24 @@ const MIN_MEMBERS_FOR_SINGLE_RESOURCE = 3;
|
|
|
321
498
|
* stack, drifted"; below it they are simply different stacks. */
|
|
322
499
|
const NEAR_OVERLAP = 0.5;
|
|
323
500
|
|
|
324
|
-
function suggestModuleName(
|
|
325
|
-
|
|
501
|
+
function suggestModuleName(
|
|
502
|
+
members: readonly MemberResource[],
|
|
503
|
+
prefix: string | null,
|
|
504
|
+
taken: ReadonlySet<string>,
|
|
505
|
+
): string {
|
|
506
|
+
const resources = members.filter((r) => !r.call);
|
|
326
507
|
const names = new Set(resources.map((r) => r.name));
|
|
327
|
-
if (names.size === 1) return [...names][0] as string;
|
|
328
508
|
// the commonest type's service words, e.g. `aws_s3_bucket` → `s3_bucket`.
|
|
329
|
-
const
|
|
330
|
-
|
|
509
|
+
const base =
|
|
510
|
+
prefix ??
|
|
511
|
+
(names.size === 1
|
|
512
|
+
? ([...names][0] as string)
|
|
513
|
+
: (resources[0]?.type ?? "shared").replace(/^[a-z]+_/, ""));
|
|
514
|
+
// never a directory the tree already has: the proposal would author into an
|
|
515
|
+
// existing module — the very one it wraps, when the names coincide.
|
|
516
|
+
let name = base;
|
|
517
|
+
for (let i = 2; taken.has(`modules/${name}`); i++) name = `${base}_${i}`;
|
|
518
|
+
return name;
|
|
331
519
|
}
|
|
332
520
|
|
|
333
521
|
/**
|
|
@@ -342,6 +530,7 @@ function suggestModuleName(resources: readonly MemberResource[], prefix: string
|
|
|
342
530
|
function classifyEntry(
|
|
343
531
|
kind: "attribute" | "block",
|
|
344
532
|
values: ({ kind: "attribute" | "block"; text: string } | undefined)[],
|
|
533
|
+
rootScopedIn: (text: string, member: number) => boolean,
|
|
345
534
|
): { kind: "identical" | "differing" | "reference" } | { kind: "refused"; reason: string } {
|
|
346
535
|
if (values.some((v) => v === undefined)) {
|
|
347
536
|
return { kind: "refused", reason: "not declared by every member" };
|
|
@@ -362,7 +551,7 @@ function classifyEntry(
|
|
|
362
551
|
};
|
|
363
552
|
}
|
|
364
553
|
const identical = new Set(texts).size === 1;
|
|
365
|
-
const rootScoped = texts.some((t) =>
|
|
554
|
+
const rootScoped = texts.some((t, i) => rootScopedIn(t, i));
|
|
366
555
|
// A NESTED BLOCK can only be carried into the module body VERBATIM. There is
|
|
367
556
|
// no variable that stands in for a block: `versioning_configuration = var.x`
|
|
368
557
|
// is not the block, and parameterising the one attribute inside it that
|
|
@@ -374,7 +563,7 @@ function classifyEntry(
|
|
|
374
563
|
kind: "refused",
|
|
375
564
|
reason: !identical
|
|
376
565
|
? "it is a nested block whose contents differ between members, and a block cannot be replaced by a variable — parameterise the attribute inside it by hand"
|
|
377
|
-
: "it is a nested block containing a root-scoped reference (`var.`/`local.`/`data.`/`module
|
|
566
|
+
: "it is a nested block containing a root-scoped reference (`var.`/`local.`/`data.`/`module.`, or a resource the module would leave behind), which resolves per root and so cannot move into a shared module verbatim",
|
|
378
567
|
};
|
|
379
568
|
}
|
|
380
569
|
if (rootScoped) return { kind: "reference" };
|
|
@@ -398,17 +587,20 @@ export function findConsolidationGroups(
|
|
|
398
587
|
|
|
399
588
|
// resources per ROOT directory. A non-root dir has no state of its own, so a
|
|
400
589
|
// `moved {}` block there would name an address in nobody's state.
|
|
590
|
+
const existingDirs = new Set(byDir.map(([dir]) => dir));
|
|
401
591
|
const resourcesByDir = new Map<string, MemberResource[]>();
|
|
592
|
+
const callsByDir = new Map<string, MemberResource[]>();
|
|
402
593
|
for (const [dir, dirFiles] of byDir) {
|
|
403
594
|
if (!roots.has(dir)) continue;
|
|
404
595
|
const resources = readResources(dirFiles);
|
|
405
596
|
if (resources.length > 0) resourcesByDir.set(dir, resources);
|
|
597
|
+
callsByDir.set(dir, readModuleCalls(dir, dirFiles));
|
|
406
598
|
}
|
|
407
599
|
|
|
408
600
|
// every candidate cluster, keyed by exact shape.
|
|
409
601
|
const byShape = new Map<string, Cluster[]>();
|
|
410
602
|
for (const [dir, resources] of resourcesByDir) {
|
|
411
|
-
for (const cluster of clustersOf(dir, resources)) {
|
|
603
|
+
for (const cluster of clustersOf(dir, resources, callsByDir.get(dir))) {
|
|
412
604
|
const key = shapeKey(cluster.resources);
|
|
413
605
|
byShape.set(key, [...(byShape.get(key) ?? []), cluster]);
|
|
414
606
|
}
|
|
@@ -441,11 +633,16 @@ export function findConsolidationGroups(
|
|
|
441
633
|
break;
|
|
442
634
|
}
|
|
443
635
|
const dirs = clusters.map((c) => c.dir).sort(byText);
|
|
636
|
+
// a cluster wrapping module calls is the wider of two offers for the same
|
|
637
|
+
// roots. When it cannot be proposed it neither declines nor claims them:
|
|
638
|
+
// the cluster without the calls comes next and speaks for those roots.
|
|
639
|
+
const wraps = first.resources.some((r) => r.call);
|
|
444
640
|
|
|
445
641
|
const meta = [
|
|
446
642
|
...new Set(clusters.flatMap((c) => c.resources.flatMap((r) => r.invisibleMeta))),
|
|
447
643
|
].sort();
|
|
448
644
|
if (meta.length > 0) {
|
|
645
|
+
if (wraps) continue;
|
|
449
646
|
declined.push({
|
|
450
647
|
dirs,
|
|
451
648
|
reason:
|
|
@@ -461,6 +658,7 @@ export function findConsolidationGroups(
|
|
|
461
658
|
...new Set(clusters.flatMap((c) => c.resources.flatMap((r) => r.unreadable))),
|
|
462
659
|
].sort();
|
|
463
660
|
if (unreadable.length > 0) {
|
|
661
|
+
if (wraps) continue;
|
|
464
662
|
declined.push({
|
|
465
663
|
dirs,
|
|
466
664
|
reason: `the value of \`${unreadable.join("`, `")}\` could not be read to a close (a truncated or malformed file), and a value nobody read cannot be compared`,
|
|
@@ -490,6 +688,12 @@ export function findConsolidationGroups(
|
|
|
490
688
|
const verdict = classifyEntry(
|
|
491
689
|
entry.kind,
|
|
492
690
|
perMember.map((p) => p.value),
|
|
691
|
+
(text, i) =>
|
|
692
|
+
isRootScoped(
|
|
693
|
+
text,
|
|
694
|
+
ordered[i]?.resources ?? [],
|
|
695
|
+
(resourcesByDir.get(ordered[i]?.dir ?? "") ?? []).map((r) => `${r.type}.${r.name}`),
|
|
696
|
+
),
|
|
493
697
|
);
|
|
494
698
|
if (verdict.kind === "refused") {
|
|
495
699
|
refusals.push(`${address}.${argument}: ${verdict.reason}`);
|
|
@@ -517,6 +721,7 @@ export function findConsolidationGroups(
|
|
|
517
721
|
}
|
|
518
722
|
|
|
519
723
|
if (refusals.length > 0) {
|
|
724
|
+
if (wraps) continue;
|
|
520
725
|
declined.push({ dirs, reason: refusals.join("; ") });
|
|
521
726
|
for (const c of clusters) claimed.add(c.dir);
|
|
522
727
|
continue;
|
|
@@ -532,8 +737,10 @@ export function findConsolidationGroups(
|
|
|
532
737
|
// name, so the type qualifier alone still collides and the agent would author
|
|
533
738
|
// two `variable` blocks with one name — invalid HCL. Escalate those to the
|
|
534
739
|
// resource's own name, which is unique within a directory by definition.
|
|
740
|
+
// A module call has no type worth naming, so its own name stands in.
|
|
535
741
|
const qualify = (v: ConsolidationVariable, by: "type" | "address") => {
|
|
536
742
|
const [type = "", name = ""] = v.resource.split(".");
|
|
743
|
+
if (type === "module") return `${name}_${v.argument}`;
|
|
537
744
|
const short = type.replace(/^[a-z]+_/, "");
|
|
538
745
|
const prefix = by === "type" ? short : `${short}_${name}`;
|
|
539
746
|
return `${prefix}_${v.argument}`;
|
|
@@ -554,7 +761,7 @@ export function findConsolidationGroups(
|
|
|
554
761
|
v.name = qualify(v, "address");
|
|
555
762
|
}
|
|
556
763
|
|
|
557
|
-
const moduleName = suggestModuleName(first.resources, first.prefix);
|
|
764
|
+
const moduleName = suggestModuleName(first.resources, first.prefix, existingDirs);
|
|
558
765
|
const callName = moduleName;
|
|
559
766
|
groups.push({
|
|
560
767
|
pattern: twin?.pattern ?? null,
|
|
@@ -562,6 +769,7 @@ export function findConsolidationGroups(
|
|
|
562
769
|
dir: c.dir,
|
|
563
770
|
environment: twin?.members.find((m) => m.dir === c.dir)?.environment ?? null,
|
|
564
771
|
name_prefix: c.prefix,
|
|
772
|
+
call_source: localSourceFrom(c.dir, `modules/${moduleName}`),
|
|
565
773
|
})),
|
|
566
774
|
resources: first.resources.map((r) => ({
|
|
567
775
|
type: r.type,
|
|
@@ -569,6 +777,11 @@ export function findConsolidationGroups(
|
|
|
569
777
|
arg_names: r.argNames,
|
|
570
778
|
multiplicity: r.multiplicity ? multiplicitySignature(r.multiplicity) : null,
|
|
571
779
|
})),
|
|
780
|
+
wrapped_calls: first.resources.flatMap((r) =>
|
|
781
|
+
r.call
|
|
782
|
+
? [{ name: r.name, source: localSourceFrom(`modules/${moduleName}`, r.call.dir) }]
|
|
783
|
+
: [],
|
|
784
|
+
),
|
|
572
785
|
proposed_module: {
|
|
573
786
|
name: moduleName,
|
|
574
787
|
path: `./modules/${moduleName}`,
|
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { type } from "arktype";
|
|
4
4
|
import { resolveBaseRef } from "#app/mcp/terraform/paths";
|
|
5
5
|
import { parseScannerJson } from "#app/mcp/terraform/scannerJson";
|
|
6
|
-
import { type RunResult, run } from "#app/mcp/terraform/subprocess";
|
|
6
|
+
import { type RunResult, run, runAsync } from "#app/mcp/terraform/subprocess";
|
|
7
7
|
import { assertToolchainIntegrity } from "#app/mcp/terraform/toolchainIntegrity";
|
|
8
8
|
|
|
9
9
|
// --- infracost (cost lens) ------------------------------------------------
|
|
@@ -132,16 +132,21 @@ export function classifyCostEscalation(
|
|
|
132
132
|
return { escalate: false };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
|
|
135
|
+
export function runInfracostBreakdown(scanCwd: string, key: string): Promise<RunResult> {
|
|
136
136
|
// Same supply-chain posture as trivy/tflint/checkov: probe the version and
|
|
137
137
|
// refuse a denylisted build BEFORE handing the binary a credential. There is
|
|
138
138
|
// no infracost advisory today, so this is a no-op gate — but it records the
|
|
139
139
|
// observed version for evidence provenance, and a future advisory needs only
|
|
140
140
|
// a denylist row (the stated rationale for probing checkov).
|
|
141
141
|
assertToolchainIntegrity("infracost", scanCwd);
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
return runAsync(
|
|
143
|
+
"infracost",
|
|
144
|
+
["breakdown", "--path", ".", "--format", "json", "--no-color"],
|
|
145
|
+
scanCwd,
|
|
146
|
+
{
|
|
147
|
+
INFRACOST_API_KEY: key,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
/**
|
|
@@ -150,7 +155,11 @@ export function runInfracostBreakdown(scanCwd: string, key: string): RunResult {
|
|
|
150
155
|
* any failure (no base ref, worktree add fails, infracost errors) returns null
|
|
151
156
|
* and the caller falls back to reporting current cost only.
|
|
152
157
|
*/
|
|
153
|
-
export function infracostBaseline(
|
|
158
|
+
export async function infracostBaseline(
|
|
159
|
+
cwd: string,
|
|
160
|
+
key: string,
|
|
161
|
+
tmpdir: string,
|
|
162
|
+
): Promise<CostBreakdown | null> {
|
|
154
163
|
const baseRef = resolveBaseRef(cwd);
|
|
155
164
|
if (!baseRef) return null;
|
|
156
165
|
const prefixResult = run("git", ["rev-parse", "--show-prefix"], cwd);
|
|
@@ -170,7 +179,7 @@ export function infracostBaseline(cwd: string, key: string, tmpdir: string): Cos
|
|
|
170
179
|
}
|
|
171
180
|
try {
|
|
172
181
|
const scanCwd = prefix ? join(worktree, prefix) : worktree;
|
|
173
|
-
const r = runInfracostBreakdown(scanCwd, key);
|
|
182
|
+
const r = await runInfracostBreakdown(scanCwd, key);
|
|
174
183
|
if (r.missing || r.status !== 0) return null;
|
|
175
184
|
return parseInfracostBreakdown(r.stdout);
|
|
176
185
|
} catch {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type } from "arktype";
|
|
2
|
+
import { BLOCK_LABEL, unquoteLabel } from "#app/mcp/terraform/hcl";
|
|
2
3
|
import { buildConcern, type Concern, type Severity } from "#app/mcp/terraform/types";
|
|
3
4
|
import { neutralizeOptional } from "#app/mcp/untrustedContent";
|
|
4
5
|
|
|
@@ -89,7 +90,10 @@ export interface TfResourceEntry {
|
|
|
89
90
|
identifiers: string[];
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
const RESOURCE_HEADER =
|
|
93
|
+
const RESOURCE_HEADER = new RegExp(
|
|
94
|
+
String.raw`(^|\n)([ \t]*)resource\s+(${BLOCK_LABEL})\s+(${BLOCK_LABEL})\s*\{`,
|
|
95
|
+
"g",
|
|
96
|
+
);
|
|
93
97
|
|
|
94
98
|
/** Collect the string literals inside the block that opens at `openBraceIdx`, scanning
|
|
95
99
|
* to the matching close brace (approximate — good enough to correlate an id/ARN). */
|
|
@@ -127,9 +131,9 @@ export function indexTfResources(
|
|
|
127
131
|
let m: RegExpExecArray | null;
|
|
128
132
|
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
|
|
129
133
|
while ((m = RESOURCE_HEADER.exec(hcl)) !== null) {
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
134
|
+
if (m[3] === undefined || m[4] === undefined) continue;
|
|
135
|
+
const type = unquoteLabel(m[3]);
|
|
136
|
+
const name = unquoteLabel(m[4]);
|
|
133
137
|
const headerStart = m.index + (m[1] ? m[1].length : 0);
|
|
134
138
|
const line = hcl.slice(0, headerStart).split("\n").length;
|
|
135
139
|
const openBraceIdx = hcl.indexOf("{", m.index);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
getMajor,
|
|
27
27
|
getMinor,
|
|
28
28
|
getPrerelease,
|
|
29
|
-
|
|
29
|
+
isLessThan,
|
|
30
30
|
normalize,
|
|
31
31
|
sortReversed,
|
|
32
32
|
} from "verkit";
|
|
@@ -103,6 +103,12 @@ export interface CurrencyVerdict {
|
|
|
103
103
|
/** how many MAJORs the constraint's best version trails the latest by —
|
|
104
104
|
* >0 signals an interface-risk upgrade that must be `needs-human`. */
|
|
105
105
|
majorsBehind: number;
|
|
106
|
+
/** the newest stable version in the major the constraint already admits,
|
|
107
|
+
* when the constraint does not admit it — the upgrade that crosses no major.
|
|
108
|
+
* An exact pin `3.15.1` with `3.15.2` published is outdated even when the
|
|
109
|
+
* latest release is two majors on; reporting only `latest` hides the one
|
|
110
|
+
* bump that needs nobody's sign-off. Null when there is none. */
|
|
111
|
+
latestInMajor: string | null;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/** drop non-semver tokens and prereleases; registries publish plain versions
|
|
@@ -135,22 +141,22 @@ export function classifyCurrency(params: {
|
|
|
135
141
|
}): CurrencyVerdict {
|
|
136
142
|
const versions = stableVersions(params.available);
|
|
137
143
|
const latest = versions.length > 0 ? (sortReversed(versions)[0] ?? null) : null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
if (params.constraint === null) {
|
|
142
|
-
return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
|
|
143
|
-
}
|
|
144
|
+
const unknown = { newestSatisfying: null, outdated: false, majorsBehind: 0, latestInMajor: null };
|
|
145
|
+
if (latest === null) return { latest: null, ...unknown };
|
|
146
|
+
if (params.constraint === null) return { latest, ...unknown };
|
|
144
147
|
const range = terraformConstraintToRange(params.constraint);
|
|
145
|
-
if (range === null) {
|
|
146
|
-
return { latest, newestSatisfying: null, outdated: false, majorsBehind: 0 };
|
|
147
|
-
}
|
|
148
|
+
if (range === null) return { latest, ...unknown };
|
|
148
149
|
const newestSatisfying = findMaxSatisfying(versions, range);
|
|
149
|
-
const outdated = newestSatisfying === null ||
|
|
150
|
+
const outdated = newestSatisfying === null || isLessThan(newestSatisfying, latest);
|
|
150
151
|
const majorsBehind = newestSatisfying
|
|
151
152
|
? Math.max(0, getMajor(latest) - getMajor(newestSatisfying))
|
|
152
153
|
: Math.max(0, getMajor(latest));
|
|
153
|
-
|
|
154
|
+
const sameMajor = newestSatisfying
|
|
155
|
+
? sortReversed(versions.filter((v) => getMajor(v) === getMajor(newestSatisfying)))[0]
|
|
156
|
+
: undefined;
|
|
157
|
+
const latestInMajor =
|
|
158
|
+
newestSatisfying && sameMajor && isLessThan(newestSatisfying, sameMajor) ? sameMajor : null;
|
|
159
|
+
return { latest, newestSatisfying, outdated, majorsBehind, latestInMajor };
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
// --- registry lookups (I/O) --------------------------------------------------
|
|
@@ -386,6 +392,8 @@ interface ProviderCurrencyRow {
|
|
|
386
392
|
constraint: string | null;
|
|
387
393
|
latest: string | null;
|
|
388
394
|
newest_satisfying: string | null;
|
|
395
|
+
/** see {@link CurrencyVerdict.latestInMajor}. */
|
|
396
|
+
latest_in_major: string | null;
|
|
389
397
|
outdated: boolean;
|
|
390
398
|
majors_behind: number;
|
|
391
399
|
lookup: LookupStatus;
|
|
@@ -397,7 +405,10 @@ interface ModuleCurrencyRow {
|
|
|
397
405
|
version: string | null;
|
|
398
406
|
latest: string | null;
|
|
399
407
|
newest_satisfying: string | null;
|
|
408
|
+
/** see {@link CurrencyVerdict.latestInMajor}. */
|
|
409
|
+
latest_in_major: string | null;
|
|
400
410
|
outdated: boolean;
|
|
411
|
+
majors_behind: number;
|
|
401
412
|
/** registry module with no `version` attribute — pin it (to `latest`). */
|
|
402
413
|
unpinned: boolean;
|
|
403
414
|
lookup: ModuleLookupStatus;
|
|
@@ -474,6 +485,7 @@ export async function checkVersionCurrency(
|
|
|
474
485
|
constraint: req.version,
|
|
475
486
|
latest: verdict.latest,
|
|
476
487
|
newest_satisfying: verdict.newestSatisfying,
|
|
488
|
+
latest_in_major: verdict.latestInMajor,
|
|
477
489
|
outdated: result.status === "ok" && verdict.outdated,
|
|
478
490
|
majors_behind: result.status === "ok" ? verdict.majorsBehind : 0,
|
|
479
491
|
lookup: result.status,
|
|
@@ -491,7 +503,9 @@ export async function checkVersionCurrency(
|
|
|
491
503
|
version: mod.version,
|
|
492
504
|
latest: verdict.latest,
|
|
493
505
|
newest_satisfying: verdict.newestSatisfying,
|
|
506
|
+
latest_in_major: verdict.latestInMajor,
|
|
494
507
|
outdated: result.status === "ok" && verdict.outdated,
|
|
508
|
+
majors_behind: result.status === "ok" ? verdict.majorsBehind : 0,
|
|
495
509
|
unpinned: mod.version === null,
|
|
496
510
|
lookup: result.status,
|
|
497
511
|
declared_in: mod.declaredIn,
|
|
@@ -513,7 +527,9 @@ export async function checkVersionCurrency(
|
|
|
513
527
|
version: mod.version,
|
|
514
528
|
latest: null,
|
|
515
529
|
newest_satisfying: null,
|
|
530
|
+
latest_in_major: null,
|
|
516
531
|
outdated: false,
|
|
532
|
+
majors_behind: 0,
|
|
517
533
|
// `unpinned` means "pin it to latest" — advice that cannot apply to a
|
|
518
534
|
// source that resolves to nothing; the row's lookup status carries why.
|
|
519
535
|
unpinned: false,
|
|
@@ -546,7 +546,7 @@ export function preventiveControlFor(
|
|
|
546
546
|
snippet: [
|
|
547
547
|
"# .github/workflows/infraweaver-pr-gate.yml",
|
|
548
548
|
"- id: assess",
|
|
549
|
-
" uses: infraweaver-io/infraweaver-action@
|
|
549
|
+
" uses: infraweaver-io/infraweaver-action@v0.3",
|
|
550
550
|
" with:",
|
|
551
551
|
" mode: assess",
|
|
552
552
|
"- if: steps.assess.outputs.findings-gate == 'fail'",
|