@intentius/chant 0.41.20 → 0.44.1

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 (144) hide show
  1. package/dist/build.d.ts +15 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/init.d.ts +2 -0
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  13. package/dist/cli/handlers/components.d.ts.map +1 -1
  14. package/dist/cli/handlers/explain.d.ts +11 -0
  15. package/dist/cli/handlers/explain.d.ts.map +1 -0
  16. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  19. package/dist/cli/plugins.d.ts +8 -0
  20. package/dist/cli/plugins.d.ts.map +1 -1
  21. package/dist/cli/registry.d.ts +2 -0
  22. package/dist/cli/registry.d.ts.map +1 -1
  23. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  24. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  25. package/dist/codegen/okf-lexicon.d.ts +36 -0
  26. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  27. package/dist/codegen/package.d.ts +2 -1
  28. package/dist/codegen/package.d.ts.map +1 -1
  29. package/dist/components/deploy-units.d.ts.map +1 -1
  30. package/dist/components/verbs/sbom.d.ts +1 -1
  31. package/dist/components/verbs/sbom.d.ts.map +1 -1
  32. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  33. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  34. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  35. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  36. package/dist/config.d.ts +12 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/deep-observation.d.ts +13 -0
  39. package/dist/deep-observation.d.ts.map +1 -1
  40. package/dist/lexicon.d.ts +47 -0
  41. package/dist/lexicon.d.ts.map +1 -1
  42. package/dist/lifecycle/observe.d.ts.map +1 -1
  43. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  44. package/dist/okf.d.ts +57 -0
  45. package/dist/okf.d.ts.map +1 -0
  46. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  47. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  48. package/dist/terraform/adopt-state.d.ts +33 -1
  49. package/dist/terraform/adopt-state.d.ts.map +1 -1
  50. package/dist/terraform/aws-resources.d.ts.map +1 -1
  51. package/dist/terraform/bridge.d.ts +7 -1
  52. package/dist/terraform/bridge.d.ts.map +1 -1
  53. package/dist/terraform/carve.d.ts +12 -0
  54. package/dist/terraform/carve.d.ts.map +1 -1
  55. package/dist/terraform/excise.d.ts +31 -0
  56. package/dist/terraform/excise.d.ts.map +1 -0
  57. package/dist/terraform/graduate.d.ts +12 -0
  58. package/dist/terraform/graduate.d.ts.map +1 -1
  59. package/dist/terraform/graph.d.ts +35 -5
  60. package/dist/terraform/graph.d.ts.map +1 -1
  61. package/dist/terraform/manifest.d.ts +89 -0
  62. package/dist/terraform/manifest.d.ts.map +1 -0
  63. package/dist/terraform/parse.d.ts +9 -4
  64. package/dist/terraform/parse.d.ts.map +1 -1
  65. package/dist/terraform/tier-map.d.ts +4 -3
  66. package/dist/terraform/tier-map.d.ts.map +1 -1
  67. package/dist/terraform/types.d.ts +7 -0
  68. package/dist/terraform/types.d.ts.map +1 -1
  69. package/dist/terraform/unified-diff.d.ts +11 -0
  70. package/dist/terraform/unified-diff.d.ts.map +1 -0
  71. package/package.json +1 -1
  72. package/src/__snapshots__/okf.test.ts.snap +381 -0
  73. package/src/audit/rules-doc.ts +1 -1
  74. package/src/build.test.ts +95 -0
  75. package/src/build.ts +46 -1
  76. package/src/cli/commands/build.ts +9 -1
  77. package/src/cli/commands/carve-apply.test.ts +94 -2
  78. package/src/cli/commands/carve-apply.ts +62 -11
  79. package/src/cli/commands/carve-bridge.test.ts +82 -4
  80. package/src/cli/commands/carve-bridge.ts +79 -11
  81. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  82. package/src/cli/commands/carve-emit.ts +184 -8
  83. package/src/cli/commands/init.ts +1 -1
  84. package/src/cli/handlers/carve-apply.ts +1 -0
  85. package/src/cli/handlers/components.ts +4 -2
  86. package/src/cli/handlers/explain.test.ts +93 -0
  87. package/src/cli/handlers/explain.ts +60 -0
  88. package/src/cli/handlers/lifecycle.ts +16 -9
  89. package/src/cli/main.ts +21 -5
  90. package/src/cli/mcp/server.test.ts +17 -0
  91. package/src/cli/mcp/tools/explain.ts +15 -3
  92. package/src/cli/plugins.ts +17 -0
  93. package/src/cli/registry.ts +2 -0
  94. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  95. package/src/codegen/docs-rule-scanning.ts +35 -14
  96. package/src/codegen/okf-lexicon.test.ts +248 -0
  97. package/src/codegen/okf-lexicon.ts +303 -0
  98. package/src/codegen/package.ts +29 -4
  99. package/src/components/config-defaults.test.ts +30 -0
  100. package/src/components/deploy-units.test.ts +13 -0
  101. package/src/components/deploy-units.ts +5 -0
  102. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  103. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  104. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  105. package/src/components/verbs/sbom.ts +1 -1
  106. package/src/components/verbs/vuln-gate.test.ts +144 -1
  107. package/src/components/verbs/vuln-gate.ts +61 -10
  108. package/src/components/verbs/vuln-scan.test.ts +94 -0
  109. package/src/components/verbs/vuln-scan.ts +44 -9
  110. package/src/config.test.ts +21 -0
  111. package/src/config.ts +18 -0
  112. package/src/deep-observation.test.ts +19 -0
  113. package/src/deep-observation.ts +32 -13
  114. package/src/lexicon.ts +50 -0
  115. package/src/lifecycle/observe.test.ts +18 -0
  116. package/src/lifecycle/observe.ts +10 -3
  117. package/src/lifecycle/snapshot.ts +5 -4
  118. package/src/okf.test.ts +169 -0
  119. package/src/okf.ts +308 -0
  120. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  121. package/src/terraform/adopt-state.test.ts +34 -1
  122. package/src/terraform/adopt-state.ts +69 -2
  123. package/src/terraform/aws-resources.test.ts +52 -2
  124. package/src/terraform/aws-resources.ts +95 -4
  125. package/src/terraform/bridge.test.ts +38 -14
  126. package/src/terraform/bridge.ts +48 -14
  127. package/src/terraform/carve.test.ts +9 -7
  128. package/src/terraform/carve.ts +0 -0
  129. package/src/terraform/excise.test.ts +92 -0
  130. package/src/terraform/excise.ts +139 -0
  131. package/src/terraform/graduate.test.ts +73 -6
  132. package/src/terraform/graduate.ts +49 -0
  133. package/src/terraform/graph.test.ts +73 -10
  134. package/src/terraform/graph.ts +124 -47
  135. package/src/terraform/manifest.test.ts +105 -0
  136. package/src/terraform/manifest.ts +162 -0
  137. package/src/terraform/parse.test.ts +39 -1
  138. package/src/terraform/parse.ts +36 -9
  139. package/src/terraform/score.test.ts +8 -8
  140. package/src/terraform/state.test.ts +4 -4
  141. package/src/terraform/tier-map.ts +60 -8
  142. package/src/terraform/types.ts +7 -0
  143. package/src/terraform/unified-diff.test.ts +77 -0
  144. package/src/terraform/unified-diff.ts +146 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Excision of a carved resource's own `resource "<type>" "<name>"` block from
