@intentius/chant 0.124.45 → 0.124.46

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 (190) hide show
  1. package/dist/capabilities/aws.paths +148 -0
  2. package/dist/capabilities/base-mappings.json +122 -0
  3. package/dist/capabilities/catalog.d.ts +89 -0
  4. package/dist/capabilities/catalog.d.ts.map +1 -0
  5. package/dist/capabilities/catalog.js +204 -0
  6. package/dist/capabilities/language.d.ts +103 -0
  7. package/dist/capabilities/language.d.ts.map +1 -0
  8. package/dist/capabilities/language.js +335 -0
  9. package/dist/capabilities/matcher.d.ts +65 -0
  10. package/dist/capabilities/matcher.d.ts.map +1 -0
  11. package/dist/capabilities/matcher.js +299 -0
  12. package/dist/capabilities/view.d.ts +207 -0
  13. package/dist/capabilities/view.d.ts.map +1 -0
  14. package/dist/capabilities/view.js +484 -0
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/lint.js +1 -0
  17. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  18. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  19. package/dist/cli/handlers/declared-graph.js +87 -0
  20. package/dist/cli/handlers/graph.d.ts +25 -0
  21. package/dist/cli/handlers/graph.d.ts.map +1 -1
  22. package/dist/cli/handlers/graph.js +93 -87
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  24. package/dist/cli/handlers/pr-env.js +2 -1
  25. package/dist/cli/handlers/search-count.d.ts +89 -0
  26. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  27. package/dist/cli/handlers/search-count.js +132 -0
  28. package/dist/cli/handlers/search.d.ts +358 -6
  29. package/dist/cli/handlers/search.d.ts.map +1 -1
  30. package/dist/cli/handlers/search.js +1065 -155
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/main.js +23 -3
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/server.js +3 -0
  35. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  36. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  37. package/dist/cli/mcp/tools/capabilities.js +27 -0
  38. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  39. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  40. package/dist/cli/mcp/workspace-tools.js +47 -2
  41. package/dist/cli/parse-args.d.ts.map +1 -1
  42. package/dist/cli/parse-args.js +64 -17
  43. package/dist/cli/registry.d.ts +22 -0
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/components/pr-env-ports.d.ts +29 -0
  46. package/dist/components/pr-env-ports.d.ts.map +1 -0
  47. package/dist/components/pr-env-ports.js +77 -0
  48. package/dist/components/pr-env-run.d.ts +5 -21
  49. package/dist/components/pr-env-run.d.ts.map +1 -1
  50. package/dist/components/pr-env-run.js +6 -70
  51. package/dist/components/pr-environment.d.ts +2 -1
  52. package/dist/components/pr-environment.d.ts.map +1 -1
  53. package/dist/components/pr-environment.js +1 -1
  54. package/dist/config-environments.d.ts +92 -0
  55. package/dist/config-environments.d.ts.map +1 -0
  56. package/dist/config-environments.js +102 -0
  57. package/dist/config.d.ts +24 -84
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +9 -96
  60. package/dist/graph-detail.d.ts.map +1 -1
  61. package/dist/graph-detail.js +15 -3
  62. package/dist/graph-effective.d.ts +33 -2
  63. package/dist/graph-effective.d.ts.map +1 -1
  64. package/dist/graph-effective.js +215 -68
  65. package/dist/graph-ir.d.ts +23 -0
  66. package/dist/graph-ir.d.ts.map +1 -1
  67. package/dist/graph-ir.js +130 -0
  68. package/dist/graph-sub-template.d.ts +11 -0
  69. package/dist/graph-sub-template.d.ts.map +1 -0
  70. package/dist/graph-sub-template.js +19 -0
  71. package/dist/lexicon.d.ts +9 -0
  72. package/dist/lexicon.d.ts.map +1 -1
  73. package/dist/lifecycle/observe.d.ts +28 -0
  74. package/dist/lifecycle/observe.d.ts.map +1 -1
  75. package/dist/lifecycle/observe.js +23 -1
  76. package/dist/lint/rule.d.ts +6 -0
  77. package/dist/lint/rule.d.ts.map +1 -1
  78. package/dist/live-endpoint.d.ts +1 -1
  79. package/dist/live-endpoint.d.ts.map +1 -1
  80. package/dist/live-endpoint.js +1 -1
  81. package/dist/observation.d.ts +48 -1
  82. package/dist/observation.d.ts.map +1 -1
  83. package/dist/observation.js +21 -5
  84. package/dist/op/activities/activity-contracts.d.ts +4 -4
  85. package/dist/wave-runtime.mjs +250 -249
  86. package/dist/workspace/capabilities-contract.d.ts +66 -0
  87. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  88. package/dist/workspace/capabilities-contract.js +107 -0
  89. package/dist/workspace/capabilities.schema.json +229 -0
  90. package/dist/workspace/compose-graph.d.ts +9 -0
  91. package/dist/workspace/compose-graph.d.ts.map +1 -1
  92. package/dist/workspace/compose-graph.js +3 -1
  93. package/dist/workspace/conformance/index.d.ts +1 -1
  94. package/dist/workspace/conformance/index.d.ts.map +1 -1
  95. package/dist/workspace/conformance/index.js +39 -2
  96. package/dist/workspace/graph-cache.d.ts +8 -0
  97. package/dist/workspace/graph-cache.d.ts.map +1 -1
  98. package/dist/workspace/graph-cache.js +27 -3
  99. package/dist/workspace/graph-cli.d.ts +39 -2
  100. package/dist/workspace/graph-cli.d.ts.map +1 -1
  101. package/dist/workspace/graph-cli.js +66 -24
  102. package/dist/workspace/graph.schema.json +148 -0
  103. package/dist/workspace/kind-readers.d.ts +3 -0
  104. package/dist/workspace/kind-readers.d.ts.map +1 -1
  105. package/dist/workspace/kind-readers.js +7 -1
  106. package/dist/workspace/member-commands.d.ts +10 -1
  107. package/dist/workspace/member-commands.d.ts.map +1 -1
  108. package/dist/workspace/member-commands.js +17 -1
  109. package/dist/workspace/nested-graph.d.ts +2 -0
  110. package/dist/workspace/nested-graph.d.ts.map +1 -1
  111. package/dist/workspace/nested-graph.js +32 -1
  112. package/dist/workspace/reason-codes.d.ts +10 -0
  113. package/dist/workspace/reason-codes.d.ts.map +1 -1
  114. package/dist/workspace/reason-codes.js +12 -0
  115. package/dist/workspace/search-cli.d.ts +204 -0
  116. package/dist/workspace/search-cli.d.ts.map +1 -0
  117. package/dist/workspace/search-cli.js +466 -0
  118. package/dist/workspace/search-contract.d.ts +56 -0
  119. package/dist/workspace/search-contract.d.ts.map +1 -0
  120. package/dist/workspace/search-contract.js +70 -0
  121. package/dist/workspace/search.schema.json +822 -0
  122. package/package.json +1 -1
  123. package/src/__snapshots__/okf.test.ts.snap +10 -0
  124. package/src/capabilities/aws.paths +148 -0
  125. package/src/capabilities/base-mappings.json +122 -0
  126. package/src/capabilities/catalog.ts +230 -0
  127. package/src/capabilities/language.test.ts +191 -0
  128. package/src/capabilities/language.ts +373 -0
  129. package/src/capabilities/matcher.ts +315 -0
  130. package/src/capabilities/view-compose.test.ts +76 -0
  131. package/src/capabilities/view.ts +631 -0
  132. package/src/cli/commands/lint.ts +1 -0
  133. package/src/cli/handlers/declared-graph.ts +99 -0
  134. package/src/cli/handlers/graph.test.ts +94 -3
  135. package/src/cli/handlers/graph.ts +123 -96
  136. package/src/cli/handlers/pr-env.ts +1 -2
  137. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  138. package/src/cli/handlers/search-count.test.ts +191 -0
  139. package/src/cli/handlers/search-count.ts +165 -0
  140. package/src/cli/handlers/search-json.test.ts +333 -0
  141. package/src/cli/handlers/search-live.test.ts +387 -0
  142. package/src/cli/handlers/search-paths.test.ts +235 -0
  143. package/src/cli/handlers/search.ts +1312 -166
  144. package/src/cli/main.test.ts +28 -3
  145. package/src/cli/main.ts +23 -3
  146. package/src/cli/mcp/server.test.ts +3 -3
  147. package/src/cli/mcp/server.ts +3 -0
  148. package/src/cli/mcp/tools/capabilities.ts +32 -0
  149. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  150. package/src/cli/mcp/workspace-tools.ts +47 -2
  151. package/src/cli/parse-args.ts +52 -13
  152. package/src/cli/registry.ts +22 -0
  153. package/src/components/pr-env-ports.ts +89 -0
  154. package/src/components/pr-env-run-bundle.test.ts +54 -0
  155. package/src/components/pr-env-run.ts +5 -76
  156. package/src/components/pr-environment.ts +2 -1
  157. package/src/config-environments.ts +133 -0
  158. package/src/config.ts +37 -124
  159. package/src/graph-detail.ts +15 -3
  160. package/src/graph-effective.ts +226 -75
  161. package/src/graph-ir.test.ts +163 -0
  162. package/src/graph-ir.ts +147 -0
  163. package/src/graph-sub-template.ts +22 -0
  164. package/src/lexicon.ts +10 -0
  165. package/src/lifecycle/observe.test.ts +27 -1
  166. package/src/lifecycle/observe.ts +43 -1
  167. package/src/lint/rule.ts +6 -0
  168. package/src/live-endpoint.ts +1 -1
  169. package/src/observation.test.ts +25 -0
  170. package/src/observation.ts +69 -5
  171. package/src/workspace/capabilities-contract.test.ts +88 -0
  172. package/src/workspace/capabilities-contract.ts +132 -0
  173. package/src/workspace/capabilities.schema.json +229 -0
  174. package/src/workspace/compose-graph.ts +10 -1
  175. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  176. package/src/workspace/conformance/index.ts +31 -2
  177. package/src/workspace/graph-cache.ts +27 -3
  178. package/src/workspace/graph-cli.ts +96 -24
  179. package/src/workspace/graph.schema.json +148 -0
  180. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  181. package/src/workspace/kind-readers.test.ts +1 -1
  182. package/src/workspace/kind-readers.ts +7 -1
  183. package/src/workspace/member-commands.ts +22 -1
  184. package/src/workspace/nested-graph.ts +32 -1
  185. package/src/workspace/reason-codes.test.ts +7 -0
  186. package/src/workspace/reason-codes.ts +12 -0
  187. package/src/workspace/search-cli.e2e.test.ts +305 -0
  188. package/src/workspace/search-cli.ts +591 -0
  189. package/src/workspace/search-contract.ts +95 -0
  190. package/src/workspace/search.schema.json +822 -0
