@intentius/chant 0.84.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 +2 -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 +13 -2
  22. package/dist/workspace/graph-cli.d.ts.map +1 -1
  23. package/dist/workspace/intent-cli.d.ts +5 -1
  24. package/dist/workspace/intent-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-joins.d.ts +31 -3
  26. package/dist/workspace/intent-joins.d.ts.map +1 -1
  27. package/dist/workspace/intent.d.ts +27 -2
  28. package/dist/workspace/intent.d.ts.map +1 -1
  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 +14 -0
  32. package/dist/workspace/reason-codes.d.ts.map +1 -1
  33. package/dist/workspace/records.d.ts +1 -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 +9 -0
  38. package/src/cli/main.ts +8 -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 +2 -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 +32 -4
  54. package/src/workspace/intent-cli.ts +26 -2
  55. package/src/workspace/intent-joins.ts +48 -3
  56. package/src/workspace/intent.schema.json +80 -17
  57. package/src/workspace/intent.test.ts +138 -20
  58. package/src/workspace/intent.ts +72 -14
  59. package/src/workspace/member-commands.ts +11 -5
  60. package/src/workspace/read-contract.test.ts +31 -3
  61. package/src/workspace/reason-codes.test.ts +34 -1
  62. package/src/workspace/reason-codes.ts +22 -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 +54 -10
@@ -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: "Search the resource catalog across loaded lexicons by keyword",
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: "Search query — matches against resource type, class name, and kind",
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: Array<CatalogEntry & { lexicon: string; score: number }> = [];
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
- // Sort: prefix matches first, then alphabetical by resourceType
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
- return (a.resourceType ?? "").localeCompare(b.resourceType ?? "");
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);
@@ -285,6 +285,8 @@ export interface ParsedArgs {
285
285
  kind?: string;
286
286
  /** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
287
287
  kinds?: string[];
288
+ /** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
289
+ composites?: boolean;
288
290
  /** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
289
291
  intent?: string;
290
292
  /** `chant workspace records --current` (#2546): leave out records a closed record supersedes. */
@@ -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[];
@@ -98,3 +98,20 @@ case "$1" in
98
98
  *) echo "Error: Unknown command: $1" >&2; exit 1 ;;
99
99
  esac
100
100
  `;
101
+
102
+ /**
103
+ * A chant older than `workspace member-run` that answers from files (#2662):
104
+ * `chant graph --components` prints the member's `components.json`, or an
105
+ * empty IR when there is none, and plain `chant graph` prints `ir.json`. A
106
+ * `components.json` holding `FAIL` makes the component graph exit 1.
107
+ */
108
+ export const FAKE_FILE_GRAPH_CHANT = `#!/bin/sh
109
+ [ "$1" = graph ] || { echo "Error: Unknown command: $1" >&2; exit 1; }
110
+ case " $* " in
111
+ *" --components "*)
112
+ if [ ! -f components.json ]; then printf '{"version":1,"nodes":[],"edges":[],"groups":{}}\\n'
113
+ elif grep -q FAIL components.json; then echo "Error: component discovery failed" >&2; exit 1
114
+ else cat components.json; fi ;;
115
+ *) cat ir.json ;;
116
+ esac
117
+ `;