@intentius/chant-lexicon-terraform 0.124.59 → 0.124.61

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.
@@ -1 +1 @@
1
- {"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4HG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAiC,cAAc,EAAE,MAAM,wCAAwC,CAAC;AAExH,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,2CAA2C,CAAC;AAGnD,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AACzG,OAAO,KAAK,EAAmB,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,kDAAkD,CAAC;AA0BlI,OAAO,EAA0D,KAAK,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAIjH,0CAA0C;AAC1C,MAAM,WAAW,uBAAuB;IACtC,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+HAA+H;IAC/H,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yGAAyG;IACzG,WAAW,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC7B,qFAAqF;IACrF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gHAAgH;IAChH,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;CAC9B;AAED,6FAA6F;AAC7F,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;IACtB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAyGD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,cAAc,CAerE;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG,oBAAoB,CAEhI;AAID;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,kBAInC,CAAC;AA4BF,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,0BAA0B,CAAC;AAEzD,iHAAiH;AACjH,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,IAAI,EAAE,uBAAuB,CAAC;CAC/B;AAsFD;;;;;;;GAOG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAEpB,IAAI,EAAE,MAAM;IADxC,QAAQ,CAAC,SAAS,QAAQ;IAC1B,YAA4B,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAGxD;CACF;AA6DD,eAAO,MAAM,wBAAwB,EAAE,UAAU,CAAC,uBAAuB,EAAE,wBAAwB,CAwHlG,CAAC;AAEF,eAAO,MAAM,uBAAuB;aAClC,KAAK,YAAG,iBAAiB;aAEzB,KAAK,YAAG,wBAAwB,EAAE,sBAAsB;aACxD,IAAI,YAAG,gBAAgB;aAEvB,MAAM,YAAG,kBAAkB;CACnB,CAAC;AAEX,eAAO,MAAM,yBAAyB,EAAE,gBAcvC,CAAC"}
1
+ {"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4HG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAiC,cAAc,EAAE,MAAM,wCAAwC,CAAC;AAExH,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,2CAA2C,CAAC;AAInD,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AACzG,OAAO,KAAK,EAAmB,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,kDAAkD,CAAC;AA0BlI,OAAO,EAA0D,KAAK,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAIjH,0CAA0C;AAC1C,MAAM,WAAW,uBAAuB;IACtC,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+HAA+H;IAC/H,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yGAAyG;IACzG,WAAW,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC7B,qFAAqF;IACrF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gHAAgH;IAChH,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;CAC9B;AAED,6FAA6F;AAC7F,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;IACtB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAyGD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,cAAc,CAerE;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG,oBAAoB,CAEhI;AAID;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,kBAInC,CAAC;AA4BF,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,0BAA0B,CAAC;AAEzD,iHAAiH;AACjH,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,IAAI,EAAE,uBAAuB,CAAC;CAC/B;AAsFD;;;;;;;GAOG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAEpB,IAAI,EAAE,MAAM;IADxC,QAAQ,CAAC,SAAS,QAAQ;IAC1B,YAA4B,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAGxD;CACF;AA6DD,eAAO,MAAM,wBAAwB,EAAE,UAAU,CAAC,uBAAuB,EAAE,wBAAwB,CA0HlG,CAAC;AAEF,eAAO,MAAM,uBAAuB;aAClC,KAAK,YAAG,iBAAiB;aAEzB,KAAK,YAAG,wBAAwB,EAAE,sBAAsB;aACxD,IAAI,YAAG,gBAAgB;aAEvB,MAAM,YAAG,kBAAkB;CACnB,CAAC;AAEX,eAAO,MAAM,yBAAyB,EAAE,gBAcvC,CAAC"}
@@ -128,6 +128,7 @@ import { dirname, join, resolve } from "node:path";
128
128
  import { loadChantConfigUpward } from "@intentius/chant/config";
129
129
  import { PlanShapeMovedError, planShapeDifferences, planShapeDigest, } from "@intentius/chant/components/covered-round";
130
130
  import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
