@intentius/chant 0.37.2 → 0.39.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 (130) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  5. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  6. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  7. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  8. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/graph.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  13. package/dist/cli/handlers/search.d.ts +30 -1
  14. package/dist/cli/handlers/search.d.ts.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/mcp/server.d.ts +26 -2
  17. package/dist/cli/mcp/server.d.ts.map +1 -1
  18. package/dist/cli/plugins.d.ts +20 -0
  19. package/dist/cli/plugins.d.ts.map +1 -1
  20. package/dist/codegen/registry.d.ts +23 -0
  21. package/dist/codegen/registry.d.ts.map +1 -0
  22. package/dist/components/sandbox/driver.d.ts.map +1 -1
  23. package/dist/composite.d.ts +23 -4
  24. package/dist/composite.d.ts.map +1 -1
  25. package/dist/deep-observation.d.ts +11 -0
  26. package/dist/deep-observation.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/kubectl-context.d.ts.map +1 -1
  34. package/dist/lexicon-config.d.ts +61 -0
  35. package/dist/lexicon-config.d.ts.map +1 -0
  36. package/dist/lexicon.d.ts +85 -37
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/deep-diff.d.ts +11 -0
  39. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/digest.d.ts.map +1 -1
  41. package/dist/lifecycle/identity.d.ts +52 -0
  42. package/dist/lifecycle/identity.d.ts.map +1 -0
  43. package/dist/lifecycle/observe.d.ts +5 -0
  44. package/dist/lifecycle/observe.d.ts.map +1 -1
  45. package/dist/lifecycle/replay.d.ts.map +1 -1
  46. package/dist/lifecycle/types.d.ts +30 -0
  47. package/dist/lifecycle/types.d.ts.map +1 -1
  48. package/dist/live-endpoint.d.ts +21 -22
  49. package/dist/live-endpoint.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/dist/op/emulator-freshness.d.ts +44 -0
  53. package/dist/op/emulator-freshness.d.ts.map +1 -0
  54. package/dist/op/emulator-lifecycle.d.ts +36 -0
  55. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +4 -2
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/ownership.d.ts +33 -0
  59. package/dist/ownership.d.ts.map +1 -1
  60. package/dist/serializer.d.ts +15 -0
  61. package/dist/serializer.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/audit/catalog.test.ts +58 -6
  64. package/src/build.ts +24 -0
  65. package/src/cli/commands/build.ts +10 -0
  66. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  67. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  68. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  69. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  70. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  71. package/src/cli/commands/check-lexicon.ts +176 -26
  72. package/src/cli/handlers/components.test.ts +17 -0
  73. package/src/cli/handlers/components.ts +1 -1
  74. package/src/cli/handlers/emulator.ts +12 -8
  75. package/src/cli/handlers/graph.test.ts +71 -12
  76. package/src/cli/handlers/graph.ts +46 -5
  77. package/src/cli/handlers/lifecycle.test.ts +25 -4
  78. package/src/cli/handlers/lifecycle.ts +19 -3
  79. package/src/cli/handlers/run-client.ts +3 -1
  80. package/src/cli/handlers/search-kind.test.ts +45 -0
  81. package/src/cli/handlers/search.ts +102 -4
  82. package/src/cli/main.ts +32 -10
  83. package/src/cli/mcp/server.test.ts +82 -0
  84. package/src/cli/mcp/server.ts +40 -5
  85. package/src/cli/param-flag-scope.test.ts +69 -0
  86. package/src/cli/plugins.test.ts +33 -1
  87. package/src/cli/plugins.ts +55 -0
  88. package/src/codegen/registry.test.ts +56 -0
  89. package/src/codegen/registry.ts +69 -0
  90. package/src/components/SPRAWL-VALIDATION.md +5 -5
  91. package/src/components/sandbox/driver.test.ts +27 -0
  92. package/src/components/sandbox/driver.ts +12 -0
  93. package/src/composite.ts +33 -4
  94. package/src/deep-observation.ts +11 -0
  95. package/src/discovery/sandbox/driver.test.ts +34 -0
  96. package/src/discovery/sandbox/driver.ts +19 -0
  97. package/src/graph-declared.test.ts +86 -0
  98. package/src/graph-declared.ts +14 -2
  99. package/src/graph-ir.ts +32 -8
  100. package/src/graph-refs.test.ts +56 -0
  101. package/src/graph-refs.ts +37 -1
  102. package/src/kubectl-context.ts +4 -1
  103. package/src/lexicon-config.test.ts +111 -0
  104. package/src/lexicon-config.ts +92 -0
  105. package/src/lexicon-doc-coverage.test.ts +128 -0
  106. package/src/lexicon-seams.test.ts +113 -0
  107. package/src/lexicon.ts +88 -38
  108. package/src/lifecycle/deep-diff.test.ts +48 -1
  109. package/src/lifecycle/deep-diff.ts +16 -0
  110. package/src/lifecycle/digest.test.ts +81 -0
  111. package/src/lifecycle/digest.ts +34 -3
  112. package/src/lifecycle/identity.test.ts +39 -0
  113. package/src/lifecycle/identity.ts +61 -0
  114. package/src/lifecycle/observe.test.ts +75 -1
  115. package/src/lifecycle/observe.ts +28 -2
  116. package/src/lifecycle/replay.test.ts +251 -0
  117. package/src/lifecycle/replay.ts +67 -19
  118. package/src/lifecycle/types.ts +26 -0
  119. package/src/live-endpoint.test.ts +51 -12
  120. package/src/live-endpoint.ts +32 -33
  121. package/src/managed-fields.test.ts +50 -0
  122. package/src/managed-fields.ts +25 -6
  123. package/src/meta/peer-deps.test.ts +111 -14
  124. package/src/op/emulator-declaration.test.ts +63 -0
  125. package/src/op/emulator-freshness.test.ts +135 -0
  126. package/src/op/emulator-freshness.ts +102 -0
  127. package/src/op/emulator-lifecycle.ts +49 -0
  128. package/src/op/index.ts +4 -2
  129. package/src/ownership.ts +41 -0
  130. package/src/serializer.ts +16 -0
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
- import { buildGraphIr, buildLiveGraphIr, sourceOverlayGraphs, type GraphIR, type IRNode } from "../../graph-ir";
3
+ import { buildGraphIr, buildLiveGraphIr, sourceOverlayGraphs, type GraphIR, type IRNode, type IREdge } from "../../graph-ir";
4
4
  import { buildDeclaredPerStack } from "../../graph-declared";
