infraweaver 0.3.12 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +554 -199
  5. package/dist/index.js +531 -176
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  13. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  14. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  15. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  16. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  17. package/dist/mcp/terraform/tree.d.ts +5 -0
  18. package/dist/mcp/terraform/types.d.ts +2 -1
  19. package/dist/mcp/terraform/verifySites.d.ts +11 -3
  20. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  21. package/package.json +2 -2
  22. package/src/agents/opencode.ts +8 -1
  23. package/src/agents/quotaTokens.ts +7 -1
  24. package/src/mcp/git.ts +64 -12
  25. package/src/mcp/localContext.ts +6 -1
  26. package/src/mcp/moduleExtraction.ts +5 -4
  27. package/src/mcp/review.ts +52 -3
  28. package/src/mcp/reviewCommentLimit.ts +169 -26
  29. package/src/mcp/reviewMarkers.ts +11 -0
  30. package/src/mcp/reviewProvenance.ts +1 -1
  31. package/src/mcp/staleFix.ts +73 -2
  32. package/src/mcp/terraform/concernResult.ts +17 -10
  33. package/src/mcp/terraform/deltaSummary.ts +4 -0
  34. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  35. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  36. package/src/mcp/terraform/nativeScan.ts +4 -0
  37. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  38. package/src/mcp/terraform/refactorScope.ts +90 -21
  39. package/src/mcp/terraform/ruleContext.ts +4 -0
  40. package/src/mcp/terraform/scanDelta.ts +67 -26
  41. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  42. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  43. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  44. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  45. package/src/mcp/terraform/tools/scan.ts +3 -0
  46. package/src/mcp/terraform/tree.ts +8 -0
  47. package/src/mcp/terraform/types.ts +2 -1
  48. package/src/mcp/terraform/verifySites.ts +23 -5
  49. package/src/modes/refactor.ts +1 -1
  50. package/src/modes/remediate-and-refactor.ts +1 -1
  51. package/src/modes/remediate.ts +1 -1
  52. package/src/phases/runAgentWithWatchdogs.ts +3 -2
@@ -12,22 +12,28 @@
12
12
  * WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
13
13
  * `provider`, `module` or `locals` block, at any depth, so
