@intentius/chant-lexicon-terraform 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 (93) hide show
  1. package/dist/__fixtures__/capabilities/chant.config.d.ts +26 -0
  2. package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
  3. package/dist/__fixtures__/capabilities/chant.config.js +13 -0
  4. package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
  5. package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
  6. package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
  7. package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
  8. package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
  9. package/dist/__fixtures__/kind-type/chant.config.js +9 -0
  10. package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
  11. package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
  12. package/dist/__fixtures__/search-roots/chant.config.js +9 -0
  13. package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
  14. package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
  15. package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
  16. package/dist/capabilities/adapter.d.ts +9 -0
  17. package/dist/capabilities/adapter.d.ts.map +1 -0
  18. package/dist/capabilities/adapter.js +25 -0
  19. package/dist/capabilities/mapping.json +166 -0
  20. package/dist/config.d.ts +52 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +23 -0
  23. package/dist/describe-resources.d.ts +101 -8
  24. package/dist/describe-resources.d.ts.map +1 -1
  25. package/dist/describe-resources.js +395 -56
  26. package/dist/hcl/parse.d.ts +10 -0
  27. package/dist/hcl/parse.d.ts.map +1 -1
  28. package/dist/hcl/parse.js +20 -2
  29. package/dist/hcl/redact.d.ts +26 -0
  30. package/dist/hcl/redact.d.ts.map +1 -0
  31. package/dist/hcl/redact.js +228 -0
  32. package/dist/integrity.json +2 -2
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/terraform.d.ts +109 -4
  35. package/dist/op/activities/terraform.d.ts.map +1 -1
  36. package/dist/op/activities/terraform.js +148 -7
  37. package/dist/plan-observation.d.ts +153 -0
  38. package/dist/plan-observation.d.ts.map +1 -0
  39. package/dist/plan-observation.js +354 -0
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/plugin.js +2 -0
  42. package/package.json +2 -2
  43. package/src/__fixtures__/ACCEPTANCE.md +53 -1
  44. package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
  45. package/src/__fixtures__/capabilities/chant.config.ts +15 -0
  46. package/src/__fixtures__/capabilities/findings/main.tf +225 -0
  47. package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
  48. package/src/__fixtures__/capabilities/package.json +1 -0
  49. package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
  50. package/src/__fixtures__/effective-topology/net/main.tf +176 -0
  51. package/src/__fixtures__/effective-topology/package.json +6 -0
  52. package/src/__fixtures__/kind-type/app/main.tf +35 -0
  53. package/src/__fixtures__/kind-type/chant.config.ts +11 -0
  54. package/src/__fixtures__/kind-type/package.json +6 -0
  55. package/src/__fixtures__/live-plan-depth/README.md +59 -0
  56. package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
  57. package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
  58. package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
  59. package/src/__fixtures__/plan-read/main.tf +46 -0
  60. package/src/__fixtures__/plan-read/next.tfvars +1 -0
  61. package/src/__fixtures__/plan-read-drift/README.md +33 -0
  62. package/src/__fixtures__/plan-read-drift/main.tf +63 -0
  63. package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
  64. package/src/__fixtures__/search-roots/chant.config.ts +11 -0
  65. package/src/__fixtures__/search-roots/net/main.tf +73 -0
  66. package/src/__fixtures__/search-roots/package.json +6 -0
  67. package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
  68. package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
  69. package/src/__fixtures__/sensitive-default/package.json +6 -0
  70. package/src/__fixtures__/show-plan.json +663 -0
  71. package/src/capabilities/adapter.ts +32 -0
  72. package/src/capabilities/mapping.json +166 -0
  73. package/src/capabilities-workspace.e2e.test.ts +251 -0
  74. package/src/capabilities.test.ts +264 -0
  75. package/src/config.ts +26 -0
  76. package/src/describe-resources.live-plan.test.ts +302 -0
  77. package/src/describe-resources.live-root-unchanged.test.ts +126 -0
  78. package/src/describe-resources.plan.test.ts +437 -0
  79. package/src/describe-resources.ts +445 -52
  80. package/src/effective-topology.test.ts +134 -0
  81. package/src/hcl/parse.ts +30 -2
  82. package/src/hcl/redact.ts +181 -0
  83. package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
  84. package/src/op/activities/terraform.test.ts +118 -1
  85. package/src/op/activities/terraform.ts +213 -9
  86. package/src/plan-observation.ts +428 -0
  87. package/src/plan-read.acceptance.test.ts +184 -0
  88. package/src/plugin.ts +2 -0
  89. package/src/redact-ir.e2e.test.ts +79 -0
  90. package/src/redact.test.ts +111 -0
  91. package/src/search-build-roots.test.ts +125 -0
  92. package/src/search-count.test.ts +144 -0
  93. package/src/search-kind-type.test.ts +136 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A sensitive variable's literal default stays out of what the commands print