5
5
  import { enrichEffectiveTopology } from "../../graph-effective";
6
6
  import { reconstructEdges, mergeCatalogs, type ReferenceCatalog } from "../../graph-refs";
@@ -127,6 +127,8 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
127
127
  source = { kind: "live" };
128
128
  }
129
129
  let live = buildLiveGraphIr(observations);
130
+ // Containment edges, kept aside until after the overlay (see below).
131
+ let containmentEdges: IREdge[] = [];
130
132
  // Live-only: enrichment is a provider call, so it has no place in a replay.
131
133
  // A recorded answer that quietly reached for the API would stop being one.
132
134
  if (!args.at) {
@@ -147,13 +149,24 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
147
149
  // topology has nothing to traverse on anything the declared graph does not
148
150
  // already model.
149
151
  const catalogs = observing.map((p) => p.referenceCatalog).filter((c): c is ReferenceCatalog => !!c);
152
+ // Held back until after the overlay — see where it is merged for why.
150
153
  if (catalogs.length > 0) {
151
154
  // Merge, never replace. A lexicon reports relationships a catalog cannot
152
155
  // reconstruct (#1273) — an instance placed in a subnet it did not declare
153
156
  // carries a template `Ref` in its attributes, not the physical subnet id,
154
157
  // so no identity index resolves it. Overwriting here dropped exactly those
155
158
  // edges and left the fold with a chain missing its first hop.
156
- const reconstructed = reconstructEdges(live.nodes, mergeCatalogs(catalogs)).edges;
159
+ // Containment travels with the references here, and only here. `->`/`<-`
160
+ // asks which nodes reach which, and being inside something is a way of
161
+ // reaching it: "which subnets have no network interfaces IN them" and
162
+ // "which VPCs have no instances IN them" are containment questions, and
163
+ // with only peer references to walk the negation matched everything.
164
+ //
165
+ // `graph` still reads `.containment` on its own and draws boundary boxes,
166
+ // so nothing gains a line it did not have.
167
+ const rebuilt = reconstructEdges(live.nodes, mergeCatalogs(catalogs));
168
+ containmentEdges = rebuilt.containmentEdges;
169
+ const reconstructed = rebuilt.edges;
157
170
  const seen = new Set((live.edges ?? []).map((e) => `${e.from}|${e.to}|${e.viaAttr ?? ""}`));
158
171
  live = {
159
172
  ...live,
@@ -177,6 +190,36 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
177
190
  ? await buildDeclaredPerStack(stacks, projectPath)
178
191
  : buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
179
192
  ir = sourceOverlayGraphs(declared, live);
193
+ // Containment goes on AFTER the overlay, not through it.
194
+ //
195
+ // `sourceOverlayGraphs` admits a live edge only when one end is foreign,
196
+ // because "declared edges already cover every declared relationship". That
197
+ // is true of the references it was written for and false of containment:
198
+ // the declared canvas carries what the source *says*, and no source says
199
+ // "this instance is in this VPC" — the instance declares a subnet, and the
200
+ // VPC is implied by the account.
201
+ //
202
+ // So an instance and its VPC, both declared, had their containment dropped
203
+ // between them, while an instance in the account's DEFAULT VPC kept it
204
+ // because that VPC is foreign. "Which VPCs have no instances" answered
205
+ // correctly for the one VPC nobody declared and wrongly for every VPC
206
+ // somebody did.
207
+ if (containmentEdges.length > 0) {
208
+ const present = new Set(ir.nodes.map((n) => n.id));
209
+ const seen = new Set(ir.edges.map((e) => `${e.from}|${e.to}|${e.viaAttr ?? ""}`));
210
+ ir = {
211
+ ...ir,
212
+ edges: [
213
+ ...ir.edges,
214
+ ...containmentEdges.filter(
215
+ (e) =>
216
+ present.has(e.from) &&
217
+ present.has(e.to) &&
218
+ !seen.has(`${e.from}|${e.to}|${e.viaAttr ?? ""}`),
219
+ ),
220
+ ],
221
+ };
222
+ }
180
223
  // Carry live-derived attrs onto the declared canvas — some facts only exist
181
224
  // in live account state (e.g. `internetFacing` for an instance in the
182
225
  // account's default VPC, whose route table chant does not model). The
@@ -190,6 +233,9 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
190
233
  // Fold derived reachability facts (effectiveIngress, internetFacing) onto
191
234
  // instance nodes so multi-hop/launch-template joins are one node predicate (#1139).
192
235
  ir = enrichEffectiveTopology(ir);
236
+ // Resolve `kind:` terms against what is actually in this graph, before any
237
+ // node is tested against them.
238
+ resolveKindTerms(terms, ir.nodes);
193
239
  const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
194
240
  const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, nodeById)));