14
14
  * `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
15
- * are reached; and the `default` of a `variable` whose name is a secret's. Not
16
- * read: comments, heredoc bodies, a name/value pair split across two keys
17
- * (`[{ name = "PASSWORD", value = "…" }]`), and a value that starts on a later
18
- * line than its name.
15
+ * are reached; the `default` of a `variable` whose name is a secret's; and
16
+ * every assignment in a `.tfvars` file, which is where a value kept out of the
17
+ * `.tf` source usually lands instead. Not read: comments, heredoc bodies, a
18
+ * name/value pair split across two keys (`[{ name = "PASSWORD", value = "…" }]`),
19
+ * and a value that starts on a later line than its name.
19
20
  *
20
- * WHAT IS NOT A FINDING. A reference, a function call, an interpolation, an
21
- * empty string, or a placeholder made only of `*` / `x` / `<…>`. Neither is an
22
- * argument whose name only mentions a secret (`password_length`, `secret_name`,
23
- * `token_validity_units`, `creation_token`): the last word of the name decides.
21
+ * WHAT IS NOT A FINDING. A reference, a function call, an interpolation or a
22
+ * template directive, an empty string, `true` / `false` / `null`, a number, or
23
+ * a placeholder made only of `*` / `x` / `<…>`. Nor is a literal that only POINTS
24
+ * at a secret — an ARN, a GCP `projects/…/secrets/…` name, a Key Vault secret
25
+ * URI or an SSM parameter path — since that is the fix, not the fault. Neither
26
+ * is an argument whose name only mentions a secret (`password_length`,
27
+ * `secret_name`, `token_validity_units`, `creation_token`): the last word of the
28
+ * name decides, and a name that is `secret` and nothing else names a Secret
29
+ * Manager secret (`secret_key_ref { secret = "db-password" }`), not its value.
24
30
  *
25
31
  * Arguments the provider can take write-only (`aws_db_instance.password`) may
26
32
  * also be reported by `secret-persisted-to-state`, which says something else:
27
33
  * that the value reaches state. This one says it is in git.
28
34
  */
29
35
 
30
- import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
36
+ import { isStringLiteral, skipNonCode, stripTrailingComment } from "#app/mcp/terraform/hcl";
31
37
  import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
32
38
  import type { SourceFile } from "#app/mcp/terraform/tree";
33
39
  import { buildConcern, type Concern } from "#app/mcp/terraform/types";
@@ -46,7 +52,18 @@ const SECRET_WORDS = new Set([
46
52
  "credentials",
47
53
  ]);
48
54
  /** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
49
- const SECRET_KEY_PREFIXES = new Set(["api", "secret", "private", "signing"]);
55
+ const SECRET_KEY_PREFIXES = new Set([
56
+ "api",
57
+ "secret",
58
+ "private",
59
+ "signing",
60
+ "access",
61
+ "account",
62
+ "client",
63
+ "shared",
64
+ "preshared",
65
+ "master",
66
+ ]);
50
67
  /** `<word>_token` names that are identifiers, not credentials. */
51
68
  const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
52
69
 
@@ -66,6 +83,9 @@ export function isSecretName(name: string): boolean {
66
83
  if (last === undefined) return false;
67
84
  if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
68
85
  if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
86
+ if (last === "string") return prev === "connection";
87
+ // `secret` alone is the NAME of a Secret Manager secret (`secret_key_ref { secret = … }`)
88
+ if (last === "secret" && w.length === 1) return false;
69
89
  return SECRET_WORDS.has(last);
70
90
  }
71
91
 
@@ -75,13 +95,38 @@ function isPlaceholder(inner: string): boolean {
75
95
  inner.trim() === "" ||
76
96
  /^[*xX.]+$/.test(inner) ||
77
97
  /^<[^>]*>$/.test(inner) ||
78
- // the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`)
79
- /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)(?:[_-].*)?$/i.test(inner) ||
98
+ /^(?:true|false|null)$/.test(inner) ||
99
+ /^-?\d+(?:\.\d+)?$/.test(inner) ||
100
+ // a template directive is computed, like an interpolation: `"%{ if … }…%{ endif }"`
101
+ inner.includes("%{") ||
102
+ // the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`,
103
+ // `"example-secret"`) — but only when that is the whole value: a lowercase tail at
104
+ // most, so `"Test-Pr0dDbPassw0rd!"` is a password that happens to start with a word
105
+ isDummy(inner) ||
80
106
  // a token's lifetime unit: `token_validity_units { access_token = "hours" }`
81
107
  /^(?:seconds|minutes|hours|days)$/.test(inner)
82
108
  );
83
109
  }
84
110
 