131
+ import { terraformResourceDigests } from "../plan-digest.js";
131
132
  import { ownPackageVersion } from "@intentius/chant/components/capability-plugin";
132
133
  import { choudoufuMemberOutputs, choudoufuMemberPlanSet, choudoufuPlanSet, choudoufuWaveApply, choudoufuWaveRoot, terraformApply, terraformInit, terraformOutputs, terraformPlan, } from "../op/activities/terraform.js";
133
134
  import { terraformPlanMoved } from "../exit-codes.js";
@@ -501,6 +502,8 @@ export const terraformApplyCapability = {
501
502
  planDigest: planned.planDigest,
502
503
  artifact: { ...planned, ...(lockRows ? { lockRows } : {}) },
503
504
  ...(changeSet ? { changeSet } : {}),
505
+ // plywood#55: each changed resource's own digest, which a merged wave compares with the pull request's per resource.
506
+ resourceDigests: terraformResourceDigests(json),
504
507
  outputs: terraformPlannedOutputs(json),
505
508
  shape: terraformPlanShape(json, input.root),
506
509
  ...(targets ? { targets } : {}),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "9d0b2a34e8ab7ef946ff820f524b61023dcbfde27bb6c087f18536fab7356b53",
4
+ "manifest.json": "252ecd1770b5886b1e634e159d6c5108b6fabc34894d051f45854bdbb3e464ff",
5
5
  "meta.json": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
6
6
  "types/index.d.ts": "022927e6a7b351cc4cf52d5f973f60ccd140e5810e17b32fb8868965aa1db161",
7
7
  "rules/plan-before-apply.ts": "7c10245b2c3335253b48ee7d8b075d89f43116bde20313a5d1031b343fef1282",
@@ -59,5 +59,5 @@
59
59
  "rules/tf047.ts": "bfa86f85c4c5efff297280fc2f15104d6319fa96dfc444a31829fb7353224092",
60
60
  "skills/chant-terraform.md": "53dd11c7b289652f92fdaf2c03766abda46d8964e31fe709e8b142ced4f19c50"
61
61
  },
62
- "composite": "2c430036fefe238c43a24320fc630c35a3430dd1cdaf4e6a54c9fac464699b0a"
62
+ "composite": "e8e8db4d9b31ae4fa9fcb0380cc57a39a5ab5bf520d313a67109a23bbfaf07f9"
63
63
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terraform",
3
- "version": "0.124.59",
3
+ "version": "0.124.61",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Terraform"
6
6
  }
@@ -25,6 +25,15 @@ export interface TerraformPlannedChange {
25
25
  replace_paths: unknown;
26
26
  importing: unknown;
27
27
  }
28
+ /**
29
+ * `after` without the values the plan says are known only after apply
30
+ * (plywood#56). terraform and tofu leave such a value out of `after` and mark
31
+ * it in `after_unknown`; a renderer that prints a stand-in instead (a null, a
32
+ * placeholder string, a value drawn while planning) would otherwise make two
33
+ * plans of one create differ. The mark itself stays in the digest, so a
34
+ * value turning from known to unknown still changes it.
35
+ */
36
+ export declare function withoutUnknown(after: unknown, unknown: unknown): unknown;
28
37
  /**
29
38
  * The change set a `terraform show -json` plan describes, canonicalised
30
39
  * (#2300): every proposed change, at its address, with the values that would
@@ -56,4 +65,13 @@ export declare function terraformPlanChangeSet(planJson: unknown): {
56
65
  * is the same plan both times.
57
66
  */
58
67
  export declare function terraformPlanDigest(planJson: unknown): string;
68
+ /**
69
+ * The digest of each resource the plan changes, by address (plywood#55): its
70
+ * change as {@link terraformPlanChangeSet} projects it, a deposed object apart
71
+ * from the live one, and each action invocation by its address. A no-op or
72
+ * read entry is not in it, and neither is an output, so what a pull request
73
+ * left alone never makes its plan differ from one made after another change
74
+ * landed on it.
75
+ */
76
+ export declare function terraformResourceDigests(planJson: unknown): Record<string, string>;
59
77
  //# sourceMappingURL=plan-digest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plan-digest.d.ts","sourceRoot":"","sources":["../src/plan-digest.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,GAAG;IACzD,eAAe,EAAE,sBAAsB,EAAE,CAAC;IAC1C,aAAa,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,EAAE,CAAC;CAC/B,CAkCA;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,CAE7D"}