195
241
  if (matches.length === 0) {
@@ -489,7 +535,7 @@ function describeTerm(t: Term): string {
489
535
  return leaf(t);
490
536
  }
491
537
 
492
- interface Term {
538
+ export interface Term {
493
539
  kind: "word" | "kind" | "tag" | "attr" | "edge";
494
540
  /** `!term` — the node must NOT satisfy this (#1280). */
495
541
  negated?: boolean;
@@ -498,6 +544,53 @@ interface Term {
498
544
  /** For edge terms: the direction and the sub-predicate matched at the far end. */
499
545
  dir?: "out" | "in";
500
546
  sub?: Term;
547
+ /**
548
+ * For kind terms: the kinds this term actually means, resolved against the
549
+ * graph (see {@link resolveKindTerms}). Absent when nothing resolved it, in
550
+ * which case the substring rule applies unchanged.
551
+ */
552
+ kinds?: Set<string>;
553
+ }
554
+
555
+ /**
556
+ * Decide which kinds a `kind:` term means, given what is in the graph.
557
+ *
558
+ * `kind:` is documented as a substring, and substrings of a CloudFormation type
559
+ * cross kind boundaries: `kind:EC2::VPC` also matches
560
+ * `AWS::EC2::VPCGatewayAttachment`, and `kind:EC2::Subnet` also matches
561
+ * `AWS::EC2::SubnetRouteTableAssociation`. An estate with 6 VPCs answers 9, and
562
+ * the caller has no reason to suspect the number they were given.
563
+ *
564
+ * That cost real answers. Asked how many VPCs have no instances, agents named
565
+ * the right VPCs and reported "9 VPCs in the estate" beside them; the grader
566
+ * failed the answer for contradicting the estate, which it did.
567
+ *
568
+ * So: a term that lines up with `::` boundaries means those kinds and only
569
+ * those. `EC2::VPC` is the last two segments of `AWS::EC2::VPC` and is not any
570
+ * run of segments in `AWS::EC2::VPCGatewayAttachment`, because
571
+ * `VPCGatewayAttachment` is not `VPC`.
572
+ *
573
+ * Substring is kept as the fallback, so `kind:Gateway` still finds both
574
+ * `InternetGateway` and `VPCGatewayAttachment` — a genuine substring search
575
+ * that no segment rule would serve. Most specific wins; nothing else changes.
576
+ */
577
+ export function resolveKindTerms(terms: Term[], nodes: IRNode[]): void {
578
+ const present = [...new Set(nodes.map((n) => n.kind).filter((k): k is string => !!k))];
579
+ const walk = (t: Term): void => {
580
+ if (t.sub) walk(t.sub);
581
+ if (t.kind !== "kind") return;
582
+ const want = t.a.toLowerCase().split("::").filter(Boolean);
583
+ if (want.length === 0) return;
584
+ const onBoundary = present.filter((k) => {
585
+ const have = k.toLowerCase().split("::");
586
+ for (let i = 0; i + want.length <= have.length; i++) {
587
+ if (want.every((w, j) => have[i + j] === w)) return true;
588
+ }
589
+ return false;
590
+ });
591
+ if (onBoundary.length > 0) t.kinds = new Set(onBoundary);
592
+ };
593
+ terms.forEach(walk);
501
594
  }
502
595
 
503
596
  function parseLeaf(tok: string): Term {
@@ -616,7 +709,12 @@ function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>)
616
709
  .filter((x): x is IRNode => !!x);
617
710
  return neighbors.some((m) => matchTerm(m, t.sub!, ir, byId));
618
711
  }
619
- if (t.kind === "kind") return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
712
+ if (t.kind === "kind") {
713
+ // Resolved kinds when the term lined up with `::` boundaries; the
714
+ // documented substring otherwise.
715
+ if (t.kinds) return t.kinds.has(n.kind ?? "");
716
+ return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
717
+ }
620
718
  if (t.kind === "attr") {
621
719
  const val = attrString((attrs as Record<string, unknown>)[t.a]);
622
720
  return t.b === undefined ? t.a in attrs : val.toLowerCase().includes(t.b.toLowerCase());
package/src/cli/main.ts CHANGED
@@ -6,6 +6,7 @@ import { formatSuccess, formatError } from "./format";
6
6
  import { loadPlugins, resolveProjectLexicons } from "./plugins";
7
7
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
8
8
  import { loadChantConfigUpward } from "../config";
9
+ import { validateLexiconConfig, formatLexiconConfigProblems } from "../lexicon-config";
9
10
  import { armSandboxConfigEvaluation } from "../config-sandbox";
10
11
  import { armSandboxPolicyExecution } from "../lint/policy-import";
11
12
  import { ENV_VAR, unknownEnvError } from "../env";
@@ -559,16 +560,17 @@ Options:
559
560
  project source; network egress is NOT blocked (see
560
561
  docs). Default: off (also settable via
561
562
  chant.config.ts's build.sandbox: true; #1045)
562
- --param <name=value> (build) Bind a declared build-time parameter
563
- (chant.config.ts's buildParams) to a value, for
564
- source to read as params.<name> (#1064) instead of
565
- process.env — repeatable. Distinct from the AWS
566
- lexicon's deploy-time Parameter(): this resolves
567
- before synthesis, so it can change which resources
568
- are produced at all. Highest precedence.
569
- --params-file <path> (build) JSON file of { "name": value } build-time
570
- parameter values (#1064). Second precedence, after
571
- --param.
563
+ --param <name=value> (build, graph, run --components) Bind a declared
564
+ build-time parameter (chant.config.ts's buildParams)
565
+ to a value, for source to read as params.<name>
566
+ (#1064) instead of process.env — repeatable.
567
+ Distinct from the AWS lexicon's deploy-time
568
+ Parameter(): this resolves before synthesis, so it
569
+ can change which resources are produced at all.
570
+ Highest precedence.
571
+ --params-file <path> (build, graph, run --components) JSON file of
572
+ { "name": value } build-time parameter values
573
+ (#1064). Second precedence, after --param.
572
574
 
573
575
  Examples:
574
576
  chant build ./infra/
@@ -668,6 +670,26 @@ async function loadPluginsOrExit(path: string): Promise<import("../lexicon").Lex
668
670
  process.exit(1);
669
671
  }
670
672
 
673
+ // #1344 — a lexicon that declares the shape of its own `chant.config.ts`
674
+ // namespace gets it validated here, once, before any command runs. The
675
+ // config schema is `.passthrough()`, so before this an unknown key inside a
676
+ // namespace was accepted and silently ignored: `forgejo: { runnerLabel: … }`
677
+ // left the dialect on its defaults with nothing said. A namespace whose
678
+ // lexicon declares no schema keeps that passthrough.
679
+ try {
680
+ const { config } = await loadChantConfigUpward(resolve(path));
681
+ const problems = validateLexiconConfig(plugins, config);
682
+ if (problems.length > 0) {
683
+ console.error(formatError({
684
+ message: `Invalid lexicon configuration in chant.config:\n${formatLexiconConfigProblems(problems)}`,
685
+ hint: "Remove or correct the key. A lexicon's namespace accepts only the keys it declares.",
686
+ }));
687
+ process.exit(1);
688
+ }
689
+ } catch {
690
+ // No config, or one that failed to load — the caller's own handling stands.
691
+ }
692
+
671
693
  return plugins;
672
694
  }
673
695
 
@@ -1151,3 +1151,85 @@ describe("McpServer", () => {
1151
1151
  });
1152
1152
  });
1153
1153
  });
1154
+
1155
+ describe("plugin namespacing is idempotent (#1341)", () => {
1156
+ const tool = (name: string) => ({
1157
+ name,
1158
+ description: "d",
1159
+ inputSchema: { type: "object" as const, properties: {} },
1160
+ handler: async () => "",
1161
+ });
1162
+ const resource = (uri: string) => ({
1163
+ uri,
1164
+ name: "n",
1165
+ description: "d",
1166
+ mimeType: "text/plain",
1167
+ handler: async () => "",
1168
+ });
1169
+
1170
+ async function registeredToolNames(plugin: LexiconPlugin): Promise<string[]> {
1171
+ const s = new McpServer([plugin]);
1172
+ const res = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
1173
+ return (res.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
1174
+ }
1175
+
1176
+ async function registeredResourceUris(plugin: LexiconPlugin): Promise<string[]> {
1177
+ const s = new McpServer([plugin]);
1178
+ const res = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "resources/list" });
1179
+ return (res.result as { resources: Array<{ uri: string }> }).resources.map((r) => r.uri);
1180
+ }
1181
+
1182
+ test("a tool declared bare registers under one namespace", async () => {
1183
+ const names = await registeredToolNames(
1184
+ createMockPlugin({ name: "gitlab", mcpTools: () => [tool("migrate")] }),
1185
+ );
1186
+ expect(names).toContain("gitlab:migrate");
1187
+ });
1188
+
1189
+ test("a tool that already carries its prefix is not prefixed twice", async () => {
1190
+ // createDiffTool emits `${lexiconName}:diff`, and eleven lexicons write the
1191
+ // prefix by hand — this is the case that shipped as `gitlab:gitlab:diff`.
1192
+ const names = await registeredToolNames(
1193
+ createMockPlugin({ name: "gitlab", mcpTools: () => [tool("gitlab:diff")] }),
1194
+ );
1195
+ expect(names).toContain("gitlab:diff");
1196
+ expect(names).not.toContain("gitlab:gitlab:diff");
1197
+ });
1198
+
1199
+ test("a prefix that merely looks like the lexicon's is left alone", async () => {
1200
+ const names = await registeredToolNames(
1201
+ createMockPlugin({ name: "git", mcpTools: () => [tool("gitlab:diff")] }),
1202
+ );
1203
+ expect(names).toContain("git:gitlab:diff");
1204
+ });
1205
+
1206
+ test("a resource declared as a bare path registers under the lexicon", async () => {
1207
+ const uris = await registeredResourceUris(
1208
+ createMockPlugin({ name: "aws", mcpResources: () => [resource("examples/s3")] }),
1209
+ );
1210
+ expect(uris).toContain("chant://aws/examples/s3");
1211
+ });
1212
+
1213
+ test("a resource carrying the colon form is not doubled", async () => {
1214
+ const uris = await registeredResourceUris(
1215
+ createMockPlugin({ name: "aws", mcpResources: () => [resource("aws:resource-catalog")] }),
1216
+ );
1217
+ expect(uris).toContain("chant://aws/resource-catalog");
1218
+ });
1219
+
1220
+ test("the chant://lexicon/<name>/ form the authoring docs taught is normalized", async () => {
1221
+ // azure followed lsp-mcp.mdx and shipped `chant://azure/chant://lexicon/azure/catalog`.
1222
+ const uris = await registeredResourceUris(
1223
+ createMockPlugin({ name: "azure", mcpResources: () => [resource("chant://lexicon/azure/catalog")] }),
1224
+ );
1225
+ expect(uris).toContain("chant://azure/catalog");
1226
+ expect(uris.every((u) => u.indexOf("chant://", 1) === -1)).toBe(true);
1227
+ });
1228
+
1229
+ test("an already-registered uri passes through unchanged", async () => {
1230
+ const uris = await registeredResourceUris(
1231
+ createMockPlugin({ name: "aws", mcpResources: () => [resource("chant://aws/catalog")] }),
1232
+ );
1233
+ expect(uris).toContain("chant://aws/catalog");
1234
+ });
1235
+ });
@@ -12,6 +12,33 @@ import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
12
12
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
13
13
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
14
14
 
15
+ /**
16
+ * The name a lexicon's MCP tool is registered under: `<lexicon>:<verb>`,
17
+ * whether or not the lexicon already wrote the prefix itself (#1341).
18
+ */
19
+ export function namespacedToolName(lexicon: string, name: string): string {
20
+ const prefix = `${lexicon}:`;
21
+ return name.startsWith(prefix) ? name : `${prefix}${name}`;
22
+ }
23
+
24
+ /**
25
+ * The URI a lexicon's MCP resource is registered under: `chant://<lexicon>/<path>`.
26
+ *
27
+ * Three authored forms reach here (#1341). The bare path is the intended one.
28
+ * `createCatalogResource` emits `<lexicon>:resource-catalog`, and
29
+ * `lexicon-authoring/lsp-mcp.mdx` taught `chant://lexicon/<lexicon>/<path>` —
30
+ * which azure followed, and which produced the unusable
31
+ * `chant://azure/chant://lexicon/azure/catalog`.
32
+ */
33
+ export function namespacedResourceUri(lexicon: string, uri: string): string {
34
+ const base = `chant://${lexicon}/`;
35
+ if (uri.startsWith(base)) return uri;
36
+ for (const authored of [`chant://lexicon/${lexicon}/`, `${lexicon}:`]) {
37
+ if (uri.startsWith(authored)) return `${base}${uri.slice(authored.length)}`;
38
+ }
39
+ return `${base}${uri}`;
40
+ }
41
+
15
42
  /**
16
43
  * MCP Server implementation
17
44
  */
@@ -52,15 +79,22 @@ export class McpServer {
52
79
  }
53
80
 
54
81
  /**
55
- * Register tools contributed by a plugin, namespaced as `lexicon:toolName`
82
+ * Register tools contributed by a plugin, namespaced as `lexicon:toolName`.
83
+ *
84
+ * Namespacing is idempotent (#1341). Core is not the only place that applies
85
+ * a prefix: `createDiffTool` in ../../lexicon-plugin-helpers.ts already emits
86
+ * `${lexiconName}:diff`, and eleven lexicons write the prefix into the name by
87
+ * hand. Applying it unconditionally produced `gitlab:gitlab:diff` and
88
+ * `aws:aws:diff` in every `chant serve mcp` session, while every doc named the
89
+ * single-prefixed form. A tool declared either way now registers under exactly
90
+ * one namespace.
56
91
  */
57
92
  private registerPluginTools(plugin: LexiconPlugin): void {
58
93
  const tools = plugin.mcpTools?.() ?? [];
59
94
  for (const tool of tools) {
60
- const namespacedName = `${plugin.name}:${tool.name}`;
61
95
  this.registerTool(
62
96
  {
63
- name: namespacedName,
97
+ name: namespacedToolName(plugin.name, tool.name),
64
98
  description: tool.description,
65
99
  inputSchema: tool.inputSchema,
66
100
  },
@@ -70,12 +104,13 @@ export class McpServer {
70
104
  }
71
105
 
72
106
  /**
73
- * Register resources contributed by a plugin, namespaced as `chant://lexicon/uri`
107
+ * Register resources contributed by a plugin, namespaced as
108
+ * `chant://lexicon/uri`, idempotently — see {@link registerPluginTools}.
74
109
  */
75
110
  private registerPluginResources(plugin: LexiconPlugin): void {
76
111
  const resources = plugin.mcpResources?.() ?? [];
77
112
  for (const resource of resources) {
78
- const namespacedUri = `chant://${plugin.name}/${resource.uri}`;
113
+ const namespacedUri = namespacedResourceUri(plugin.name, resource.uri);
79
114
  this.pluginResources.set(namespacedUri, {
80
115
  definition: {
81
116
  uri: namespacedUri,
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * `--param`/`--params-file` started as build-only flags and grew: `chant graph`
8
+ * gained them with #1359 (graph and build disagreed about the same source),
9
+ * `chant run --components` with #1108. The help text in `./main.ts` kept saying
10
+ * "(build)", so the CLI's own documentation told users the flag did nothing on
11
+ * the two commands that had just learned it.
12
+ *
13
+ * Asserting the property rather than the wording: every handler that actually
14
+ * reads `args.param` must be named in the flag's help annotation. A new handler
15
+ * honoring the flag fails this test until the help is updated with it.
16
+ */
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const handlersDir = join(here, "handlers");
19
+
20
+ /** The command name a handler file implements — `handlers/graph.ts` backs `chant graph`. */
21
+ function commandOf(file: string): string {
22
+ return file.replace(/\.ts$/, "");
23
+ }
24
+
25
+ function handlersReadingParamFlags(): string[] {
26
+ return readdirSync(handlersDir)
27
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
28
+ .filter((f) => /\bargs\.param\b/.test(readFileSync(join(handlersDir, f), "utf-8")))
29
+ .map(commandOf)
30
+ .sort();
31
+ }
32
+
33
+ /**
34
+ * The help text for one flag: everything from its marker up to the next flag
35
+ * entry or the end of the options block, whichever comes first. Both bounds
36
+ * matter — the LAST flag in a block has no `--` after it, and without the
37
+ * blank-line bound its "block" would run to the end of the file and match
38
+ * every command name incidentally.
39
+ */
40
+ function helpBlockFor(flag: string): string {
41
+ const source = readFileSync(join(here, "main.ts"), "utf-8");
42
+ const start = source.indexOf(` ${flag}`);
43
+ expect(start, `${flag} is not documented in main.ts's help`).toBeGreaterThan(-1);
44
+ const rest = source.slice(start + flag.length);
45
+ const ends = [rest.search(/\n {2}--/), rest.search(/\n\s*\n/)].filter((i) => i !== -1);
46
+ expect(ends.length, `${flag}'s help block has no terminator`).toBeGreaterThan(0);
47
+ return rest.slice(0, Math.min(...ends));
48
+ }
49
+
50
+ describe("--param help annotation tracks the handlers that honor it", () => {
51
+ test("at least build, graph and run read the flag", () => {
52
+ // Guards the test itself: if the detection regex ever stops matching, the
53
+ // assertions below would pass vacuously against an empty set.
54
+ expect(handlersReadingParamFlags()).toEqual(expect.arrayContaining(["build", "graph", "run"]));
55
+ });
56
+
57
+ test.each(["--param <name=value>", "--params-file <path>"])(
58
+ "%s names every command that honors it",
59
+ (flag) => {
60
+ const block = helpBlockFor(flag);
61
+ for (const command of handlersReadingParamFlags()) {
62
+ expect(
63
+ block.includes(command),
64
+ `handlers/${command}.ts reads args.param, but ${flag}'s help does not mention "${command}"`,
65
+ ).toBe(true);
66
+ }
67
+ },
68
+ );
69
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { loadPlugin, loadPlugins } from "./plugins";
2
+ import { loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
3
3
  import { isLexiconPlugin } from "../lexicon";
4
4
 
5
5
  describe("loadPlugin", () => {
@@ -29,3 +29,35 @@ describe("loadPlugins", () => {
29
29
  expect(plugins).toHaveLength(0);
30
30
  });
31
31
  });
32
+
33
+ /**
34
+ * chant #1442 — the installed version of each lexicon package, read for the
35
+ * build digest.
36
+ */
37
+ describe("resolveLexiconVersions", () => {
38
+ test("reads the real installed version of a workspace lexicon", () => {
39
+ const versions = resolveLexiconVersions(["k8s"]);
40
+ expect(versions.k8s).toMatch(/^\d+\.\d+\.\d+/);
41
+ });
42
+
43
+ test("resolves several lexicons in one call", () => {
44
+ const versions = resolveLexiconVersions(["k8s", "aws"]);
45
+ expect(Object.keys(versions).sort()).toEqual(["aws", "k8s"]);
46
+ });
47
+
48
+ test("omits a lexicon that is not installed rather than inventing a version", () => {
49
+ // "unknown" would compare unequal to itself across builds and report a
50
+ // version change on every diff.
51
+ expect(resolveLexiconVersions(["definitely-not-a-lexicon"])).toEqual({});
52
+ });
53
+
54
+ test("an unresolvable name does not prevent the resolvable ones", () => {
55
+ const versions = resolveLexiconVersions(["definitely-not-a-lexicon", "k8s"]);
56
+ expect(versions.k8s).toBeDefined();
57
+ expect(versions["definitely-not-a-lexicon"]).toBeUndefined();
58
+ });
59
+
60
+ test("no names yields an empty map", () => {
61
+ expect(resolveLexiconVersions([])).toEqual({});
62
+ });
63
+ });
@@ -1,3 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
1
4
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
2
5
  import { loadChantConfigUpward } from "../config";
3
6
  import { findInfraFiles, detectLexicons } from "../index";
@@ -40,6 +43,58 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
40
43
  throw new Error(`Package ${packageName} does not export a LexiconPlugin or Serializer`);
41
44
  }
42
45
 
46
+ /**
47
+ * The installed version of each named lexicon package (chant #1442).
48
+ *
49
+ * A `LexiconPlugin` does not carry its own version — `LexiconManifest` does,
50
+ * but that is an artifact type describing generated output, not something the
51
+ * plugin object exposes. The version wanted here is a property of the
52
+ * *installed package*, so it is read from the package's own `package.json`.
53
+ *
54
+ * Resolved by walking up from the package's entry point rather than resolving
55
+ * `<pkg>/package.json` directly: lexicon packages declare an `exports` map
56
+ * that does not include `./package.json`, so the direct specifier is blocked
57
+ * by Node. `./manifest` (→ `dist/manifest.json`) is not used either — it is a
58
+ * `prepack` build artifact, absent in a monorepo dev tree, and its absence
59
+ * once read as four unrelated bugs (#1367).
60
+ *
61
+ * A lexicon whose version cannot be determined is omitted rather than
62
+ * recorded as `"unknown"`: a digest that says nothing is honest, one that
63
+ * says `unknown` compares unequal to itself across builds.
64
+ */
65
+ export function resolveLexiconVersions(lexiconNames: readonly string[]): Record<string, string> {
66
+ const require_ = createRequire(import.meta.url);
67
+ const versions: Record<string, string> = {};
68
+
69
+ for (const name of lexiconNames) {
70
+ const packageName = `@intentius/chant-lexicon-${name}`;
71
+ try {
72
+ let dir = dirname(require_.resolve(packageName));
73
+ // Bounded walk — a resolved entry point is never deeply nested inside
74
+ // its own package, and an unbounded loop here would climb to `/`.
75
+ for (let depth = 0; depth < 10; depth++) {
76
+ const candidate = join(dir, "package.json");
77
+ if (existsSync(candidate)) {
78
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { name?: string; version?: string };
79
+ // Only the lexicon's OWN package.json — a nested dependency's
80
+ // manifest would otherwise be read as the lexicon's version.
81
+ if (pkg.name === packageName && pkg.version) {
82
+ versions[name] = pkg.version;
83
+ break;
84
+ }
85
+ }
86
+ const parent = dirname(dir);
87
+ if (parent === dir) break;
88
+ dir = parent;
89
+ }
90
+ } catch {
91
+ // Not installed, or not resolvable from here — omit it.
92
+ }
93
+ }
94
+
95
+ return versions;
96
+ }
97
+
43
98
  /**
44
99
  * Load plugins for all detected lexicon names.
45
100
  * Calls `init()` on each plugin if present.
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadLexiconRegistry, LexiconRegistryMissingError } from "./registry";
6
+
7
+ const REGISTRY = { Bucket: { resourceType: "AWS::S3::Bucket", kind: "resource" } };
8
+
9
+ function pkg(): string {
10
+ return mkdtempSync(join(tmpdir(), "chant-registry-"));
11
+ }
12
+
13
+ describe("loadLexiconRegistry (#1367)", () => {
14
+ test("reads the dev copy — src/generated, which is what a working checkout has", () => {
15
+ const dir = pkg();
16
+ mkdirSync(join(dir, "src", "generated"), { recursive: true });
17
+ writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify(REGISTRY));
18
+ expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
19
+ });
20
+
21
+ test("falls back to dist/meta.json — what an installed package ships", () => {
22
+ const dir = pkg();
23
+ mkdirSync(join(dir, "dist"), { recursive: true });
24
+ writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify(REGISTRY));
25
+ expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
26
+ });
27
+
28
+ test("prefers the dev copy when both exist", () => {
29
+ const dir = pkg();
30
+ mkdirSync(join(dir, "src", "generated"), { recursive: true });
31
+ mkdirSync(join(dir, "dist"), { recursive: true });
32
+ writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify({ Dev: REGISTRY.Bucket }));
33
+ writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify({ Dist: REGISTRY.Bucket }));
34
+ expect(Object.keys(loadLexiconRegistry(dir, "aws"))).toEqual(["Dev"]);
35
+ });
36
+
37
+ test("throws with the command to run, rather than a module-not-found or an empty map", () => {
38
+ // The whole point. An empty map reads downstream as a lexicon with no
39
+ // resource types, which is how azure's import came to emit
40
+ // `// Unknown resource type: Microsoft.…` and look like a coverage gap.
41
+ const dir = pkg();
42
+ expect(() => loadLexiconRegistry(dir, "azure")).toThrow(LexiconRegistryMissingError);
43
+ expect(() => loadLexiconRegistry(dir, "azure")).toThrow(/npm run --prefix lexicons\/azure prepack/);
44
+ });
45
+
46
+ test("names the lexicon that is missing, not a generic one", () => {
47
+ const dir = pkg();
48
+ try {
49
+ loadLexiconRegistry(dir, "gcp");
50
+ throw new Error("should have thrown");
51
+ } catch (err) {
52
+ expect((err as LexiconRegistryMissingError).lexicon).toBe("gcp");
53
+ expect((err as Error).message).toContain("lexicons/gcp");
54
+ }
55
+ });
56
+ });