111
+ function isDummy(inner: string): boolean {
112
+ const m = /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)([_-].*)?$/i.exec(inner);
113
+ return m !== null && (m[1] === undefined || /^[_-][a-z0-9_-]*$/.test(m[1]));
114
+ }
115
+
116
+ /**
117
+ * A literal that names where a secret is kept rather than holding it: a Secrets
118
+ * Manager / SSM ARN, a GCP Secret Manager resource name, a Key Vault secret URI,
119
+ * or an SSM parameter path. Passing one of these is the remedy this rule asks for.
120
+ */
121
+ function isSecretReference(inner: string): boolean {
122
+ return (
123
+ inner.startsWith("arn:") ||
124
+ /^projects\/[^/]+\/secrets\//.test(inner) ||
125
+ /^https:\/\/[\w-]+\.vault\.azure\.net\/secrets\//i.test(inner) ||
126
+ /^\/[\w./-]+$/.test(inner)
127
+ );
128
+ }
129
+
85
130
  export interface HardcodedSecret {
86
131
  /** the argument, map key or variable name. */
87
132
  name: string;
@@ -90,17 +135,52 @@ export interface HardcodedSecret {
90
135
  line: number;
91
136
  }
92
137
 
93
- /** a top-level block header: `resource "aws_db_instance" "db" {`, `locals {` */
94
- const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{\s*$/;
138
+ /** a top-level block header, `resource "aws_db_instance" "db" {`, and whatever follows its `{` */
139
+ const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{(.*)$/;
95
140
  /** `name = value`, the name bare or quoted */
96
141
  const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
97
142
  const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
98
143
  /** a `key = "string"` pair inside a one-line object */
99
144
  const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
100
145
 
101
- function literalValue(raw: string): boolean {
146
+ function literalValue(name: string, raw: string): boolean {
102
147
  const value = stripTrailingComment(raw).replace(/,$/, "").trim();
103
- return isStringLiteral(value) && !isPlaceholder(value.slice(1, -1));
148
+ if (!isStringLiteral(value)) return false;
149
+ const inner = value.slice(1, -1);
150
+ if (isPlaceholder(inner) || isSecretReference(inner)) return false;
151
+ // an `auth` setting's mode, not its credential: `iam_auth = "DISABLED"`, `auth = "NONE"`
152
+ return !(words(name).at(-1) === "auth" && /^[A-Z]+(?:_[A-Z]+)*$/.test(inner));
153
+ }
154
+
155
+ /** the name a `variable "…"` holder declares. */
156
+ function variableName(holder: string): string {
157
+ return /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
158
+ }
159
+
160
+ /** True when `line` leaves a block comment open past its end (`foo = 1 /* …`). */
161
+ function opensBlockComment(line: string): boolean {
162
+ for (let i = 0; i < line.length; i++) {
163
+ const span = skipNonCode(line, i);
164
+ if (!span) continue;
165
+ if (line.startsWith("/*", i) && !line.includes("*/", i + 2)) return true;
166
+ i = span.end;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /** True when the text after a header's `{` also closes that block. */
172
+ function closesOnLine(rest: string): boolean {
173
+ let depth = 1;
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const span = skipNonCode(rest, i);
176
+ if (span) {
177
+ i = span.end;
178
+ continue;
179
+ }
180
+ if (rest[i] === "{") depth++;
181
+ else if (rest[i] === "}" && --depth === 0) return true;
182
+ }
183
+ return false;
104
184
  }
105
185
 
106
186
  /**
@@ -111,8 +191,21 @@ function literalValue(raw: string): boolean {
111
191
  * parser is shared by every scanner and budgeted (see nativeScan.perf.test).
112
192
  */
113
193
  export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
194
+ return findInLines(hcl, null);
195
+ }
196
+
197
+ /**
198
+ * Every secret-named literal in a `.tfvars` file. A tfvars file is nothing but
199
+ * assignments, so every one is read, at any depth, under the file's own name.
200
+ */
201
+ export function findTfvarsSecrets(hcl: string, path: string): HardcodedSecret[] {
202
+ return findInLines(hcl, `tfvars input ${path}`);
203
+ }
204
+
205
+ /** `fileHolder` set: the whole file is one holder (a tfvars file), with no blocks. */
206
+ function findInLines(hcl: string, fileHolder: string | null): HardcodedSecret[] {
114
207
  const out: HardcodedSecret[] = [];
115
- let holder: string | null = null;
208
+ let holder: string | null = fileHolder;
116
209
  let heredoc: string | null = null;
117
210
  let inComment = false;
118
211
  const lines = hcl.split("\n");
@@ -132,12 +225,30 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
132
225
  continue;
133
226
  }
134
227
  if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
135
- const header = HEADER.exec(line);
228
+ // a comment opened mid-line hides the lines after it; this line's code still counts
229
+ if (opensBlockComment(line)) inComment = true;
230
+ const header = fileHolder === null ? HEADER.exec(line) : null;
136
231
  if (header) {
137
232
  holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
233
+ // a block written on one line, `resource "a" "b" { password = "…" }`, carries its body
234
+ const rest = header[3] as string;
235
+ const opens = HEREDOC.exec(rest);
236
+ if (opens) heredoc = opens[1] as string;
237
+ if (!holder.startsWith("output ") && !holder.startsWith("terraform")) {
238
+ for (const pair of rest.matchAll(INLINE_PAIR)) {
239
+ const key = (pair[1] ?? pair[2]) as string;
240
+ const isVariable = holder.startsWith("variable ");
241
+ if (isVariable && key !== "default") continue;
242
+ const name = isVariable ? variableName(holder) : key;
243
+ if (isSecretName(name) && literalValue(name, pair[3] as string)) {
244
+ out.push({ name, holder, line: i + 1 });
245
+ }
246
+ }
247
+ }
248
+ if (closesOnLine(rest)) holder = null;
138
249
  continue;
139
250
  }
140
- if (line.startsWith("}")) {
251
+ if (fileHolder === null && line.startsWith("}")) {
141
252
  holder = null;
142
253
  continue;
143
254
  }
@@ -150,21 +261,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
150
261
  const value = m[3] as string;
151
262
  if (holder.startsWith("variable ")) {
152
263
  // a variable's literal is its default, and the variable's name says what it holds
153
- const variable = /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
154
- if (name === "default" && isSecretName(variable) && literalValue(value)) {
264
+ const variable = variableName(holder);
265
+ if (name === "default" && isSecretName(variable) && literalValue(variable, value)) {
155
266
  out.push({ name: variable, holder, line: i + 1 });
156
267
  }
157
268
  continue;
158
269
  }
159
270
  if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
160
- if (isSecretName(name) && literalValue(value)) {
271
+ if (isSecretName(name) && literalValue(name, value)) {
161
272
  out.push({ name, holder, line: i + 1 });
162
273
  continue;
163
274
  }
164
275
  // an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
165
276
  for (const pair of value.matchAll(INLINE_PAIR)) {
166
277
  const key = (pair[1] ?? pair[2]) as string;
167
- if (isSecretName(key) && literalValue(pair[3] as string)) {
278
+ if (isSecretName(key) && literalValue(key, pair[3] as string)) {
168
279
  out.push({ name: key, holder, line: i + 1 });
169
280
  }
170
281
  }
@@ -172,11 +283,21 @@ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
172
283
  return out;
173
284
  }
174
285
 
175
- /** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
176
- export function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[] {
286
+ /**
287
+ * Scan an already-read tree into native concerns. Pure; no I/O of its own.
288
+ * `tfvarsFiles` are the `.tfvars` inputs, read as assignments rather than blocks.
289
+ */
290
+ export function scanHardcodedSecrets(
291
+ files: readonly SourceFile[],
292
+ tfvarsFiles: readonly SourceFile[] = [],
293
+ ): Concern[] {
177
294
  const concerns: Concern[] = [];
178
- for (const { path, hcl } of files) {
179
- for (const hit of findHardcodedSecrets(hcl)) {
295
+ const hits = [
296
+ ...files.map((f) => ({ path: f.path, found: findHardcodedSecrets(f.hcl) })),
297
+ ...tfvarsFiles.map((f) => ({ path: f.path, found: findTfvarsSecrets(f.hcl, f.path) })),
298
+ ];
299
+ for (const { path, found } of hits) {
300
+ for (const hit of found) {
180
301
  concerns.push(
181
302
  buildConcern({
182
303
  source: NATIVE_SOURCE,
@@ -131,7 +131,8 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
131
131
  name: "hardcoded-secrets",
132
132
  rules: [HARDCODED_SECRET_ARGUMENT],
133
133
  // Per file: the argument's name and its literal are both on the line itself.
134
- scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files) }),
134
+ // The only rule that reads `.tfvars`: an input file holds values, not config.
135
+ scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files, ctx.tfvarsFiles) }),
135
136
  },
136
137
  {
137
138
  name: "write-only",
@@ -26,6 +26,7 @@ import {
26
26
  describeOversize,
27
27
  describeTruncation,
28
28
  readTfTree,
29
+ readTfvarsTree,
29
30
  readTofuTree,
30
31
  type SourceTree,
31
32
  } from "#app/mcp/terraform/tree";
@@ -74,6 +75,9 @@ export function scanNative(cwd: string, opts: NativeScanOptions = {}): ScannerOu
74
75
  // walk over a suffix most repos do not use at all, and threading it through
75
76
  // the context keeps the scanners uniform.
76
77
  tofuFiles: readTofuTree(cwd).files,
78
+ // likewise only for the hardcoded-secret rule: a value kept out of the `.tf`
79
+ // source is most often committed here instead
80
+ tfvarsFiles: readTfvarsTree(cwd).files,
77
81
  truncated: tree.truncated,
78
82
  repeatedLiteralThreshold: opts.repeatedLiteralThreshold ?? DEFAULT_REPEAT_THRESHOLD,
79
83
  });
@@ -31,14 +31,17 @@ import { describeMissingMoves, type PairAnalysis } from "#app/mcp/terraform/plan
31
31
  import {
32
32
  instanceKeysOf,
33
33
  isModuleAddress,
34
+ moduleHops,
34
35
  normaliseAddress,
35
36
  qualify,
36
37
  stripIndices,
38
+ stripModuleHops,
37
39
  } from "#app/mcp/terraform/refactor/addresses";
38
40
  import { type MovedBlock, moveId } from "#app/mcp/terraform/refactor/movedBlocks";
39
41
  import {
40
42
  describeMultiplicity,
41
43
  enumerateInstanceKeys,
44
+ type Multiplicity,
42
45
  type ResourceRef,
43
46
  sameMultiplicity,
44
47
  sameMultiplicityLevels,
@@ -384,10 +387,11 @@ function correspond(
384
387
  * than sending a correct refactor away for a plan it does not need.
385
388
  *
386
389
  * Abstains (null) on anything it cannot read exactly: a chain of more than one
387
- * repetition, an expression that is not a literal collection, or a declared block
388
- * that names no single instance. Extra blocks naming keys the collection does not
389
- * contain are NOT an error — a `moved` block whose source never existed is a
390
- * no-op, and a mistyped key shows up as the missing one it left behind.
390
+ * repetition, an expression that is not a literal collection, a declared block
391
+ * that names no single instance, or one whose key level cannot be located.
392
+ * Extra blocks naming keys the collection does not contain are NOT an error — a
393
+ * `moved` block whose source never existed is a no-op, and a mistyped key shows
394
+ * up as the missing one it left behind.
391
395
  */
392
396
  function uncoveredInstanceKeys(p: Pairing): string[] | null {
393
397
  const level = p.before.multiplicity;
@@ -400,25 +404,54 @@ function uncoveredInstanceKeys(p: Pairing): string[] | null {
400
404
  // is their exact spelling.
401
405
  const kind = level[0].kind;
402
406
  const written = (k: string) => (kind === "count" ? k : `"${k}"`);
407
+ const fromAt = level[0].at;
408
+ const toAt = p.after.multiplicity[0]?.at;
409
+ if (fromAt === undefined || toAt === undefined) return null;
403
410
  const declared = new Set<string>();
404
411
  for (const blk of p.blocks) {
412
+ // a chain hop or a module-level block names an address neither tree
413
+ // attributes, so where its repetition sits is unknown
414
+ if (
415
+ stripIndices(normaliseAddress(blk.from)) !== p.before.address ||
416
+ stripIndices(normaliseAddress(blk.to)) !== p.after.address
417
+ )
418
+ return null;
405
419
  // A relevelling repeats BOTH sides, at different levels: the instance is
406
420
  // `aws_s3_bucket.data["logs"]` before and `module.data["logs"].…` after, so a
407
- // block covers it only when each side names that same key. A key on one side
408
- // alone (`aws_s3_bucket.data["logs"]` → `module.data.aws_s3_bucket.this`)
409
- // points at an address the repetition no longer has, and Terraform plans it
410
- // as a destroy and a create.
421
+ // block covers it only when each side names that same key, at the level that
422
+ // side repeats. A key on one side alone (`aws_s3_bucket.data["logs"]` →
423
+ // `module.data.aws_s3_bucket.this`), or on the wrong level
424
+ // (`module.data.aws_s3_bucket.this["logs"]`), points at an address the
425
+ // repetition does not have, and Terraform plans it as a destroy and a create.
411
426
  const from = instanceKeysOf(blk.from);
412
427
  const to = instanceKeysOf(blk.to);
413
428
  // more than one key per side is a chain this proof does not read; no key on
414
429
  // either side names no single instance
415
430
  if (from.length > 1 || to.length > 1) return null;
416
431
  if (from.length === 0 && to.length === 0) return null;
417
- if (from.length === 1 && to.length === 1 && from[0] === to[0]) declared.add(from[0] as string);
432
+ if (from.length !== 1 || to.length !== 1 || from[0] !== to[0]) continue;
433
+ const fromPlaced = keyAtLevel(blk.from, fromAt);
434
+ const toPlaced = keyAtLevel(blk.to, toAt);
435
+ // a side on the wrong level covers nothing, whatever the other side says
436
+ if (fromPlaced === false || toPlaced === false) continue;
437
+ if (fromPlaced === null || toPlaced === null) return null;
438
+ declared.add(from[0] as string);
418
439
  }
419
440
  return expected.filter((k) => !declared.has(written(k)));
420
441
  }
421
442
 
443
+ /**
444
+ * Whether the one instance key `address` writes sits on the level `at` that
445
+ * repeats, or null when that level cannot be located: a module repetition on a
446
+ * path through several calls is on one of them, and the chain does not say which.
447
+ */
448
+ function keyAtLevel(address: string, at: NonNullable<Multiplicity["at"]>): boolean | null {
449
+ const onResource = instanceKeysOf(stripModuleHops(normaliseAddress(address))).length > 0;
450
+ if (at === "resource") return onResource;
451
+ if (onResource) return false;
452
+ return moduleHops(address).length === 1 ? true : null;
453
+ }
454
+
422
455
  /**
423
456
  * Compute the keys-free equivalence verdict from the before/after resource
424
457
  * address sets and the `moved {}` blocks present in the after tree. Validate/fmt
@@ -12,19 +12,72 @@
12
12
  * people call (terraform-aws-vpc, say). Moving its resources into a submodule is
13
13
  * provably equivalent, and still churn for every consumer: their plans gain a
14
14
  * wall of `moved` addresses for no change in what they get. There, extraction
15
- * and normalisation candidates come back marked `report_only` — named in the
16
- * run's summary, never opened as a PR. Consolidating roots that duplicate each
17
- * other is unaffected: that removes real duplication, not style.
15
+ * and normalisation candidates in the module's own code come back marked
16
+ * `report_only` — named in the run's summary, never opened as a PR. A directory
17
+ * inside it that deploys (`envs/prod/` with its own provider or backend) is a
18
+ * configuration like any other, and its candidates stay actionable.
19
+ * Consolidating roots that duplicate each other is unaffected: that removes real
20
+ * duplication, not style.
18
21
  */
19
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
+ import { readdirSync, readFileSync, statSync } from "node:fs";
20
23
  import { join } from "node:path";
24
+ import { BLOCK_LABEL, parseBlocks } from "#app/mcp/terraform/hcl";
21
25
 
22
- /** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
26
+ /**
27
+ * true for a path inside an `examples/` directory at any depth, or an `example/`
28
+ * one at the repository root. The registry's convention is `examples/`; the
29
+ * singular is accepted only where that convention puts it, because a module
30
+ * NAMED `example` (`modules/example/`) is code someone calls.
31
+ */
23
32
  export function isExamplePath(path: string): boolean {
24
- return /(?:^|\/)examples?\//.test(path.replace(/\\/g, "/"));
33
+ return /(?:^|\/)examples\/|^example\//i.test(path.replace(/\\/g, "/"));
34
+ }
35
+
36
+ const PROVIDER_OR_BACKEND = new RegExp(
37
+ String.raw`(?:^|\n)\s*(?:provider|backend)\s+${BLOCK_LABEL}\s*\{`,
38
+ );
39
+ const CLOUD_BLOCK = /(?:^|\n)\s*cloud\s*\{/;
40
+
41
+ /** whether one file's text configures a provider, a backend or HCP Terraform. */
42
+ function configuresDeployment(name: string, text: string): boolean {
43
+ if (name.endsWith(".tf.json")) {
44
+ let doc: unknown;
45
+ try {
46
+ doc = JSON.parse(text);
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (typeof doc !== "object" || doc === null) return false;
51
+ const { provider, terraform } = doc as { provider?: unknown; terraform?: unknown };
52
+ if (provider !== undefined) return true;
53
+ // `terraform` is an object, or an array of them, in the JSON syntax
54
+ return [terraform]
55
+ .flat()
56
+ .some((t) => typeof t === "object" && t !== null && ("backend" in t || "cloud" in t));
57
+ }
58
+ if (PROVIDER_OR_BACKEND.test(text)) return true;
59
+ // `cloud` is a block name other schemas use too; only the settings block's counts
60
+ return parseBlocks(text, ["terraform"]).some((b) => CLOUD_BLOCK.test(b.body));
25
61
  }
26
62
 
27
- const PROVIDER_OR_BACKEND = /^\s*(?:provider\s+"|backend\s+")/m;
63
+ const isTerraformFile = (name: string) => name.endsWith(".tf") || name.endsWith(".tf.json");
64
+
65
+ /** whether the Terraform directly in `dir` is a root someone deploys from. */
66
+ function deploysFrom(dir: string): boolean {
67
+ let names: string[];
68
+ try {
69
+ names = readdirSync(dir).filter(isTerraformFile);
70
+ } catch {
71
+ return false;
72
+ }
73
+ return names.some((name) => {
74
+ try {
75
+ return configuresDeployment(name, readFileSync(join(dir, name), "utf8"));
76
+ } catch {
77
+ return false;
78
+ }
79
+ });
80
+ }
28
81
 
29
82
  /** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
30
83
  function holdsTerraform(dir: string, depth: number): boolean {
@@ -53,23 +106,14 @@ function holdsTerraform(dir: string, depth: number): boolean {
53
106
  * deployment, or null when it does not.
54
107
  */
55
108
  export function libraryRepoReason(cwd: string): string | null {
56
- let rootFiles: string[];
109
+ let rootEntries: string[];
57
110
  try {
58
- rootFiles = readdirSync(cwd).filter((f) => f.endsWith(".tf"));
111
+ rootEntries = readdirSync(cwd);
59
112
  } catch {
60
113
  return null;
61
114
  }
62
- if (rootFiles.length === 0) return null;
63
- for (const f of rootFiles) {
64
- let text: string;
65
- try {
66
- text = readFileSync(join(cwd, f), "utf8");
67
- } catch {
68
- continue;
69
- }
70
- if (PROVIDER_OR_BACKEND.test(text)) return null;
71
- }
72
- const examples = ["examples", "example"].find((d) => existsSync(join(cwd, d)));
115
+ if (!rootEntries.some(isTerraformFile) || deploysFrom(cwd)) return null;
116
+ const examples = rootEntries.find((d) => /^examples?$/i.test(d));
73
117
  if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
74
118
  return (
75
119
  `the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
@@ -77,7 +121,32 @@ export function libraryRepoReason(cwd: string): string | null {
77
121
  );
78
122
  }
79
123
 
80
- /** what a detector tool adds to its result when the repository is a library. */
124
+ /**
125
+ * For a library repository, why a candidate in `file` (repo-relative) is
126
+ * reported rather than restructured — or null when the file belongs to a
127
+ * directory, or sits under one, that deploys on its own. Outside a library,
128
+ * always null.
129
+ */
130
+ export function libraryScope(cwd: string): (file: string) => string | null {
131
+ const reason = libraryRepoReason(cwd);
132
+ if (reason === null) return () => null;
133
+ const deploys = new Map<string, boolean>();
134
+ return (file) => {
135
+ const dirs = file.replace(/\\/g, "/").split("/").slice(0, -1);
136
+ for (let i = dirs.length; i > 0; i--) {
137
+ const dir = dirs.slice(0, i).join("/");
138
+ let d = deploys.get(dir);
139
+ if (d === undefined) {
140
+ d = deploysFrom(join(cwd, dir));
141
+ deploys.set(dir, d);
142
+ }
143
+ if (d) return null;
144
+ }
145
+ return reason;
146
+ };
147
+ }
148
+
149
+ /** what a detector tool adds to a candidate in a library's own code. */
81
150
  export function reportOnly(reason: string | null): {
82
151
  report_only?: true;
83
152
  report_only_reason?: string;
@@ -46,6 +46,8 @@ export interface RuleContext {
46
46
  readonly files: readonly SourceFile[];
47
47
  /** the `.tofu` half. Only the dialect rule needs it; empty on a repo with none. */
48
48
  readonly tofuFiles: readonly SourceFile[];
49
+ /** the `.tfvars` inputs. Only the hardcoded-secret rule reads them; empty on a repo with none. */
50
+ readonly tfvarsFiles: readonly SourceFile[];
49
51
  /** non-null when the walk was bounded before it finished — see [[tree]]. */
50
52
  readonly truncated: TreeTruncation;
51
53
  /** how many times a literal must repeat in one directory before it is reported. */
@@ -80,6 +82,7 @@ export interface RuleContextInput {
80
82
  cwd: string;
81
83
  files: readonly SourceFile[];
82
84
  tofuFiles?: readonly SourceFile[];
85
+ tfvarsFiles?: readonly SourceFile[];
83
86
  truncated?: TreeTruncation;
84
87
  repeatedLiteralThreshold: number;
85
88
  }
@@ -89,6 +92,7 @@ export function createRuleContext(input: RuleContextInput): RuleContext {
89
92
  cwd: input.cwd,
90
93
  files: input.files,
91
94
  tofuFiles: input.tofuFiles ?? [],
95
+ tfvarsFiles: input.tfvarsFiles ?? [],
92
96
  truncated: input.truncated ?? null,
93
97
  repeatedLiteralThreshold: input.repeatedLiteralThreshold,
94
98
  };