1
+ {"version":3,"file":"plan-digest.d.ts","sourceRoot":"","sources":["../src/plan-digest.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAQxE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,GAAG;IACzD,eAAe,EAAE,sBAAsB,EAAE,CAAC;IAC1C,aAAa,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,EAAE,CAAC;CAC/B,CAkCA;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAalF"}
@@ -6,6 +6,25 @@
6
6
  * what lets terragucci bundle it into one file (#3421).
7
7
  */
8
8
  import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
9
+ /**
10
+ * `after` without the values the plan says are known only after apply
11
+ * (plywood#56). terraform and tofu leave such a value out of `after` and mark
12
+ * it in `after_unknown`; a renderer that prints a stand-in instead (a null, a
13
+ * placeholder string, a value drawn while planning) would otherwise make two
14
+ * plans of one create differ. The mark itself stays in the digest, so a
15
+ * value turning from known to unknown still changes it.
16
+ */
17
+ export function withoutUnknown(after, unknown) {
18
+ if (unknown === true)
19
+ return undefined;
20
+ if (Array.isArray(after) && Array.isArray(unknown))
21
+ return after.map((v, i) => (unknown[i] === true ? null : withoutUnknown(v, unknown[i])));
22
+ if (after !== null && typeof after === "object" && !Array.isArray(after) && unknown !== null && typeof unknown === "object" && !Array.isArray(unknown)) {
23
+ const marks = unknown;
24
+ return Object.fromEntries(Object.entries(after).filter(([k]) => marks[k] !== true).map(([k, v]) => [k, withoutUnknown(v, marks[k])]));
25
+ }
26
+ return after;
27
+ }
9
28
  /**
10
29
  * The change set a `terraform show -json` plan describes, canonicalised
11
30
  * (#2300): every proposed change, at its address, with the values that would
@@ -37,7 +56,7 @@ export function terraformPlanChangeSet(planJson) {
37
56
  deposed: e.deposed,
38
57
  actions: change.actions,
39
58
  before: change.before,
40
- after: change.after,
59
+ after: withoutUnknown(change.after, change.after_unknown),
41
60
  after_unknown: change.after_unknown,
42
61
  replace_paths: change.replace_paths,
43
62
  importing: change.importing,
@@ -65,3 +84,26 @@ export function terraformPlanChangeSet(planJson) {
65
84
  export function terraformPlanDigest(planJson) {
66
85
  return computePlanDigest("terraform-plan", terraformPlanChangeSet(planJson));
67
86
  }
87
+ /**
88
+ * The digest of each resource the plan changes, by address (plywood#55): its
89
+ * change as {@link terraformPlanChangeSet} projects it, a deposed object apart
90
+ * from the live one, and each action invocation by its address. A no-op or
91
+ * read entry is not in it, and neither is an output, so what a pull request
92
+ * left alone never makes its plan differ from one made after another change
93
+ * landed on it.
94
+ */
95
+ export function terraformResourceDigests(planJson) {
96
+ const { resourceChanges, actionInvocations } = terraformPlanChangeSet(planJson);
97
+ const out = {};
98
+ for (const c of resourceChanges) {
99
+ const actions = Array.isArray(c.actions) ? c.actions : [];
100
+ if (actions.length === 0 || actions.every((a) => a === "no-op" || a === "read"))
101
+ continue;
102
+ out[`${String(c.address)}${c.deposed ? `#deposed:${String(c.deposed)}` : ""}`] = computePlanDigest("terraform-resource", c);
103
+ }
104
+ for (const a of actionInvocations ?? []) {
105
+ const address = a?.address;
106
+ out[`action:${typeof address === "string" ? address : JSON.stringify(a)}`] = computePlanDigest("terraform-action", a);
107
+ }
108
+ return Object.fromEntries(Object.entries(out).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)));
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.59",
3
+ "version": "0.124.61",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -93,7 +93,7 @@
93
93
  "access": "public"
94
94
  },
95
95
  "peerDependencies": {
96
- "@intentius/chant": "^0.124.59",
96
+ "@intentius/chant": "^0.124.61",
97
97
  "zod": "^4.3.6"
98
98
  },
99
99
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
@@ -217,6 +217,7 @@ describe("terraform-apply", () => {
217
217
  member: "app",
218
218
  planDigest: "jcs1-sha256:app",
219
219
  artifact: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" },
220
+ resourceDigests: {},
220
221
  outputs: { known: {}, unknown: [] },
221
222
  shape: { member: "app", entries: [], unknownInputs: [] },
222
223
  });
@@ -136,6 +136,7 @@ import {
136
136
  } from "@intentius/chant/components/covered-round";
137
137
  import type { ChangeSetPart } from "@intentius/chant/change-set";
138
138
  import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
139
+ import { terraformResourceDigests } from "../plan-digest";
139
140
  import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
140
141
  import type { EngineMemberRef, EngineOutputSource, EnginePlannedOutputs } from "@intentius/chant/components/verbs/member-outputs";
141
142
  import {
@@ -580,6 +581,8 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
580
581
  planDigest: planned.planDigest,
581
582
  artifact: { ...planned, ...(lockRows ? { lockRows } : {}) },
582
583
  ...(changeSet ? { changeSet } : {}),
584
+ // plywood#55: each changed resource's own digest, which a merged wave compares with the pull request's per resource.
585
+ resourceDigests: terraformResourceDigests(json),
583
586
  outputs: terraformPlannedOutputs(json),
584
587
  shape: terraformPlanShape(json, input.root),
585
588
  ...(targets ? { targets } : {}),
@@ -0,0 +1,89 @@
1
+ /**
2
+ * What a terraform or tofu plan's digest leaves out (plywood#56): the values
3
+ * the plan says are known only after apply. A create of a resource with a
4
+ * generated name (`name_prefix`, a random id, a computed output) has to give
5
+ * the same digest in every plan of the same change.
6
+ */
7
+ import { describe, expect, test } from "vitest";
8
+ import { terraformPlanDigest, terraformResourceDigests, withoutUnknown } from "./plan-digest";
9
+
10
+ function plan(after: Record<string, unknown>, unknown: Record<string, unknown> = { id: true, name: true }) {
11
+ return {
12
+ format_version: "1.2",
13
+ terraform_version: "1.9.0",
14
+ timestamp: "2026-10-31T00:00:00Z",
15
+ resource_changes: [
16
+ { address: "aws_sqs_queue.worker", mode: "managed", type: "aws_sqs_queue", name: "worker", provider_name: "registry.opentofu.org/hashicorp/aws", change: { actions: ["create"], before: null, after, after_unknown: unknown } },
17
+ { address: "random_id.suffix", mode: "managed", type: "random_id", name: "suffix", provider_name: "registry.opentofu.org/hashicorp/random", change: { actions: ["create"], before: null, after: { byte_length: 4, keepers: null }, after_unknown: { id: true, hex: true } } },
18
+ ],
19
+ output_changes: {},
20
+ };
21
+ }
22
+
23
+ describe("a create's digest leaves out values known only after apply", () => {
24
+ test("the same create planned twice has one digest, whatever stood in for the unknowns", () => {
25
+ const first = plan({ visibility_timeout_seconds: 30, tags: { team: "a" } });
26
+ const second = plan({ visibility_timeout_seconds: 30, tags: { team: "a" }, name: "worker-20261031120000", id: "placeholder-1" });
27
+ const third = plan({ visibility_timeout_seconds: 30, tags: { team: "a" }, name: null, id: null });
28
+ expect(terraformPlanDigest(second)).toBe(terraformPlanDigest(first));
29
+ expect(terraformPlanDigest(third)).toBe(terraformPlanDigest(first));
30
+ });
31
+
32
+ test("a changed input a reviewer saw still changes it", () => {
33
+ const base = plan({ visibility_timeout_seconds: 30 });
34
+ expect(terraformPlanDigest(plan({ visibility_timeout_seconds: 60 }))).not.toBe(terraformPlanDigest(base));
35
+ expect(terraformPlanDigest(plan({ visibility_timeout_seconds: 30, delay_seconds: 5 }))).not.toBe(terraformPlanDigest(base));
36
+ });
37
+
38
+ test("a value turning from known to unknown changes it", () => {
39
+ const known = plan({ visibility_timeout_seconds: 30, name: "worker" }, { id: true });
40
+ const unknown = plan({ visibility_timeout_seconds: 30 }, { id: true, name: true });
41
+ expect(terraformPlanDigest(known)).not.toBe(terraformPlanDigest(unknown));
42
+ });
43
+
44
+ test("nested and listed unknowns are left out, known neighbours are kept", () => {
45
+ expect(withoutUnknown({ a: { b: "x", c: "gen" }, l: ["p", "gen", "q"] }, { a: { c: true }, l: [false, true, false] })).toEqual({ a: { b: "x" }, l: ["p", null, "q"] });
46
+ expect(withoutUnknown({ a: 1 }, {})).toEqual({ a: 1 });
47
+ expect(withoutUnknown(null, { id: true })).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("the digest of each changed resource (plywood#55)", () => {
52
+ const two = (queueAfter: Record<string, unknown>, bucketActions: string[]) => ({
53
+ resource_changes: [
54
+ { address: "aws_sqs_queue.worker", type: "aws_sqs_queue", name: "worker", change: { actions: ["create"], before: null, after: queueAfter, after_unknown: { id: true, name: true } } },
55
+ { address: "aws_s3_bucket.logs", type: "aws_s3_bucket", name: "logs", change: { actions: bucketActions, before: { acl: "private" }, after: { acl: bucketActions[0] === "update" ? "public-read" : "private" }, after_unknown: {} } },
56
+ ],
57
+ action_invocations: [{ address: "action.aws_lambda_invoke.warm", type: "aws_lambda_invoke" }],
58
+ });
59
+
60
+ test("a no-op is not in it, and an create of unknown values has one digest across plans", () => {
61
+ const a = terraformResourceDigests(two({ delay_seconds: 0 }, ["no-op"]));
62
+ const b = terraformResourceDigests(two({ delay_seconds: 0, name: "placeholder", id: "x" }, ["no-op"]));
63
+ expect(Object.keys(a)).toEqual(["action:action.aws_lambda_invoke.warm", "aws_sqs_queue.worker"]);
64
+ expect(b).toEqual(a);
65
+ });
66
+
67
+ test("a change to another resource leaves this one's digest as it was", () => {
68
+ const quiet = terraformResourceDigests(two({ delay_seconds: 0 }, ["no-op"]));
69
+ const busy = terraformResourceDigests(two({ delay_seconds: 0 }, ["update"]));
70
+ expect(busy["aws_sqs_queue.worker"]).toBe(quiet["aws_sqs_queue.worker"]);
71
+ expect(Object.keys(busy)).toContain("aws_s3_bucket.logs");
72
+ });
73
+
74
+ test("a reviewed input changes it", () => {
75
+ const a = terraformResourceDigests(two({ delay_seconds: 0 }, ["no-op"]));
76
+ const b = terraformResourceDigests(two({ delay_seconds: 9 }, ["no-op"]));
77
+ expect(b["aws_sqs_queue.worker"]).not.toBe(a["aws_sqs_queue.worker"]);
78
+ });
79
+
80
+ test("a deposed object is kept apart from the live one", () => {
81
+ const doc = {
82
+ resource_changes: [
83
+ { address: "aws_x.a", change: { actions: ["create"], before: null, after: {}, after_unknown: {} } },
84
+ { address: "aws_x.a", deposed: "abc", change: { actions: ["delete"], before: {}, after: null, after_unknown: {} } },
85
+ ],
86
+ };
87
+ expect(Object.keys(terraformResourceDigests(doc))).toEqual(["aws_x.a", "aws_x.a#deposed:abc"]);
88
+ });
89
+ });
@@ -29,6 +29,24 @@ export interface TerraformPlannedChange {
29
29
  importing: unknown;
30
30
  }
31
31
 
32
+ /**
33
+ * `after` without the values the plan says are known only after apply
34
+ * (plywood#56). terraform and tofu leave such a value out of `after` and mark
35
+ * it in `after_unknown`; a renderer that prints a stand-in instead (a null, a
36
+ * placeholder string, a value drawn while planning) would otherwise make two
37
+ * plans of one create differ. The mark itself stays in the digest, so a
38
+ * value turning from known to unknown still changes it.
39
+ */
40
+ export function withoutUnknown(after: unknown, unknown: unknown): unknown {
41
+ if (unknown === true) return undefined;
42
+ if (Array.isArray(after) && Array.isArray(unknown)) return after.map((v, i) => (unknown[i] === true ? null : withoutUnknown(v, unknown[i])));
43
+ if (after !== null && typeof after === "object" && !Array.isArray(after) && unknown !== null && typeof unknown === "object" && !Array.isArray(unknown)) {
44
+ const marks = unknown as Record<string, unknown>;
45
+ return Object.fromEntries(Object.entries(after as Record<string, unknown>).filter(([k]) => marks[k] !== true).map(([k, v]) => [k, withoutUnknown(v, marks[k])]));
46
+ }
47
+ return after;
48
+ }
49
+
32
50
  /**
33
51
  * The change set a `terraform show -json` plan describes, canonicalised
34
52
  * (#2300): every proposed change, at its address, with the values that would
@@ -64,7 +82,7 @@ export function terraformPlanChangeSet(planJson: unknown): {
64
82
  deposed: e.deposed,
65
83
  actions: change.actions,
66
84
  before: change.before,
67
- after: change.after,
85
+ after: withoutUnknown(change.after, change.after_unknown),
68
86
  after_unknown: change.after_unknown,
69
87
  replace_paths: change.replace_paths,
70
88
  importing: change.importing,
@@ -97,3 +115,26 @@ export function terraformPlanChangeSet(planJson: unknown): {
97
115
  export function terraformPlanDigest(planJson: unknown): string {
98
116
  return computePlanDigest("terraform-plan", terraformPlanChangeSet(planJson));
99
117
  }
118
+
119
+ /**
120
+ * The digest of each resource the plan changes, by address (plywood#55): its
121
+ * change as {@link terraformPlanChangeSet} projects it, a deposed object apart
122
+ * from the live one, and each action invocation by its address. A no-op or
123
+ * read entry is not in it, and neither is an output, so what a pull request
124
+ * left alone never makes its plan differ from one made after another change
125
+ * landed on it.
126
+ */
127
+ export function terraformResourceDigests(planJson: unknown): Record<string, string> {
128
+ const { resourceChanges, actionInvocations } = terraformPlanChangeSet(planJson);
129
+ const out: Record<string, string> = {};
130
+ for (const c of resourceChanges) {
131
+ const actions = Array.isArray(c.actions) ? c.actions : [];
132
+ if (actions.length === 0 || actions.every((a) => a === "no-op" || a === "read")) continue;
133
+ out[`${String(c.address)}${c.deposed ? `#deposed:${String(c.deposed)}` : ""}`] = computePlanDigest("terraform-resource", c);
134
+ }
135
+ for (const a of actionInvocations ?? []) {
136
+ const address = (a as { address?: unknown } | null)?.address;
137
+ out[`action:${typeof address === "string" ? address : JSON.stringify(a)}`] = computePlanDigest("terraform-action", a);
138
+ }
139
+ return Object.fromEntries(Object.entries(out).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)));
140
+ }