@intentius/chant 0.62.0 → 0.63.0

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 (107) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  16. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  17. package/dist/lifecycle/index.d.ts +1 -0
  18. package/dist/lifecycle/index.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-digest.d.ts +33 -0
  20. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  21. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  22. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  23. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  24. package/dist/op/activities/lifecycle.d.ts +27 -0
  25. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  26. package/dist/op/activities/reconcile.d.ts +196 -27
  27. package/dist/op/activities/reconcile.d.ts.map +1 -1
  28. package/dist/op/builders.d.ts +6 -0
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/composites/apply-op.d.ts +6 -0
  31. package/dist/op/composites/apply-op.d.ts.map +1 -1
  32. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  33. package/dist/op/gate-summary.d.ts +16 -0
  34. package/dist/op/gate-summary.d.ts.map +1 -1
  35. package/dist/op/gate.d.ts +104 -13
  36. package/dist/op/gate.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +3 -2
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +17 -0
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-output.d.ts.map +1 -1
  42. package/dist/op/op-ir.d.ts +8 -1
  43. package/dist/op/op-ir.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +2 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/types.d.ts +19 -0
  47. package/dist/op/types.d.ts.map +1 -1
  48. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  49. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  50. package/dist/terraform/graph.d.ts +18 -2
  51. package/dist/terraform/graph.d.ts.map +1 -1
  52. package/dist/terraform/parse.d.ts.map +1 -1
  53. package/dist/terraform/types.d.ts +7 -0
  54. package/dist/terraform/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli/handlers/operator.test.ts +130 -0
  57. package/src/cli/handlers/operator.ts +56 -2
  58. package/src/cli/handlers/run.ts +19 -0
  59. package/src/cli/main.ts +2 -0
  60. package/src/cli/registry.ts +2 -0
  61. package/src/components/cli-support.ts +29 -4
  62. package/src/components/driver-output.ts +10 -0
  63. package/src/components/driver.test.ts +31 -0
  64. package/src/components/driver.ts +54 -8
  65. package/src/discovery/fold-import.test.ts +55 -0
  66. package/src/fold/fold.test.ts +152 -0
  67. package/src/fold/fold.ts +102 -2
  68. package/src/fold/subset-doc-parity.test.ts +35 -1
  69. package/src/fold/subset.ts +10 -0
  70. package/src/lifecycle/gate-ledger.test.ts +133 -1
  71. package/src/lifecycle/gate-ledger.ts +108 -0
  72. package/src/lifecycle/index.ts +1 -0
  73. package/src/lifecycle/plan-digest.test.ts +49 -0
  74. package/src/lifecycle/plan-digest.ts +86 -0
  75. package/src/lifecycle/run-ledger.ts +1 -0
  76. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  77. package/src/op/activities/lexicon-upgrade.ts +19 -3
  78. package/src/op/activities/lifecycle.ts +51 -2
  79. package/src/op/activities/reconcile.test.ts +512 -26
  80. package/src/op/activities/reconcile.ts +307 -34
  81. package/src/op/builders.ts +7 -1
  82. package/src/op/composites/apply-op.ts +16 -0
  83. package/src/op/composites/composites.test.ts +15 -2
  84. package/src/op/composites/reconcile-op.test.ts +18 -0
  85. package/src/op/composites/reconcile-op.ts +7 -1
  86. package/src/op/gate-summary.test.ts +33 -0
  87. package/src/op/gate-summary.ts +31 -0
  88. package/src/op/gate.test.ts +111 -1
  89. package/src/op/gate.ts +181 -23
  90. package/src/op/index.ts +5 -2
  91. package/src/op/local-executor.test.ts +226 -3
  92. package/src/op/local-executor.ts +61 -12
  93. package/src/op/local-output.test.ts +38 -0
  94. package/src/op/local-output.ts +24 -1
  95. package/src/op/op-ir.test.ts +22 -0
  96. package/src/op/op-ir.ts +9 -0
  97. package/src/op/runtime.ts +2 -0
  98. package/src/op/types.ts +19 -0
  99. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  100. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  101. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  102. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  103. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  104. package/src/terraform/graph.test.ts +148 -1
  105. package/src/terraform/graph.ts +144 -6
  106. package/src/terraform/parse.ts +4 -1
  107. package/src/terraform/types.ts +7 -0
