@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts +2 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +1 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/composites.d.ts +44 -0
- package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
- package/dist/cli/mcp/tools/search.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +139 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +16 -1
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +21 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -0
- package/dist/workspace/intent-joins.d.ts +121 -0
- package/dist/workspace/intent-joins.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +310 -0
- package/dist/workspace/intent.d.ts.map +1 -0
- package/dist/workspace/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +7 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +20 -0
- package/src/cli/main.ts +18 -0
- package/src/cli/mcp/resource-handlers.ts +17 -0
- package/src/cli/mcp/server.test.ts +140 -4
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp/tools/composites.ts +98 -0
- package/src/cli/mcp/tools/search.ts +47 -5
- package/src/cli/registry.ts +6 -0
- package/src/components/cli-support.test.ts +16 -0
- package/src/components/cli-support.ts +8 -2
- package/src/composite.ts +9 -0
- package/src/lexicon.ts +47 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -0
- package/src/workspace/composites.schema.json +471 -0
- package/src/workspace/composites.test.ts +244 -0
- package/src/workspace/composites.ts +295 -0
- package/src/workspace/graph-cli.ts +39 -3
- package/src/workspace/intent-cli.ts +119 -0
- package/src/workspace/intent-joins.ts +210 -0
- package/src/workspace/intent.schema.json +1141 -0
- package/src/workspace/intent.test.ts +566 -0
- package/src/workspace/intent.ts +999 -0
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +46 -3
- package/src/workspace/reason-codes.test.ts +39 -2
- package/src/workspace/reason-codes.ts +40 -0
- package/src/workspace/records-contract.test.ts +22 -2
- package/src/workspace/records.schema.json +2 -2
- package/src/workspace/records.test.ts +93 -0
- 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 =
|
|
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
|
|
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 {
|