@intentius/chant 0.124.44 → 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 (249) 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/ci-identity.d.ts +183 -12
  16. package/dist/ci-identity.d.ts.map +1 -1
  17. package/dist/ci-identity.js +362 -53
  18. package/dist/cli/commands/lint.d.ts.map +1 -1
  19. package/dist/cli/commands/lint.js +1 -0
  20. package/dist/cli/handlers/build.d.ts.map +1 -1
  21. package/dist/cli/handlers/build.js +17 -1
  22. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  23. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  24. package/dist/cli/handlers/declared-graph.js +87 -0
  25. package/dist/cli/handlers/graph.d.ts +25 -0
  26. package/dist/cli/handlers/graph.d.ts.map +1 -1
  27. package/dist/cli/handlers/graph.js +93 -87
  28. package/dist/cli/handlers/pr-env.d.ts +8 -2
  29. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  30. package/dist/cli/handlers/pr-env.js +127 -225
  31. package/dist/cli/handlers/pr.d.ts +3 -0
  32. package/dist/cli/handlers/pr.d.ts.map +1 -1
  33. package/dist/cli/handlers/pr.js +1 -0
  34. package/dist/cli/handlers/search-count.d.ts +89 -0
  35. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  36. package/dist/cli/handlers/search-count.js +132 -0
  37. package/dist/cli/handlers/search.d.ts +358 -6
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/handlers/search.js +1065 -155
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +23 -3
  42. package/dist/cli/mcp/server.d.ts.map +1 -1
  43. package/dist/cli/mcp/server.js +3 -0
  44. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  45. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  46. package/dist/cli/mcp/tools/capabilities.js +27 -0
  47. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  48. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  49. package/dist/cli/mcp/workspace-tools.js +47 -2
  50. package/dist/cli/parse-args.d.ts.map +1 -1
  51. package/dist/cli/parse-args.js +64 -17
  52. package/dist/cli/registry.d.ts +22 -0
  53. package/dist/cli/registry.d.ts.map +1 -1
  54. package/dist/components/cli-support.d.ts +2 -0
  55. package/dist/components/cli-support.d.ts.map +1 -1
  56. package/dist/components/cli-support.js +4 -2
  57. package/dist/components/fan-out-support.d.ts +3 -0
  58. package/dist/components/fan-out-support.d.ts.map +1 -1
  59. package/dist/components/fan-out-support.js +1 -1
  60. package/dist/components/pr-env-ports.d.ts +29 -0
  61. package/dist/components/pr-env-ports.d.ts.map +1 -0
  62. package/dist/components/pr-env-ports.js +77 -0
  63. package/dist/components/pr-env-run.d.ts +254 -0
  64. package/dist/components/pr-env-run.d.ts.map +1 -0
  65. package/dist/components/pr-env-run.js +376 -0
  66. package/dist/components/pr-environment.d.ts +91 -15
  67. package/dist/components/pr-environment.d.ts.map +1 -1
  68. package/dist/components/pr-environment.js +95 -9
  69. package/dist/components/pr-pipeline.d.ts +10 -1
  70. package/dist/components/pr-pipeline.d.ts.map +1 -1
  71. package/dist/config-environments.d.ts +92 -0
  72. package/dist/config-environments.d.ts.map +1 -0
  73. package/dist/config-environments.js +102 -0
  74. package/dist/config.d.ts +24 -84
  75. package/dist/config.d.ts.map +1 -1
  76. package/dist/config.js +9 -96
  77. package/dist/graph-detail.d.ts.map +1 -1
  78. package/dist/graph-detail.js +15 -3
  79. package/dist/graph-effective.d.ts +33 -2
  80. package/dist/graph-effective.d.ts.map +1 -1
  81. package/dist/graph-effective.js +215 -68
  82. package/dist/graph-ir.d.ts +23 -0
  83. package/dist/graph-ir.d.ts.map +1 -1
  84. package/dist/graph-ir.js +130 -0
  85. package/dist/graph-sub-template.d.ts +11 -0
  86. package/dist/graph-sub-template.d.ts.map +1 -0
  87. package/dist/graph-sub-template.js +19 -0
  88. package/dist/lexicon.d.ts +9 -0
  89. package/dist/lexicon.d.ts.map +1 -1
  90. package/dist/lifecycle/observe.d.ts +28 -0
  91. package/dist/lifecycle/observe.d.ts.map +1 -1
  92. package/dist/lifecycle/observe.js +23 -1
  93. package/dist/lint/rule.d.ts +6 -0
  94. package/dist/lint/rule.d.ts.map +1 -1
  95. package/dist/live-endpoint.d.ts +1 -1
  96. package/dist/live-endpoint.d.ts.map +1 -1
  97. package/dist/live-endpoint.js +1 -1
  98. package/dist/observation.d.ts +48 -1
  99. package/dist/observation.d.ts.map +1 -1
  100. package/dist/observation.js +21 -5
  101. package/dist/op/activities/activity-contracts.d.ts +4 -4
  102. package/dist/op/op-waves.d.ts +17 -0
  103. package/dist/op/op-waves.d.ts.map +1 -1
  104. package/dist/op/op-waves.js +23 -1
  105. package/dist/pr-forge.d.ts +15 -1
  106. package/dist/pr-forge.d.ts.map +1 -1
  107. package/dist/pr-forge.js +28 -16
  108. package/dist/pr-requires.d.ts +5 -1
  109. package/dist/pr-requires.d.ts.map +1 -1
  110. package/dist/wave-runtime.mjs +251 -250
  111. package/dist/workspace/capabilities-contract.d.ts +66 -0
  112. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  113. package/dist/workspace/capabilities-contract.js +107 -0
  114. package/dist/workspace/capabilities.schema.json +229 -0
  115. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  116. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  117. package/dist/workspace/ci-cloud-identity.js +16 -1
  118. package/dist/workspace/ci-run.d.ts +35 -8
  119. package/dist/workspace/ci-run.d.ts.map +1 -1
  120. package/dist/workspace/ci-run.js +95 -21
  121. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  122. package/dist/workspace/ci-secrets.js +4 -3
  123. package/dist/workspace/compose-graph.d.ts +9 -0
  124. package/dist/workspace/compose-graph.d.ts.map +1 -1
  125. package/dist/workspace/compose-graph.js +3 -1
  126. package/dist/workspace/conformance/index.d.ts +1 -1
  127. package/dist/workspace/conformance/index.d.ts.map +1 -1
  128. package/dist/workspace/conformance/index.js +39 -2
  129. package/dist/workspace/declaration.d.ts +10 -0
  130. package/dist/workspace/declaration.d.ts.map +1 -1
  131. package/dist/workspace/declaration.js +17 -1
  132. package/dist/workspace/declaration.schema.json +36 -1
  133. package/dist/workspace/graph-cache.d.ts +8 -0
  134. package/dist/workspace/graph-cache.d.ts.map +1 -1
  135. package/dist/workspace/graph-cache.js +27 -3
  136. package/dist/workspace/graph-cli.d.ts +39 -2
  137. package/dist/workspace/graph-cli.d.ts.map +1 -1
  138. package/dist/workspace/graph-cli.js +66 -24
  139. package/dist/workspace/graph.schema.json +148 -0
  140. package/dist/workspace/kind-readers.d.ts +3 -0
  141. package/dist/workspace/kind-readers.d.ts.map +1 -1
  142. package/dist/workspace/kind-readers.js +7 -1
  143. package/dist/workspace/member-commands.d.ts +10 -1
  144. package/dist/workspace/member-commands.d.ts.map +1 -1
  145. package/dist/workspace/member-commands.js +17 -1
  146. package/dist/workspace/nested-graph.d.ts +2 -0
  147. package/dist/workspace/nested-graph.d.ts.map +1 -1
  148. package/dist/workspace/nested-graph.js +32 -1
  149. package/dist/workspace/reason-codes.d.ts +10 -0
  150. package/dist/workspace/reason-codes.d.ts.map +1 -1
  151. package/dist/workspace/reason-codes.js +12 -0
  152. package/dist/workspace/search-cli.d.ts +204 -0
  153. package/dist/workspace/search-cli.d.ts.map +1 -0
  154. package/dist/workspace/search-cli.js +466 -0
  155. package/dist/workspace/search-contract.d.ts +56 -0
  156. package/dist/workspace/search-contract.d.ts.map +1 -0
  157. package/dist/workspace/search-contract.js +70 -0
  158. package/dist/workspace/search.schema.json +822 -0
  159. package/package.json +1 -1
  160. package/src/__snapshots__/okf.test.ts.snap +10 -0
  161. package/src/capabilities/aws.paths +148 -0
  162. package/src/capabilities/base-mappings.json +122 -0
  163. package/src/capabilities/catalog.ts +230 -0
  164. package/src/capabilities/language.test.ts +191 -0
  165. package/src/capabilities/language.ts +373 -0
  166. package/src/capabilities/matcher.ts +315 -0
  167. package/src/capabilities/view-compose.test.ts +76 -0
  168. package/src/capabilities/view.ts +631 -0
  169. package/src/ci-identity-targets.test.ts +275 -0
  170. package/src/ci-identity.ts +394 -61
  171. package/src/cli/commands/lint.ts +1 -0
  172. package/src/cli/handlers/build.ts +15 -1
  173. package/src/cli/handlers/declared-graph.ts +99 -0
  174. package/src/cli/handlers/graph.test.ts +94 -3
  175. package/src/cli/handlers/graph.ts +123 -96
  176. package/src/cli/handlers/pr-env.ts +135 -227
  177. package/src/cli/handlers/pr.ts +4 -0
  178. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  179. package/src/cli/handlers/search-count.test.ts +191 -0
  180. package/src/cli/handlers/search-count.ts +165 -0
  181. package/src/cli/handlers/search-json.test.ts +333 -0
  182. package/src/cli/handlers/search-live.test.ts +387 -0
  183. package/src/cli/handlers/search-paths.test.ts +235 -0
  184. package/src/cli/handlers/search.ts +1312 -166
  185. package/src/cli/main.test.ts +28 -3
  186. package/src/cli/main.ts +23 -3
  187. package/src/cli/mcp/server.test.ts +3 -3
  188. package/src/cli/mcp/server.ts +3 -0
  189. package/src/cli/mcp/tools/capabilities.ts +32 -0
  190. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  191. package/src/cli/mcp/workspace-tools.ts +47 -2
  192. package/src/cli/parse-args.ts +52 -13
  193. package/src/cli/registry.ts +22 -0
  194. package/src/components/cli-support.ts +6 -1
  195. package/src/components/fan-out-support.ts +4 -1
  196. package/src/components/pr-env-ports.ts +89 -0
  197. package/src/components/pr-env-run-bundle.test.ts +54 -0
  198. package/src/components/pr-env-run.test.ts +290 -0
  199. package/src/components/pr-env-run.ts +563 -0
  200. package/src/components/pr-environment.ts +161 -13
  201. package/src/components/pr-pipeline.ts +10 -1
  202. package/src/config-environments.ts +133 -0
  203. package/src/config.ts +37 -124
  204. package/src/graph-detail.ts +15 -3
  205. package/src/graph-effective.ts +226 -75
  206. package/src/graph-ir.test.ts +163 -0
  207. package/src/graph-ir.ts +147 -0
  208. package/src/graph-sub-template.ts +22 -0
  209. package/src/lexicon.ts +10 -0
  210. package/src/lifecycle/observe.test.ts +27 -1
  211. package/src/lifecycle/observe.ts +43 -1
  212. package/src/lint/rule.ts +6 -0
  213. package/src/live-endpoint.ts +1 -1
  214. package/src/observation.test.ts +25 -0
  215. package/src/observation.ts +69 -5
  216. package/src/op/op-waves.ts +38 -1
  217. package/src/pr-forge-bundle.test.ts +1 -1
  218. package/src/pr-forge.ts +44 -17
  219. package/src/pr-requires.test.ts +43 -6
  220. package/src/pr-requires.ts +5 -1
  221. package/src/workspace/capabilities-contract.test.ts +88 -0
  222. package/src/workspace/capabilities-contract.ts +132 -0
  223. package/src/workspace/capabilities.schema.json +229 -0
  224. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  225. package/src/workspace/ci-cloud-identity.ts +14 -1
  226. package/src/workspace/ci-run.test.ts +74 -1
  227. package/src/workspace/ci-run.ts +122 -29
  228. package/src/workspace/ci-secrets.test.ts +1 -1
  229. package/src/workspace/ci-secrets.ts +4 -3
  230. package/src/workspace/compose-graph.ts +10 -1
  231. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  232. package/src/workspace/conformance/index.ts +31 -2
  233. package/src/workspace/declaration.schema.json +36 -1
  234. package/src/workspace/declaration.test.ts +7 -0
  235. package/src/workspace/declaration.ts +24 -2
  236. package/src/workspace/graph-cache.ts +27 -3
  237. package/src/workspace/graph-cli.ts +96 -24
  238. package/src/workspace/graph.schema.json +148 -0
  239. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  240. package/src/workspace/kind-readers.test.ts +1 -1
  241. package/src/workspace/kind-readers.ts +7 -1
  242. package/src/workspace/member-commands.ts +22 -1
  243. package/src/workspace/nested-graph.ts +32 -1
  244. package/src/workspace/reason-codes.test.ts +7 -0
  245. package/src/workspace/reason-codes.ts +12 -0
  246. package/src/workspace/search-cli.e2e.test.ts +305 -0
  247. package/src/workspace/search-cli.ts +591 -0
  248. package/src/workspace/search-contract.ts +95 -0
  249. package/src/workspace/search.schema.json +822 -0
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { GraphIR, IRNode } from "../../graph-ir";
3
+ import {
4
+ countGroups,
5
+ formatCounts,
6
+ groupMembers,
7
+ groupOf,
8
+ hasManyTerraformRoots,
9
+ parseCountBy,
10
+ terraformType,
11
+ type CountBy,
12
+ } from "./search-count";
13
+
14
+ /** A Terraform block as `buildGraphIr` projects one: key `<root>/[module.x/]<address>`. */
15
+ function tf(root: string, address: string, kind: string, callers: string[] = [], extra: Record<string, unknown> = {}): IRNode {
16
+ const id = [root, ...callers, address].join("/");
17
+ return {
18
+ id,
19
+ kind,
20
+ lexicon: "terraform",
21
+ attrs: { address, root, ...(callers.length > 0 ? { callers } : {}), ...extra },
22
+ } as IRNode;
23
+ }
24
+
25
+ const app = [
26
+ tf("app", "aws_instance.web", "Terraform::Resource", [], { expansion: "count" }),
27
+ tf("app", "aws_instance.worker", "Terraform::Resource"),
28
+ tf("app", "aws_s3_bucket.assets", "Terraform::Resource"),
29
+ tf("app", "data.aws_iam_policy.boundary", "Terraform::Data"),
30
+ tf("app", "var.region", "Terraform::Variable"),
31
+ tf("app", "aws_cloudfront_distribution.edge", "Terraform::Resource", ["module.cdn"]),
32
+ tf("app", "aws_s3_bucket.logs", "Terraform::Resource", ["module.cdn", "module.bucket"]),
33
+ ];
34
+
35
+ const sum = (groups: Array<{ count: number }>) => groups.reduce((s, g) => s + g.count, 0);
36
+
37
+ describe("--count-by parsing", () => {
38
+ it("accepts kind, module and attr:<name>", () => {
39
+ expect(parseCountBy("kind")).toEqual({ by: "kind" });
40
+ expect(parseCountBy("module")).toEqual({ by: "module" });
41
+ expect(parseCountBy("attr:region")).toEqual({ by: "attr", name: "region" });
42
+ });
43
+
44
+ it("refuses anything else and names the spellings", () => {
45
+ for (const bad of ["type", "attr:", "attr", "Kind"]) {
46
+ const r = parseCountBy(bad);
47
+ expect("error" in r).toBe(true);
48
+ if ("error" in r) expect(r.hint).toContain("--count-by attr:<name>");
49
+ }
50
+ });
51
+ });
52
+
53
+ describe("--count-by kind", () => {
54
+ it("groups a Terraform resource by its resource type, not by Terraform::Resource", () => {
55
+ expect(terraformType(app[0])).toBe("aws_instance");
56
+ expect(terraformType(app[3])).toBe("data.aws_iam_policy");
57
+ expect(terraformType(app[4])).toBeUndefined();
58
+ const groups = countGroups(app, { by: "kind" });
59
+ expect(groups).toEqual([
60
+ { group: "aws_instance", count: 2 },
61
+ { group: "aws_s3_bucket", count: 2 },
62
+ { group: "Terraform::Variable", count: 1 },
63
+ { group: "aws_cloudfront_distribution", count: 1 },
64
+ { group: "data.aws_iam_policy", count: 1 },
65
+ ]);
66
+ });
67
+
68
+ it("keeps the kind of every other lexicon", () => {
69
+ const n = { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: {} } as IRNode;
70
+ expect(groupOf(n, { by: "kind" })).toBe("AWS::EC2::Instance");
71
+ });
72
+
73
+ it("reads the type off the id when the address attribute is absent", () => {
74
+ const n = { id: "app/module.cdn/aws_s3_bucket.logs~2", kind: "Terraform::Resource", lexicon: "terraform", attrs: {} } as IRNode;
75
+ expect(terraformType(n)).toBe("aws_s3_bucket");
76
+ });
77
+ });
78
+
79
+ describe("--count-by module", () => {
80
+ it("takes the module from the address, and a root-module block is (root)", () => {
81
+ expect(countGroups(app, { by: "module" })).toEqual([
82
+ { group: "(root)", count: 5 },
83
+ { group: "module.cdn", count: 1 },
84
+ { group: "module.cdn.module.bucket", count: 1 },
85
+ ]);
86
+ });
87
+
88
+ it("reads the call chain off the entity key when callers is not recorded", () => {
89
+ const n = { id: "app/module.cdn/aws_s3_bucket.logs", kind: "Terraform::Resource", lexicon: "terraform", attrs: {} } as IRNode;
90
+ expect(groupOf(n, { by: "module" })).toBe("module.cdn");
91
+ });
92
+
93
+ it("qualifies the module with its root when the graph holds more than one root", () => {
94
+ const nodes = [...app, tf("network", "aws_vpc.main", "Terraform::Resource"), tf("network", "aws_subnet.a", "Terraform::Resource", ["module.cdn"])];
95
+ const ir = { nodes, edges: [] } as unknown as GraphIR;
96
+ expect(hasManyTerraformRoots(ir)).toBe(true);
97
+ expect(hasManyTerraformRoots({ nodes: app, edges: [] } as unknown as GraphIR)).toBe(false);
98
+ const groups = countGroups(nodes, { by: "module" }, true);
99
+ expect(groups.map((g) => g.group)).toEqual([
100
+ "app/(root)",
101
+ "app/module.cdn",
102
+ "app/module.cdn.module.bucket",
103
+ "network/(root)",
104
+ "network/module.cdn",
105
+ ]);
106
+ });
107
+
108
+ it("groups a node of another lexicon by its composite, or (none)", () => {
109
+ const inComposite = { id: "a", kind: "AWS::S3::Bucket", lexicon: "aws", attrs: {}, compositeParent: "Site" } as IRNode;
110
+ const bare = { id: "b", kind: "AWS::S3::Bucket", lexicon: "aws", attrs: {} } as IRNode;
111
+ expect(groupOf(inComposite, { by: "module" })).toBe("Site");
112
+ expect(groupOf(bare, { by: "module" })).toBe("(none)");
113
+ });
114
+ });
115
+
116
+ describe("--count-by attr:<name>", () => {
117
+ it("counts a node lacking the attribute under (none), and the groups add up to the total", () => {
118
+ const by: CountBy = { by: "attr", name: "expansion" };
119
+ const groups = countGroups(app, by);
120
+ expect(groups).toEqual([
121
+ { group: "(none)", count: 6 },
122
+ { group: "count", count: 1 },
123
+ ]);
124
+ // Never dropped: every matched node is in exactly one group.
125
+ expect(sum(groups)).toBe(app.length);
126
+ const lines = formatCounts(groups, by);
127
+ expect(lines.at(-1)).toBe(`— ${app.length} total · 2 groups by attr:expansion`);
128
+ });
129
+
130
+ it("adds up for every grouping, on a mixed graph with gaps", () => {
131
+ const mixed = [
132
+ ...app,
133
+ { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: { region: "us-east-1" } } as IRNode,
134
+ { id: "db", kind: "AWS::RDS::DBInstance", lexicon: "aws", attrs: { region: null } } as IRNode,
135
+ { id: "odd", kind: "", lexicon: "aws", attrs: { region: "" } } as unknown as IRNode,
136
+ ];
137
+ for (const by of [{ by: "kind" }, { by: "module" }, { by: "attr", name: "region" }, { by: "attr", name: "nope" }] as CountBy[]) {
138
+ expect(sum(countGroups(mixed, by))).toBe(mixed.length);
139
+ }
140
+ expect(countGroups(mixed, { by: "attr", name: "region" })).toEqual([
141
+ { group: "(none)", count: 9 },
142
+ { group: "us-east-1", count: 1 },
143
+ ]);
144
+ });
145
+
146
+ it("matches the attribute name case-insensitively, as --show does", () => {
147
+ const n = { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: { Region: "eu-west-1" } } as IRNode;
148
+ expect(groupOf(n, { by: "attr", name: "region" })).toBe("eu-west-1");
149
+ });
150
+ });
151
+
152
+ describe("grouped output", () => {
153
+ it("prints one line per group, sorted by count then name, and a total line", () => {
154
+ const groups = countGroups(
155
+ [
156
+ { id: "1", kind: "B", lexicon: "x", attrs: {} },
157
+ { id: "2", kind: "A", lexicon: "x", attrs: {} },
158
+ { id: "3", kind: "C", lexicon: "x", attrs: {} },
159
+ { id: "4", kind: "C", lexicon: "x", attrs: {} },
160
+ ] as IRNode[],
161
+ { by: "kind" },
162
+ );
163
+ expect(formatCounts(groups, { by: "kind" })).toEqual(["2 C", "1 A", "1 B", "— 4 total · 3 groups by kind"]);
164
+ });
165
+
166
+ it("right-aligns the counts", () => {
167
+ const nodes = Array.from({ length: 11 }, (_, i) => ({ id: `n${i}`, kind: i === 0 ? "Z" : "Y", lexicon: "x", attrs: {} })) as IRNode[];
168
+ expect(formatCounts(countGroups(nodes, { by: "kind" }), { by: "kind" })).toEqual([
169
+ "10 Y",
170
+ " 1 Z",
171
+ "— 11 total · 2 groups by kind",
172
+ ]);
173
+ });
174
+ });
175
+
176
+ describe("--count-by --json lists each group's ids (#4382)", () => {
177
+ it("lists the matches of each group in match order, the count is their number, and the groups are countGroups'", () => {
178
+ for (const by of [{ by: "kind" }, { by: "module" }, { by: "attr", name: "expansion" }] as CountBy[]) {
179
+ const groups = groupMembers(app, by);
180
+ expect(groups.map(({ group, count }) => ({ group, count }))).toEqual(countGroups(app, by));
181
+ for (const g of groups) expect(g.ids).toHaveLength(g.count);
182
+ // Every match is in exactly one group.
183
+ expect(groups.flatMap((g) => g.ids).sort()).toEqual(app.map((n) => n.id).sort());
184
+ }
185
+ expect(groupMembers(app, { by: "module" })[0]).toEqual({
186
+ group: "(root)",
187
+ count: 5,
188
+ ids: ["app/aws_instance.web", "app/aws_instance.worker", "app/aws_s3_bucket.assets", "app/data.aws_iam_policy.boundary", "app/var.region"],
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,165 @@
1
+ import type { GraphIR, IRNode } from "../../graph-ir";
2
+ import { terraformType } from "../../graph-effective";
3
+
4
+ // The one reading of a terraform node's type lives with the folds that also
5
+ // select on it, so `--count-by kind` and `kind:` cannot disagree (#4366).
6
+ export { terraformType };
7
+
8
+ /**
9
+ * `chant search --count-by <kind|module|attr:<name>>` (#4360): grouped counts
10
+ * over the matched set, one line per group, instead of one row per match.
11
+ *
12
+ * An agent asking "how many resources of each type does each module own" used
13
+ * to run one search per value, or read every row and count them itself. The
14
+ * second is the token cost `search` exists to avoid.
15
+ *
16
+ * Every matched node lands in exactly one group. A node with no value for the
17
+ * grouped dimension is counted under `(none)`, never dropped, so the groups
18
+ * always add up to the total line.
19
+ */
20
+ export type CountBy = { by: "kind" } | { by: "module" } | { by: "attr"; name: string };
21
+
22
+ /** The group a node with no value for the grouped dimension is counted under. */
23
+ export const NONE_GROUP = "(none)";
24
+ /** The module group of a Terraform block declared in the root module itself. */
25
+ export const ROOT_MODULE_GROUP = "(root)";
26
+
27
+ /** Parse a `--count-by` value, or say what the accepted spellings are. */
28
+ export function parseCountBy(raw: string): CountBy | { error: string; hint: string } {
29
+ const value = raw.trim();
30
+ if (value === "kind") return { by: "kind" };
31
+ if (value === "module") return { by: "module" };
32
+ const attr = /^attr:(.+)$/.exec(value);
33
+ if (attr && attr[1].trim()) return { by: "attr", name: attr[1].trim() };
34
+ return {
35
+ error: `--count-by "${raw}" is not a grouping`,
36
+ hint: "group by one of: --count-by kind, --count-by module, --count-by attr:<name>",
37
+ };
38
+ }
39
+
40
+ /** A Terraform node's root name, from its attrs or the first segment of its id. */
41
+ function terraformRoot(n: IRNode): string {
42
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
43
+ if (typeof attrs.root === "string") return attrs.root;
44
+ const slash = n.id.indexOf("/");
45
+ return slash === -1 ? "" : n.id.slice(0, slash);
46
+ }
47
+
48
+ /**
49
+ * The module a Terraform node sits in: its call chain as Terraform writes it
50
+ * (`module.cdn`, `module.cdn.module.bucket`), or `(root)` for a block of the
51
+ * root module itself. The chain is on `attrs.callers`; an entity key
52
+ * `<root>/module.cdn/<address>` carries the same chain when it is absent.
53
+ */
54
+ function terraformModule(n: IRNode): string {
55
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
56
+ const callers = Array.isArray(attrs.callers)
57
+ ? attrs.callers.filter((c): c is string => typeof c === "string")
58
+ : n.id.split("/").slice(1, -1);
59
+ return callers.length === 0 ? ROOT_MODULE_GROUP : callers.join(".");
60
+ }
61
+
62
+ /** An attribute's value as a group name, matched by exact name and then case-insensitively, as `--show` does. */
63
+ function attrGroup(n: IRNode, name: string): string {
64
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
65
+ const key = name in attrs ? name : Object.keys(attrs).find((k) => k.toLowerCase() === name.toLowerCase());
66
+ const v = key === undefined ? undefined : attrs[key];
67
+ if (v === undefined || v === null || v === "") return NONE_GROUP;
68
+ return typeof v === "object" ? JSON.stringify(v) : String(v);
69
+ }
70
+
71
+ /**
72
+ * The group one node belongs to.
73
+ *
74
+ * `module` on a Terraform node is its call chain. When the graph holds more
75
+ * than one Terraform root, the chain is prefixed with the root (`app/(root)`,
76
+ * `net/module.vpc`): `module.vpc` in two roots is two modules, applied
77
+ * separately, and counting them as one would add up resources that never sit
78
+ * in the same state. A node of another lexicon groups under the composite that
79
+ * expanded it, chant's own unit of reuse, or `(none)`.
80
+ */
81
+ export function groupOf(n: IRNode, countBy: CountBy, multiRoot = false): string {
82
+ if (countBy.by === "kind") return terraformType(n) ?? (n.kind || NONE_GROUP);
83
+ if (countBy.by === "attr") return attrGroup(n, countBy.name);
84
+ if (n.lexicon === "terraform") {
85
+ const module = terraformModule(n);
86
+ return multiRoot ? `${terraformRoot(n) || NONE_GROUP}/${module}` : module;
87
+ }
88
+ return n.compositeParent || NONE_GROUP;
89
+ }
90
+
91
+ /** Whether the graph holds blocks from more than one Terraform root. */
92
+ export function hasManyTerraformRoots(ir: GraphIR): boolean {
93
+ const roots = new Set<string>();
94
+ for (const n of ir.nodes) if (n.lexicon === "terraform") roots.add(terraformRoot(n));
95
+ return roots.size > 1;
96
+ }
97
+
98
+ /** Count `matches` per group, sorted by count (highest first) and then by name. */
99
+ export function countGroups(matches: IRNode[], countBy: CountBy, multiRoot = false): Array<{ group: string; count: number }> {
100
+ return groupMembers(matches, countBy, multiRoot).map(({ group, count }) => ({ group, count }));
101
+ }
102
+
103
+ /**
104
+ * The groups with the ids of the matches in each, in match order (#4382), in
105
+ * the order {@link countGroups} gives. `count` is always `ids.length`. A
106
+ * reader that draws the groups (behold#922) lights each group's nodes from
107
+ * `ids`, so it never has to read a node's group itself.
108
+ */
109
+ export function groupMembers(matches: IRNode[], countBy: CountBy, multiRoot = false): Array<{ group: string; count: number; ids: string[] }> {
110
+ const ids = new Map<string, string[]>();
111
+ for (const n of matches) {
112
+ const g = groupOf(n, countBy, multiRoot);
113
+ const list = ids.get(g);
114
+ if (list) list.push(n.id);
115
+ else ids.set(g, [n.id]);
116
+ }
117
+ return [...ids]
118
+ .map(([group, members]) => ({ group, count: members.length, ids: members }))
119
+ .sort((a, b) => b.count - a.count || (a.group < b.group ? -1 : a.group > b.group ? 1 : 0));
120
+ }
121
+
122
+ /** `--count-by` with `--json`: the groups, in printed order, each with its matched ids, and the total they add up to. */
123
+ export interface CountsJson {
124
+ query: string;
125
+ source: unknown;
126
+ countBy: string;
127
+ groups: Array<{ group: string; count: number; ids: string[] }>;
128
+ total: number;
129
+ liveFailures?: string[];
130
+ }
131
+
132
+ export function countsJson(
133
+ query: string,
134
+ source: unknown,
135
+ groups: Array<{ group: string; count: number; ids: string[] }>,
136
+ countBy: CountBy,
137
+ liveFailures: string[] = [],
138
+ ): CountsJson {
139
+ return {
140
+ query,
141
+ source,
142
+ countBy: countByLabel(countBy),
143
+ groups,
144
+ total: groups.reduce((sum, g) => sum + g.count, 0),
145
+ ...(liveFailures.length > 0 ? { liveFailures } : {}),
146
+ };
147
+ }
148
+
149
+ /** The label a `--count-by` value is printed with on the total line. */
150
+ function countByLabel(countBy: CountBy): string {
151
+ return countBy.by === "attr" ? `attr:${countBy.name}` : countBy.by;
152
+ }
153
+
154
+ /**
155
+ * Render grouped counts: one `<count> <group>` line per group, counts right
156
+ * aligned, then the total. The total is the number of matches, which is the
157
+ * sum of the group counts by construction.
158
+ */
159
+ export function formatCounts(groups: Array<{ group: string; count: number }>, countBy: CountBy): string[] {
160
+ const total = groups.reduce((sum, g) => sum + g.count, 0);
161
+ const width = String(groups[0]?.count ?? 0).length;
162
+ const lines = groups.map((g) => `${String(g.count).padStart(width)} ${g.group}`);
163
+ lines.push(`— ${total} total · ${groups.length} group${groups.length === 1 ? "" : "s"} by ${countByLabel(countBy)}`);
164
+ return lines;
165
+ }