@@ -81,10 +81,32 @@ export function refFromAccessor(accessor: string): RawRef | null {
81
81
  return { address: `${parts[0]}.${parts[1]}`, attr: attrAfter(2) };
82
82
  }
83
83
 
84
+ /**
85
+ * The referrer key a `local.<name>` accessor names, or null for anything else.
86
+ *
87
+ * `refFromAccessor` is right to refuse `local.*`: a local is a substitution,
88
+ * has no address in the plan graph, and nothing carves one. But the resource a
89
+ * local ultimately names is a dependency all the same, so the accessor is
90
+ * carried this far to be resolved through the locals table (#2324) rather than
91
+ * dropped at the resource-head guard.
92
+ */
93
+ function localKeyFromAccessor(accessor: string): string | null {
94
+ const parts = accessorSegments(accessor);
95
+ const name = parts[1];
96
+ return parts[0] === "local" && name !== undefined && NAME.test(name) ? `local.${name}` : null;
97
+ }
98
+
84
99
  /**
85
100
  * Every string value carrying an interpolation across the tree's resource,
86
- * module and output blocks — exactly the expressions `parse.ts` must resolve
87
- * through the AST before `buildGraph` can classify them.
101
+ * module, output, `locals` and `data` blocks — exactly the expressions
102
+ * `parse.ts` must resolve through the AST before `buildGraph` can classify
103
+ * them.
104
+ *
105
+ * `locals` and `data` are here because a reference can reach a resource
106
+ * through them (#2324): a survivor reading `local.assets_id`, or a `data`
107
+ * source keyed off the carved resource, is a dependency the plan breaks on
108
+ * just as hard as a direct one. Skipping their bodies meant the AST never saw
109
+ * those expressions at all.
88
110
  */