3
+ * the surviving Terraform source (#998 — closes the "auto-excise" limitation).
4
+ *
5
+ * After `terraform state rm`, the block left behind would re-CREATE the
6
+ * resource on the next apply — the one edit the bridge previously left to
7
+ * hand-work. This removes the carve set's own blocks as part of the survivor
8
+ * rewrite, so the one bridge `.patch` (#1589) carries the whole handoff edit.
9
+ *
10
+ * Pure text transform. The scanner tracks brace depth outside of strings,
11
+ * comments, template interpolations, and heredocs — enough for real `.tf`
12
+ * files without pulling in the wasm parser for what is a subtractive edit.
13
+ */
14
+
15
+ export interface ExciseTarget {
16
+ /** Terraform address, e.g. `aws_s3_bucket.assets`. */
17
+ address: string;
18
+ type: string;
19
+ name: string;
20
+ }
21
+
22
+ export interface ExciseResult {
23
+ content: string;
24
+ /** Addresses whose block was found and removed, in file order. */
25
+ excised: string[];
26
+ }
27
+
28
+ const HEADER = /^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/;
29
+
30
+ /**
31
+ * Remove the targets' own `resource` blocks from one `.tf` file's text. A
32
+ * comment line directly above a removed block stays (it may describe more
33
+ * than the block); the blank line the removal leaves behind is collapsed.
34
+ */
35
+ export function exciseResourceBlocks(content: string, targets: ExciseTarget[]): ExciseResult {
36
+ if (targets.length === 0) return { content, excised: [] };
37
+ const wanted = new Map(targets.map((t) => [`${t.type}.${t.name}`, t.address]));
38
+ const lines = content.split("\n");
39
+ const excised: string[] = [];
40
+ const out: string[] = [];
41
+
42
+ let i = 0;
43
+ while (i < lines.length) {
44
+ const m = lines[i].match(HEADER);
45
+ const key = m ? `${m[1]}.${m[2]}` : undefined;
46
+ if (!m || !wanted.has(key!)) {
47
+ out.push(lines[i]);
48
+ i++;
49
+ continue;
50
+ }
51
+
52
+ const end = blockEnd(lines, i);
53
+ if (end === -1) {
54
+ // Unbalanced block — leave the file alone rather than corrupt it.
55
+ out.push(lines[i]);
56
+ i++;
57
+ continue;
58
+ }
59
+ excised.push(wanted.get(key!)!);
60
+ i = end + 1;
61
+ // Swallow the separator blank line so the removal leaves no doubled gap.
62
+ if (i < lines.length && lines[i].trim() === "") i++;
63
+ }
64
+
65
+ return { content: out.join("\n"), excised };
66
+ }
67
+
68
+ /** Index of the line closing the block opened on `start`, or -1 if unbalanced. */
69
+ function blockEnd(lines: string[], start: number): number {
70
+ // Context stack: block/object braces, strings, `${` interpolations, block
71
+ // comments. The header's `{` pushes the first "brace"; the block closes when
72
+ // the stack empties.
73
+ const stack: Array<"brace" | "string" | "interp" | "comment"> = [];
74
+ let opened = false;
75
+ let heredoc: string | null = null;
76
+
77
+ for (let ln = start; ln < lines.length; ln++) {
78
+ const line = lines[ln];
79
+ if (heredoc !== null) {
80
+ if (line.trim() === heredoc) heredoc = null;
81
+ continue;
82
+ }
83
+
84
+ for (let c = 0; c < line.length; c++) {
85
+ const top = stack[stack.length - 1];
86
+ const ch = line[c];
87
+ const next = line[c + 1];
88
+
89
+ if (top === "comment") {
90
+ if (ch === "*" && next === "/") {
91
+ stack.pop();
92
+ c++;
93
+ }
94
+ continue;
95
+ }
96
+
97
+ if (top === "string") {
98
+ if (ch === "\\") {
99
+ c++;
100
+ } else if (ch === "$" && next === "{" && line[c - 1] !== "$") {
101
+ stack.push("interp");
102
+ c++;
103
+ } else if (ch === '"') {
104
+ stack.pop();
105
+ }
106
+ continue;
107
+ }
108
+
109
+ // Code position (top of file block, a nested brace, or inside `${...}`).
110
+ if (ch === "#" || (ch === "/" && next === "/")) break; // line comment
111
+ if (ch === "/" && next === "*") {
112
+ stack.push("comment");
113
+ c++;
114
+ continue;
115
+ }
116
+ if (ch === '"') {
117
+ stack.push("string");
118
+ continue;
119
+ }
120
+ if (ch === "<" && next === "<") {
121
+ const hd = line.slice(c).match(/^<<-?([A-Za-z_][A-Za-z0-9_-]*)/);
122
+ if (hd) {
123
+ heredoc = hd[1];
124
+ break;
125
+ }
126
+ }
127
+ if (ch === "{") {
128
+ stack.push("brace");
129
+ opened = true;
130
+ continue;
131
+ }
132
+ if (ch === "}") {
133
+ if (top === "brace" || top === "interp") stack.pop();
134
+ if (opened && stack.length === 0) return ln;
135
+ }
136
+ }
137
+ }
138
+ return -1;
139
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraph } from "./graph";
2
+ import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
3
  import { boundaryReport } from "./carve";
4
- import { graduationPlan, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
4
+ import { graduationPlan, stampOwnershipIntoSource, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
5
5
  import type { Hcl2JsonTree } from "./types";
6
6
 
7
7
  const bucketTree: Hcl2JsonTree = {
@@ -13,7 +13,7 @@ const bucketTree: Hcl2JsonTree = {
13
13
 
14
14
  describe("graduationPlan", () => {
15
15
  test("resolves the ownership marker + tags (chant-owned)", () => {
16
- const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
16
+ const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
17
17
  const plan = graduationPlan(report, { stack: "assets", env: "prod" });
18
18
 
19
19
  expect(plan.marker).toEqual({ stack: "assets", env: "prod" });
@@ -25,14 +25,14 @@ describe("graduationPlan", () => {
25
25
  });
26
26
 
27
27
  test("stack defaults to the resource's local name", () => {
28
- const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
28
+ const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
29
29
  const plan = graduationPlan(report);
30
30
  expect(plan.marker.stack).toBe("assets");
31
31
  expect(plan.ownershipTags).not.toHaveProperty(DEFAULT_TAG_OWNERSHIP_KEYS.env); // no env → omitted
32
32
  });
33
33
 
34
34
  test("runbook is reversible and BYOL (import rollback, no chant-runs-apply)", () => {
35
- const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
35
+ const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
36
36
  const plan = graduationPlan(report, { env: "prod" });
37
37
  const runbook = plan.steps.join("\n");
38
38
  expect(runbook).toMatch(/terraform import aws_s3_bucket\.assets/);
@@ -42,8 +42,75 @@ describe("graduationPlan", () => {
42
42
 
43
43
  test("warns when outbound edges leave deferred inputs to wire", () => {
44
44
  // Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
45
- const report = boundaryReport(buildGraph(bucketTree), "aws_lambda_function.api")!;
45
+ const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_lambda_function.api")!;
46
46
  const plan = graduationPlan(report, { env: "prod" });
47
47
  expect(plan.warnings.join(" ")).toMatch(/deferred deploy-time input/i);
48
48
  });
49
49
  });
50
+
51
+ const TAGS = { "chant:managed-by": "chant", "chant:stack": "assets", "chant:env": "prod" };
52
+
53
+ describe("stampOwnershipIntoSource", () => {
54
+ test("merges into an existing Tags prop, replacing stale chant keys", () => {
55
+ const src = [
56
+ 'import { Bucket } from "@intentius/chant-lexicon-aws";',
57
+ "",
58
+ "export const assets = new Bucket({",
59
+ ' BucketName: "myapp-assets-prod",',
60
+ ' Tags: [{"Key":"Team","Value":"web"},{"Key":"chant:stack","Value":"old"}],',
61
+ "});",
62
+ "",
63
+ ].join("\n");
64
+
65
+ const res = stampOwnershipIntoSource(src, TAGS)!;
66
+ expect(res.changed).toBe(true);
67
+ const tagsLine = res.content.split("\n").find((l) => l.includes("Tags:"))!;
68
+ const tags = JSON.parse(tagsLine.trim().replace(/^Tags: /, "").replace(/,$/, "")) as Array<{ Key: string; Value: string }>;
69
+ expect(tags).toEqual([
70
+ { Key: "Team", Value: "web" },
71
+ { Key: "chant:managed-by", Value: "chant" },
72
+ { Key: "chant:stack", Value: "assets" },
73
+ { Key: "chant:env", Value: "prod" },
74
+ ]);
75
+ });
76
+
77
+ test("inserts a Tags prop when the constructor has none (unmapped comment untouched)", () => {
78
+ const src = [
79
+ "export const api = new LogGroup({",
80
+ ' LogGroupName: "/myapp/api",',
81
+ "});",
82
+ "",
83
+ "/* Unmapped Terraform attributes (reconcile to native props before building):",
84
+ '{ "skip_destroy": false }',
85
+ "*/",
86
+ "",
87
+ ].join("\n");
88
+
89
+ const res = stampOwnershipIntoSource(src, TAGS)!;
90
+ expect(res.changed).toBe(true);
91
+ const lines = res.content.split("\n");
92
+ expect(lines[2]).toBe(` Tags: ${JSON.stringify(Object.entries(TAGS).map(([Key, Value]) => ({ Key, Value })))},`);
93
+ expect(lines[3]).toBe("});");
94
+ expect(res.content).toContain("skip_destroy"); // the comment survives
95
+ });
96
+
97
+ test("handles an empty props object", () => {
98
+ const src = "export const igw = new InternetGateway({});\n";
99
+ const res = stampOwnershipIntoSource(src, TAGS)!;
100
+ expect(res.changed).toBe(true);
101
+ expect(res.content).toContain("new InternetGateway({\n Tags: [");
102
+ expect(res.content).toMatch(/\}\);\n$/);
103
+ });
104
+
105
+ test("is idempotent: a second stamp changes nothing", () => {
106
+ const src = 'export const assets = new Bucket({\n BucketName: "b",\n});\n';
107
+ const first = stampOwnershipIntoSource(src, TAGS)!;
108
+ const second = stampOwnershipIntoSource(first.content, TAGS)!;
109
+ expect(second.changed).toBe(false);
110
+ expect(second.content).toBe(first.content);
111
+ });
112
+
113
+ test("returns null when there is no constructor to stamp", () => {
114
+ expect(stampOwnershipIntoSource("// nothing here\n", TAGS)).toBeNull();
115
+ });
116
+ });
@@ -76,3 +76,52 @@ export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}
76
76
 
77
77
  return { target: report.target, marker, ownershipTags, steps, warnings };
78
78
  }
79
+
80
+ /**
81
+ * Stamp the ownership tags into an emitted chant source file (`carve apply
82
+ * --write-source`). The emitted source is machine-generated (`adoptFromState`),
83
+ * so its shape is known: one prop per line, `Tags` as a single-line JSON array.
84
+ * Merges into an existing `Tags` prop (replacing stale chant keys) or inserts
85
+ * one into the constructor's props object. Returns null when the file has no
86
+ * recognizable constructor to stamp.
87
+ */
88
+ export function stampOwnershipIntoSource(
89
+ content: string,
90
+ tags: Record<string, string>,
91
+ ): { content: string; changed: boolean } | null {
92
+ const entries = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
93
+ const keys = new Set(Object.keys(tags));
94
+ const lines = content.split("\n");
95
+
96
+ // Merge into an existing Tags prop.
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const m = lines[i].match(/^(\s*)Tags: (\[.*\]),$/);
99
+ if (!m) continue;
100
+ let existing: Array<{ Key: string; Value: unknown }>;
101
+ try {
102
+ existing = JSON.parse(m[2]) as Array<{ Key: string; Value: unknown }>;
103
+ } catch {
104
+ continue;
105
+ }
106
+ const merged = [...existing.filter((t) => !keys.has(t.Key)), ...entries];
107
+ const rendered = `${m[1]}Tags: ${JSON.stringify(merged)},`;
108
+ if (rendered === lines[i]) return { content, changed: false };
109
+ lines[i] = rendered;
110
+ return { content: lines.join("\n"), changed: true };
111
+ }
112
+
113
+ // No Tags prop yet: insert one into the constructor's props object.
114
+ const tagsLine = ` Tags: ${JSON.stringify(entries)},`;
115
+ for (let i = lines.length - 1; i >= 0; i--) {
116
+ if (/^\}\);\s*$/.test(lines[i])) {
117
+ lines.splice(i, 0, tagsLine);
118
+ return { content: lines.join("\n"), changed: true };
119
+ }
120
+ const empty = lines[i].match(/^(export const .+ = new \w+\()\{\}(\);)\s*$/);
121
+ if (empty) {
122
+ lines[i] = `${empty[1]}{\n${tagsLine}\n}${empty[2]}`;
123
+ return { content: lines.join("\n"), changed: true };
124
+ }
125
+ }
126
+ return null;
127
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraph, inboundEdges, outboundEdges } from "./graph";
2
+ import { inboundEdges, outboundEdges, refFromAccessor } from "./graph";
3
+ import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
4
  import type { Hcl2JsonTree } from "./types";
4
5
 
5
6
  /**
@@ -37,7 +38,7 @@ const workedExample: Hcl2JsonTree = {
37
38
 
38
39
  describe("buildGraph", () => {
39
40
  test("extracts resource nodes with addresses", () => {
40
- const g = buildGraph(workedExample);
41
+ const g = buildFixtureGraph(workedExample);
41
42
  expect(g.nodes.map((n) => n.address)).toEqual([
42
43
  "aws_lambda_function.api",
43
44
  "aws_s3_bucket.assets",
@@ -47,16 +48,16 @@ describe("buildGraph", () => {
47
48
  });
48
49
 
49
50
  test("records directed edges with referenced attributes, deduped per target", () => {
50
- const g = buildGraph(workedExample);
51
+ const g = buildFixtureGraph(workedExample);
51
52
  // versioning → bucket (.id), lambda → bucket (.bucket, .arn)
52
53
  expect(g.edges).toEqual([
53
- { from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"] },
54
- { from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"] },
54
+ { from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"], via: ["environment"] },
55
+ { from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["bucket"] },
55
56
  ]);
56
57
  });
57
58
 
58
59
  test("inbound/outbound classification", () => {
59
- const g = buildGraph(workedExample);
60
+ const g = buildFixtureGraph(workedExample);
60
61
  // Two survivors depend on the bucket; the bucket depends on nothing.
61
62
  expect(inboundEdges(g, "aws_s3_bucket.assets").map((e) => e.from)).toEqual([
62
63
  "aws_lambda_function.api",
@@ -78,7 +79,7 @@ describe("buildGraph", () => {
78
79
  aws_route53_record: { cdn: [{ name: "${module.cdn.domain}" }] },
79
80
  },
80
81
  };
81
- const g = buildGraph(tree);
82
+ const g = buildFixtureGraph(tree);
82
83
  const cdn = g.nodes.find((n) => n.address === "module.cdn");
83
84
  expect(cdn).toMatchObject({ kind: "module", name: "cdn" });
84
85
  // module → bucket, and record → module
@@ -86,11 +87,13 @@ describe("buildGraph", () => {
86
87
  from: "module.cdn",
87
88
  to: "aws_s3_bucket.assets",
88
89
  attrs: ["arn"],
90
+ via: ["bucket_arn"],
89
91
  });
90
92
  expect(g.edges).toContainEqual({
91
93
  from: "aws_route53_record.cdn",
92
94
  to: "module.cdn",
93
95
  attrs: ["domain"],
96
+ via: ["name"],
94
97
  });
95
98
  });
96
99
 
@@ -104,7 +107,7 @@ describe("buildGraph", () => {
104
107
  },
105
108
  },
106
109
  };
107
- const g = buildGraph(tree);
110
+ const g = buildFixtureGraph(tree);
108
111
  const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
109
112
  expect(byAddr["aws_instance.web"]).toMatchObject({ hasDynamic: true, instances: 1 });
110
113
  expect(byAddr["aws_instance.worker"]).toMatchObject({ hasDynamic: true, instances: 1 });
@@ -118,20 +121,80 @@ describe("buildGraph", () => {
118
121
  aws_instance: { web: [{ ami: "${data.aws_ami.ubuntu.id}" }] },
119
122
  },
120
123
  };
121
- const g = buildGraph(tree);
124
+ const g = buildFixtureGraph(tree);
122
125
  expect(g.nodes.map((n) => n.address)).toEqual(["aws_instance.web"]); // data is not a node
123
126
  expect(g.edges).toEqual([]); // ref to data → no resource edge
124
127
  expect(g.nodes[0].hasDynamic).toBe(true);
125
128
  });
126
129
 
130
+ test("an expression whose AST found no references contributes no edge", () => {
131
+ // `${var.m["aws_s3_bucket.assets.arn"]}` — the AST reports only `var.m`;
132
+ // the quoted address inside the brackets is a map key, not a reference.
133
+ const tree: Hcl2JsonTree = {
134
+ resource: {
135
+ aws_s3_bucket: { assets: [{ bucket: "x" }] },
136
+ aws_lambda_function: { api: [{ handler: '${var.m["aws_s3_bucket.assets.arn"]}' }] },
137
+ },
138
+ };
139
+ const g = buildFixtureGraph(tree);
140
+ expect(g.edges).toEqual([]);
141
+ });
142
+
143
+ test("a dotted identity attr resolves through nested blocks (#998)", () => {
144
+ const tree: Hcl2JsonTree = {
145
+ resource: {
146
+ kubernetes_manifest: {
147
+ app_config: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config", namespace: "web" } } }],
148
+ templated: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "${var.name}" } } }],
149
+ },
150
+ aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
151
+ },
152
+ };
153
+ const g = buildFixtureGraph(tree);
154
+ const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
155
+ expect(byAddr["kubernetes_manifest.app_config"].identity).toBe("app-config");
156
+ expect(byAddr["kubernetes_manifest.templated"].identity).toBeUndefined(); // interpolated
157
+ expect(byAddr["aws_s3_bucket.assets"].identity).toBe("myapp-assets-prod"); // flat attr unaffected
158
+ });
159
+
127
160
  test("var / local references are not edges", () => {
128
161
  const tree: Hcl2JsonTree = {
129
162
  resource: {
130
163
  aws_s3_bucket: { assets: [{ bucket: "${var.name}", tags: { env: "${local.env}" } }] },
131
164
  },
132
165
  };
133
- const g = buildGraph(tree);
166
+ const g = buildFixtureGraph(tree);
134
167
  expect(g.edges).toEqual([]);
135
168
  expect(g.nodes[0].hasDynamic).toBe(false); // var/local alone are not the dynamic markers
136
169
  });
137
170
  });
171
+
172
+ describe("refFromAccessor", () => {
173
+ test("classifies resource, module, and data accessors", () => {
174
+ expect(refFromAccessor("aws_s3_bucket.assets.bucket")).toEqual({
175
+ address: "aws_s3_bucket.assets",
176
+ attr: "bucket",
177
+ });
178
+ expect(refFromAccessor("aws_s3_bucket.assets")).toEqual({ address: "aws_s3_bucket.assets", attr: undefined });
179
+ expect(refFromAccessor("module.cdn.domain")).toEqual({ address: "module.cdn", attr: "domain" });
180
+ expect(refFromAccessor("data.aws_ami.ubuntu.id")).toEqual({ address: "data.aws_ami.ubuntu", attr: "id" });
181
+ });
182
+
183
+ test("non-resource heads are not references", () => {
184
+ for (const accessor of ["var.name", "local.env", "each.value", "count.index", "self.arn", "path.module", "terraform.workspace"]) {
185
+ expect(refFromAccessor(accessor)).toBeNull();
186
+ }
187
+ });
188
+
189
+ test("quoted map keys and numeric indexes never become the attribute", () => {
190
+ // `var.m["aws_s3_bucket.assets.arn"]` → the AST renders `var.m."aws_s3_bucket.assets.arn"`
191
+ expect(refFromAccessor('var.m."aws_s3_bucket.assets.arn"')).toBeNull();
192
+ // `aws_instance.web[0].id` → `aws_instance.web.0.id`
193
+ expect(refFromAccessor("aws_instance.web.0.id")).toEqual({ address: "aws_instance.web", attr: "id" });
194
+ // `aws_s3_bucket.assets.tags["a.b"]` → the key must not shadow the attr
195
+ expect(refFromAccessor('aws_s3_bucket.assets.tags."a.b"')).toEqual({
196
+ address: "aws_s3_bucket.assets",
197
+ attr: "tags",
198
+ });
199
+ });
200
+ });