@intentius/chant 0.83.0 → 0.85.0

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 (66) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +6 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  18. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  19. package/dist/workspace/composites.d.ts +139 -0
  20. package/dist/workspace/composites.d.ts.map +1 -0
  21. package/dist/workspace/graph-cli.d.ts +16 -1
  22. package/dist/workspace/graph-cli.d.ts.map +1 -1
  23. package/dist/workspace/intent-cli.d.ts +21 -0
  24. package/dist/workspace/intent-cli.d.ts.map +1 -0
  25. package/dist/workspace/intent-joins.d.ts +121 -0
  26. package/dist/workspace/intent-joins.d.ts.map +1 -0
  27. package/dist/workspace/intent.d.ts +310 -0
  28. package/dist/workspace/intent.d.ts.map +1 -0
  29. package/dist/workspace/member-commands.d.ts +7 -2
  30. package/dist/workspace/member-commands.d.ts.map +1 -1
  31. package/dist/workspace/reason-codes.d.ts +29 -0
  32. package/dist/workspace/reason-codes.d.ts.map +1 -1
  33. package/dist/workspace/records.d.ts +7 -1
  34. package/dist/workspace/records.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/graph.ts +4 -0
  37. package/src/cli/main.test.ts +20 -0
  38. package/src/cli/main.ts +18 -0
  39. package/src/cli/mcp/resource-handlers.ts +17 -0
  40. package/src/cli/mcp/server.test.ts +140 -4
  41. package/src/cli/mcp/server.ts +5 -1
  42. package/src/cli/mcp/tools/composites.ts +98 -0
  43. package/src/cli/mcp/tools/search.ts +47 -5
  44. package/src/cli/registry.ts +6 -0
  45. package/src/components/cli-support.test.ts +16 -0
  46. package/src/components/cli-support.ts +8 -2
  47. package/src/composite.ts +9 -0
  48. package/src/lexicon.ts +47 -0
  49. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  50. package/src/workspace/composites.schema.json +471 -0
  51. package/src/workspace/composites.test.ts +244 -0
  52. package/src/workspace/composites.ts +295 -0
  53. package/src/workspace/graph-cli.ts +39 -3
  54. package/src/workspace/intent-cli.ts +119 -0
  55. package/src/workspace/intent-joins.ts +210 -0
  56. package/src/workspace/intent.schema.json +1141 -0
  57. package/src/workspace/intent.test.ts +566 -0
  58. package/src/workspace/intent.ts +999 -0
  59. package/src/workspace/member-commands.ts +11 -5
  60. package/src/workspace/read-contract.test.ts +46 -3
  61. package/src/workspace/reason-codes.test.ts +39 -2
  62. package/src/workspace/reason-codes.ts +40 -0
  63. package/src/workspace/records-contract.test.ts +22 -2
  64. package/src/workspace/records.schema.json +2 -2
  65. package/src/workspace/records.test.ts +93 -0
  66. package/src/workspace/records.ts +55 -11
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The read contract for `chant workspace graph --composites` (#2662): the
3
+ * schema is a valid draft 2020-12 document whose closed code lists match the
4
+ * code, and real output validates against it, for a built workspace whose
5
+ * members answer through a fake chant, for failures, for `--at`, and for each
6
+ * reason the list can be empty. The reference workspace runs a real chant
7
+ * (`read-contract.test.ts`).
8
+ */
9
+
10
+ import { writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { afterAll, describe, expect, test } from "vitest";
13
+ import type { GraphIR, IRNode } from "../graph-ir";
14
+ import { cleanScratch, commitAll, contract, declaration, FAKE_FILE_GRAPH_CHANT, repo, validSchema } from "./__fixtures__/contract-repo";
15
+ import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
16
+ import {
17
+ COMPOSITES_CONTRACT_VERSION,
18
+ COMPOSITES_ERROR_CODES,
19
+ COMPOSITES_OUTPUT_SCHEMA_ID,
20
+ COMPOSITES_REASON_CODES,
21
+ compositeInstances,
22
+ joinComponents,
23
+ workspaceComposites,
24
+ type ComponentEntry,
25
+ type CompositesDocument,
26
+ } from "./composites";
27
+ import { GRAPH_ERROR_CODES } from "./graph-cli";
28
+ import schema from "./composites.schema.json";
29
+
30
+ afterAll(cleanScratch);
31
+
32
+ const { expectValid } = contract(schema);
33
+
34
+ type Result = Exclude<CompositesDocument, { error: unknown }>;
35
+
36
+ function result(doc: CompositesDocument): Result {
37
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
38
+ return doc;
39
+ }
40
+
41
+ const composite = (id: string, kind: string, instance: string, lexicon = "aws"): IRNode => ({ id, kind: "Resource", lexicon, attrs: {}, compositeParent: kind, compositeInstance: instance });
42
+
43
+ const ir = (nodes: IRNode[], extra: Partial<GraphIR> = {}): string => `${JSON.stringify({ version: 1, nodes, edges: [], groups: {}, ...extra })}\n`;
44
+
45
+ const componentIr = (components: { name: string; composites?: string[]; archetype?: string }[]): string =>
46
+ ir(
47
+ components.map((c) => ({
48
+ id: c.name,
49
+ kind: "Component",
50
+ lexicon: "chant",
51
+ attrs: { wave: 1, liveNames: [c.name], ...(c.composites ? { composites: c.composites } : {}), ...(c.archetype ? { archetype: c.archetype } : {}) },
52
+ sourceLoc: { file: `src/${c.name}.component.ts` },
53
+ })),
54
+ { groups: { byWave: { "wave-1": components.map((c) => c.name) } } },
55
+ );
56
+
57
+ /**
58
+ * app declares three instances and exports ImageUri; delivery links to it and
59
+ * declares two components; jobs declares an instance and a component of its
60
+ * own; docs is kind other.
61
+ */
62
+ function fixture(): string {
63
+ return repo({
64
+ "chant.workspace.json": declaration([
65
+ { name: "app", dir: "app", kind: "chant" },
66
+ { name: "delivery", dir: "delivery", kind: "chant", links: [{ member: "app", output: "ImageUri" }] },
67
+ { name: "jobs", dir: "jobs", kind: "chant" },
68
+ { name: "docs", dir: "docs", kind: "other", because: "prose" },
69
+ ]),
70
+ "app/chant.config.ts": "export default {};\n",
71
+ "app/ir.json": ir(
72
+ [
73
+ composite("backendRepo", "LoomBackend", "backend"),
74
+ composite("backendService", "LoomBackend", "backend"),
75
+ composite("siteBucket", "StaticSite", "site"),
76
+ composite("cacheCluster", "CacheCluster", "cache"),
77
+ { id: "Loose", kind: "Queue", lexicon: "aws", attrs: {} },
78
+ ],
79
+ { exports: [{ name: "ImageUri", node: "backendRepo", attr: "Uri" }] },
80
+ ),
81
+ "delivery/chant.config.ts": "export default {};\n",
82
+ "delivery/ir.json": ir([]),
83
+ "delivery/components.json": componentIr([
84
+ { name: "loom-backend", archetype: "service" },
85
+ { name: "edge", composites: ["StaticSite"], archetype: "infra" },
86
+ ]),
87
+ "jobs/chant.config.ts": "export default {};\n",
88
+ "jobs/ir.json": ir([composite("queueTopic", "WorkQueue", "queue")]),
89
+ "jobs/components.json": componentIr([{ name: "queue-runner", composites: ["WorkQueue", "CacheCluster"] }]),
90
+ "docs/README.md": "",
91
+ ".gitignore": "node_modules\n",
92
+ "node_modules/.bin/chant": { text: FAKE_FILE_GRAPH_CHANT, mode: 0o755 },
93
+ });
94
+ }
95
+
96
+ describe("composites output schema", () => {
97
+ test("is a valid draft 2020-12 document with the published $id", () => {
98
+ expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
99
+ expect(validSchema(schema)).toBe(true);
100
+ expect(schema.$id).toBe(COMPOSITES_OUTPUT_SCHEMA_ID);
101
+ expect(COMPOSITES_CONTRACT_VERSION).toBe(1);
102
+ });
103
+
104
+ test("lists exactly the reason and error codes the code can return", () => {
105
+ expect(schema.$defs.reason.properties.code.enum).toEqual([...COMPOSITES_REASON_CODES]);
106
+ expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...COMPOSITES_ERROR_CODES]);
107
+ expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
108
+ expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
109
+ });
110
+ });
111
+
112
+ describe("the join", () => {
113
+ const instances = compositeInstances({
114
+ nodes: [
115
+ { ...composite("a/xBucket", "LoomBackend", "a/x"), member: "a" },
116
+ { ...composite("a/xRole", "OperatorRole", "a/x", "gcp"), member: "a" },
117
+ { ...composite("a/billingTable", "Table", "a/billing"), member: "a" },
118
+ ],
119
+ });
120
+ const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null });
121
+
122
+ test("an instance carries every kind and lexicon of its nodes", () => {
123
+ expect(instances.map((i) => [i.id, i.instance, i.kinds, i.lexicons])).toEqual([
124
+ ["a/billing", "billing", ["Table"], ["aws"]],
125
+ ["a/x", "x", ["LoomBackend", "OperatorRole"], ["aws", "gcp"]],
126
+ ]);
127
+ });
128
+
129
+ test("declared composites match exactly, and stop name matching for that component", () => {
130
+ const rows = joinComponents(instances, [component("a", "loom-backend", ["Table"]), component("a", "other", ["LoomBackend"])], []);
131
+ expect(rows.find((r) => r.id === "a/x")!.components).toEqual([{ component: "a/other", by: "composites", against: "kind", value: "LoomBackend", label: "exact", via: "member" }]);
132
+ expect(rows.find((r) => r.id === "a/billing")!.components.map((m) => m.component)).toEqual(["a/loom-backend"]);
133
+ });
134
+
135
+ test("a component with no composites matches a kind by joinKey, then the instance's name", () => {
136
+ const rows = joinComponents(instances, [component("a", "loom-backend"), component("a", "Billing"), component("a", "nothing")], []);
137
+ expect(rows.find((r) => r.id === "a/x")!.components).toEqual([{ component: "a/loom-backend", by: "name", against: "kind", value: "LoomBackend", label: "folded", via: "member" }]);
138
+ expect(rows.find((r) => r.id === "a/billing")!.components).toEqual([{ component: "a/Billing", by: "name", against: "instance", value: "billing", label: "folded", via: "member" }]);
139
+ });
140
+
141
+ test("across members, a resolved link from the component's member makes it via link", () => {
142
+ const link = (status: "resolved" | "missing") => ({ consumer: "b", producer: "a", output: "O", kind: "output", origin: "declared" as const, label: "exact" as const, input: null, resolves: "source" as const, status, reason: null });
143
+ const c = [component("b", "table-deployer", ["Table"])];
144
+ expect(joinComponents(instances, c, [link("resolved")]).find((r) => r.id === "a/billing")!.components[0].via).toBe("link");
145
+ expect(joinComponents(instances, c, [link("missing")]).find((r) => r.id === "a/billing")!.components[0].via).toBe("unlinked");
146
+ expect(joinComponents(instances, c, []).find((r) => r.id === "a/x")!.components).toEqual([]);
147
+ });
148
+ });
149
+
150
+ describe("chant workspace graph --composites on a built workspace", () => {
151
+ test("lists each instance with the components that can deploy it, and an empty set for the gap", async () => {
152
+ const { doc, failed } = await workspaceComposites({ cwd: fixture() });
153
+ const g = result(doc);
154
+ expectValid(g);
155
+ expect(failed).toBe(false);
156
+ expect(g.members.map((m) => [m.name, m.status, m.reason?.code ?? null])).toEqual([
157
+ ["app", "read", null],
158
+ ["delivery", "read", null],
159
+ ["jobs", "read", null],
160
+ ["docs", "skipped", "kind-not-run"],
161
+ ]);
162
+ expect(g.components).toEqual([
163
+ { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts" },
164
+ { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts" },
165
+ { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts" },
166
+ ]);
167
+ const rows = Object.fromEntries(g.composites.map((c) => [c.id, c]));
168
+ expect(Object.keys(rows)).toEqual(["app/backend", "app/cache", "app/site", "jobs/queue"]);
169
+ expect(rows["app/backend"]).toMatchObject({ member: "app", instance: "backend", kinds: ["LoomBackend"], lexicons: ["aws"], nodes: ["app/backendRepo", "app/backendService"] });
170
+ expect(rows["app/backend"].components).toEqual([{ component: "delivery/loom-backend", by: "name", against: "kind", value: "LoomBackend", label: "folded", via: "link" }]);
171
+ expect(rows["app/site"].components).toEqual([{ component: "delivery/edge", by: "composites", against: "kind", value: "StaticSite", label: "exact", via: "link" }]);
172
+ // jobs does not link to app: the match is data, marked as crossing no link.
173
+ expect(rows["app/cache"].components).toEqual([{ component: "jobs/queue-runner", by: "composites", against: "kind", value: "CacheCluster", label: "exact", via: "unlinked" }]);
174
+ expect(rows["jobs/queue"].components.map((m) => [m.component, m.via])).toEqual([["jobs/queue-runner", "member"]]);
175
+ expect(g.reasons).toEqual([]);
176
+ expect(g.summary).toEqual({ composites: 4, withComponent: 4, withoutComponent: 0, components: 3 });
177
+ });
178
+
179
+ test("an instance no component names is listed with an empty set", async () => {
180
+ const root = fixture();
181
+ writeFileSync(join(root, "jobs", "components.json"), componentIr([]));
182
+ const g = result((await workspaceComposites({ cwd: root })).doc);
183
+ expectValid(g);
184
+ expect(g.composites.find((c) => c.id === "app/cache")!.components).toEqual([]);
185
+ expect(g.composites.find((c) => c.id === "jobs/queue")!.components).toEqual([]);
186
+ expect(g.summary).toMatchObject({ withComponent: 2, withoutComponent: 2 });
187
+ });
188
+
189
+ test("--at <rev> reads each member as it was at the revision", async () => {
190
+ const root = fixture();
191
+ const first = commitAll(root, "one");
192
+ writeFileSync(join(root, "delivery", "components.json"), componentIr([]));
193
+ const now = result((await workspaceComposites({ cwd: root })).doc);
194
+ expect(now.components.map((c) => c.id)).toEqual(["jobs/queue-runner"]);
195
+ const at = result((await workspaceComposites({ cwd: root, at: first })).doc);
196
+ expectValid(at);
197
+ expect(at.at).toBe(first);
198
+ expect(at.components.map((c) => c.id)).toEqual(["delivery/edge", "delivery/loom-backend", "jobs/queue-runner"]);
199
+ });
200
+
201
+ test("a member whose component graph fails is listed as failed, and the rest still join", async () => {
202
+ const root = fixture();
203
+ writeFileSync(join(root, "jobs", "components.json"), "FAIL\n");
204
+ const { doc, failed } = await workspaceComposites({ cwd: root });
205
+ const g = result(doc);
206
+ expectValid(g);
207
+ expect(failed).toBe(true);
208
+ expect(g.members.find((m) => m.name === "jobs")).toMatchObject({ status: "failed", reason: { code: "command-failed" } });
209
+ expect(g.members.find((m) => m.name === "jobs")!.reason!.message).toContain("chant graph --components exited 1");
210
+ expect(g.composites.find((c) => c.id === "jobs/queue")!.components).toEqual([]);
211
+ expect(g.components.map((c) => c.id)).toEqual(["delivery/edge", "delivery/loom-backend"]);
212
+ });
213
+ });
214
+
215
+ describe("why the list is empty", () => {
216
+ test("a workspace with no chant member says so", async () => {
217
+ const root = repo({ "chant.workspace.json": declaration([{ name: "docs", dir: "docs", kind: "other", because: "prose" }]), "docs/README.md": "" });
218
+ const g = result((await workspaceComposites({ cwd: root })).doc);
219
+ expectValid(g);
220
+ expect(g.composites).toEqual([]);
221
+ expect(g.reasons.map((r) => r.code)).toEqual(["composites-no-chant-member"]);
222
+ });
223
+
224
+ test("members with no composite and no component say both", async () => {
225
+ const root = repo({
226
+ "chant.workspace.json": declaration([{ name: "api", dir: "api", kind: "chant" }]),
227
+ "api/chant.config.ts": "",
228
+ "api/ir.json": ir([{ id: "Queue", kind: "Queue", lexicon: "aws", attrs: {} }]),
229
+ ".gitignore": "node_modules\n",
230
+ "node_modules/.bin/chant": { text: FAKE_FILE_GRAPH_CHANT, mode: 0o755 },
231
+ });
232
+ const g = result((await workspaceComposites({ cwd: root })).doc);
233
+ expectValid(g);
234
+ expect(g.reasons.map((r) => r.code)).toEqual(["composites-none-declared", "composites-no-component"]);
235
+ expect(g.summary).toEqual({ composites: 0, withComponent: 0, withoutComponent: 0, components: 0 });
236
+ });
237
+
238
+ test("a declaration that can't be read is a failure document with a closed code", async () => {
239
+ const { doc, failed } = await workspaceComposites({ cwd: repo({}) });
240
+ expectValid(doc);
241
+ expect(failed).toBe(true);
242
+ expect("error" in doc && doc.error.code).toBe("declaration-missing");
243
+ });
244
+ });
@@ -0,0 +1,295 @@
1
+ /**
2
+ * `chant workspace graph --composites` (#2662): each composite instance the
3
+ * workspace's members declare, joined to the components that can deploy it.
4
+ *
5
+ * The composed graph already knows which composite instances each member
6
+ * declares (a node's `compositeInstance` and `compositeParent`), and a
7
+ * member's `chant graph --components --format ir` knows its components and
8
+ * the composite kinds each names in its contract's `composites`. This module
9
+ * joins the two and prints the rows. chant supplies the data and never the
10
+ * choice (ws-052): the document says how each component matched, and a
11
+ * reader decides what to offer.
12
+ *
13
+ * A component matches an instance in one of two ways:
14
+ *
15
+ * - `composites`: the component's contract lists one of the instance's
16
+ * composite kinds, compared exactly.
17
+ * - `name`: the component declares no `composites`, and its `name` joins the
18
+ * instance's kind or the instance's own name with the core `joinKey()`
19
+ * (`LoomBackend` and `loom-backend` join, labelled `folded`). This is the
20
+ * naming convention the component contract documents for a component that
21
+ * says nothing (#1492).
22
+ *
23
+ * Every match says how it crosses members (`via`): `member` when the
24
+ * component and the instance are in the same member, `link` when the
25
+ * component's member reads the instance's member through a resolved member
26
+ * link (#2539), and `unlinked` otherwise. An instance with no component is
27
+ * listed with an empty `components`, so the gap is visible.
28
+ *
29
+ * Each member runs under its own toolchain, as for `workspace graph`, and at
30
+ * the same revision with `--at`. Nothing is written and no listener starts.
31
+ */
32
+
33
+ import { formatError } from "../cli/format";
34
+ import type { CommandContext } from "../cli/registry";
35
+ import { joinLabel, type JoinLabel } from "../join-key";
36
+ import type { ComposedMember, MemberReason, WorkspaceGraph } from "./compose-graph";
37
+ import { readMemberIr } from "./compose-graph";
38
+ import { GRAPH_ERROR_CODES, workspaceGraph, type GraphQuery } from "./graph-cli";
39
+ import type { LinkRow } from "./links";
40
+ import { emitDocument, type UnitResult } from "./member-commands";
41
+ import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
42
+ import type { ReasonCode } from "./reason-codes";
43
+
44
+ /** `$id` of the JSON Schema for the document, shipped beside this file. */
45
+ export const COMPOSITES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/composites/v1/composites.schema.json";
46
+
47
+ /** The read-contract version the document follows. */
48
+ export const COMPOSITES_CONTRACT_VERSION = 1;
49
+
50
+ /** Why the composites couldn't be read at all: the graph's codes. */
51
+ export const COMPOSITES_ERROR_CODES = GRAPH_ERROR_CODES;
52
+
53
+ /** Why the list is empty, or why no instance has a component. Closed: part of the read contract. */
54
+ export const COMPOSITES_REASON_CODES = [
55
+ /** No member of kind chant was read, so nothing declares a composite or a component. */
56
+ "composites-no-chant-member",
57
+ /** The members read declare no composite instance. */
58
+ "composites-none-declared",
59
+ /** The members read declare no component, so no instance has one. */
60
+ "composites-no-component",
61
+ ] as const satisfies readonly ReasonCode[];
62
+ export type CompositesReasonCode = (typeof COMPOSITES_REASON_CODES)[number];
63
+
64
+ export interface CompositesMember {
65
+ name: string;
66
+ dir: string;
67
+ kind: string;
68
+ /** `read` when both its graph and its component graph were read. */
69
+ status: "read" | "skipped" | "failed";
70
+ reason: MemberReason | null;
71
+ chant: string | null;
72
+ }
73
+
74
+ export interface ComponentEntry {
75
+ /** `<member>/<name>`. */
76
+ id: string;
77
+ name: string;
78
+ member: string;
79
+ /** Declared or inferred; null when the member's chant does not print it. */
80
+ archetype: string | null;
81
+ /** The contract's `composites`, or null when it declares none. */
82
+ composites: string[] | null;
83
+ /** The component's file from the workspace root, when the member's chant names it. */
84
+ file: string | null;
85
+ }
86
+
87
+ export interface ComponentMatch {
88
+ /** The {@link ComponentEntry} id. */
89
+ component: string;
90
+ by: "composites" | "name";
91
+ /** What was matched: one of the instance's kinds, or the instance's own name. */
92
+ against: "kind" | "instance";
93
+ value: string;
94
+ label: JoinLabel;
95
+ via: "member" | "link" | "unlinked";
96
+ }
97
+
98
+ export interface CompositeInstanceRow {
99
+ /** `<member>/<instance>`, the composed graph's `compositeInstance`. */
100
+ id: string;
101
+ member: string;
102
+ /** The instance's name in its member: the export name. */
103
+ instance: string;
104
+ /** The composite kinds its nodes came from; more than one when composites nest. */
105
+ kinds: string[];
106
+ lexicons: string[];
107
+ /** The composed ids of its nodes. */
108
+ nodes: string[];
109
+ components: ComponentMatch[];
110
+ }
111
+
112
+ interface Head {
113
+ $schema: string;
114
+ contract: number;
115
+ chant: string;
116
+ }
117
+
118
+ export type CompositesDocument =
119
+ | (Head & {
120
+ at: string | null;
121
+ workspace: { name: string; root: string };
122
+ members: CompositesMember[];
123
+ composites: CompositeInstanceRow[];
124
+ components: ComponentEntry[];
125
+ reasons: { code: CompositesReasonCode; message: string }[];
126
+ summary: { composites: number; withComponent: number; withoutComponent: number; components: number };
127
+ })
128
+ | (Head & { error: { code: WorkspaceErrorCode; message: string; location: ErrorLocation | null } });
129
+
130
+ export interface CompositesResult {
131
+ doc: CompositesDocument;
132
+ failed: boolean;
133
+ }
134
+
135
+ /** Read one member's component graph run into components, or the reason it can't be read. */
136
+ function readComponents(member: string, dir: string, run: UnitResult): { components: ComponentEntry[] } | { reason: MemberReason } {
137
+ if (run.exitCode !== 0) {
138
+ const tail = run.stderr.trim().split("\n").slice(-5).join("\n");
139
+ return { reason: { code: "command-failed", message: `chant graph --components exited ${run.exitCode}${tail ? `: ${tail}` : ""}` } };
140
+ }
141
+ const read = readMemberIr(run.stdout);
142
+ if ("reason" in read) return { reason: { ...read.reason, message: `chant graph --components: ${read.reason.message}` } };
143
+ const components: ComponentEntry[] = [];
144
+ for (const n of read.ir.nodes) {
145
+ if (n.kind !== "Component") continue;
146
+ const attrs = n.attrs ?? {};
147
+ const composites = Array.isArray(attrs.composites) ? (attrs.composites as unknown[]).filter((c): c is string => typeof c === "string") : null;
148
+ const file = n.sourceLoc?.file;
149
+ components.push({
150
+ id: `${member}/${n.id}`,
151
+ name: n.id,
152
+ member,
153
+ archetype: typeof attrs.archetype === "string" ? attrs.archetype : null,
154
+ composites: composites && composites.length > 0 ? composites : null,
155
+ file: file ? (dir === "." ? file : `${dir}/${file}`) : null,
156
+ });
157
+ }
158
+ return { components };
159
+ }
160
+
161
+ /** The composite instances in a composed graph, without their components. */
162
+ export function compositeInstances(graph: Pick<WorkspaceGraph, "nodes">): CompositeInstanceRow[] {
163
+ const byId = new Map<string, { member: string; kinds: Set<string>; lexicons: Set<string>; nodes: string[] }>();
164
+ for (const n of graph.nodes) {
165
+ if (!n.compositeInstance) continue;
166
+ const row = byId.get(n.compositeInstance) ?? { member: n.member, kinds: new Set(), lexicons: new Set(), nodes: [] };
167
+ if (n.compositeParent) row.kinds.add(n.compositeParent);
168
+ row.lexicons.add(n.lexicon);
169
+ row.nodes.push(n.id);
170
+ byId.set(n.compositeInstance, row);
171
+ }
172
+ return [...byId.entries()]
173
+ .sort(([a], [b]) => a.localeCompare(b))
174
+ .map(([id, r]) => ({
175
+ id,
176
+ member: r.member,
177
+ instance: id.slice(r.member.length + 1),
178
+ kinds: [...r.kinds].sort(),
179
+ lexicons: [...r.lexicons].sort(),
180
+ nodes: r.nodes.sort(),
181
+ components: [],
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * Join instances to components. `links` are the composed graph's member link
187
+ * rows: a component crosses to another member's instance `via: "link"` when
188
+ * its member is the consumer of a resolved link whose producer is the
189
+ * instance's member.
190
+ */
191
+ export function joinComponents(instances: CompositeInstanceRow[], components: ComponentEntry[], links: WorkspaceGraph["links"]): CompositeInstanceRow[] {
192
+ const linked = new Set<string>();
193
+ for (const r of links) {
194
+ if ("consumer" in r && "producer" in r && (r as LinkRow).status === "resolved") linked.add(`${r.consumer}\u0000${(r as LinkRow).producer}`);
195
+ }
196
+ const via = (c: ComponentEntry, i: CompositeInstanceRow): ComponentMatch["via"] =>
197
+ c.member === i.member ? "member" : linked.has(`${c.member}\u0000${i.member}`) ? "link" : "unlinked";
198
+
199
+ return instances.map((i) => {
200
+ const matches: ComponentMatch[] = [];
201
+ for (const c of components) {
202
+ if (c.composites) {
203
+ const kind = i.kinds.find((k) => c.composites!.includes(k));
204
+ if (kind) matches.push({ component: c.id, by: "composites", against: "kind", value: kind, label: "exact", via: via(c, i) });
205
+ continue;
206
+ }
207
+ let match: Omit<ComponentMatch, "component" | "via"> | undefined;
208
+ for (const k of i.kinds) {
209
+ const label = joinLabel(c.name, k);
210
+ if (label && (!match || (label === "exact" && match.label !== "exact"))) match = { by: "name", against: "kind", value: k, label };
211
+ }
212
+ if (!match) {
213
+ const label = joinLabel(c.name, i.instance);
214
+ if (label) match = { by: "name", against: "instance", value: i.instance, label };
215
+ }
216
+ if (match) matches.push({ component: c.id, ...match, via: via(c, i) });
217
+ }
218
+ matches.sort((a, b) => a.component.localeCompare(b.component));
219
+ return { ...i, components: matches };
220
+ });
221
+ }
222
+
223
+ function memberEntry(m: ComposedMember, componentReason: MemberReason | null): CompositesMember {
224
+ const reason = m.reason ?? componentReason;
225
+ const status = m.status === "composed" ? (componentReason ? "failed" : "read") : m.status;
226
+ return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant };
227
+ }
228
+
229
+ /** Read the workspace's composites and components, and build the document. Never throws a `WorkspaceReadError`. */
230
+ export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "components">): Promise<CompositesResult> {
231
+ const head: Head = { $schema: COMPOSITES_OUTPUT_SCHEMA_ID, contract: COMPOSITES_CONTRACT_VERSION, chant: readerVersion() };
232
+ const { doc: graph, failed, components: runs } = await workspaceGraph({ ...query, components: true });
233
+ if ("error" in graph) return { doc: { ...head, error: graph.error }, failed: true };
234
+
235
+ const byMember = new Map((runs ?? []).map((r) => [r.unit.member, r]));
236
+ const members: CompositesMember[] = [];
237
+ const components: ComponentEntry[] = [];
238
+ let componentsFailed = false;
239
+ for (const m of graph.members) {
240
+ const run = m.status === "composed" ? byMember.get(m.name) : undefined;
241
+ let reason: MemberReason | null = null;
242
+ if (run) {
243
+ const read = readComponents(m.name, m.dir, run);
244
+ if ("reason" in read) {
245
+ reason = read.reason;
246
+ componentsFailed = true;
247
+ } else components.push(...read.components);
248
+ }
249
+ members.push(memberEntry(m, reason));
250
+ }
251
+ components.sort((a, b) => a.id.localeCompare(b.id));
252
+
253
+ const composites = joinComponents(compositeInstances(graph), components, graph.links);
254
+ const read = members.filter((m) => m.status === "read");
255
+ const reasons: { code: CompositesReasonCode; message: string }[] = [];
256
+ if (!members.some((m) => m.kind === "chant" && m.status !== "skipped")) {
257
+ reasons.push({ code: "composites-no-chant-member", message: "no member of kind chant was read, so nothing declares a composite or a component" });
258
+ } else if (read.length > 0) {
259
+ const names = read.map((m) => m.name).join(", ");
260
+ if (composites.length === 0) reasons.push({ code: "composites-none-declared", message: `the members read (${names}) declare no composite instance` });
261
+ if (components.length === 0) reasons.push({ code: "composites-no-component", message: `the members read (${names}) declare no component in a *.component.ts file` });
262
+ }
263
+ const withComponent = composites.filter((c) => c.components.length > 0).length;
264
+ return {
265
+ doc: {
266
+ ...head,
267
+ at: graph.at,
268
+ workspace: graph.workspace,
269
+ members,
270
+ composites,
271
+ components,
272
+ reasons,
273
+ summary: { composites: composites.length, withComponent, withoutComponent: composites.length - withComponent, components: components.length },
274
+ },
275
+ failed: failed || componentsFailed,
276
+ };
277
+ }
278
+
279
+ /** `chant workspace graph --composites`: print the document as JSON, always, like the composed graph. */
280
+ export async function runWorkspaceComposites(ctx: CommandContext, cwd: string): Promise<number> {
281
+ const { args } = ctx;
282
+ const { doc, failed } = await workspaceComposites({
283
+ cwd,
284
+ at: args.at,
285
+ members: args.members,
286
+ args,
287
+ onStderr: (text) => process.stderr.write(text),
288
+ });
289
+ emitDocument(doc, args.output);
290
+ if ("error" in doc) {
291
+ const l = doc.error.location;
292
+ console.error(formatError({ message: `${doc.error.code}: ${l ? `${l.file}:${l.line}:${l.column}: ` : ""}${doc.error.message}` }));
293
+ }
294
+ return failed ? 1 : 0;
295
+ }
@@ -5,6 +5,11 @@
5
5
  * document (`compose-graph.ts`). With `--kind`, the records of that kind and
6
6
  * their asset and constrains links join it (#2549, `record-assets.ts`).
7
7
  *
8
+ * With `--intent <path[:start-end]>` it prints the intent graph over one
9
+ * region instead (#2651, `intent.ts`), a document of its own in the read
10
+ * contract. With `--composites` it prints each composite instance with the
11
+ * components that can deploy it (#2662, `composites.ts`), another.
12
+ *
8
13
  * The document is part of the read contract, described by `graph.schema.json`
9
14
  * beside this file. It is printed for a failure too, with the error's reason
10
15
  * code, so a reader always has JSON to parse.
@@ -43,7 +48,8 @@ export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/worksp
43
48
  /** Why the graph couldn't be read at all: the declaration's codes, `--at`'s included. */
44
49
  export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
45
50
 
46
- const USAGE = "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]";
51
+ const USAGE =
52
+ "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
47
53
 
48
54
  interface Head {
49
55
  $schema: string;
@@ -73,12 +79,25 @@ export interface GraphQuery {
73
79
  * rows of `links`.
74
80
  */
75
81
  kind?: string;
82
+ /**
83
+ * Also run each member's `chant graph --components --format ir` (#2662),
84
+ * under the same toolchain and at the same revision, and return the answers
85
+ * in {@link GraphResult.components}. The document is unchanged.
86
+ */
87
+ components?: boolean;
76
88
  }
77
89
 
78
90
  export interface GraphResult {
79
91
  doc: GraphDocument;
80
92
  /** A member failed or couldn't be read, or the declaration couldn't be read. */
81
93
  failed: boolean;
94
+ /** With {@link GraphQuery.components}: each member's component graph run, in plan order. */
95
+ components?: UnitResult[];
96
+ }
97
+
98
+ /** The command line a member runs for its component graph (#2662). */
99
+ export function componentGraphArgv(args: Partial<ParsedArgs>): string[] {
100
+ return ["graph", ".", "--components", "--format", "ir", ...(args.env ? ["--env", args.env] : [])];
82
101
  }
83
102
 
84
103
  /**
@@ -174,7 +193,12 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
174
193
  exported = exportRevision(located, plan.groups.flatMap((g) => g.units.map((u) => u.dir)));
175
194
  plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported) });
176
195
  }
177
- const results = await executePlan(plan, (query.args ?? {}) as ParsedArgs);
196
+ const args = (query.args ?? {}) as ParsedArgs;
197
+ const [results, components] = await Promise.all([
198
+ executePlan(plan, args),
199
+ query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
200
+ ]);
201
+ for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
178
202
  for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
179
203
  const { inputs, failed } = compose(plan, results, query.members, declaration.members);
180
204
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
@@ -196,7 +220,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
196
220
  query.onStderr?.(`${formatError({ message: `--kind ${query.kind}: ${err.code}: ${err.message}`, hint: USAGE })}\n`);
197
221
  }
198
222
  }
199
- return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed };
223
+ return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed, ...(components ? { components } : {}) };
200
224
  } catch (err) {
201
225
  if (!(err instanceof WorkspaceReadError)) throw err;
202
226
  return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
@@ -216,6 +240,18 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
216
240
  const handed = await handToRootChant(cwd, args.at);
217
241
  if (handed !== undefined) return handed;
218
242
 
243
+ // Composite instances joined to components (#2662) are their own document.
244
+ if (args.composites) {
245
+ if (args.intent !== undefined || args.kind !== undefined) {
246
+ console.error(formatError({ message: "--composites takes neither --intent nor --kind", hint: USAGE }));
247
+ return 1;
248
+ }
249
+ if (!args.dryRun) return (await import("./composites")).runWorkspaceComposites(ctx, cwd);
250
+ }
251
+
252
+ // The intent graph over one region (#2651) is its own document.
253
+ if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
254
+
219
255
  if (args.dryRun) {
220
256
  let plan: MemberPlan;
221
257
  try {