89
111
  export function collectExpressions(tree: Hcl2JsonTree): string[] {
90
112
  const exprs = new Set<string>();
@@ -100,6 +122,8 @@ export function collectExpressions(tree: Hcl2JsonTree): string[] {
100
122
  for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
101
123
  for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
102
124
  for (const blocks of Object.values(tree.output ?? {})) visit(blocks);
125
+ visit(tree.locals);
126
+ for (const named of Object.values(tree.data ?? {})) for (const blocks of Object.values(named)) visit(blocks);
103
127
  return [...exprs].sort();
104
128
  }
105
129
 
@@ -111,7 +135,12 @@ function refsInValue(value: unknown, exprRefs: ExpressionRefs): RawRef[] {
111
135
  if (!v.includes("${")) return;
112
136
  for (const accessor of exprRefs.get(v) ?? []) {
113
137
  const ref = refFromAccessor(accessor);
114
- if (ref) refs.push(ref);
138
+ if (ref) {
139
+ refs.push(ref);
140
+ continue;
141
+ }
142
+ const localKey = localKeyFromAccessor(accessor);
143
+ if (localKey) refs.push({ address: localKey });
115
144
  }
116
145
  } else if (Array.isArray(v)) {
117
146
  v.forEach(visit);
@@ -137,6 +166,105 @@ function refsInBlock(block: unknown, exprRefs: ExpressionRefs): RawRef[] {
137
166
  return refs;
138
167
  }
139
168
 
169
+ /**
170
+ * Non-node referrers: `local.<name>` and `data.<type>.<name>` → the references
171
+ * in the body each one stands for (#2324).
172
+ *
173
+ * Neither is a graph node — a local is a substitution, and a data source is
174
+ * not carvable infrastructure — but both sit on a path between a survivor and
175
+ * a resource, which is the shape an `output` block already has here. A
176
+ * `locals` block arrives from hcl2json as an array of its assignments, one
177
+ * element per `locals` block in the estate.
178
+ */
179
+ function referrerTable(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): Map<string, RawRef[]> {
180
+ const table = new Map<string, RawRef[]>();
181
+ const add = (key: string, refs: RawRef[]): void => {
182
+ const existing = table.get(key);
183
+ if (existing) existing.push(...refs);
184
+ else table.set(key, refs);
185
+ };
186
+ const localsBlocks = Array.isArray(tree.locals) ? tree.locals : tree.locals ? [tree.locals] : [];
187
+ for (const block of localsBlocks) {
188
+ if (!block || typeof block !== "object") continue;
189
+ for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
190
+ add(`local.${name}`, refsInValue(value, exprRefs));
191
+ }
192
+ }
193
+ for (const [type, named] of Object.entries(tree.data ?? {})) {
194
+ for (const [name, blocks] of Object.entries(named)) {
195
+ add(`data.${type}.${name}`, refsInBlock(Array.isArray(blocks) ? blocks[0] : blocks, exprRefs));
196
+ }
197
+ }
198
+ return table;
199
+ }
200
+
201
+ /** Identity of a reference for de-duplication: the address plus the attribute read off it. */
202
+ function refKey(ref: RawRef): string {
203
+ return `${ref.address}\u0000${ref.attr ?? ""}`;
204
+ }
205
+
206
+ /**
207
+ * Resolve every referrer entry to the node references it ultimately names.
208
+ *
209
+ * One substitution pass is not enough: a local can read another local, and a
210
+ * `data` source can read a local that reads another data source. So this
211
+ * iterates to a fixpoint — each round replaces a referrer key appearing in an
212
+ * entry with that key's own resolved references, and the loop stops when a
213
+ * round adds nothing new.
214
+ *
215
+ * Growth is monotone and bounded by (referrers x distinct references), so a
216
+ * self-referential local (`a = local.a`, skipped outright) or a cycle
217
+ * (`a = local.b`, `b = local.a`) converges instead of recursing. Terraform
218
+ * rejects both, but the advisor only reads an estate and must not hang on one
219
+ * it disagrees with.
220
+ */
221
+ function resolveReferrers(table: ReadonlyMap<string, RawRef[]>): Map<string, RawRef[]> {
222
+ // Seed each entry with the references that already name something outside
223
+ // the table; the loop then folds in what the referrer keys resolve to.
224
+ const resolved = new Map<string, Map<string, RawRef>>();
225
+ for (const [key, refs] of table) {
226
+ resolved.set(key, new Map(refs.filter((r) => !table.has(r.address)).map((r) => [refKey(r), r])));
227
+ }
228
+ let changed = true;
229
+ while (changed) {
230
+ changed = false;
231
+ for (const [key, refs] of table) {
232
+ const into = resolved.get(key)!;
233
+ for (const ref of refs) {
234
+ if (ref.address === key) continue; // `a = local.a` — nothing to fold in but itself
235
+ const from = resolved.get(ref.address);
236
+ if (!from) continue;
237
+ for (const [id, inner] of from) {
238
+ if (into.has(id)) continue;
239
+ into.set(id, inner);
240
+ changed = true;
241
+ }
242
+ }
243
+ }
244
+ }
245
+ return new Map([...resolved].map(([key, refs]) => [key, [...refs.values()]]));
246
+ }
247
+
248
+ /**
249
+ * A block's references as the graph should see them: one that reaches a
250
+ * `local` or `data` referrer additionally yields the node reference it
251
+ * resolves to.
252
+ *
253
+ * The original reference is kept — a `data.*` address is what marks the
254
+ * referring node dynamic, and an address that is not a node is dropped at edge
255
+ * time anyway. The resolved reference keeps the referring block's own `via`
256
+ * attribute, since that is what a deferred input is named after, and takes the
257
+ * carried resource attribute from the referrer's body.
258
+ */
259
+ function throughReferrers(refs: readonly RawRef[], resolved: ReadonlyMap<string, RawRef[]>): RawRef[] {
260
+ const out: RawRef[] = [];
261
+ for (const ref of refs) {
262
+ out.push(ref);
263
+ for (const inner of resolved.get(ref.address) ?? []) out.push({ ...inner, via: ref.via });
264
+ }
265
+ return out;
266
+ }
267
+
140
268
  /** A block carries `count`/`for_each` → dynamic, single instance until state resolves it. */
141
269
  function blockHasMeta(block: unknown, key: string): boolean {
142
270
  return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
@@ -201,10 +329,20 @@ function dataSourceValues(block: unknown, type: string): Record<string, string>
201
329
  * exactly like a resource's does. So an output contributes an edge tagged
202
330
  * `fromKind: "output"` from the pseudo-address `output.<name>` (#1638),
203
331
  * which the scorer weights lower and `carve bridge` patches.
332
+ *
333
+ * `locals` and `data` blocks are non-node referrers of a third kind (#2324):
334
+ * they carry a reference between two nodes rather than terminating it. A
335
+ * survivor reading `local.assets_id`, or reading a `data` source keyed off the
336
+ * carved resource, depends on that resource, so the reference is resolved
337
+ * through the referrer to the resource it ultimately names and the edge is
338
+ * recorded against the surviving node — the thing a reader can actually carve
339
+ * or leave behind. The referrer's own body is where `carve bridge` then lands
340
+ * the rewrite.
204
341
  */
205
342
  export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
206
343
  const nodes: TfNode[] = [];
207
344
  const dataAddresses = new Set<string>();
345
+ const referrers = resolveReferrers(referrerTable(tree, exprRefs));
208
346
 
209
347
  // First pass: register data-source addresses so refs to them can be spotted.
210
348
  for (const [type, named] of Object.entries(tree.data ?? {})) {
@@ -218,7 +356,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
218
356
  const address = `${type}.${name}`;
219
357
  const block = Array.isArray(blocks) ? blocks[0] : blocks;
220
358
  const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
221
- const refs = refsInBlock(block, exprRefs);
359
+ const refs = throughReferrers(refsInBlock(block, exprRefs), referrers);
222
360
  const touchesData = refs.some((r) => dataAddresses.has(r.address));
223
361
  rawRefsByNode.set(address, refs);
224
362
  nodes.push({
@@ -239,7 +377,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
239
377
  const address = `module.${name}`;
240
378
  const block = Array.isArray(blocks) ? blocks[0] : blocks;
241
379
  const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
242
- const refs = refsInBlock(block, exprRefs);
380
+ const refs = throughReferrers(refsInBlock(block, exprRefs), referrers);
243
381
  const touchesData = refs.some((r) => dataAddresses.has(r.address));
244
382
  rawRefsByNode.set(address, refs);
245
383
  nodes.push({
@@ -256,7 +394,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
256
394
  const outputRefs = new Map<string, RawRef[]>();
257
395
  for (const [name, blocks] of Object.entries(tree.output ?? {})) {
258
396
  const block = Array.isArray(blocks) ? blocks[0] : blocks;
259
- outputRefs.set(`output.${name}`, refsInBlock(block, exprRefs));
397
+ outputRefs.set(`output.${name}`, throughReferrers(refsInBlock(block, exprRefs), referrers));
260
398
  }
261
399
 
262
400
  // Edges: keep only references that resolve to a known node.
@@ -68,7 +68,7 @@ async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Pr
68
68
  return refs;
69
69
  }
70
70
 
71
- /** Deep-merge hcl2json trees across files (resource/module/data/output namespaces). */
71
+ /** Deep-merge hcl2json trees across files (resource/module/data/output/locals namespaces). */
72
72
  function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
73
73
  for (const section of ["resource", "data"] as const) {
74
74
  const src = next[section];
@@ -82,6 +82,9 @@ function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
82
82
  // Outputs usually live in their own file (#1638) — merge them, or the graph
83
83
  // never sees the estate's outputs.tf at all.
84
84
  if (next.output) into.output = { ...(into.output ?? {}), ...next.output };
85
+ // `locals` is an array of blocks, not a keyed namespace, so files concatenate
86
+ // rather than overwrite — a `locals` block in every .tf is idiomatic (#2324).
87
+ if (next.locals) into.locals = [...(into.locals ?? []), ...next.locals];
85
88
  }
86
89
 
87
90
  /** List every `.tf` file directly under `dir` (non-recursive; matches Terraform's own module scoping). */
@@ -97,5 +97,12 @@ export interface Hcl2JsonTree {
97
97
  data?: Record<string, Record<string, unknown[]>>;
98
98
  /** Root-module `output` blocks. Not nodes — they reference, they are not carvable (#1638). */
99
99
  output?: Record<string, unknown[]>;
100
+ /**
101
+ * Root-module `locals` blocks, as hcl2json renders them: one array element
102
+ * per `locals` block, each an object of its assignments. Not nodes either —
103
+ * a local is a substitution — but a reference can reach a resource through
104
+ * one, so the graph resolves `local.x` to what it names (#2324).
105
+ */
106
+ locals?: unknown[];
100
107
  [k: string]: unknown;
101
108
  }