@@ -1,4 +1,7 @@
1
1
  import type { GraphIR, IRNode } from "./graph-ir";
2
+ import { CapabilityEngine, type Evidence } from "./capabilities/matcher";
3
+ import { buildView, type CapabilityPack, type View } from "./capabilities/view";
4
+ import { resolvePacks, shippedRules } from "./capabilities/catalog";
2
5
 
3
6
  /**
4
7
  * Fold DERIVED reachability facts onto EC2 instance nodes so a single-node query
@@ -21,96 +24,244 @@ import type { GraphIR, IRNode } from "./graph-ir";
21
24
  * With these, "instances SSH-reachable from the internet" is one predicate:
22
25
  * `kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0`
23
26
  * — no hand-joined CLI sweep, no over/under-counting.
27
+ *
28
+ * Both are capability rules (#4375): `internet-facing`, `in-sg` and
29
+ * `ingress-of` in `capabilities/aws.paths`, over the lexicon-neutral view, so
30
+ * one rule serves CloudFormation kinds and Terraform's AWS types (#4362)
31
+ * alike. What stays here is the attribute surface: which nodes get the facts,
32
+ * and how a rule's evidence is spelled as `proto:port:cidr` and `...Via`.
24
33
  */
25
- export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
26
- const byId = new Map(ir.nodes.map((n) => [n.id, n]));
27
- const edges = ir.edges ?? [];
28
- const kind = (n?: IRNode): string => n?.kind ?? "";
29
- const isKind = (n: IRNode | undefined, suffix: string): boolean =>
30
- !!n && (kind(n) === suffix || kind(n).endsWith("::" + suffix));
31
- const via = (...names: string[]) => (v: string): boolean => names.includes(v);
32
-
33
- /** Out-neighbours of `id` (edges from → to), optionally filtered by viaAttr. */
34
- const out = (id: string, pred?: (v: string) => boolean): IRNode[] =>
35
- edges
36
- .filter((e) => e.from === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
37
- .map((e) => byId.get(e.to))
38
- .filter((x): x is IRNode => !!x);
39
- /** In-neighbours of `id` (edges to ← from), optionally filtered by viaAttr. */
40
- const incoming = (id: string, pred?: (v: string) => boolean): IRNode[] =>
41
- edges
42
- .filter((e) => e.to === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
43
- .map((e) => byId.get(e.from))
44
- .filter((x): x is IRNode => !!x);
45
-
46
- const normalizeIngress = (sg: IRNode): string[] => {
47
- const rules = (sg.attrs as Record<string, unknown> | undefined)?.["SecurityGroupIngress"];
48
- if (!Array.isArray(rules)) return [];
49
- return rules.map((r) => {
50
- const rule = r as Record<string, unknown>;
51
- const proto = String(rule.IpProtocol ?? "-1");
52
- const from = rule.FromPort as number | undefined;
53
- const to = rule.ToPort as number | undefined;
54
- const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
55
- const cidr =
56
- (rule.CidrIp as string | undefined) ??
57
- (rule.CidrIpv6 as string | undefined) ??
58
- (rule.SourceSecurityGroupId ? `sg:${String(rule.SourceSecurityGroupId)}` : "?");
59
- return `${proto}:${port}:${cidr}`;
60
- });
61
- };
62
-
63
- /** Security groups reachable from an instance — direct and via launch template. */
64
- const effectiveSgs = (inst: IRNode): IRNode[] => {
65
- const direct = out(inst.id, via("SecurityGroupIds", "SecurityGroupId"));
66
- const templates = out(inst.id, via("LaunchTemplate", "LaunchTemplateId"));
67
- const viaTemplate = templates
68
- .flatMap((lt) => out(lt.id, via("LaunchTemplateData", "SecurityGroupIds", "SecurityGroupId")))
69
- .filter((n) => isKind(n, "SecurityGroup"));
70
- const all = [...direct.filter((n) => isKind(n, "SecurityGroup")), ...viaTemplate];
71
- return [...new Map(all.map((s) => [s.id, s])).values()];
72
- };
73
-
74
- /** The IGW an instance's subnet routes to (evidence), or undefined. */
75
- const internetFacingVia = (inst: IRNode): string | undefined => {
76
- for (const subnet of out(inst.id, via("SubnetId")).filter((n) => isKind(n, "Subnet"))) {
77
- const assocs = incoming(subnet.id, via("SubnetId")).filter((a) => isKind(a, "SubnetRouteTableAssociation"));
78
- const routeTables = assocs.flatMap((a) => out(a.id, via("RouteTableId")).filter((n) => isKind(n, "RouteTable")));
79
- for (const rt of routeTables) {
80
- const routes = incoming(rt.id, via("RouteTableId")).filter((n) => isKind(n, "Route"));
81
- for (const route of routes) {
82
- const dest = (route.attrs as Record<string, unknown> | undefined)?.["DestinationCidrBlock"];
83
- const igw = out(route.id, via("GatewayId")).find((g) => isKind(g, "InternetGateway"));
84
- if (igw && (dest == null || dest === "0.0.0.0/0")) {
85
- const id = igw.id.includes("::") ? igw.id.slice(igw.id.lastIndexOf("::") + 2) : igw.id;
86
- return `${rt.id.includes("::") ? rt.id.slice(rt.id.lastIndexOf("::") + 2) : rt.id} → ${id}`;
87
- }
88
- }
89
- }
90
- }
91
- return undefined;
92
- };
34
+ export function enrichEffectiveTopology(ir: GraphIR, packs: CapabilityPack[] = []): GraphIR {
35
+ const view = buildView(ir, resolvePacks(packs));
36
+ const engine = new CapabilityEngine(view, shippedRules().rules);
37
+ const facing = engine.set("internet-facing");
38
+ const groups = engine.relation("in-sg").out;
93
39
 
94
40
  const nodes = ir.nodes.map((n) => {
95
- if (!isKind(n, "Instance")) return n;
96
- const effectiveIngress = effectiveSgs(n).flatMap(normalizeIngress);
41
+ const tf = terraformResourceType(n) === "aws_instance";
42
+ if (!tf && !isKind(n, "Instance")) return n;
43
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
44
+ const reached = [...(groups.get(n.id) ?? new Map<string, Evidence>())];
45
+ const ingress = reached.flatMap(([sg]) => ingressStrings(view, sg));
46
+ const effectiveIngress = tf ? [...new Set(ingress)] : ingress;
97
47
  // A live enrichment may already have set internetFacing (+ its evidence) for
98
48
  // a subnet chant doesn't model declaratively (e.g. the account's default
99
49
  // VPC). Keep that truth; otherwise derive it from the declared route topology.
100
- const attrs = (n.attrs ?? {}) as Record<string, unknown>;
101
50
  const liveFacing = attrs["internetFacing"] === true;
102
- const declaredVia = internetFacingVia(n);
51
+ const evidence = facing.get(n.id);
52
+ const declaredVia = evidence && evidence.hops.length > 0 ? facingVia(view, evidence, tf) : undefined;
103
53
  const via = (attrs["internetFacingVia"] as string | undefined) ?? declaredVia;
54
+ const ingressVia = tf ? reached.map(([sg, ev]) => groupPath(view, ev, sg)).join(", ") : "";
104
55
  return {
105
56
  ...n,
106
- attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
57
+ attrs: {
58
+ ...attrs,
59
+ effectiveIngress,
60
+ internetFacing: liveFacing || !!declaredVia,
61
+ ...(via ? { internetFacingVia: via } : {}),
62
+ ...(ingressVia ? { effectiveIngressVia: `security groups ${ingressVia}` } : {}),
63
+ },
107
64
  };
108
65
  });
109
66
  // Record what this pass computed, so a caller can report the graph's derived surface
110
67
  // without hardcoding attribute names. Only kinds actually enriched are listed.
111
68
  const enriched = nodes.some((n) => isKind(n, "Instance"));
112
- const derivedAttrs = enriched
113
- ? { ...(ir.derivedAttrs ?? {}), Instance: ["internetFacing", "effectiveIngress"] }
69
+ const enrichedTf = nodes.some((n) => terraformResourceType(n) === "aws_instance");
70
+ const derivedAttrs = enriched || enrichedTf
71
+ ? {
72
+ ...(ir.derivedAttrs ?? {}),
73
+ ...(enriched ? { Instance: ["internetFacing", "effectiveIngress"] } : {}),
74
+ ...(enrichedTf ? { aws_instance: ["internetFacing", "effectiveIngress"] } : {}),
75
+ }
114
76
  : ir.derivedAttrs;
115
77
  return { ...ir, nodes, ...(derivedAttrs ? { derivedAttrs } : {}) };
116
78
  }
79
+
80
+ function isKind(n: IRNode | undefined, suffix: string): boolean {
81
+ const kind = n?.kind ?? "";
82
+ return !!n && (kind === suffix || kind.endsWith("::" + suffix));
83
+ }
84
+
85
+ function bare(id: string): string {
86
+ return id.includes("::") ? id.slice(id.lastIndexOf("::") + 2) : id;
87
+ }
88
+
89
+ /** A node's name in a fold's evidence: a terraform address, or a bare logical id. */
90
+ function display(view: View, id: string): string {
91
+ const v = view.nodes.get(id);
92
+ if (v && v.lexicon === "terraform") return id.slice(id.lastIndexOf("/") + 1);
93
+ return bare(id);
94
+ }
95
+
96
+ /** The ids an evidence path visits, in walk order. */
97
+ function visited(ev: Evidence, start?: string): string[] {
98
+ const out: string[] = start ? [start] : [];
99
+ let at = start;
100
+ for (const h of ev.hops) {
101
+ const next = at === undefined ? h.to : h.from === at ? h.to : h.from;
102
+ if (at === undefined) out.push(h.from === next ? h.to : h.from);
103
+ out.push(next);
104
+ at = next;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** `internetFacingVia`, as each fold always spelled it. */
110
+ function facingVia(view: View, ev: Evidence, tf: boolean): string | undefined {
111
+ const ids = visited(ev);
112
+ const of = (label: string) => ids.find((id) => view.nodes.get(id)?.label === label);
113
+ const rt = of("RouteTable");
114
+ const igw = of("InternetGateway");
115
+ if (!rt || !igw) return undefined;
116
+ if (!tf) return `${bare(rt)} → ${bare(igw)}`;
117
+ const subnet = of("Subnet");
118
+ const route = of("Route");
119
+ const how = route && view.nodes.get(route)?.attrs.block ? "inline route" : route ? display(view, route) : "route";
120
+ return `subnet route ${subnet ? display(view, subnet) : "?"} -> ${display(view, rt)} -> ${display(view, igw)} (${how})`;
121
+ }
122
+
123
+ /** One reached group, as `effectiveIngressVia` names it: the group, or the template it came through. */
124
+ function groupPath(view: View, ev: Evidence, sg: string): string {
125
+ const lt = visited(ev).find((id) => view.nodes.get(id)?.label === "LaunchTemplate");
126
+ return lt ? `${display(view, lt)} -> ${display(view, sg)}` : display(view, sg);
127
+ }
128
+
129
+ const STYLE_ORDER = ["cfn", "tf-inline", "tf-sg-rule", "tf-vpc-rule"];
130
+
131
+ /** A group's ingress rules as `proto:port:source` strings, inline rules first. */
132
+ function ingressStrings(view: View, sg: string): string[] {
133
+ const rules = view.edges
134
+ .filter((e) => e.from === sg && e.name === "ingress")
135
+ .map((e) => view.nodes.get(e.to)!)
136
+ .filter((r) => r && r.label === "IngressRule");
137
+ const order = (r: (typeof rules)[number]) => (r.attrs.block ? -1 : STYLE_ORDER.indexOf(String(r.attrs.style)));
138
+ return [...rules]
139
+ .sort((a, b) => order(a) - order(b))
140
+ .flatMap((r) => {
141
+ const raw = (r.body ?? {}) as Body;
142
+ switch (r.attrs.style) {
143
+ case "tf-inline":
144
+ return terraformIngress(raw, { proto: "protocol", cidrs: ["cidr_blocks", "ipv6_cidr_blocks", "prefix_list_ids"], groups: ["security_groups"] });
145
+ case "tf-sg-rule":
146
+ return terraformIngress(raw, { proto: "protocol", cidrs: ["cidr_blocks", "ipv6_cidr_blocks", "prefix_list_ids"], groups: ["source_security_group_id"] });
147
+ case "tf-vpc-rule":
148
+ return terraformIngress(raw, { proto: "ip_protocol", cidrs: ["cidr_ipv4", "cidr_ipv6", "prefix_list_id"], groups: ["referenced_security_group_id"] });
149
+ default:
150
+ return [cloudFormationIngress(raw)];
151
+ }
152
+ });
153
+ }
154
+
155
+ /** One CloudFormation ingress rule as `proto:port:source`. */
156
+ function cloudFormationIngress(rule: Body): string {
157
+ const proto = String(rule.IpProtocol ?? "-1");
158
+ const from = rule.FromPort as number | undefined;
159
+ const to = rule.ToPort as number | undefined;
160
+ const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
161
+ const source = rule.SourceSecurityGroupId;
162
+ const group = source && typeof source === "object" && typeof (source as { $ref?: unknown }).$ref === "string"
163
+ ? String((source as { $ref: string }).$ref).replace(/\.[A-Za-z]+$/, "")
164
+ : source;
165
+ const cidr =
166
+ (rule.CidrIp as string | undefined) ??
167
+ (rule.CidrIpv6 as string | undefined) ??
168
+ (group ? `sg:${String(group)}` : "?");
169
+ return `${proto}:${port}:${cidr}`;
170
+ }
171
+
172
+ /** The chant entity types every terraform `resource` and `data` block carries. */
173
+ const TERRAFORM_RESOURCE = "Terraform::Resource";
174
+ const TERRAFORM_DATA = "Terraform::Data";
175
+
176
+ /**
177
+ * The Terraform type of a `resource` or `data` node: `aws_instance` for
178
+ * `aws_instance.web`, `data.aws_ami` for `data.aws_ami.ubuntu`. Undefined for
179
+ * any other node.
180
+ *
181
+ * This is the one reading of a terraform node's type. `chant search`'s `kind:`
182
+ * (#4366), its rows and `--explain` count, `--count-by kind` (#4360) and the
183
+ * effective-topology folds (#4362) all call it, so they cannot disagree.
184
+ *
185
+ * The terraform lexicon puts every resource on the IR as `Terraform::Resource`
186
+ * (and every data source as `Terraform::Data`) and carries the block's type
187
+ * label as `attrs.type` (#4366), which is read first. A node without one (an
188
+ * older producer, or a `chant graph --live` node built from state) falls back
189
+ * to the block's address (`aws_instance.web`, or `module.app.aws_instance.web`)
190
+ * in `attrs.address`, whose second-to-last segment is the type, and failing
191
+ * that to its id, whose last path segment is the address too. A data source
192
+ * keeps its `data.` prefix: `data.aws_ami` reads an AMI, it does not manage one.
193
+ */
194
+ export function terraformType(n: IRNode | undefined): string | undefined {
195
+ if (!n || (n.kind !== TERRAFORM_RESOURCE && n.kind !== TERRAFORM_DATA)) return undefined;
196
+ const label = (n.attrs as Record<string, unknown> | undefined)?.["type"];
197
+ let type = typeof label === "string" && label.length > 0 ? label : undefined;
198
+ if (type === undefined) {
199
+ const parts = terraformAddress(n).split(".");
200
+ type = parts.length >= 2 ? parts[parts.length - 2] : undefined;
201
+ }
202
+ if (type === undefined) return undefined;
203
+ return n.kind === TERRAFORM_DATA ? `data.${type}` : type;
204
+ }
205
+
206
+ /**
207
+ * The provider resource type of a terraform `resource` node (`aws_instance`),
208
+ * or undefined for any other node, data sources included. See {@link terraformType}.
209
+ */
210
+ export function terraformResourceType(n: IRNode | undefined): string | undefined {
211
+ return n?.kind === TERRAFORM_RESOURCE ? terraformType(n) : undefined;
212
+ }
213
+
214
+ function terraformAddress(n: IRNode): string {
215
+ const address = (n.attrs as Record<string, unknown> | undefined)?.["address"];
216
+ if (typeof address === "string" && address.length > 0) return address;
217
+ return n.id.slice(n.id.lastIndexOf("/") + 1).replace(/~\d+$/, "");
218
+ }
219
+
220
+ type Body = Record<string, unknown>;
221
+
222
+ function bodyOf(n: IRNode): Body {
223
+ const body = (n.attrs as Record<string, unknown> | undefined)?.["body"];
224
+ return body && typeof body === "object" && !Array.isArray(body) ? (body as Body) : {};
225
+ }
226
+
227
+ /** A block-or-list attribute as a list: hcl2json renders nested blocks as arrays. */
228
+ function list(value: unknown): unknown[] {
229
+ if (value == null) return [];
230
+ return Array.isArray(value) ? value : [value];
231
+ }
232
+
233
+ /** `${aws_security_group.x.id}` -> `aws_security_group.x`; anything else unchanged. */
234
+ function sourceRef(value: unknown): string {
235
+ const s = String(value);
236
+ const m = /^\$\{([A-Za-z0-9_.-]+?)(?:\.(?:id|arn|name))?\}$/.exec(s);
237
+ return m ? m[1] : s;
238
+ }
239
+
240
+ /**
241
+ * One terraform ingress rule, in any of its three spellings, normalized to the
242
+ * `proto:port:source` strings the CloudFormation fold produces. A rule with
243
+ * several CIDRs or sources yields one string per source, so each is queryable
244
+ * on its own.
245
+ */
246
+ function terraformIngress(rule: Body, fields: {
247
+ proto: string;
248
+ cidrs: string[];
249
+ groups: string[];
250
+ }): string[] {
251
+ const rawProto = String(rule[fields.proto] ?? "-1").toLowerCase();
252
+ const proto = rawProto === "all" ? "-1" : rawProto;
253
+ const from = rule["from_port"];
254
+ const to = rule["to_port"];
255
+ const port =
256
+ proto === "-1" || from == null
257
+ ? "all"
258
+ : String(from) === String(to ?? from)
259
+ ? `${from}`
260
+ : `${from}-${to}`;
261
+ const sources: string[] = [];
262
+ for (const f of fields.cidrs) for (const c of list(rule[f])) sources.push(String(c));
263
+ for (const f of fields.groups) for (const g of list(rule[f])) sources.push(`sg:${sourceRef(g)}`);
264
+ if (rule["self"] === true) sources.push("sg:self");
265
+ if (sources.length === 0) sources.push("?");
266
+ return sources.map((src) => `${proto}:${port}:${src}`);
267
+ }
@@ -6,6 +6,8 @@ import { LexiconOutput } from "./lexicon-output";
6
6
  import { resolveAttrRefs } from "./discovery/resolve";
7
7
  import { setProvenance } from "./provenance";
8
8
  import { INTRINSIC_MARKER } from "./intrinsic";
9
+ import { applyDetail } from "./graph-detail";
10
+ import { buildInterpolatedString, defaultInterpolationSerializer } from "./intrinsic-interpolation";
9
11
 
10
12
  function decl<T extends object>(base: T): Declarable & T {
11
13
  return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
@@ -392,3 +394,164 @@ describe("applyLexiconOutputs (#3931)", () => {
392
394
  expect(applyLexiconOutputs(ir, [{ outputs: [] }])).toBe(ir);
393
395
  });
394
396
  });
397
+
398
+ /**
399
+ * A `Sub`-tagged intrinsic shaped like the aws lexicon's `SubIntrinsic`: the
400
+ * template parts and live values it was written with, serialized the same way.
401
+ */
402
+ function sub(parts: TemplateStringsArray, ...values: unknown[]): object {
403
+ const serialize = defaultInterpolationSerializer((name, attr) => `\${${name}.${attr}}`, (r) => `\${${r}}`);
404
+ return {
405
+ [INTRINSIC_MARKER]: true,
406
+ values,
407
+ toJSON: () => ({ "Fn::Sub": buildInterpolatedString([...parts], values, serialize) }),
408
+ };
409
+ }
410
+
411
+ /** An attribute AttrRef held on its producer, as a typed lexicon's resources hold theirs, so `resolveAttrRefs` names it. */
412
+ function attr(entity: Declarable, name: string): AttrRef {
413
+ const r = new AttrRef(entity, name);
414
+ (entity as unknown as Record<string, unknown>)[name] = r;
415
+ return r;
416
+ }
417
+
418
+ /** A two-argument `Sub`, as an intrinsic serializes it. */
419
+ function subWithVars(template: string, vars: Record<string, unknown>): object {
420
+ return { [INTRINSIC_MARKER]: true, toJSON: () => ({ "Fn::Sub": [template, vars] }) };
421
+ }
422
+
423
+ describe("a reference inside a CloudFormation Sub (#4373)", () => {
424
+ function estate(policyResource: unknown, extra: Array<[string, Declarable]> = []) {
425
+ const assets = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
426
+ const secret = decl({ lexicon: "aws", entityType: "AWS::SecretsManager::Secret" });
427
+ const policy = decl({
428
+ lexicon: "aws",
429
+ entityType: "AWS::IAM::Policy",
430
+ props: { PolicyName: "web", PolicyDocument: { Statement: [{ Effect: "Allow", Resource: policyResource }] } },
431
+ });
432
+ const entities = new Map<string, Declarable>([["assets", assets], ["secret", secret], ["policy", policy], ...extra]);
433
+ resolveAttrRefs(entities);
434
+ return { ir: buildGraphIr(entities), assets, secret };
435
+ }
436
+
437
+ test("${Res.Attr} is an edge through the top-level attribute, and attrs keep the template", () => {
438
+ const assets = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
439
+ const policy = decl({
440
+ lexicon: "aws",
441
+ entityType: "AWS::IAM::Policy",
442
+ props: { PolicyDocument: { Statement: [{ Resource: sub`${attr(assets, "Arn")}/*` }] } },
443
+ });
444
+ const entities = new Map<string, Declarable>([["assets", assets], ["policy", policy]]);
445
+ resolveAttrRefs(entities);
446
+ const ir = buildGraphIr(entities);
447
+
448
+ expect(ir.edges).toEqual([{ from: "policy", to: "assets", kind: "ref", viaAttr: "PolicyDocument" }]);
449
+ const attrs = ir.nodes.find((n) => n.id === "policy")!.attrs;
450
+ expect(attrs.PolicyDocument).toEqual({ Statement: [{ Resource: { $sub: "${assets.Arn}/*" } }] });
451
+ });
452
+
453
+ test("the edge is on every detail tier that carries edges; T3 names the producer attribute", () => {
454
+ const assets = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
455
+ const secret = decl({ lexicon: "aws", entityType: "AWS::SecretsManager::Secret" });
456
+ const db = decl({
457
+ lexicon: "aws",
458
+ entityType: "AWS::RDS::DBInstance",
459
+ props: {
460
+ MasterUserPassword: sub`{{resolve:secretsmanager:${ref("secret")}}}`,
461
+ Policy: { Resource: sub`${attr(assets, "Arn")}/*` },
462
+ },
463
+ });
464
+ const entities = new Map<string, Declarable>([["assets", assets], ["secret", secret], ["db", db]]);
465
+ resolveAttrRefs(entities);
466
+ const ir = buildGraphIr(entities);
467
+
468
+ const t2 = applyDetail(ir, 2);
469
+ expect(t2.edges).toEqual([
470
+ { from: "db", to: "assets", kind: "ref", viaAttr: "Policy" },
471
+ { from: "db", to: "secret", kind: "ref", viaAttr: "MasterUserPassword" },
472
+ ]);
473
+ // A top-level Sub is a reference envelope, kept at T2 like a $ref.
474
+ expect(t2.nodes.find((n) => n.id === "db")!.attrs.MasterUserPassword).toEqual({
475
+ $sub: "{{resolve:secretsmanager:${secret}}}",
476
+ });
477
+ // T3: the producer attribute when the template names one; none for a bare ${Name}.
478
+ expect(applyDetail(ir, 3).edges).toEqual([
479
+ { from: "db", to: "assets", kind: "ref", viaAttr: "Policy", toAttr: "Arn" },
480
+ { from: "db", to: "secret", kind: "ref", viaAttr: "MasterUserPassword" },
481
+ ]);
482
+ // T1 keeps a composite-free graph's edges.
483
+ expect(applyDetail(ir, 1).edges.map((e) => `${e.from}->${e.to}`).sort()).toEqual(["db->assets", "db->secret"]);
484
+ });
485
+
486
+ test("${Res} written by name in the template text is an edge too", () => {
487
+ const { ir } = estate(sub`arn:aws:s3:::\${assets}/*`);
488
+ expect(ir.edges).toEqual([{ from: "policy", to: "assets", kind: "ref", viaAttr: "PolicyDocument" }]);
489
+ });
490
+
491
+ test("a Sub with only pseudo-parameters adds no edge", () => {
492
+ const { ir } = estate(sub`arn:aws:s3:::logs-\${AWS::Region}-\${AWS::AccountId}/*`);
493
+ expect(ir.edges).toEqual([]);
494
+ const statement = (ir.nodes.find((n) => n.id === "policy")!.attrs.PolicyDocument as { Statement: unknown[] }).Statement[0];
495
+ expect(statement).toEqual({ Effect: "Allow", Resource: { $sub: "arn:aws:s3:::logs-${AWS::Region}-${AWS::AccountId}/*" } });
496
+ });
497
+
498
+ test("${!Escaped} is literal text, not a reference", () => {
499
+ const { ir } = estate(sub`\${!assets}/*`);
500
+ expect(ir.edges).toEqual([]);
501
+ });
502
+
503
+ test("two-argument form: the variable map's references are edges, the names it binds are not", () => {
504
+ const { ir } = estate(
505
+ subWithVars("${Bucket}/${assets}/${Region}", {
506
+ Bucket: { "Fn::GetAtt": ["secret", "Arn"] },
507
+ Region: { Ref: "AWS::Region" },
508
+ }),
509
+ );
510
+ // `${assets}` is unbound, so a resource name; `${Bucket}` is the map's own.
511
+ expect(ir.edges).toEqual([
512
+ { from: "policy", to: "assets", kind: "ref", viaAttr: "PolicyDocument" },
513
+ { from: "policy", to: "secret", kind: "ref", viaAttr: "PolicyDocument" },
514
+ ]);
515
+ const statement = (ir.nodes.find((n) => n.id === "policy")!.attrs.PolicyDocument as { Statement: unknown[] }).Statement[0];
516
+ expect(statement).toMatchObject({
517
+ Resource: { $sub: "${Bucket}/${assets}/${Region}", $vars: { Bucket: { "Fn::GetAtt": ["secret", "Arn"] } } },
518
+ });
519
+ });
520
+
521
+ test("a Sub written as plain CloudFormation JSON, with live values in its map", () => {
522
+ const assets = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
523
+ const secret = decl({ lexicon: "aws", entityType: "AWS::SecretsManager::Secret" });
524
+ const policy = decl({
525
+ lexicon: "aws",
526
+ entityType: "AWS::IAM::Policy",
527
+ props: { Resource: { "Fn::Sub": ["${assets.Arn}/${Key}", { Key: attr(secret, "Id") }] } },
528
+ });
529
+ const entities = new Map<string, Declarable>([["assets", assets], ["secret", secret], ["policy", policy]]);
530
+ resolveAttrRefs(entities);
531
+ const ir = buildGraphIr(entities);
532
+ expect(ir.edges).toEqual([
533
+ { from: "policy", to: "assets", kind: "ref", viaAttr: "Resource" },
534
+ { from: "policy", to: "secret", kind: "ref", viaAttr: "Resource" },
535
+ ]);
536
+ expect(ir.nodes.find((n) => n.id === "policy")!.attrs.Resource).toEqual({
537
+ $sub: "${assets.Arn}/${Key}",
538
+ $vars: { Key: { $ref: "secret.Id" } },
539
+ });
540
+ expect(applyDetail(ir, 3).edges.find((e) => e.to === "assets")!.toAttr).toBe("Arn");
541
+ });
542
+
543
+ test("a Sub whose AttrRef has no logical name still yields the edge from its live value", () => {
544
+ const assets = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
545
+ const unnamed = new AttrRef(assets, "Arn"); // never resolved: toJSON() throws
546
+ const policy = decl({ lexicon: "aws", entityType: "AWS::IAM::Policy", props: { Resource: sub`${unnamed}/*` } });
547
+ const ir = buildGraphIr(new Map<string, Declarable>([["assets", assets], ["policy", policy]]));
548
+ expect(ir.edges).toEqual([{ from: "policy", to: "assets", kind: "ref", viaAttr: "Resource" }]);
549
+ expect(ir.nodes.find((n) => n.id === "policy")!.attrs.Resource).toEqual({ $intrinsic: true });
550
+ });
551
+
552
+ test("an intrinsic that is not a Sub stays opaque", () => {
553
+ const joinLike = { [INTRINSIC_MARKER]: true, values: [ref("assets")], toJSON: () => ({ "Fn::Join": ["", []] }) };
554
+ const { ir } = estate(joinLike);
555
+ expect(ir.edges).toEqual([]);
556
+ });
557
+ });