3
+ * (chant #4377): the real `chant graph --format ir` and `chant search --show`
4
+ * over `__fixtures__/sensitive-default`, and `chant workspace graph` over a
5
+ * workspace holding the same file, each grepped for every secret.
6
+ */
7
+ import { cpSync, readFileSync, symlinkSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
11
+ import { runGraph } from "@intentius/chant/cli/handlers/graph";
12
+ import { runSearch } from "@intentius/chant/cli/handlers/search";
13
+ import { cleanScratch, declaration, REPO, repo } from "../../../packages/core/src/workspace/__fixtures__/contract-repo";
14
+ import { workspaceGraph } from "../../../packages/core/src/workspace/graph-cli";
15
+ import { SENSITIVE_PLACEHOLDER } from "./hcl/redact";
16
+ import { terraformPlugin } from "./plugin";
17
+
18
+ const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "sensitive-default");
19
+ const SECRETS = ["tok-string-9f3a", "5432917", "admin-map-77", "pw-map-c0ffee"];
20
+
21
+ async function inFixture(run: () => Promise<number>): Promise<{ exit: number; out: string; err: string }> {
22
+ const out: string[] = [];
23
+ const err: string[] = [];
24
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void out.push(String(line)));
25
+ vi.spyOn(console, "error").mockImplementation((line: unknown) => void err.push(String(line)));
26
+ const cwd = process.cwd();
27
+ process.chdir(FIXTURE);
28
+ try {
29
+ return { exit: await run(), out: out.join("\n"), err: err.join("\n") };
30
+ } finally {
31
+ process.chdir(cwd);
32
+ vi.restoreAllMocks();
33
+ }
34
+ }
35
+
36
+ afterEach(() => vi.restoreAllMocks());
37
+
38
+ describe("the fixture's secrets stay out of the commands' output", () => {
39
+ it("chant graph --format ir", async () => {
40
+ const r = await inFixture(() =>
41
+ runGraph({ args: { command: "graph", path: ".", format: "ir" } as never, plugins: [terraformPlugin], serializers: [] }),
42
+ );
43
+ expect(r.exit, r.err).toBe(0);
44
+ expect(r.out).toContain(SENSITIVE_PLACEHOLDER);
45
+ expect(r.out).toContain("us-east-1");
46
+ for (const secret of SECRETS) expect(r.out).not.toContain(secret);
47
+ });
48
+
49
+ it("chant search --show body,source", async () => {
50
+ const r = await inFixture(() =>
51
+ runSearch({ args: { path: "kind:Terraform::Variable", show: "body,source", json: true } as never, plugins: [terraformPlugin], serializers: [] }),
52
+ );
53
+ expect(r.exit, r.err).toBe(0);
54
+ expect(r.out).toContain("api_token");
55
+ expect(r.out).toContain(SENSITIVE_PLACEHOLDER);
56
+ for (const secret of SECRETS) expect(r.out).not.toContain(secret);
57
+ });
58
+ });
59
+
60
+ describe("chant workspace graph", () => {
61
+ afterAll(cleanScratch);
62
+
63
+ it("composes a terraform member without its sensitive defaults", async () => {
64
+ const version = (JSON.parse(readFileSync(join(REPO, "lexicons", "terraform", "package.json"), "utf-8")) as { version: string }).version;
65
+ const root = repo({
66
+ "chant.workspace.json": declaration([{ name: "net", dir: "estates/net", kind: "terraform" }], {
67
+ pins: [{ package: "@intentius/chant-lexicon-terraform", version }],
68
+ }),
69
+ ".gitignore": "node_modules\n",
70
+ });
71
+ cpSync(join(FIXTURE, "net", "main.tf"), join(root, "estates", "net", "main.tf"), { recursive: true });
72
+ symlinkSync(join(REPO, "node_modules"), join(root, "node_modules"), "dir");
73
+ const { doc } = await workspaceGraph({ cwd: root });
74
+ const text = JSON.stringify(doc);
75
+ expect(text).toContain("net/net/var.api_token");
76
+ expect(text).toContain(SENSITIVE_PLACEHOLDER);
77
+ for (const secret of SECRETS) expect(text).not.toContain(secret);
78
+ }, 240_000);
79
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A sensitive variable's literal default is left out of the IR (chant #4377).
3
+ *
4
+ * Unit tests on the scanner and on `blocksToEntities`. The commands' output
5
+ * is grepped in redact-ir.e2e.test.ts.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { redactSensitiveSource, SENSITIVE_PLACEHOLDER } from "./hcl/redact";
9
+ import { blocksToEntities, type TerraformEntity } from "./hcl/parse";
10
+
11
+ const FILE = `variable "token" {
12
+ type = string
13
+ default = "hunter2"
14
+ sensitive = true
15
+ }
16
+
17
+ variable "port" {
18
+ default = 5432
19
+ sensitive = true
20
+ }
21
+
22
+ variable "creds" {
23
+ sensitive = true
24
+ default = {
25
+ user = "admin"
26
+ pass = "s3cret"
27
+ note = <<-EOT
28
+ heredoc } with "quotes"
29
+ EOT
30
+ }
31
+ validation {
32
+ condition = length(var.creds) > 0
33
+ error_message = "default = \\"nope\\""
34
+ }
35
+ }
36
+
37
+ variable "region" {
38
+ default = "us-east-1"
39
+ }
40
+
41
+ variable "plain" {
42
+ default = "visible"
43
+ sensitive = false
44
+ }
45
+
46
+ resource "aws_db_instance" "main" {
47
+ password = var.token # default = "hunter2"
48
+ }
49
+ `;
50
+
51
+ describe("redactSensitiveSource", () => {
52
+ const out = redactSensitiveSource(FILE);
53
+
54
+ it("replaces a string, a number and a multi-line map default", () => {
55
+ expect(out).toContain("default = (sensitive)\n sensitive = true\n}\n\nvariable \"port\"");
56
+ expect(out).toContain("default = (sensitive)\n sensitive = true\n}\n\nvariable \"creds\"");
57
+ expect(out).toContain("sensitive = true\n default = (sensitive)\n validation {");
58
+ for (const secret of ["hunter2\"\n sensitive", "5432", "s3cret", "admin", "heredoc"]) expect(out).not.toContain(secret);
59
+ });
60
+
61
+ it("leaves everything else byte for byte", () => {
62
+ expect(out).toContain('default = "us-east-1"');
63
+ expect(out).toContain('default = "visible"');
64
+ expect(out).toContain('error_message = "default = \\"nope\\""');
65
+ expect(out).toContain('password = var.token # default = "hunter2"');
66
+ });
67
+
68
+ it("handles a one-line block", () => {
69
+ expect(redactSensitiveSource('variable "t" { default = "hunter2", sensitive = true }')).toBe('variable "t" { default = (sensitive), sensitive = true }');
70
+ expect(redactSensitiveSource('variable "t" { sensitive = true, default = "hunter2" }')).toBe('variable "t" { sensitive = true, default = (sensitive) }');
71
+ });
72
+ });
73
+
74
+ describe("blocksToEntities", () => {
75
+ const source = `variable "s" {
76
+ default = "str-secret"
77
+ sensitive = true
78
+ }
79
+ variable "n" {
80
+ default = 424242
81
+ sensitive = true
82
+ }
83
+ variable "m" {
84
+ sensitive = true
85
+ default = {
86
+ a = "map-secret"
87
+ b = 7
88
+ }
89
+ }
90
+ variable "open" {
91
+ default = "visible-default"
92
+ }
93
+ `;
94
+
95
+ it("redacts default in source and body but keeps the key, for a string, a number and a map", async () => {
96
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "app");
97
+ for (const name of ["s", "n", "m"]) {
98
+ const e = entities.get(`app/var.${name}`) as TerraformEntity;
99
+ expect("default" in e.props.body).toBe(true);
100
+ expect(e.props.body.default).toBe(SENSITIVE_PLACEHOLDER);
101
+ for (const secret of ["str-secret", "424242", "map-secret"]) expect(JSON.stringify(e.props)).not.toContain(secret);
102
+ }
103
+ });
104
+
105
+ it("leaves a non-sensitive default alone, in body and source", async () => {
106
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "app");
107
+ const e = entities.get("app/var.open") as TerraformEntity;
108
+ expect(e.props.body.default).toBe("visible-default");
109
+ expect(e.props.source).toContain('default = "visible-default"');
110
+ });
111
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `chant search` answers over the graph `chant graph` draws, on a project
3
+ * whose only entities are a `terraform.roots` root (chant #4365, #4368).
4
+ *
5
+ * The fixture is `__fixtures__/search-roots/`: one root, `net`, with a VPC,
6
+ * two subnets, three security groups, a rule and two instances. It is
7
+ * lint-clean, because `chant graph --format ir` refuses lint-dirty source. Every assertion runs the real `runSearch` and the real
8
+ * `runGraph --format ir` in that directory, so the node ids compared are the
9
+ * ones each command prints.
10
+ */
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
14
+ import type { GraphIR } from "@intentius/chant/graph-ir";
15
+ import { runGraph } from "@intentius/chant/cli/handlers/graph";
16
+ import { runSearch, type SearchJson } from "@intentius/chant/cli/handlers/search";
17
+ import type { LexiconPlugin } from "@intentius/chant/lexicon";
18
+ import { terraformPlugin } from "./plugin";
19
+
20
+ const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "search-roots");
21
+
22
+ /** Run one command in the fixture with stdout and stderr captured. */
23
+ async function inFixture(
24
+ run: () => Promise<number>,
25
+ ): Promise<{ exit: number; out: string[]; err: string[] }> {
26
+ const out: string[] = [];
27
+ const err: string[] = [];
28
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void out.push(String(line)));
29
+ vi.spyOn(console, "error").mockImplementation((line: unknown) => void err.push(String(line)));
30
+ const cwd = process.cwd();
31
+ process.chdir(FIXTURE);
32
+ try {
33
+ return { exit: await run(), out, err };
34
+ } finally {
35
+ process.chdir(cwd);
36
+ vi.restoreAllMocks();
37
+ }
38
+ }
39
+
40
+ async function search(query: string, plugins: LexiconPlugin[] = [terraformPlugin]): Promise<{ exit: number; json?: SearchJson; out: string[]; err: string[] }> {
41
+ const r = await inFixture(() => runSearch({ args: { path: query, json: true } as never, plugins, serializers: [] }));
42
+ return { ...r, ...(r.exit === 0 ? { json: JSON.parse(r.out.join("\n")) as SearchJson } : {}) };
43
+ }
44
+
45
+ const ids = (json: SearchJson | undefined): string[] => (json?.matches ?? []).map((m) => m.id).sort();
46
+
47
+ let graph: GraphIR;
48
+
49
+ beforeAll(async () => {
50
+ const r = await inFixture(() =>
51
+ runGraph({ args: { command: "graph", path: ".", format: "ir" } as never, plugins: [terraformPlugin], serializers: [] }),
52
+ );
53
+ expect(r.exit, r.err.join("\n")).toBe(0);
54
+ graph = JSON.parse(r.out.join("\n")) as GraphIR;
55
+ });
56
+
57
+ afterEach(() => vi.restoreAllMocks());
58
+
59
+ describe("search over a terraform.roots project (#4365, #4368)", () => {
60
+ it("kind:Terraform::Resource returns one row per resource graph --format ir lists, with the same ids", async () => {
61
+ const { exit, json } = await search("kind:Terraform::Resource");
62
+ expect(exit).toBe(0);
63
+ const drawn = graph.nodes.filter((n) => n.kind === "Terraform::Resource").map((n) => n.id).sort();
64
+ expect(drawn.length).toBe(9);
65
+ expect(ids(json)).toEqual(drawn);
66
+ });
67
+
68
+ it("attr:address=aws_security_group. returns the root's three security groups under graph's ids", async () => {
69
+ const { json } = await search("attr:address=aws_security_group.");
70
+ expect(ids(json)).toEqual(["net/aws_security_group.batch", "net/aws_security_group.db", "net/aws_security_group.web"]);
71
+ const drawn = new Set(graph.nodes.map((n) => n.id));
72
+ for (const id of ids(json)) expect(drawn.has(id)).toBe(true);
73
+ });
74
+
75
+ it("-> and <- follow the edges graph draws", async () => {
76
+ // Every group references the VPC through vpc_id.
77
+ const out = await search("attr:address=aws_security_group. ->[via=vpc_id]attr:address=aws_vpc.main");
78
+ expect(ids(out.json)).toEqual(["net/aws_security_group.batch", "net/aws_security_group.db", "net/aws_security_group.web"]);
79
+ const edge = graph.edges.find((e) => e.from === "net/aws_security_group.db" && e.to === "net/aws_vpc.main");
80
+ expect(edge?.viaAttr).toBe("vpc_id");
81
+
82
+ // The rule names db as its group and web as its source; the qualifier
83
+ // keeps only the first.
84
+ const ruled = await search("attr:address=aws_security_group. <-attr:address=aws_security_group_rule.");
85
+ expect(ids(ruled.json)).toEqual(["net/aws_security_group.db", "net/aws_security_group.web"]);
86
+ const owned = await search("attr:address=aws_security_group. <-[via=security_group_id]attr:address=aws_security_group_rule.");
87
+ expect(ids(owned.json)).toEqual(["net/aws_security_group.db"]);
88
+
89
+ // Transitive (#4359): the instance reaches the VPC through its subnet.
90
+ const deep = await search("attr:address=aws_instance.db ->{2}attr:address=aws_vpc.main");
91
+ expect(ids(deep.json)).toEqual(["net/aws_instance.db"]);
92
+ expect(deep.json!.matches[0].paths[0].hops.length).toBe(2);
93
+ });
94
+
95
+ it("a bare word searches the block, not the whole .tf file in its source attribute", async () => {
96
+ // Every node in the fixture shares one main.tf, which mentions
97
+ // aws_security_group, so searching `source` matched all nine.
98
+ const { json } = await search("aws_security_group");
99
+ expect(ids(json)).toEqual([
100
+ // References to a group in the block's body still match.
101
+ "net/aws_instance.db",
102
+ "net/aws_instance.web",
103
+ "net/aws_security_group.batch",
104
+ "net/aws_security_group.db",
105
+ "net/aws_security_group.web",
106
+ "net/aws_security_group_rule.db_pg",
107
+ ]);
108
+ // The file is still searchable when that is what the caller asks for.
109
+ const inFile = await search("attr:source=aws_security_group");
110
+ expect(ids(inFile.json)).toContain("net/aws_vpc.main");
111
+ });
112
+
113
+ it("a contributor error fails the search with that error, not (no matches)", async () => {
114
+ const broken: LexiconPlugin = {
115
+ ...terraformPlugin,
116
+ buildRoots: async () => {
117
+ throw new Error("terraform.roots.net: hcl2json could not parse main.tf");
118
+ },
119
+ };
120
+ const { exit, out, err } = await search("kind:Terraform::Resource", [broken]);
121
+ expect(exit).toBe(1);
122
+ expect(out.join("\n")).not.toContain("no matches");
123
+ expect(err.join("\n")).toContain("hcl2json could not parse main.tf");
124
+ });
125
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `chant search --count-by` on Terraform roots (chant #4360), run through the
3
+ * real handler: the roots are read by this lexicon's `buildRoots()`, joined to
4
+ * discovery the way `chant graph` joins them, and grouped by core.
5
+ *
6
+ * The fixture is `src/__fixtures__/graph-roots/` (two roots, `app` calling a
7
+ * local `cdn` module), copied to a temporary project with a `chant.config.ts`
8
+ * naming both roots.
9
+ */
10
+
11
+ import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
16
+ import { runSearch } from "@intentius/chant/cli/handlers/search";
17
+ import type { CommandContext } from "@intentius/chant/cli/registry";
18
+ import { terraformPlugin } from "./plugin";
19
+
20
+ const TREE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "graph-roots");
21
+
22
+ let dir: string;
23
+ let cwd: string;
24
+
25
+ beforeAll(() => {
26
+ dir = mkdtempSync(join(tmpdir(), "chant-search-count-"));
27
+ cpSync(join(TREE, "app"), join(dir, "app"), { recursive: true });
28
+ cpSync(join(TREE, "network"), join(dir, "network"), { recursive: true });
29
+ writeFileSync(
30
+ join(dir, "chant.config.ts"),
31
+ `export default { lexicons: ["terraform"], terraform: { binary: "terraform", roots: { app: { dir: "./app" }, network: { dir: "./network" } } } };\n`,
32
+ );
33
+ cwd = process.cwd();
34
+ process.chdir(dir);
35
+ });
36
+
37
+ afterAll(() => {
38
+ process.chdir(cwd);
39
+ rmSync(dir, { recursive: true, force: true });
40
+ });
41
+
42
+ /** Run `chant search` and return its exit code and stdout lines. */
43
+ async function search(args: Partial<CommandContext["args"]>): Promise<{ code: number; out: string[] }> {
44
+ const out: string[] = [];
45
+ const log = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
46
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
47
+ try {
48
+ const code = await runSearch({ args: { path: "", ...args }, plugins: [terraformPlugin], serializers: [] } as unknown as CommandContext);
49
+ return { code, out };
50
+ } finally {
51
+ log.mockRestore();
52
+ err.mockRestore();
53
+ }
54
+ }
55
+
56
+ /** The group lines of a grouped answer, as `[count, group]`. */
57
+ function groups(out: string[]): Array<[number, string]> {
58
+ return out
59
+ .map((l) => /^\s*(\d+)\s{2}(.+)$/.exec(l))
60
+ .filter((m): m is RegExpExecArray => !!m)
61
+ .map((m) => [Number(m[1]), m[2]]);
62
+ }
63
+
64
+ /** The total printed on the `— N total` line. */
65
+ function total(out: string[]): number {
66
+ const line = out.find((l) => /^— \d+ total/.test(l));
67
+ return Number(/^— (\d+) total/.exec(line ?? "")?.[1]);
68
+ }
69
+
70
+ describe("chant search on a Terraform project", () => {
71
+ it("sees the roots' blocks at all", async () => {
72
+ const { code, out } = await search({ path: "kind:Terraform::Resource" });
73
+ expect(code).toBe(0);
74
+ expect(out).toContain("app/aws_instance.web aws_instance");
75
+ expect(out).toContain("network/aws_vpc.main aws_vpc");
76
+ });
77
+ });
78
+
79
+ describe("--count-by on a Terraform project", () => {
80
+ it("module comes from the address, root-module blocks are (root), qualified per root", async () => {
81
+ const { code, out } = await search({ countBy: "module" });
82
+ expect(code).toBe(0);
83
+ const g = groups(out);
84
+ expect(g).toContainEqual([2, "app/module.cdn"]);
85
+ expect(g.find(([, name]) => name === "app/(root)")).toBeDefined();
86
+ expect(g.find(([, name]) => name === "network/(root)")).toBeDefined();
87
+ expect(g.reduce((s, [c]) => s + c, 0)).toBe(total(out));
88
+ });
89
+
90
+ it("kind groups Terraform resources by resource type", async () => {
91
+ const { out } = await search({ path: "kind:Terraform::Resource", countBy: "kind" });
92
+ const g = groups(out);
93
+ expect(g.map(([, name]) => name)).toEqual(["aws_cloudfront_distribution", "aws_instance", "aws_s3_bucket", "aws_vpc"]);
94
+ expect(total(out)).toBe(4);
95
+ });
96
+
97
+ it("combined with a query, counts only the matches", async () => {
98
+ const all = await search({ countBy: "module" });
99
+ const resources = await search({ path: "kind:Terraform::Resource", countBy: "module" });
100
+ expect(total(resources.out)).toBe(4);
101
+ expect(total(all.out)).toBeGreaterThan(4);
102
+ expect(groups(resources.out)).toEqual([
103
+ [2, "app/(root)"],
104
+ [1, "app/module.cdn"],
105
+ [1, "network/(root)"],
106
+ ]);
107
+ });
108
+
109
+ it("an attribute most blocks lack is counted under (none), and the groups add up", async () => {
110
+ const { out } = await search({ countBy: "attr:expansion" });
111
+ const g = groups(out);
112
+ expect(g).toContainEqual([1, "count"]);
113
+ expect(g.find(([, name]) => name === "(none)")).toBeDefined();
114
+ expect(g.reduce((s, [c]) => s + c, 0)).toBe(total(out));
115
+ });
116
+
117
+ it("with --json, prints the groups and the total as one object", async () => {
118
+ const { code, out } = await search({ path: "kind:Terraform::Resource", countBy: "module", json: true });
119
+ expect(code).toBe(0);
120
+ expect(out).toHaveLength(1);
121
+ const body = JSON.parse(out[0]);
122
+ expect(body).toMatchObject({ countBy: "module", total: 4, source: { kind: "declared" } });
123
+ expect(body.groups).toEqual([
124
+ { group: "app/(root)", count: 2, ids: ["app/aws_instance.web", "app/aws_s3_bucket.assets"] },
125
+ { group: "app/module.cdn", count: 1, ids: ["app/module.cdn/aws_cloudfront_distribution.cdn"] },
126
+ { group: "network/(root)", count: 1, ids: ["network/aws_vpc.main"] },
127
+ ]);
128
+ expect(body.matches).toBeUndefined();
129
+ // #4367: the provenance line rides beside the groups.
130
+ expect(body.provenance).toBe("declared only · no observation · physical ids unavailable");
131
+ });
132
+
133
+ it("with --json, a grouping it cannot read is a document with error (#4367)", async () => {
134
+ const { code, out } = await search({ countBy: "provider", json: true });
135
+ expect(code).toBe(1);
136
+ expect(out).toHaveLength(1);
137
+ expect(JSON.parse(out[0]).error.message).toBeTruthy();
138
+ });
139
+
140
+ it("refuses a grouping it cannot read", async () => {
141
+ const { code } = await search({ countBy: "provider" });
142
+ expect(code).toBe(1);
143
+ });
144
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `chant search "kind:<terraform type>"` (chant #4366).
3
+ *
4
+ * Every terraform resource is `Terraform::Resource` on the IR, so `kind:` used
5
+ * to match nothing finer than that. The lexicon now carries each resource's
6
+ * type and name as `attrs.type` and `attrs.name`, and `kind:` reads the type
7
+ * with the `::`-boundary rule moved onto `_`. The fixture
8
+ * (`src/__fixtures__/kind-type/`) holds two `aws_instance`, an `aws_iam_role`
9
+ * and an `aws_iam_role_policy`, whose type begins with the role's, and one
10
+ * `data "aws_ami"`. `--count-by kind` (#4360) groups by the same type, through
11
+ * the same function, which the last test holds it to.
12
+ */
13
+
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
17
+ import { buildGraphIr, type GraphIR, type IRNode } from "@intentius/chant/graph-ir";
18
+ import { enrichEffectiveTopology } from "@intentius/chant/graph-effective";
19
+ import { __searchInternals, resolveKindTerms, runSearch } from "@intentius/chant/cli/handlers/search";
20
+ import { countGroups } from "@intentius/chant/cli/handlers/search-count";
21
+ import { renderTerraformRoots } from "./hcl/roots";
22
+ import { terraformPlugin } from "./plugin";
23
+
24
+ const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "kind-type");
25
+ const { parseQuery, matchTerm, explain } = __searchInternals;
26
+
27
+ let ir: GraphIR;
28
+
29
+ beforeAll(async () => {
30
+ const rendered = await renderTerraformRoots({ projectRoot: FIXTURE, roots: { app: { dir: "./app" } } });
31
+ expect(rendered.warnings).toEqual([]);
32
+ ir = enrichEffectiveTopology(buildGraphIr(rendered.entities));
33
+ });
34
+
35
+ function search(query: string, graph: GraphIR = ir): string[] {
36
+ const terms = parseQuery(query);
37
+ resolveKindTerms(terms, graph.nodes);
38
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
39
+ return graph.nodes.filter((n) => terms.every((t) => matchTerm(n, t, graph, byId))).map((n) => n.id).sort();
40
+ }
41
+
42
+ describe("the lexicon's resource labels", () => {
43
+ it("carries type and name on a resource", () => {
44
+ const web = ir.nodes.find((n) => n.id === "app/aws_instance.web")!;
45
+ expect(web.kind).toBe("Terraform::Resource");
46
+ expect(web.attrs.type).toBe("aws_instance");
47
+ expect(web.attrs.name).toBe("web");
48
+ });
49
+
50
+ it("carries them on a data source, without the data. prefix", () => {
51
+ const ami = ir.nodes.find((n) => n.id === "app/data.aws_ami.base")!;
52
+ expect(ami.attrs.type).toBe("aws_ami");
53
+ expect(ami.attrs.name).toBe("base");
54
+ });
55
+
56
+ it("carries neither on any other block", () => {
57
+ const other = ir.nodes.filter((n) => n.kind !== "Terraform::Resource" && n.kind !== "Terraform::Data");
58
+ expect(other.length).toBeGreaterThan(0);
59
+ for (const n of other) expect(n.attrs.type).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe('search "kind:<terraform type>"', () => {
64
+ afterEach(() => vi.restoreAllMocks());
65
+
66
+ it("kind:aws_instance returns exactly the two instances", () => {
67
+ expect(search("kind:aws_instance")).toEqual(["app/aws_instance.web", "app/aws_instance.worker"]);
68
+ });
69
+
70
+ it("kind:aws_iam_role stops at the type and leaves out aws_iam_role_policy", () => {
71
+ expect(search("kind:aws_iam_role")).toEqual(["app/aws_iam_role.app"]);
72
+ });
73
+
74
+ it("kind:aws_iam_role matches nothing on a graph with a policy and no role", () => {
75
+ const noRole = { ...ir, nodes: ir.nodes.filter((n) => n.id !== "app/aws_iam_role.app") };
76
+ expect(search("kind:aws_iam_role", noRole)).toEqual([]);
77
+ });
78
+
79
+ it("a trailing _ or a bare provider name is a prefix, like kind:EC2", () => {
80
+ expect(search("kind:aws_iam_")).toEqual(["app/aws_iam_role.app", "app/aws_iam_role_policy.app"]);
81
+ expect(search("kind:aws")).toHaveLength(4);
82
+ });
83
+
84
+ it("a data source is data.<type>, and kind:<type> alone is only resources", () => {
85
+ expect(search("kind:data.aws_ami")).toEqual(["app/data.aws_ami.base"]);
86
+ expect(search("kind:aws_ami")).toEqual([]);
87
+ expect(search("kind:Terraform::Data")).toEqual(["app/data.aws_ami.base"]);
88
+ });
89
+
90
+ it("kind:<group> returns exactly what --count-by kind counts in that group (#4360)", () => {
91
+ const groups = countGroups(ir.nodes, { by: "kind" });
92
+ expect(groups.map((g) => g.group)).toEqual(expect.arrayContaining(["aws_instance", "aws_iam_role", "aws_iam_role_policy", "data.aws_ami"]));
93
+ for (const { group, count } of groups) expect(search(`kind:${group}`), group).toHaveLength(count);
94
+ });
95
+
96
+ it("kind:Terraform::Resource still returns every resource", () => {
97
+ expect(search("kind:Terraform::Resource")).toHaveLength(4);
98
+ expect(search("kind:Resource")).toHaveLength(4);
99
+ });
100
+
101
+ it("composes with negation and edges", () => {
102
+ expect(search("kind:aws_iam_ !kind:aws_iam_role")).toEqual(["app/aws_iam_role_policy.app"]);
103
+ expect(search("kind:aws_iam_role_policy ->kind:aws_iam_role")).toEqual(["app/aws_iam_role_policy.app"]);
104
+ });
105
+
106
+ it("reads a node with no attrs.type from its address, as a live or older node", () => {
107
+ const bare: IRNode = { id: "app/aws_instance.legacy", kind: "Terraform::Resource", lexicon: "terraform", attrs: { address: "aws_instance.legacy" } };
108
+ expect(search("kind:aws_instance", { ...ir, nodes: [...ir.nodes, bare] })).toContain("app/aws_instance.legacy");
109
+ });
110
+
111
+ it("--explain counts against the type", () => {
112
+ const lines: string[] = [];
113
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
114
+ const terms = parseQuery("kind:aws_instance attr:name=web");
115
+ resolveKindTerms(terms, ir.nodes);
116
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
117
+ const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, byId)));
118
+ explain(terms, matches, ir, byId, "kind:aws_instance attr:name=web");
119
+ expect(lines[0]).toBe("— 1 of 2 aws_instance matched (query: kind:aws_instance attr:name=web)");
120
+ expect(lines).toContain(" · excluded app/aws_instance.worker — fails attr:name=web");
121
+ });
122
+
123
+ it("end to end: the row shows the type", async () => {
124
+ const lines: string[] = [];
125
+ vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
126
+ const cwd = process.cwd();
127
+ process.chdir(FIXTURE);
128
+ try {
129
+ const exit = await runSearch({ args: { path: "kind:aws_instance" } as never, plugins: [terraformPlugin], serializers: [] });
130
+ expect(exit).toBe(0);
131
+ } finally {
132
+ process.chdir(cwd);
133
+ }
134
+ expect(lines.filter((l) => l.startsWith("app/"))).toEqual(["app/aws_instance.web aws_instance", "app/aws_instance.worker aws_instance"]);
135
+ });
136
+ });