@intentius/chant-lexicon-terraform 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 (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,166 @@
1
+ {
2
+ "lexicon": "terraform",
3
+ "types": "terraform",
4
+ "body": "body",
5
+ "refs": "terraform",
6
+ "labels": {
7
+ "aws_vpc": "Vpc",
8
+ "aws_default_vpc": {
9
+ "label": "Vpc",
10
+ "set": {
11
+ "isDefault": true
12
+ }
13
+ },
14
+ "aws_default_security_group": "SecurityGroup",
15
+ "aws_network_interface": "NetworkInterface",
16
+ "aws_ebs_volume": "Volume",
17
+ "aws_lambda_function": "Function",
18
+ "aws_lambda_function_url": "FunctionUrl",
19
+ "aws_iam_instance_profile": "InstanceProfile",
20
+ "aws_iam_role": "Role",
21
+ "aws_iam_policy": "Policy",
22
+ "aws_iam_role_policy": "Policy",
23
+ "aws_iam_role_policy_attachment": "PolicyAttachment",
24
+ "aws_s3_bucket_policy": "ResourcePolicy",
25
+ "aws_sqs_queue_policy": "ResourcePolicy",
26
+ "aws_s3_bucket": "Bucket",
27
+ "aws_s3_bucket_public_access_block": "PublicAccessBlock",
28
+ "aws_dynamodb_table": "Table",
29
+ "aws_db_instance": "Database",
30
+ "aws_rds_cluster": "Database",
31
+ "aws_secretsmanager_secret": "Secret",
32
+ "aws_sqs_queue": "Queue",
33
+ "aws_efs_file_system": "FileSystem",
34
+ "aws_ssm_parameter": "Parameter",
35
+ "aws_ecs_service": "Service",
36
+ "aws_ecs_task_definition": "TaskDefinition",
37
+ "aws_db_subnet_group": "SubnetGroup",
38
+ "aws_nat_gateway": "NatGateway",
39
+ "aws_lb": "LoadBalancer",
40
+ "aws_alb": "LoadBalancer"
41
+ },
42
+ "edges": {
43
+ "Instance": {
44
+ "iam_instance_profile": "profile"
45
+ },
46
+ "LaunchTemplate": {
47
+ "iam_instance_profile": "profile"
48
+ },
49
+ "Subnet": {
50
+ "vpc_id": "vpc"
51
+ },
52
+ "SecurityGroup": {
53
+ "vpc_id": "vpc"
54
+ },
55
+ "NetworkInterface": {
56
+ "subnet_id": "subnet",
57
+ "security_groups": "sg"
58
+ },
59
+ "Database": {
60
+ "vpc_security_group_ids": "sg",
61
+ "db_subnet_group_name": "subnetGroup"
62
+ },
63
+ "Function": {
64
+ "role": "role",
65
+ "vpc_config": {
66
+ "SecurityGroup": "sg",
67
+ "Subnet": "subnet"
68
+ }
69
+ },
70
+ "FunctionUrl": {
71
+ "function_name": "function"
72
+ },
73
+ "InstanceProfile": {
74
+ "role": "role"
75
+ },
76
+ "Policy": {
77
+ "role": "attachedTo"
78
+ },
79
+ "PolicyAttachment": {
80
+ "role": "role",
81
+ "policy_arn": "policy"
82
+ },
83
+ "ResourcePolicy": {
84
+ "bucket": "policyOf",
85
+ "queue_url": "policyOf"
86
+ },
87
+ "PublicAccessBlock": {
88
+ "bucket": "blocks"
89
+ },
90
+ "Service": {
91
+ "task_definition": "task",
92
+ "network_configuration": {
93
+ "SecurityGroup": "sg",
94
+ "Subnet": "subnet"
95
+ }
96
+ },
97
+ "TaskDefinition": {
98
+ "task_role_arn": "role"
99
+ },
100
+ "SubnetGroup": {
101
+ "subnet_ids": "subnet"
102
+ },
103
+ "NatGateway": {
104
+ "subnet_id": "subnet"
105
+ },
106
+ "LoadBalancer": {
107
+ "subnets": "subnet",
108
+ "security_groups": "sg"
109
+ }
110
+ },
111
+ "links": {
112
+ "PolicyAttachment": {
113
+ "from": "policy",
114
+ "to": "role",
115
+ "edge": "attachedTo"
116
+ }
117
+ },
118
+ "attrs": {
119
+ "FunctionUrl": {
120
+ "authType": "authorization_type"
121
+ },
122
+ "Role": {
123
+ "name": "name"
124
+ },
125
+ "Bucket": {
126
+ "name": "bucket",
127
+ "acl": "acl"
128
+ },
129
+ "PublicAccessBlock": {
130
+ "blockPublicPolicy": "block_public_policy"
131
+ },
132
+ "Table": {
133
+ "name": "name"
134
+ },
135
+ "Database": {
136
+ "encrypted": "storage_encrypted"
137
+ },
138
+ "FileSystem": {
139
+ "encrypted": "encrypted"
140
+ },
141
+ "Volume": {
142
+ "encrypted": "encrypted"
143
+ },
144
+ "Parameter": {
145
+ "paramType": "type"
146
+ }
147
+ },
148
+ "documents": {
149
+ "Role": {
150
+ "assume_role_policy": "trust",
151
+ "inline_policy[].policy": "permission"
152
+ },
153
+ "Policy": {
154
+ "policy": "permission"
155
+ },
156
+ "ResourcePolicy": {
157
+ "policy": "resource"
158
+ }
159
+ },
160
+ "tags": [
161
+ "tags"
162
+ ],
163
+ "classificationTags": [
164
+ "data-classification"
165
+ ]
166
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Capabilities across a declared workspace (#4375, #4378).
3
+ *
4
+ * `platform` declares a role whose policy reads a bucket by ARN string, and
5
+ * `data` declares that bucket. Searched member by member, neither sees the
6
+ * other, so no answer could join them. A capability term runs its rules once,
7
+ * under the workspace's chant, over the graph `chant workspace graph`
8
+ * composes, so `reaches` crosses from one member into the other.
9
+ *
10
+ * Each member's view is mapped by its own chant (#4378): two members whose
11
+ * terraform lexicons map classification tags differently each answer by
12
+ * their own mapping, and a member whose chant predates the view is
13
+ * `member-chant-too-old`, not an empty answer.
14
+ *
15
+ * Also: `chant workspace search --capabilities --json` is the `capabilities`
16
+ * document, and chant's MCP `capabilities` tool returns it unchanged.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join, resolve } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
25
+ import { workspaceSearch } from "@intentius/chant/workspace/search-cli";
26
+ import { workspaceGraph } from "@intentius/chant/workspace/graph-cli";
27
+ import { readerBin, type Toolchain } from "@intentius/chant/workspace/member-commands";
28
+ import { createCapabilitiesHandler } from "@intentius/chant/cli/mcp/tools/capabilities";
29
+ import { contract } from "../../../packages/core/src/workspace/__fixtures__/contract-repo";
30
+ import searchSchema from "../../../packages/core/src/workspace/search.schema.json";
31
+ import capabilitiesSchema from "../../../packages/core/src/workspace/capabilities.schema.json";
32
+ import graphSchema from "../../../packages/core/src/workspace/graph.schema.json";
33
+
34
+ const REPO = resolve(import.meta.dirname, "..", "..", "..");
35
+ const TIMEOUT = 240_000;
36
+ const chant = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
37
+ const reader: Toolchain = { command: chant, identity: realpathSync(readerBin()), source: "reader" };
38
+
39
+ let root: string;
40
+ let mapped: string;
41
+
42
+ function member(name: string, rootDir: string, tf: string, at = root, lexicon = `"terraform"`): void {
43
+ mkdirSync(join(at, name, rootDir), { recursive: true });
44
+ writeFileSync(
45
+ join(at, name, "chant.config.ts"),
46
+ `import type { ChantConfig } from "@intentius/chant/config";
47
+ import "@intentius/chant-lexicon-terraform";
48
+ export default { lexicons: [${lexicon}], terraform: { roots: { ${rootDir}: { dir: "./${rootDir}" } } } } satisfies ChantConfig;
49
+ `,
50
+ );
51
+ writeFileSync(join(at, name, "package.json"), JSON.stringify({ name, private: true, type: "module" }));
52
+ writeFileSync(join(at, name, rootDir, "main.tf"), tf);
53
+ }
54
+
55
+ /** A workspace root with chant and the terraform lexicon installed, committed once its members are written. */
56
+ function workspace(prefix: string, name: string, members: string[]): string {
57
+ const at = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
58
+ mkdirSync(join(at, "node_modules", "@intentius"), { recursive: true });
59
+ symlinkSync(join(REPO, "packages", "core"), join(at, "node_modules", "@intentius", "chant"), "dir");
60
+ symlinkSync(join(REPO, "lexicons", "terraform"), join(at, "node_modules", "@intentius", "chant-lexicon-terraform"), "dir");
61
+ writeFileSync(join(at, ".gitignore"), "node_modules/\n");
62
+ writeFileSync(join(at, "chant.workspace.json"), JSON.stringify({ name, schema: 1, members: members.map((m) => ({ name: m, dir: m, kind: "chant" })) }));
63
+ return at;
64
+ }
65
+
66
+ function commit(at: string): void {
67
+ const git = (...args: string[]) => execFileSync("git", args, { cwd: at, stdio: "ignore" });
68
+ git("init", "-q");
69
+ git("add", "-A");
70
+ git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init");
71
+ }
72
+
73
+ /** Two buckets, one tagged the way the stock mapping reads classification, one the way the `legacy` mapping does. */
74
+ const BUCKETS = `resource "aws_s3_bucket" "by_classification" {
75
+ bucket = "BUCKET-a"
76
+ tags = { "data-classification" = "restricted" }
77
+ }
78
+ resource "aws_s3_bucket" "by_sensitivity" {
79
+ bucket = "BUCKET-b"
80
+ tags = { "sensitivity" = "restricted" }
81
+ }
82
+ `;
83
+
84
+ beforeAll(() => {
85
+ root = workspace("chant-cap-ws-", "split", ["platform", "data"]);
86
+ member(
87
+ "platform",
88
+ "net",
89
+ `resource "aws_iam_role" "app" {
90
+ assume_role_policy = jsonencode({
91
+ Version = "2012-10-17"
92
+ Statement = [{ Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" }, Action = "sts:AssumeRole" }]
93
+ })
94
+ }
95
+ resource "aws_iam_role_policy" "app" {
96
+ role = aws_iam_role.app.id
97
+ policy = jsonencode({
98
+ Version = "2012-10-17"
99
+ Statement = [{ Effect = "Allow", Action = "s3:GetObject", Resource = "arn:aws:s3:::split-customer-data/*" }]
100
+ })
101
+ }
102
+ `,
103
+ );
104
+ member(
105
+ "data",
106
+ "store",
107
+ `resource "aws_s3_bucket" "customers" {
108
+ bucket = "split-customer-data"
109
+ tags = { "data-classification" = "restricted" }
110
+ }
111
+ `,
112
+ );
113
+ commit(root);
114
+ process.env.CHANT_CACHE_DIR = join(root, ".cache");
115
+
116
+ // A second workspace (#4378). `stock` loads the terraform lexicon as
117
+ // installed. `legacy` loads it through a module of its own whose mapping
118
+ // reads classification from a `sensitivity` tag, as a lexicon version
119
+ // with a different mapping would. `old` runs a chant from before
120
+ // --capability-view: it has no member-run and refuses the flag, as every
121
+ // chant since #1127 refuses one it doesn't know.
122
+ mapped = workspace("chant-cap-ws-mapped-", "mapped", ["stock", "legacy", "old"]);
123
+ member("stock", "store", BUCKETS.replace(/BUCKET/g, "stock"), mapped);
124
+ member("legacy", "store", BUCKETS.replace(/BUCKET/g, "legacy"), mapped, `{ name: "terraform", module: "./terraform-lexicon.ts" }`);
125
+ writeFileSync(
126
+ join(mapped, "legacy", "terraform-lexicon.ts"),
127
+ `import { terraformPlugin } from "@intentius/chant-lexicon-terraform";
128
+ const pack = terraformPlugin.capabilityPack!;
129
+ export const legacyTerraform = { ...terraformPlugin, capabilityPack: { ...pack, mapping: { ...pack.mapping, classificationTags: ["sensitivity"] } } };
130
+ `,
131
+ );
132
+ member("old", "store", BUCKETS.replace(/BUCKET/g, "old"), mapped);
133
+ mkdirSync(join(mapped, "old", "node_modules", ".bin"), { recursive: true });
134
+ const oldChant = join(mapped, "old", "node_modules", ".bin", "chant");
135
+ writeFileSync(
136
+ oldChant,
137
+ `#!/bin/sh
138
+ case " $* " in
139
+ *" member-run "*) exit 1 ;;
140
+ *" --capability-view "*) echo 'Unknown flag: --capability-view' >&2; echo 'Run "chant --help" to see supported flags.' >&2; exit 1 ;;
141
+ esac
142
+ exec ${chant.map((a) => `'${a}'`).join(" ")} "$@"
143
+ `,
144
+ );
145
+ chmodSync(oldChant, 0o755);
146
+ writeFileSync(join(mapped, ".gitignore"), "node_modules/\n!old/node_modules/\n");
147
+ commit(mapped);
148
+ });
149
+
150
+ afterAll(() => {
151
+ rmSync(root, { recursive: true, force: true });
152
+ rmSync(mapped, { recursive: true, force: true });
153
+ });
154
+
155
+ describe("workspace search with a capability", () => {
156
+ test(
157
+ "reaches crosses from the member that declares the role into the one that declares the bucket",
158
+ async () => {
159
+ const { doc, failed } = await workspaceSearch({ cwd: root, query: "cap:holds-secret <-[cap=reaches]kind:aws_iam_role", reader });
160
+ contract(searchSchema).expectValid(doc);
161
+ expect(failed).toBe(false);
162
+ if ("error" in doc) throw new Error(doc.error.message);
163
+ const d = doc as unknown as {
164
+ matches: { id: string; member: string; displayId: string; paths: { term: string; hops: { from: string; to: string; edge?: string }[] }[] }[];
165
+ members: { name: string; status: string; alone?: boolean }[];
166
+ capabilities: { name: string }[];
167
+ };
168
+ expect(d.matches.map((m) => [m.id, m.member, m.displayId])).toEqual([["data/store/aws_s3_bucket.customers", "data", "store/aws_s3_bucket.customers"]]);
169
+ const path = d.matches[0].paths.find((p) => p.term.startsWith("<-"))!;
170
+ expect(path.hops.map((h) => [h.from, h.edge])).toEqual([
171
+ ["platform/net/aws_iam_role_policy.app#policy[0].Statement[0]", "resource"],
172
+ ["platform/net/aws_iam_role_policy.app", "statement"],
173
+ ["platform/net/aws_iam_role_policy.app", "attachedTo"],
174
+ ]);
175
+ expect(d.members.map((m) => [m.name, m.status, m.alone ?? false])).toEqual([
176
+ ["platform", "searched", false],
177
+ ["data", "searched", false],
178
+ ]);
179
+ expect(d.capabilities.map((c) => c.name)).toEqual(["holds-secret", "reaches"]);
180
+ },
181
+ TIMEOUT,
182
+ );
183
+
184
+ test("a capability term over a workspace refuses a live read", async () => {
185
+ const { doc } = await workspaceSearch({ cwd: root, query: "cap:holds-secret", flags: { live: true, env: "dev" }, reader });
186
+ expect(doc).toMatchObject({ error: { code: "usage-invalid" } });
187
+ });
188
+ });
189
+
190
+ describe("each member mapped by its own chant (#4378)", () => {
191
+ test(
192
+ "two members whose lexicons map classification differently each answer by their own mapping, and a chant from before the view is member-chant-too-old",
193
+ async () => {
194
+ const { doc, failed } = await workspaceSearch({ cwd: mapped, query: "cap:holds-secret", reader });
195
+ contract(searchSchema).expectValid(doc);
196
+ if ("error" in doc) throw new Error(doc.error.message);
197
+ const d = doc as unknown as {
198
+ matches: { id: string }[];
199
+ members: { name: string; status: string; reason: { code: string; message: string } | null; matches?: string[] }[];
200
+ };
201
+ // The workspace's chant would read both members through the stock
202
+ // mapping and answer stock's and legacy's by_classification.
203
+ expect(d.matches.map((m) => m.id)).toEqual(["legacy/store/aws_s3_bucket.by_sensitivity", "stock/store/aws_s3_bucket.by_classification"]);
204
+ expect(d.members.map((m) => [m.name, m.status, m.reason?.code ?? null])).toEqual([
205
+ ["stock", "searched", null],
206
+ ["legacy", "searched", null],
207
+ ["old", "failed", "member-chant-too-old"],
208
+ ]);
209
+ expect(d.members.find((m) => m.name === "old")!.matches).toBeUndefined();
210
+ expect(failed).toBe(true);
211
+ },
212
+ TIMEOUT,
213
+ );
214
+
215
+ test(
216
+ "workspace graph --capability-view carries each member's view with composed ids, and null for the chant that printed none",
217
+ async () => {
218
+ const { doc } = await workspaceGraph({ cwd: mapped, reader, args: { detail: 3, capabilityView: true }, noCache: true });
219
+ contract(graphSchema).expectValid(doc);
220
+ if ("error" in doc) throw new Error(doc.error.message);
221
+ const members = doc.members as unknown as { name: string; status: string; capabilityView?: { lexicons: string[]; nodes: { id: string; label: string; attrs: Record<string, unknown> }[] } | null }[];
222
+ const classified = (name: string) =>
223
+ members.find((m) => m.name === name)!.capabilityView!.nodes.filter((n) => n.label === "Bucket" && n.attrs.classification !== undefined).map((n) => n.id);
224
+ expect(classified("stock")).toEqual(["stock/store/aws_s3_bucket.by_classification"]);
225
+ expect(classified("legacy")).toEqual(["legacy/store/aws_s3_bucket.by_sensitivity"]);
226
+ const old = members.find((m) => m.name === "old")!;
227
+ expect([old.status, old.capabilityView]).toEqual(["composed", null]);
228
+ // Without the flag the members carry no view at all.
229
+ const plain = await workspaceGraph({ cwd: mapped, reader, args: { detail: 3 } });
230
+ if ("error" in plain.doc) throw new Error(plain.doc.error.message);
231
+ expect(plain.doc.members.every((m) => !("capabilityView" in m))).toBe(true);
232
+ },
233
+ TIMEOUT,
234
+ );
235
+ });
236
+
237
+ describe("the capabilities document in a workspace", () => {
238
+ test(
239
+ "workspace search --capabilities prints it for the members' lexicons, and the MCP tool returns it unchanged",
240
+ async () => {
241
+ const cli = JSON.parse(
242
+ execFileSync(chant[0], [...chant.slice(1), "workspace", "search", "--capabilities", "--json"], { cwd: root, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1" } }),
243
+ );
244
+ contract(capabilitiesSchema).expectValid(cli);
245
+ expect(cli.capabilities.find((c: { name: string }) => c.name === "reaches").lexicons).toEqual({ terraform: "supported" });
246
+ const viaMcp = await createCapabilitiesHandler(root, chant)({});
247
+ expect(viaMcp).toEqual(cli);
248
+ },
249
+ TIMEOUT,
250
+ );
251
+ });