@intentius/chant 0.61.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 (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  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/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -40,7 +40,14 @@ export interface RuntimeAdapter {
40
40
  * needs to round-trip content through a shell (`sh -c "echo … | cmd"`) just
41
41
  * to feed a command that reads from stdin (e.g. `git hash-object --stdin`).
42
42
  */
43
- spawn(cmd: string[], opts?: { cwd?: string; stdin?: string }): Promise<SpawnResult>;
43
+ /**
44
+ * `opts.env` merges over the parent environment rather than replacing it —
45
+ * a caller pinning `LC_ALL=C` to parse a command's output must not also
46
+ * have to reconstruct `PATH`, `HOME` and everything else the child needs.
47
+ * An entry whose value is `""` is passed through as empty, which is how a
48
+ * gettext override like `LANGUAGE` is neutralised.
49
+ */
50
+ spawn(cmd: string[], opts?: { cwd?: string; stdin?: string; env?: Record<string, string> }): Promise<SpawnResult>;
44
51
  /** Commands to use when spawning package manager / executor */
45
52
  readonly commands: RuntimeCommands;
46
53
  }
@@ -64,12 +71,19 @@ class NodeRuntimeAdapter implements RuntimeAdapter {
64
71
  return picomatch(pattern)(filePath);
65
72
  }
66
73
 
67
- async spawn(cmd: string[], opts?: { cwd?: string; stdin?: string }): Promise<SpawnResult> {
74
+ async spawn(
75
+ cmd: string[],
76
+ opts?: { cwd?: string; stdin?: string; env?: Record<string, string> },
77
+ ): Promise<SpawnResult> {
68
78
  return new Promise((resolve) => {
69
79
  const child = execFile(
70
80
  cmd[0],
71
81
  cmd.slice(1),
72
- { cwd: opts?.cwd, maxBuffer: 10 * 1024 * 1024 },
82
+ {
83
+ cwd: opts?.cwd,
84
+ maxBuffer: 10 * 1024 * 1024,
85
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
86
+ },
73
87
  (err, stdout, stderr) => {
74
88
  resolve({
75
89
  stdout: stdout ?? "",
@@ -1,6 +1,48 @@
1
1
  import { buildGraph, collectExpressions, type ExpressionRefs } from "../graph";
2
2
  import type { Hcl2JsonTree, TfGraph } from "../types";
3
3
 
4
+ /**
5
+ * Shortest-hop count from `address` back through inbound edges — how many
6
+ * dependents-of-dependents deep the estate goes behind that node (#2323).
7
+ * Production has no reachability pass yet (that is the spike's sub-issue 3);
8
+ * this is a test-only BFS over the same `TfGraph` shape, so a fixture's depth
9
+ * can be asserted without waiting on that feature. Folds/outputs are not
10
+ * special-cased: an output pseudo-address has no inbound edges by
11
+ * construction (`buildGraph` never lets anything reference an output), so it
12
+ * is always a dead end and never inflates the count.
13
+ */
14
+ function inboundDepthOf(graph: TfGraph, address: string): number {
15
+ let frontier = new Set([address]);
16
+ const visited = new Set(frontier);
17
+ let depth = 0;
18
+ for (;;) {
19
+ const next = new Set<string>();
20
+ for (const e of graph.edges) {
21
+ if (frontier.has(e.to) && !visited.has(e.from)) next.add(e.from);
22
+ }
23
+ if (next.size === 0) return depth;
24
+ depth++;
25
+ for (const addr of next) visited.add(addr);
26
+ frontier = next;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The longest inbound chain anywhere in the graph — the same "depth" column
32
+ * the spike measured (docs/design/carve-dependency-blast-radius-spike.md
33
+ * section 2): 1 hop is a direct dependent, 2 is a dependent of a dependent,
34
+ * and so on. Every shipped fixture tops out at 1; `depth-estate` is built to
35
+ * clear that.
36
+ */
37
+ export function maxTransitiveInboundDepth(graph: TfGraph): number {
38
+ let max = 0;
39
+ for (const node of graph.nodes) {
40
+ const depth = inboundDepthOf(graph, node.address);
41
+ if (depth > max) max = depth;
42
+ }
43
+ return max;
44
+ }
45
+
4
46
  /**
5
47
  * Test-only stand-in for the hcl2json expression AST (`parse.ts`'s
6
48
  * `resolveExpressionRefs`). Fixture expressions are simple `${<accessor>}`
@@ -0,0 +1,138 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveAdvise, carveJson } from "../../cli/commands/carve";
6
+ import { carveBridge } from "../../cli/commands/carve-bridge";
7
+ import { loadHcl2json } from "../parse";
8
+
9
+ /**
10
+ * The three-route estate from the blast-radius spike (#2324), through the real
11
+ * wasm parser: one bucket with three dependents, one reading it directly, one
12
+ * through a `locals` block and one through a `data` source. The advisor used to
13
+ * report a single dependent and band the bucket a clean leaf, and `carve bridge`
14
+ * left the other two routes pointing at a resource Terraform no longer manages.
15
+ *
16
+ * Skips cleanly when the optional parser is absent, like the other estate tests.
17
+ */
18
+ let parserAvailable = false;
19
+ try {
20
+ await loadHcl2json();
21
+ parserAvailable = true;
22
+ } catch {
23
+ parserAvailable = false;
24
+ }
25
+
26
+ const ESTATE = `resource "aws_s3_bucket" "assets" {
27
+ bucket = "app-assets"
28
+ }
29
+
30
+ locals {
31
+ assets_id = aws_s3_bucket.assets.id
32
+ }
33
+
34
+ resource "aws_lambda_function" "via_local" {
35
+ function_name = "via-local"
36
+ environment { variables = { B = local.assets_id } }
37
+ }
38
+
39
+ data "aws_s3_bucket" "lookup" {
40
+ bucket = aws_s3_bucket.assets.bucket
41
+ }
42
+
43
+ resource "aws_lambda_function" "via_data" {
44
+ function_name = "via-data"
45
+ environment { variables = { B = data.aws_s3_bucket.lookup.arn } }
46
+ }
47
+
48
+ resource "aws_lambda_function" "direct" {
49
+ function_name = "direct"
50
+ environment { variables = { B = aws_s3_bucket.assets.arn } }
51
+ }
52
+ `;
53
+
54
+ /**
55
+ * The same estate with the direct reference taken out, so every remaining route
56
+ * to the bucket runs through a referrer. This is the case that actually breaks:
57
+ * with no direct reference the advisor found no inbound edge at all, so
58
+ * `carve bridge` generated no data source and rewrote nothing, and the excised
59
+ * bucket left two dangling references behind.
60
+ */
61
+ const INDIRECT_ONLY = ESTATE.split('resource "aws_lambda_function" "direct"')[0];
62
+
63
+ async function withEstate<T>(source: string, fn: (dir: string) => Promise<T>): Promise<T> {
64
+ const dir = mkdtempSync(join(tmpdir(), "chant-2324-"));
65
+ try {
66
+ writeFileSync(join(dir, "main.tf"), source);
67
+ return await fn(dir);
68
+ } finally {
69
+ rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ describe("dependencies reached through locals and data blocks", () => {
74
+ test("the advisor counts all three dependents, not just the direct one", async () => {
75
+ if (!parserAvailable) return;
76
+ await withEstate(ESTATE, async (dir) => {
77
+ const report = carveJson(await carveAdvise({ from: dir }));
78
+ const bucket = report.resources.find((r) => r.address === "aws_s3_bucket.assets")!;
79
+
80
+ expect(bucket.breakdown.inbound).toBe(3);
81
+ expect(bucket.boundary!.inbound.map((e) => e.survivor)).toEqual([
82
+ "aws_lambda_function.direct",
83
+ "aws_lambda_function.via_data",
84
+ "aws_lambda_function.via_local",
85
+ ]);
86
+
87
+ // Three dependents at -12 each: no longer the top band on a third of the truth.
88
+ expect(bucket.score).toBe(64);
89
+ expect(bucket.band).toBe("carvable w/ edits");
90
+ });
91
+ });
92
+
93
+ test("carve bridge rewires the locals and data routes, not only the direct one", async () => {
94
+ if (!parserAvailable) return;
95
+ await withEstate(ESTATE, async (dir) => {
96
+ const bridge = await carveBridge({
97
+ from: dir,
98
+ select: "aws_s3_bucket.assets",
99
+ output: join(dir, "carveout"),
100
+ });
101
+ expect(bridge.ok).toBe(true);
102
+
103
+ const rewritten = bridge.plan!.rewrites.find((r) => r.changed)!.rewritten;
104
+ // Every reference to the carved bucket now goes through the data source...
105
+ expect(rewritten).toContain("assets_id = data.aws_s3_bucket.assets.id");
106
+ expect(rewritten).toContain("bucket = data.aws_s3_bucket.assets.bucket");
107
+ expect(rewritten).toContain("B = data.aws_s3_bucket.assets.arn");
108
+ expect(rewritten).not.toMatch(/(?<!data\.)aws_s3_bucket\.assets\./);
109
+ // ...and the carved block itself is gone, so the next apply cannot re-create it.
110
+ expect(bridge.plan!.excised).toEqual(["aws_s3_bucket.assets"]);
111
+
112
+ // The two indirect readers are untouched: their own references were never
113
+ // to the bucket, and the referrers they read through still resolve.
114
+ expect(rewritten).toContain("B = local.assets_id");
115
+ expect(rewritten).toContain("B = data.aws_s3_bucket.lookup.arn");
116
+ });
117
+ });
118
+
119
+ test("an estate whose only routes are indirect still gets a data source and a rewrite", async () => {
120
+ if (!parserAvailable) return;
121
+ await withEstate(INDIRECT_ONLY, async (dir) => {
122
+ const bridge = await carveBridge({
123
+ from: dir,
124
+ select: "aws_s3_bucket.assets",
125
+ output: join(dir, "carveout"),
126
+ });
127
+ expect(bridge.ok).toBe(true);
128
+
129
+ // Without an inbound edge there is no data source to rewrite references
130
+ // to, and the excised bucket leaves the locals and data bodies dangling.
131
+ expect(bridge.plan!.dataSources.map((d) => d.address)).toEqual(["aws_s3_bucket.assets"]);
132
+ const rewritten = bridge.plan!.rewrites.find((r) => r.changed)!.rewritten;
133
+ expect(rewritten).toContain("assets_id = data.aws_s3_bucket.assets.id");
134
+ expect(rewritten).toContain("bucket = data.aws_s3_bucket.assets.bucket");
135
+ expect(rewritten).not.toMatch(/(?<!data\.)aws_s3_bucket\.assets\./);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,141 @@
1
+ # A Terraform estate built to close the gaps the carve dependency/blast-radius
2
+ # spike found in every other fixture (docs/design/carve-dependency-blast-
3
+ # radius-spike.md, #2323): none of them has a two-hop dependency chain, and
4
+ # none has a `depends_on` edge. Every resource below carries a comment saying
5
+ # which of those gaps it demonstrates; several are lifted verbatim from the
6
+ # spike's appendix, which lacked the `depends_on`, `count` and module cases
7
+ # folded in here.
8
+
9
+ # --- The depth chain: vpc -> subnet -> db_subnet_group -> rds_cluster -> instance ---
10
+ # aws_vpc.main sits three inbound hops upstream of aws_rds_cluster_instance.one
11
+ # (vpc -> subnet.a -> db_subnet_group.main -> rds_cluster.main -> instance/
12
+ # lambda), the transitive chain no shipped fixture has. Every other fixture's
13
+ # max transitive inbound depth is 1.
14
+ resource "aws_vpc" "main" {
15
+ cidr_block = "10.0.0.0/16"
16
+ }
17
+
18
+ resource "aws_subnet" "a" {
19
+ vpc_id = aws_vpc.main.id
20
+ cidr_block = "10.0.1.0/24"
21
+ }
22
+
23
+ resource "aws_subnet" "b" {
24
+ vpc_id = aws_vpc.main.id
25
+ cidr_block = "10.0.2.0/24"
26
+ }
27
+
28
+ # count: without `--state`, `applyStateCounts` never runs, so a `.tf`-only
29
+ # parse reports 1 instance here and flags `hasDynamic`. The shipped
30
+ # terraform.tfstate resolves it to 3, so the fixture also exercises
31
+ # `readStateInstanceCounts`/`applyStateCounts` when advised with `--state`.
32
+ resource "aws_subnet" "private" {
33
+ count = 3
34
+ vpc_id = aws_vpc.main.id
35
+ cidr_block = "10.1.${count.index}.0/24"
36
+ }
37
+
38
+ resource "aws_security_group" "db" {
39
+ vpc_id = aws_vpc.main.id
40
+ name = "db"
41
+ }
42
+
43
+ resource "aws_security_group" "app" {
44
+ vpc_id = aws_vpc.main.id
45
+ name = "app"
46
+ }
47
+
48
+ resource "aws_db_subnet_group" "main" {
49
+ name = "main"
50
+ subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
51
+ }
52
+
53
+ resource "aws_kms_key" "data" {
54
+ description = "rds + s3"
55
+ }
56
+
57
+ resource "aws_rds_cluster" "main" {
58
+ cluster_identifier = "app-db"
59
+ db_subnet_group_name = aws_db_subnet_group.main.name
60
+ vpc_security_group_ids = [aws_security_group.db.id]
61
+ kms_key_id = aws_kms_key.data.arn
62
+ }
63
+
64
+ # Leave in Terraform: no native mapping (score 0), and the far end of the
65
+ # depth chain above — the node the whole fixture exists to put three hops
66
+ # behind aws_vpc.main.
67
+ resource "aws_rds_cluster_instance" "one" {
68
+ identifier = "app-db-1"
69
+ cluster_identifier = aws_rds_cluster.main.id
70
+ }
71
+
72
+ # --- A second, shorter chain off the same VPC: lb -> target group -> listener ---
73
+ resource "aws_lb" "public" {
74
+ name = "app-lb"
75
+ subnets = [aws_subnet.a.id, aws_subnet.b.id]
76
+ }
77
+
78
+ resource "aws_lb_target_group" "app" {
79
+ name = "app-tg"
80
+ port = 8080
81
+ vpc_id = aws_vpc.main.id
82
+ }
83
+
84
+ resource "aws_lb_listener" "https" {
85
+ load_balancer_arn = aws_lb.public.arn
86
+ port = 443
87
+ default_action {
88
+ target_group_arn = aws_lb_target_group.app.arn
89
+ type = "forward"
90
+ }
91
+ }
92
+
93
+ # Clean leaf: a bucket a Lambda reads from. Its versioning sub-resource folds
94
+ # into it (same shape as `sample-estate`) and is never ranked on its own.
95
+ resource "aws_s3_bucket" "assets" {
96
+ bucket = "app-assets"
97
+ }
98
+
99
+ resource "aws_s3_bucket_versioning" "assets" {
100
+ bucket = aws_s3_bucket.assets.id
101
+ versioning_configuration {
102
+ status = "Enabled"
103
+ }
104
+ }
105
+
106
+ # depends_on: nothing below reads an attribute off this log group, but the
107
+ # Lambda names it in `depends_on` — a real pattern (ordering a function's log
108
+ # group ahead of the function). hcl2json renders `depends_on =
109
+ # [aws_cloudwatch_log_group.api]` as the interpolation
110
+ # "${aws_cloudwatch_log_group.api}", which `refsInBlock` already picks up with
111
+ # `via: ["depends_on"]` and no attribute; nothing in the fixture corpus
112
+ # exercised it before this estate.
113
+ resource "aws_cloudwatch_log_group" "api" {
114
+ name = "/myapp/api"
115
+ retention_in_days = 30
116
+ }
117
+
118
+ resource "aws_lambda_function" "api" {
119
+ function_name = "app-api"
120
+ kms_key_arn = aws_kms_key.data.arn
121
+ depends_on = [aws_cloudwatch_log_group.api]
122
+ environment {
123
+ variables = {
124
+ ASSETS = aws_s3_bucket.assets.bucket
125
+ DB = aws_rds_cluster.main.endpoint
126
+ SG = aws_security_group.app.id
127
+ }
128
+ }
129
+ }
130
+
131
+ # module: a module block is one graph node whatever it contains — state is
132
+ # read for root-module resources only, so radius through a module undercounts
133
+ # by however much the module holds. No other fixture has one.
134
+ module "platform" {
135
+ source = "./modules/platform"
136
+ vpc_id = aws_vpc.main.id
137
+ subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
138
+ }
139
+
140
+ output "lb_dns" { value = aws_lb.public.dns_name }
141
+ output "db_endpoint" { value = aws_rds_cluster.main.endpoint }
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 4,
3
+ "terraform_version": "1.7.0",
4
+ "resources": [
5
+ {
6
+ "mode": "managed",
7
+ "type": "aws_subnet",
8
+ "name": "private",
9
+ "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
10
+ "instances": [
11
+ { "attributes": { "id": "subnet-priv-0", "cidr_block": "10.1.0.0/24" } },
12
+ { "attributes": { "id": "subnet-priv-1", "cidr_block": "10.1.1.0/24" } },
13
+ { "attributes": { "id": "subnet-priv-2", "cidr_block": "10.1.2.0/24" } }
14
+ ]
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { join } from "path";
3
+ import { carveAdvise, carveJson, type CarveJsonResource } from "../../cli/commands/carve";
4
+ import { loadHcl2json, parseTerraformDir } from "../parse";
5
+ import { maxTransitiveInboundDepth } from "./build-graph";
6
+
7
+ /**
8
+ * `depth-estate` (#2323) closes the gap the carve dependency/blast-radius
9
+ * spike found: every shipped fixture — `sample-estate`, `gcp-estate`, the
10
+ * carve-out examples, the CDK fixture — has a max transitive inbound depth of
11
+ * 1 and no `depends_on` edge (docs/design/carve-dependency-blast-radius-spike.md
12
+ * section 2). A future transitive-reachability implementation would pass the
13
+ * whole existing suite whether it were correct, inverted, or a stub returning
14
+ * the direct count. This estate has a real chain, a `depends_on` edge, a
15
+ * `count` block and a module, so it can't.
16
+ */
17
+ let parserAvailable = false;
18
+ try {
19
+ await loadHcl2json();
20
+ parserAvailable = true;
21
+ } catch {
22
+ parserAvailable = false;
23
+ }
24
+
25
+ const ESTATE = join(__dirname, "depth-estate");
26
+ const STATE = join(ESTATE, "terraform.tfstate");
27
+
28
+ describe("the depth-estate fixture actually has depth", () => {
29
+ test("max transitive inbound depth is 3, not the 1 every other fixture tops out at", async () => {
30
+ if (!parserAvailable) return;
31
+ const graph = await parseTerraformDir(ESTATE);
32
+
33
+ // The guard that matters: a snapshot alone can't tell a flat estate from
34
+ // one with real depth, so this is asserted directly against the graph the
35
+ // production parse path builds — not against the pinned scores below,
36
+ // which would still pass at depth 1.
37
+ const depth = maxTransitiveInboundDepth(graph);
38
+ expect(depth).toBeGreaterThan(1);
39
+ // Pinned to the exact chain the fixture was built around: aws_vpc.main ->
40
+ // aws_subnet.a -> aws_db_subnet_group.main -> aws_rds_cluster.main ->
41
+ // {aws_rds_cluster_instance.one, aws_lambda_function.api}, 3 inbound hops.
42
+ expect(depth).toBe(3);
43
+ });
44
+ });
45
+
46
+ describe("carve advise against depth-estate", () => {
47
+ test("bands the chain, the depends_on edge, the count block and the module", async () => {
48
+ if (!parserAvailable) return;
49
+ const r = await carveAdvise({ from: ESTATE });
50
+ expect(r.ok).toBe(true);
51
+ const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
52
+
53
+ // The root of the depth chain: 7 direct inbound edges (2 subnets, the
54
+ // count-expanded subnet, 2 security groups, the target group, the
55
+ // module), no tier penalty (aws_vpc is tier 1).
56
+ expect(byAddr["aws_vpc.main"].band).toBe("leave in Terraform");
57
+ expect(byAddr["aws_vpc.main"].score).toBe(16); // 100 - 12*7
58
+ expect(byAddr["aws_vpc.main"].breakdown.inbound).toBe(7);
59
+
60
+ // One hop down: a data-source patch each for the db subnet group and the
61
+ // load balancer, both of which read it directly.
62
+ expect(byAddr["aws_subnet.a"].band).toBe("carvable w/ edits");
63
+ expect(byAddr["aws_subnet.a"].score).toBe(60); // 100 - 12*3 - 4*1
64
+
65
+ // The far end of the chain: no native mapping, so it scores 0 regardless
66
+ // of how deep it sits.
67
+ expect(byAddr["aws_rds_cluster_instance.one"].score).toBe(0);
68
+ expect(byAddr["aws_rds_cluster_instance.one"].band).toBe("leave in Terraform");
69
+
70
+ // depends_on: the log group carries no attribute reference from the
71
+ // Lambda, only a bare `depends_on` — and still counts as one inbound edge
72
+ // (#2323's second gap, real since hcl2json but untested until this).
73
+ expect(byAddr["aws_cloudwatch_log_group.api"].band).toBe("clean leaf");
74
+ expect(byAddr["aws_cloudwatch_log_group.api"].score).toBe(88); // 100 - 12*1
75
+ expect(byAddr["aws_cloudwatch_log_group.api"].breakdown.inbound).toBe(1);
76
+
77
+ // count: a `.tf`-only parse can't know the fan-out, so it stays at 1
78
+ // instance and gets the flat -10 `hasDynamic` penalty rather than an
79
+ // instances penalty.
80
+ expect(byAddr["aws_subnet.private"].breakdown.hasDynamic).toBe(true);
81
+ expect(byAddr["aws_subnet.private"].breakdown.instances).toBe(1);
82
+ expect(byAddr["aws_subnet.private"].score).toBe(86); // 100 - 4*1(outbound) - 10(dynamic)
83
+
84
+ // module: one node regardless of what it holds, tier 2 (a chant
85
+ // composite reshapes), 3 outbound edges for the vpc_id and subnet_ids
86
+ // inputs it takes.
87
+ expect(byAddr["module.platform"].kind).toBe("module");
88
+ expect(byAddr["module.platform"].breakdown.outbound).toBe(3);
89
+ expect(byAddr["module.platform"].score).toBe(73); // 100 - 4*3 - 15
90
+
91
+ // The report never proposes a mutation.
92
+ expect((r.results ?? []).length).toBeGreaterThan(0);
93
+ });
94
+
95
+ test("the depends_on edge round-trips through the JSON boundary report from both ends", async () => {
96
+ if (!parserAvailable) return;
97
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
98
+ const byAddr = Object.fromEntries(report.resources.map((x) => [x.address, x]));
99
+
100
+ // Seen from the survivor: an inbound edge with no attrs, via depends_on,
101
+ // bridged the same as any other inbound cut (a data-source patch).
102
+ expect(byAddr["aws_cloudwatch_log_group.api"].boundary).toEqual({
103
+ inbound: [
104
+ {
105
+ direction: "inbound",
106
+ survivor: "aws_lambda_function.api",
107
+ carved: "aws_cloudwatch_log_group.api",
108
+ attrs: [],
109
+ via: ["depends_on"],
110
+ bridge: "tf-data-source",
111
+ required: "immediately",
112
+ },
113
+ ],
114
+ outbound: [],
115
+ });
116
+
117
+ // Seen from the depending side: the same edge, now a deferred input.
118
+ const lambdaOut = byAddr["aws_lambda_function.api"].boundary!.outbound;
119
+ expect(lambdaOut).toContainEqual({
120
+ direction: "outbound",
121
+ survivor: "aws_cloudwatch_log_group.api",
122
+ carved: "aws_lambda_function.api",
123
+ attrs: [],
124
+ via: ["depends_on"],
125
+ bridge: "deferred-input",
126
+ required: "at-apply",
127
+ });
128
+ });
129
+
130
+ const allEdges = (resources: CarveJsonResource[]) =>
131
+ resources.flatMap((r) => [...(r.boundary?.inbound ?? []), ...(r.boundary?.outbound ?? [])]);
132
+
133
+ test("every edge endpoint is a real address in the estate, module included", async () => {
134
+ if (!parserAvailable) return;
135
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
136
+ const ranked = new Set(report.resources.map((r) => r.address));
137
+ for (const edge of allEdges(report.resources)) {
138
+ expect(ranked.has(edge.carved)).toBe(true);
139
+ expect(edge.survivor).not.toBe(edge.carved);
140
+ }
141
+ expect(ranked.has("module.platform")).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe("carve advise --state against depth-estate", () => {
146
+ test("resolves the count block's real fan-out, on top of the flat hasDynamic penalty", async () => {
147
+ if (!parserAvailable) return;
148
+ const r = await carveAdvise({ from: ESTATE, statePath: STATE });
149
+ expect(r.ok).toBe(true);
150
+ const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
151
+
152
+ // `readStateInstanceCounts`/`applyStateCounts` only run with `--state`
153
+ // (state.ts:64,:81) — the shipped terraform.tfstate resolves the 3
154
+ // instances the .tf-only parse above could not see. `hasDynamic` stays
155
+ // true (state doesn't erase that it's a count block); the instances
156
+ // penalty now also applies.
157
+ expect(byAddr["aws_subnet.private"].breakdown.instances).toBe(3);
158
+ expect(byAddr["aws_subnet.private"].breakdown.hasDynamic).toBe(true);
159
+ expect(byAddr["aws_subnet.private"].score).toBe(80); // 100 - 4*1 - 10 - 3*2
160
+ expect(byAddr["aws_subnet.private"].breakdown.penalties.instances).toBe(-6);
161
+ });
162
+ });