@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
package/src/cli/main.test.ts
CHANGED
|
@@ -688,6 +688,26 @@ describe("workspace records (#2546)", () => {
|
|
|
688
688
|
});
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
+
describe("workspace graph --composites (#2662)", () => {
|
|
692
|
+
test("parses as a boolean on workspace graph", () => {
|
|
693
|
+
const args = parseArgs(["workspace", "graph", "--composites", "--at", "HEAD", "--json"]);
|
|
694
|
+
expect(args).toMatchObject({ command: "workspace", path: "graph", composites: true, at: "HEAD", json: true });
|
|
695
|
+
expect(() => parseArgs(["workspace", "graph", "--composites=yes"])).toThrow();
|
|
696
|
+
expect(resolveCommand(args, commandRegistry)?.def.name).toBe("workspace graph");
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("workspace graph --intent (#2651)", () => {
|
|
701
|
+
test("parses the region and every --kind, in order", () => {
|
|
702
|
+
const args = parseArgs(["workspace", "graph", "--intent", "app/server.mjs:3-7", "--kind", "a.kind.mjs", "--kind", "b.kind.mjs", "--at", "HEAD", "--json"]);
|
|
703
|
+
expect(args).toMatchObject({ command: "workspace", path: "graph", intent: "app/server.mjs:3-7", kinds: ["a.kind.mjs", "b.kind.mjs"], kind: "b.kind.mjs", at: "HEAD", json: true });
|
|
704
|
+
expect(parseArgs(["workspace", "graph", "--intent=app"]).intent).toBe("app");
|
|
705
|
+
expect(() => parseArgs(["workspace", "graph", "--intent"])).toThrow(/--intent needs a region/);
|
|
706
|
+
expect(() => parseArgs(["workspace", "graph", "--intent", "--json"])).toThrow(/--intent needs a region/);
|
|
707
|
+
expect(resolveCommand(args, commandRegistry)?.def.name).toBe("workspace graph");
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
691
711
|
describe("workspace init and ls (#2534)", () => {
|
|
692
712
|
test("resolve to their commands in the one workspace group, with the directory as extra positional", () => {
|
|
693
713
|
const ls = parseArgs(["workspace", "ls", "examples", "--at", "HEAD", "--json"]);
|
package/src/cli/main.ts
CHANGED
|
@@ -69,6 +69,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
69
69
|
"--strict",
|
|
70
70
|
"--validate",
|
|
71
71
|
"--use-composites",
|
|
72
|
+
"--composites",
|
|
72
73
|
"--stacks",
|
|
73
74
|
"--components",
|
|
74
75
|
"--up",
|
|
@@ -363,6 +364,15 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
363
364
|
// `chant workspace records|graph|check --kind <kind file>` (#2546, #2549)
|
|
364
365
|
result.kind = args[++i];
|
|
365
366
|
if (!result.kind || result.kind.startsWith("-")) throw new Error("--kind needs a kind file: --kind <path>");
|
|
367
|
+
// Repeatable for `workspace graph --intent` (#2651); the others read the last one.
|
|
368
|
+
(result.kinds ??= []).push(result.kind);
|
|
369
|
+
} else if (arg === "--composites") {
|
|
370
|
+
// `chant workspace graph --composites` (#2662)
|
|
371
|
+
result.composites = true;
|
|
372
|
+
} else if (arg === "--intent") {
|
|
373
|
+
// `chant workspace graph --intent <path[:start-end]>` (#2651)
|
|
374
|
+
result.intent = args[++i];
|
|
375
|
+
if (!result.intent || result.intent.startsWith("-")) throw new Error("--intent needs a region: --intent <path[:start-end]>");
|
|
366
376
|
} else if (arg === "--current") {
|
|
367
377
|
result.current = true;
|
|
368
378
|
} else if (arg === "--require") {
|
|
@@ -767,6 +777,14 @@ Workspace (level 1, #2524):
|
|
|
767
777
|
read-contract document. --at <rev> runs each member's
|
|
768
778
|
source as it was at that commit; --kind adds the
|
|
769
779
|
records' asset and constrains links
|
|
780
|
+
workspace graph --composites [--at <rev>] [--member <name>] [-o <file>]
|
|
781
|
+
Each composite instance the members declare, with the
|
|
782
|
+
components whose contract can deploy it; an instance
|
|
783
|
+
with none lists an empty set
|
|
784
|
+
workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]
|
|
785
|
+
The intent graph over one region: the commits that
|
|
786
|
+
touched it, the decisions whose constrains cover it,
|
|
787
|
+
the artifacts they pin, and findings with closed codes
|
|
770
788
|
|
|
771
789
|
Lifecycle (alias: lc):
|
|
772
790
|
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolve, join, dirname } from "node:path";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import type { ResourceDefinition } from "./types";
|
|
4
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
5
|
+
import { collectComposites } from "./tools/composites";
|
|
4
6
|
import { getContext } from "./resources/context";
|
|
5
7
|
import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
|
|
6
8
|
import { discoverOps } from "../../op/discover";
|
|
@@ -44,6 +46,12 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
|
|
|
44
46
|
description: "Latest run state for a named Op",
|
|
45
47
|
mimeType: "application/json",
|
|
46
48
|
},
|
|
49
|
+
{
|
|
50
|
+
uri: "chant://composites",
|
|
51
|
+
name: "Composite catalog",
|
|
52
|
+
description: "Every composite the loaded lexicons export, with what each bundles and its parameters (#2662)",
|
|
53
|
+
mimeType: "application/json",
|
|
54
|
+
},
|
|
47
55
|
{
|
|
48
56
|
uri: "chant://knowledge",
|
|
49
57
|
name: "Knowledge bundle",
|
|
@@ -99,9 +107,18 @@ export function collectExamples(
|
|
|
99
107
|
export async function handleResourcesRead(
|
|
100
108
|
params: Record<string, unknown>,
|
|
101
109
|
pluginResources: Map<string, PluginResourceEntry>,
|
|
110
|
+
plugins: LexiconPlugin[] = [],
|
|
102
111
|
): Promise<unknown> {
|
|
103
112
|
const uri = params.uri as string;
|
|
104
113
|
|
|
114
|
+
// The whole composite catalog (#2662), static data from each lexicon's
|
|
115
|
+
// `composites()`; the `composites` tool filters the same list.
|
|
116
|
+
if (uri === "chant://composites") {
|
|
117
|
+
return {
|
|
118
|
+
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(collectComposites(plugins), null, 2) }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
if (uri === "chant://context") {
|
|
106
123
|
return {
|
|
107
124
|
contents: [
|
|
@@ -778,6 +778,142 @@ describe("McpServer", () => {
|
|
|
778
778
|
});
|
|
779
779
|
});
|
|
780
780
|
|
|
781
|
+
// -----------------------------------------------------------------------
|
|
782
|
+
// Composite catalog (#2662)
|
|
783
|
+
// -----------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
describe("composite catalog", () => {
|
|
786
|
+
const awsLike = createMockPlugin({
|
|
787
|
+
name: "aws",
|
|
788
|
+
composites: () => [
|
|
789
|
+
{
|
|
790
|
+
name: "LambdaSqs",
|
|
791
|
+
lexicon: "aws",
|
|
792
|
+
description: "A Lambda function fed by an SQS queue through an event source mapping.",
|
|
793
|
+
bundles: ["EventSourceMapping", "Function", "Queue", "Role"],
|
|
794
|
+
params: [{ name: "queueName", type: "Value<string>", required: false }],
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "VpcDefault",
|
|
798
|
+
lexicon: "aws",
|
|
799
|
+
description: "A VPC with public and private subnets.",
|
|
800
|
+
bundles: ["Subnet", "Vpc"],
|
|
801
|
+
params: [{ name: "cidr", type: "string", required: false }],
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
mcpResources: () => [
|
|
805
|
+
{
|
|
806
|
+
uri: "resource-catalog",
|
|
807
|
+
name: "Catalog",
|
|
808
|
+
description: "Resource catalog",
|
|
809
|
+
mimeType: "application/json",
|
|
810
|
+
handler: async () => JSON.stringify([
|
|
811
|
+
{ className: "Queue", resourceType: "AWS::SQS::Queue", kind: "resource" },
|
|
812
|
+
]),
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
const k8sLike = createMockPlugin({
|
|
817
|
+
name: "k8s",
|
|
818
|
+
composites: () => [
|
|
819
|
+
{
|
|
820
|
+
name: "WebApp",
|
|
821
|
+
lexicon: "k8s",
|
|
822
|
+
description: "A Deployment and Service.",
|
|
823
|
+
bundles: ["Deployment", "Service"],
|
|
824
|
+
params: [{ name: "image", type: "string", required: true }],
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
});
|
|
828
|
+
const noComposites = createMockPlugin({ name: "cedar" });
|
|
829
|
+
|
|
830
|
+
async function call(s: McpServer, name: string, args: Record<string, unknown>) {
|
|
831
|
+
const response = await s.handleRequest({
|
|
832
|
+
jsonrpc: "2.0",
|
|
833
|
+
id: 1,
|
|
834
|
+
method: "tools/call",
|
|
835
|
+
params: { name, arguments: args },
|
|
836
|
+
});
|
|
837
|
+
return JSON.parse((response.result as { content: Array<{ text: string }> }).content[0].text);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
test("the composites tool answers what a lexicon has, with bundles and params", async () => {
|
|
841
|
+
const s = new McpServer([awsLike, k8sLike, noComposites]);
|
|
842
|
+
const parsed = await call(s, "composites", { lexicon: "aws" });
|
|
843
|
+
expect(parsed.total).toBe(2);
|
|
844
|
+
expect(parsed.composites.map((c: { name: string }) => c.name)).toEqual(["LambdaSqs", "VpcDefault"]);
|
|
845
|
+
expect(parsed.composites[0].bundles).toContain("Queue");
|
|
846
|
+
expect(parsed.composites[0].params[0].name).toBe("queueName");
|
|
847
|
+
expect(parsed.lexicons).toEqual(["aws", "k8s", "cedar"]);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("with no filter it lists every lexicon's composites; a lexicon with none adds nothing", async () => {
|
|
851
|
+
const s = new McpServer([awsLike, k8sLike, noComposites]);
|
|
852
|
+
const parsed = await call(s, "composites", {});
|
|
853
|
+
expect(parsed.total).toBe(3);
|
|
854
|
+
expect((await call(s, "composites", { lexicon: "cedar" })).total).toBe(0);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("query matches a bundled kind, and a name match ranks first", async () => {
|
|
858
|
+
const s = new McpServer([awsLike, k8sLike]);
|
|
859
|
+
const byKind = await call(s, "composites", { query: "queue" });
|
|
860
|
+
expect(byKind.composites.map((c: { name: string }) => c.name)).toEqual(["LambdaSqs"]);
|
|
861
|
+
const byName = await call(s, "composites", { query: "vpc" });
|
|
862
|
+
expect(byName.composites[0].name).toBe("VpcDefault");
|
|
863
|
+
expect((await call(s, "composites", { limit: 1 })).composites).toHaveLength(1);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("chant://composites lists the whole catalog", async () => {
|
|
867
|
+
const s = new McpServer([awsLike, k8sLike, noComposites]);
|
|
868
|
+
const list = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "resources/list" });
|
|
869
|
+
const uris = (list.result as { resources: Array<{ uri: string }> }).resources.map((r) => r.uri);
|
|
870
|
+
expect(uris).toContain("chant://composites");
|
|
871
|
+
|
|
872
|
+
const read = await s.handleRequest({
|
|
873
|
+
jsonrpc: "2.0",
|
|
874
|
+
id: 2,
|
|
875
|
+
method: "resources/read",
|
|
876
|
+
params: { uri: "chant://composites" },
|
|
877
|
+
});
|
|
878
|
+
const contents = (read.result as { contents: Array<{ mimeType: string; text: string }> }).contents;
|
|
879
|
+
expect(contents[0].mimeType).toBe("application/json");
|
|
880
|
+
const catalog = JSON.parse(contents[0].text);
|
|
881
|
+
expect(catalog.map((c: { lexicon: string; name: string }) => `${c.lexicon}:${c.name}`)).toEqual([
|
|
882
|
+
"aws:LambdaSqs", "aws:VpcDefault", "k8s:WebApp",
|
|
883
|
+
]);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("chant://composites is an empty list with no plugins", async () => {
|
|
887
|
+
const read = await new McpServer().handleRequest({
|
|
888
|
+
jsonrpc: "2.0",
|
|
889
|
+
id: 1,
|
|
890
|
+
method: "resources/read",
|
|
891
|
+
params: { uri: "chant://composites" },
|
|
892
|
+
});
|
|
893
|
+
const contents = (read.result as { contents: Array<{ text: string }> }).contents;
|
|
894
|
+
expect(JSON.parse(contents[0].text)).toEqual([]);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test("search finds a resource type and the composite that bundles it", async () => {
|
|
898
|
+
const s = new McpServer([awsLike, k8sLike]);
|
|
899
|
+
const parsed = await call(s, "search", { query: "queue" });
|
|
900
|
+
expect(parsed.total).toBe(2);
|
|
901
|
+
const kinds = parsed.results.map((r: { kind: string }) => r.kind).sort();
|
|
902
|
+
expect(kinds).toEqual(["composite", "resource"]);
|
|
903
|
+
const composite = parsed.results.find((r: { kind: string }) => r.kind === "composite");
|
|
904
|
+
expect(composite).toMatchObject({ name: "LambdaSqs", lexicon: "aws", bundles: expect.arrayContaining(["Queue"]) });
|
|
905
|
+
expect(composite.params).toBeUndefined();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("search's lexicon filter applies to composites too", async () => {
|
|
909
|
+
const s = new McpServer([awsLike, k8sLike]);
|
|
910
|
+
const parsed = await call(s, "search", { query: "deployment", lexicon: "aws" });
|
|
911
|
+
expect(parsed.total).toBe(0);
|
|
912
|
+
const k8s = await call(s, "search", { query: "deployment", lexicon: "k8s" });
|
|
913
|
+
expect(k8s.results.map((r: { name: string }) => r.name)).toEqual(["WebApp"]);
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
781
917
|
// -----------------------------------------------------------------------
|
|
782
918
|
// Scaffold tool with plugins
|
|
783
919
|
// -----------------------------------------------------------------------
|
|
@@ -1466,23 +1602,23 @@ describe("McpServer", () => {
|
|
|
1466
1602
|
|
|
1467
1603
|
const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
1468
1604
|
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
|
1469
|
-
expect(tools).toHaveLength(
|
|
1605
|
+
expect(tools).toHaveLength(14);
|
|
1470
1606
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
1471
|
-
"build", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
|
|
1607
|
+
"build", "composites", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
|
|
1472
1608
|
"op-approve", "op-list", "op-report", "op-run", "op-status",
|
|
1473
1609
|
"scaffold", "search",
|
|
1474
1610
|
]);
|
|
1475
1611
|
|
|
1476
1612
|
const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
|
|
1477
1613
|
const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
|
|
1478
|
-
expect(resources).toHaveLength(
|
|
1614
|
+
expect(resources).toHaveLength(9);
|
|
1479
1615
|
});
|
|
1480
1616
|
|
|
1481
1617
|
test("server with empty plugins array works", async () => {
|
|
1482
1618
|
const s = new McpServer([]);
|
|
1483
1619
|
const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
1484
1620
|
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
|
1485
|
-
expect(tools).toHaveLength(
|
|
1621
|
+
expect(tools).toHaveLength(14);
|
|
1486
1622
|
});
|
|
1487
1623
|
});
|
|
1488
1624
|
});
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { importTool, handleImport } from "./tools/import";
|
|
|
6
6
|
import { explainTool, handleExplain } from "./tools/explain";
|
|
7
7
|
import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
|
|
8
8
|
import { searchTool, createSearchHandler } from "./tools/search";
|
|
9
|
+
import { compositesTool, createCompositesHandler } from "./tools/composites";
|
|
9
10
|
import type { LexiconPlugin } from "../../lexicon";
|
|
10
11
|
import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
|
|
11
12
|
import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
|
|
@@ -90,8 +91,10 @@ export class McpServer {
|
|
|
90
91
|
private tools: Map<string, ToolDefinition> = new Map();
|
|
91
92
|
private toolHandlers: Map<string, ToolHandler> = new Map();
|
|
92
93
|
private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
|
|
94
|
+
private plugins: LexiconPlugin[];
|
|
93
95
|
|
|
94
96
|
constructor(plugins?: LexiconPlugin[]) {
|
|
97
|
+
this.plugins = plugins ?? [];
|
|
95
98
|
// Register core tools
|
|
96
99
|
this.registerTool(buildTool, handleBuild);
|
|
97
100
|
this.registerTool(lintTool, handleLint);
|
|
@@ -99,6 +102,7 @@ export class McpServer {
|
|
|
99
102
|
this.registerTool(explainTool, handleExplain);
|
|
100
103
|
this.registerTool(scaffoldTool, createScaffoldHandler(plugins ?? []));
|
|
101
104
|
this.registerTool(searchTool, createSearchHandler(plugins ?? []));
|
|
105
|
+
this.registerTool(compositesTool, createCompositesHandler(plugins ?? []));
|
|
102
106
|
|
|
103
107
|
// Register state tools
|
|
104
108
|
const snapshot = createSnapshotTool(plugins ?? []);
|
|
@@ -252,7 +256,7 @@ export class McpServer {
|
|
|
252
256
|
return buildResourcesList(this.pluginResources);
|
|
253
257
|
|
|
254
258
|
case "resources/read":
|
|
255
|
-
return handleResourcesRead(params, this.pluginResources);
|
|
259
|
+
return handleResourcesRead(params, this.pluginResources, this.plugins);
|
|
256
260
|
|
|
257
261
|
default:
|
|
258
262
|
throw new Error(`Unknown method: ${method}`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CompositeEntry, LexiconPlugin } from "../../../lexicon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `composites` tool (#2662): what composites the loaded lexicons export,
|
|
5
|
+
* what each one bundles and what it takes. "What composites do you have for
|
|
6
|
+
* aws?" is `{ lexicon: "aws" }`.
|
|
7
|
+
*
|
|
8
|
+
* Everything here reads `LexiconPlugin.composites()`, which is static catalog
|
|
9
|
+
* data. No composite is loaded or called and no provider is asked anything.
|
|
10
|
+
*/
|
|
11
|
+
export const compositesTool = {
|
|
12
|
+
name: "composites",
|
|
13
|
+
description:
|
|
14
|
+
"List the composites the loaded lexicons export: what each builds, the resource kinds it bundles, and its parameters",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object" as const,
|
|
17
|
+
properties: {
|
|
18
|
+
lexicon: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Only composites from this lexicon (e.g. 'aws', 'k8s')",
|
|
21
|
+
},
|
|
22
|
+
query: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Keyword matched against the composite's name, description, bundled resource kinds and parameter names",
|
|
25
|
+
},
|
|
26
|
+
limit: {
|
|
27
|
+
type: "number",
|
|
28
|
+
description: "Maximum number of composites to return (default: 50)",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every composite the plugins contribute, in plugin order. A plugin whose
|
|
36
|
+
* `composites()` throws contributes nothing rather than failing the listing.
|
|
37
|
+
*/
|
|
38
|
+
export function collectComposites(plugins: LexiconPlugin[]): CompositeEntry[] {
|
|
39
|
+
const entries: CompositeEntry[] = [];
|
|
40
|
+
for (const plugin of plugins) {
|
|
41
|
+
let contributed: CompositeEntry[] = [];
|
|
42
|
+
try {
|
|
43
|
+
contributed = plugin.composites?.() ?? [];
|
|
44
|
+
} catch {
|
|
45
|
+
contributed = [];
|
|
46
|
+
}
|
|
47
|
+
for (const entry of contributed) entries.push({ ...entry, lexicon: entry.lexicon || plugin.name });
|
|
48
|
+
}
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How well an entry matches a lowercased keyword: 0 for no match, higher for
|
|
54
|
+
* better. A name match beats a bundled kind, which beats a word in the
|
|
55
|
+
* description or a parameter name, so `queue` lists `LambdaSqs` ahead of a
|
|
56
|
+
* composite that merely mentions queues.
|
|
57
|
+
*/
|
|
58
|
+
export function compositeMatchScore(entry: CompositeEntry, lowerQuery: string): number {
|
|
59
|
+
const name = entry.name.toLowerCase();
|
|
60
|
+
if (name.startsWith(lowerQuery)) return 4;
|
|
61
|
+
if (name.includes(lowerQuery)) return 3;
|
|
62
|
+
if (entry.bundles.some((b) => b.toLowerCase().includes(lowerQuery))) return 2;
|
|
63
|
+
if (entry.description.toLowerCase().includes(lowerQuery)) return 1;
|
|
64
|
+
if (entry.params.some((p) => p.name.toLowerCase().includes(lowerQuery))) return 1;
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createCompositesHandler(
|
|
69
|
+
plugins: LexiconPlugin[],
|
|
70
|
+
): (params: Record<string, unknown>) => Promise<unknown> {
|
|
71
|
+
return async (params) => {
|
|
72
|
+
const lexicon = typeof params.lexicon === "string" && params.lexicon !== "" ? params.lexicon : undefined;
|
|
73
|
+
const query = typeof params.query === "string" && params.query !== "" ? params.query : undefined;
|
|
74
|
+
const limit = typeof params.limit === "number" ? params.limit : 50;
|
|
75
|
+
|
|
76
|
+
let entries = collectComposites(plugins);
|
|
77
|
+
if (lexicon) entries = entries.filter((e) => e.lexicon === lexicon);
|
|
78
|
+
|
|
79
|
+
if (query) {
|
|
80
|
+
const lowerQuery = query.toLowerCase();
|
|
81
|
+
entries = entries
|
|
82
|
+
.map((entry) => ({ entry, score: compositeMatchScore(entry, lowerQuery) }))
|
|
83
|
+
.filter(({ score }) => score > 0)
|
|
84
|
+
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
|
85
|
+
.map(({ entry }) => entry);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...(lexicon ? { lexicon } : {}),
|
|
90
|
+
...(query ? { query } : {}),
|
|
91
|
+
// The loaded lexicons, so an empty answer for `lexicon: "cedar"` reads
|
|
92
|
+
// as "cedar exports none" when cedar is listed, and "not loaded" when not.
|
|
93
|
+
lexicons: plugins.map((p) => p.name),
|
|
94
|
+
total: entries.length,
|
|
95
|
+
composites: entries.slice(0, limit),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { LexiconPlugin } from "../../../lexicon";
|
|
2
|
+
import { collectComposites, compositeMatchScore } from "./composites";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Search tool definition for MCP
|
|
5
6
|
*/
|
|
6
7
|
export const searchTool = {
|
|
7
8
|
name: "search",
|
|
8
|
-
description:
|
|
9
|
+
description:
|
|
10
|
+
"Search the resource catalog and the composite catalog across loaded lexicons by keyword; a composite comes back with kind 'composite'",
|
|
9
11
|
inputSchema: {
|
|
10
12
|
type: "object" as const,
|
|
11
13
|
properties: {
|
|
12
14
|
query: {
|
|
13
15
|
type: "string",
|
|
14
|
-
description:
|
|
16
|
+
description:
|
|
17
|
+
"Search query: matches a resource's type, class name and kind, and a composite's name, description and the resource kinds it bundles",
|
|
15
18
|
},
|
|
16
19
|
lexicon: {
|
|
17
20
|
type: "string",
|
|
@@ -32,6 +35,20 @@ interface CatalogEntry {
|
|
|
32
35
|
kind?: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/** A composite in search results (#2662): `kind` is always `"composite"`. */
|
|
39
|
+
interface CompositeResult {
|
|
40
|
+
kind: "composite";
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
bundles: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SearchResult = (CatalogEntry | CompositeResult) & { lexicon: string; score: number };
|
|
47
|
+
|
|
48
|
+
function sortKey(result: SearchResult): string {
|
|
49
|
+
return "resourceType" in result ? result.resourceType ?? "" : result.name;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Create a search handler with access to loaded plugins
|
|
37
54
|
*/
|
|
@@ -44,7 +61,7 @@ export function createSearchHandler(
|
|
|
44
61
|
const limit = (params.limit as number) ?? 20;
|
|
45
62
|
|
|
46
63
|
const lowerQuery = query.toLowerCase();
|
|
47
|
-
const results:
|
|
64
|
+
const results: SearchResult[] = [];
|
|
48
65
|
|
|
49
66
|
const candidates = lexiconFilter
|
|
50
67
|
? plugins.filter((p) => p.name === lexiconFilter)
|
|
@@ -86,10 +103,35 @@ export function createSearchHandler(
|
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
|
|
89
|
-
//
|
|
106
|
+
// Composites (#2662), so one query finds both a resource type and the
|
|
107
|
+
// composite that bundles it. A name or bundled-kind prefix ranks with the
|
|
108
|
+
// resource prefix matches. The full entry (params included) is the
|
|
109
|
+
// `composites` tool's answer; search keeps a result short.
|
|
110
|
+
for (const entry of collectComposites(candidates)) {
|
|
111
|
+
const score = compositeMatchScore(entry, lowerQuery);
|
|
112
|
+
if (score === 0) continue;
|
|
113
|
+
const isPrefix =
|
|
114
|
+
entry.name.toLowerCase().startsWith(lowerQuery) ||
|
|
115
|
+
entry.bundles.some((b) => b.toLowerCase().startsWith(lowerQuery));
|
|
116
|
+
results.push({
|
|
117
|
+
kind: "composite",
|
|
118
|
+
name: entry.name,
|
|
119
|
+
description: entry.description,
|
|
120
|
+
bundles: entry.bundles,
|
|
121
|
+
lexicon: entry.lexicon,
|
|
122
|
+
score: isPrefix ? 1 : 0,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort: prefix matches first; within a tier composites ahead of resource
|
|
127
|
+
// types, since a handful of composites would otherwise sort behind every
|
|
128
|
+
// `AWS::...` type the same word prefixes; then alphabetical.
|
|
90
129
|
results.sort((a, b) => {
|
|
91
130
|
if (a.score !== b.score) return b.score - a.score;
|
|
92
|
-
|
|
131
|
+
const aComposite = a.kind === "composite" ? 0 : 1;
|
|
132
|
+
const bComposite = b.kind === "composite" ? 0 : 1;
|
|
133
|
+
if (aComposite !== bComposite) return aComposite - bComposite;
|
|
134
|
+
return sortKey(a).localeCompare(sortKey(b));
|
|
93
135
|
});
|
|
94
136
|
|
|
95
137
|
const limited = results.slice(0, limit);
|
package/src/cli/registry.ts
CHANGED
|
@@ -283,6 +283,12 @@ export interface ParsedArgs {
|
|
|
283
283
|
at?: string;
|
|
284
284
|
/** `chant workspace records --kind <path>` (#2546): the record kind file to read records through. */
|
|
285
285
|
kind?: string;
|
|
286
|
+
/** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
|
|
287
|
+
kinds?: string[];
|
|
288
|
+
/** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
|
|
289
|
+
composites?: boolean;
|
|
290
|
+
/** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
|
|
291
|
+
intent?: string;
|
|
286
292
|
/** `chant workspace records --current` (#2546): leave out records a closed record supersedes. */
|
|
287
293
|
current?: boolean;
|
|
288
294
|
/** `chant workspace records|verify --require attested` (#2547): the provenance level a gate requires. */
|
|
@@ -389,6 +389,22 @@ export const backup =
|
|
|
389
389
|
expect(result.composites?.["aws-plane"]).toEqual(["ArtifactBucket", "OperatorRole"]);
|
|
390
390
|
expect(result.composites?.plain).toBeUndefined();
|
|
391
391
|
});
|
|
392
|
+
|
|
393
|
+
test("carries each component's archetype, declared or inferred (#2662)", async () => {
|
|
394
|
+
await writeFile(
|
|
395
|
+
join(testDir, "lib.component.ts"),
|
|
396
|
+
`export const lib = { name: "lib", archetype: "service", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`,
|
|
397
|
+
);
|
|
398
|
+
await writeFile(
|
|
399
|
+
join(testDir, "plain.component.ts"),
|
|
400
|
+
`export const plain = { name: "plain", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "test" }] }] };`,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const result = await computeComponentGraph(testDir);
|
|
404
|
+
|
|
405
|
+
expect(result.success).toBe(true);
|
|
406
|
+
expect(result.archetypes).toEqual({ lib: "service", plain: "infra" });
|
|
407
|
+
});
|
|
392
408
|
});
|
|
393
409
|
|
|
394
410
|
// ── runComponents (#585) ─────────────────────────────────────────────────────
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { lexiconModulePath, lexiconNames } from "../lexicon-module";
|
|
26
26
|
import { discoverComponents } from "./discover";
|
|
27
27
|
import type { BuildParamProvenance } from "../provenance";
|
|
28
|
-
import { projectToJson, type Archetype } from "./component";
|
|
28
|
+
import { inferArchetype, projectToJson, type Archetype } from "./component";
|
|
29
29
|
import {
|
|
30
30
|
resolveComponentGraph,
|
|
31
31
|
runInterpretDriver,
|
|
@@ -152,6 +152,10 @@ export interface ComponentGraphResult {
|
|
|
152
152
|
* entirely rather than defaulting to a guess; a consumer with no entry here
|
|
153
153
|
* keeps applying its own naming-convention default. */
|
|
154
154
|
composites?: Record<string, string[]>;
|
|
155
|
+
/** Component name → its archetype, declared or inferred from the composition
|
|
156
|
+
* (`inferArchetype`), so a reader can label a component without its source
|
|
157
|
+
* (#2662). */
|
|
158
|
+
archetypes?: Record<string, Archetype>;
|
|
155
159
|
error?: string;
|
|
156
160
|
}
|
|
157
161
|
|
|
@@ -187,7 +191,9 @@ export async function computeComponentGraph(
|
|
|
187
191
|
// component name → declared composite kind(s) (#1492), only for components
|
|
188
192
|
// that declared them — no identity fallback (see ComponentGraphResult doc).
|
|
189
193
|
const composites: Record<string, string[]> = {};
|
|
194
|
+
const archetypes: Record<string, Archetype> = {};
|
|
190
195
|
for (const [name, discovered] of result.components) {
|
|
196
|
+
archetypes[name] = discovered.component.archetype ?? inferArchetype(discovered.component);
|
|
191
197
|
files[name] = relative(path, discovered.filePath);
|
|
192
198
|
const declared = discovered.component.liveNames;
|
|
193
199
|
liveNames[name] = declared && declared.length > 0 ? [...declared] : [name];
|
|
@@ -201,7 +207,7 @@ export async function computeComponentGraph(
|
|
|
201
207
|
for (const c of driverComponents) {
|
|
202
208
|
for (const dep of c.dependsOn ?? []) edges.push({ from: c.name, to: dep });
|
|
203
209
|
}
|
|
204
|
-
return { success: true, order, waves, edges, files, liveNames, composites };
|
|
210
|
+
return { success: true, order, waves, edges, files, liveNames, composites, archetypes };
|
|
205
211
|
} catch (err) {
|
|
206
212
|
if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
|
|
207
213
|
return { success: false, order: [], waves: [], edges: [], error: err.message };
|
package/src/composite.ts
CHANGED
|
@@ -71,6 +71,15 @@ export function isCompositeInstance(value: unknown): value is CompositeInstance
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Type guard: is this value a composite definition, what `Composite()` and
|
|
76
|
+
* `withDefaults()` return? A lexicon's composite catalog test uses it to find
|
|
77
|
+
* the composites a module exports (#2662).
|
|
78
|
+
*/
|
|
79
|
+
export function isCompositeDefinition(value: unknown): value is CompositeDefinition<unknown> {
|
|
80
|
+
return typeof value === "function" && typeof (value as { compositeName?: unknown }).compositeName === "string";
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
/**
|
|
75
84
|
* Global registry of composite definitions.
|
|
76
85
|
*/
|
package/src/lexicon.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { BehaviourKinds } from "./behaviour-kinds";
|
|
|
28
28
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
29
29
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
30
30
|
import type { CommandGroup } from "./cli/command-group";
|
|
31
|
+
import type { Archetype } from "./components/component";
|
|
31
32
|
|
|
32
33
|
// Re-exported so a lexicon that hosts Op runs (#2121) can type its
|
|
33
34
|
// `opRuntime` from the same entry it imports the plugin contract from.
|
|
@@ -929,6 +930,45 @@ export interface AuditEntitiesInput {
|
|
|
929
930
|
baseDir?: string;
|
|
930
931
|
}
|
|
931
932
|
|
|
933
|
+
/**
|
|
934
|
+
* One parameter of a composite, read from the composite's declared props type
|
|
935
|
+
* (#2662).
|
|
936
|
+
*/
|
|
937
|
+
export interface CompositeParam {
|
|
938
|
+
/** The prop name as a caller writes it. */
|
|
939
|
+
name: string;
|
|
940
|
+
/** The declared TypeScript type, as source text (long inline types are shortened). */
|
|
941
|
+
type: string;
|
|
942
|
+
/** False when the prop is optional. */
|
|
943
|
+
required: boolean;
|
|
944
|
+
/** The prop's JSDoc summary, when it has one. */
|
|
945
|
+
description?: string;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* A composite this lexicon exports, as static catalog data (#2662).
|
|
950
|
+
*
|
|
951
|
+
* The catalog is what `chant serve mcp` answers "what composites do you have
|
|
952
|
+
* for aws?" from: the `composites` tool, the `chant://composites` resource and
|
|
953
|
+
* `search` results of kind `composite` all read it, and none of them calls a
|
|
954
|
+
* provider. A lexicon writes it once, from source, and a test in the lexicon
|
|
955
|
+
* holds it to the composites the package actually exports.
|
|
956
|
+
*/
|
|
957
|
+
export interface CompositeEntry {
|
|
958
|
+
/** The exported name a caller imports (an alias gets its own entry). */
|
|
959
|
+
name: string;
|
|
960
|
+
/** The lexicon that exports it. */
|
|
961
|
+
lexicon: string;
|
|
962
|
+
/** One line saying what it builds. */
|
|
963
|
+
description: string;
|
|
964
|
+
/** Resource kinds (the lexicon's class names) its members are, nested composites flattened. */
|
|
965
|
+
bundles: string[];
|
|
966
|
+
/** Its props, from the declared props type. */
|
|
967
|
+
params: CompositeParam[];
|
|
968
|
+
/** The component archetype that ships it, when that is known. */
|
|
969
|
+
archetype?: Archetype;
|
|
970
|
+
}
|
|
971
|
+
|
|
932
972
|
export interface LexiconPlugin {
|
|
933
973
|
// ── Required ──────────────────────────────────────────────
|
|
934
974
|
/** Human-readable name (e.g. "aws", "gcp") */
|
|
@@ -1212,6 +1252,13 @@ export interface LexiconPlugin {
|
|
|
1212
1252
|
/** Generate documentation pages */
|
|
1213
1253
|
docs?(options?: { verbose?: boolean }): Promise<void>;
|
|
1214
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* The composites this lexicon exports, as static data (#2662). Read by the
|
|
1257
|
+
* MCP `composites` tool, the `chant://composites` resource and `search`.
|
|
1258
|
+
* Omit for a lexicon that exports no composites.
|
|
1259
|
+
*/
|
|
1260
|
+
composites?(): CompositeEntry[];
|
|
1261
|
+
|
|
1215
1262
|
// MCP
|
|
1216
1263
|
/** Return MCP tool contributions */
|
|
1217
1264
|
mcpTools?(): McpToolContribution[];
|