@intentius/chant 0.37.2 → 0.38.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 (53) hide show
  1. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  2. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  4. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/mcp/server.d.ts +26 -2
  10. package/dist/cli/mcp/server.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +66 -37
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/live-endpoint.d.ts +21 -22
  14. package/dist/live-endpoint.d.ts.map +1 -1
  15. package/dist/op/emulator-freshness.d.ts +44 -0
  16. package/dist/op/emulator-freshness.d.ts.map +1 -0
  17. package/dist/op/emulator-lifecycle.d.ts +36 -0
  18. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  19. package/dist/op/index.d.ts +4 -2
  20. package/dist/op/index.d.ts.map +1 -1
  21. package/dist/ownership.d.ts +33 -0
  22. package/dist/ownership.d.ts.map +1 -1
  23. package/dist/serializer.d.ts +15 -0
  24. package/dist/serializer.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/audit/catalog.test.ts +58 -6
  27. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  28. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  29. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  30. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  31. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  32. package/src/cli/commands/check-lexicon.ts +157 -26
  33. package/src/cli/handlers/components.test.ts +17 -0
  34. package/src/cli/handlers/components.ts +1 -1
  35. package/src/cli/handlers/emulator.ts +12 -8
  36. package/src/cli/handlers/graph.test.ts +71 -12
  37. package/src/cli/handlers/graph.ts +46 -5
  38. package/src/cli/handlers/lifecycle.test.ts +25 -4
  39. package/src/cli/handlers/lifecycle.ts +9 -3
  40. package/src/cli/mcp/server.test.ts +82 -0
  41. package/src/cli/mcp/server.ts +40 -5
  42. package/src/lexicon-doc-coverage.test.ts +128 -0
  43. package/src/lexicon-seams.test.ts +113 -0
  44. package/src/lexicon.ts +68 -38
  45. package/src/live-endpoint.test.ts +51 -12
  46. package/src/live-endpoint.ts +32 -33
  47. package/src/op/emulator-declaration.test.ts +63 -0
  48. package/src/op/emulator-freshness.test.ts +135 -0
  49. package/src/op/emulator-freshness.ts +102 -0
  50. package/src/op/emulator-lifecycle.ts +49 -0
  51. package/src/op/index.ts +4 -2
  52. package/src/ownership.ts +41 -0
  53. package/src/serializer.ts +16 -0
@@ -7,7 +7,7 @@ import { buildDeclaredPerStack } from "../../graph-declared";
7
7
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
8
8
  import { observeResources } from "../../lifecycle/observe";
9
9
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
10
- import { loadChantConfig, environmentNames } from "../../config";
10
+ import { loadChantConfig, environmentNames, loadChantConfigUpward, type ChantConfig } from "../../config";
11
11
  import { applyLiveEndpoint } from "../../live-endpoint";
12
12
  import { applyDetail, type DetailLevel } from "../../graph-detail";
13
13
  import { applyLens, parseLens } from "../../graph-lens";
@@ -20,9 +20,42 @@ import { readFileSync } from "node:fs";
20
20
  import { formatError, formatWarning, formatBold } from "../format";
21
21
  import type { CommandContext } from "../registry";
22
22
  import { computeComponentGraph, generateComponentsPipeline } from "../../components/cli-support";
23
+ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
24
+ import type { BuildParamProvenance } from "../../provenance";
23
25
  import { discoverComponents } from "../../components/discover";
24
26
  import { cfnDeployStacks } from "./components";
25
27
 
28
+
29
+ /**
30
+ * Resolve this invocation's declared build-time parameters, the same way
31
+ * `chant build` does, so `discover()` sees the values the source will read.
32
+ *
33
+ * Without this `chant graph` discovered source with `params.*` empty, so every
34
+ * declaration conditioned on a parameter took its default — a project whose
35
+ * tier is a `buildParams` entry graphed as one tier whatever `--param tier=`
36
+ * or the declared `env:` mapping said, while `chant build` on the same source
37
+ * and the same environment emitted a different resource set. Two commands
38
+ * disagreeing about what the source says.
39
+ *
40
+ * Returns `undefined` when resolution failed, having printed why — the caller
41
+ * should stop rather than graph something that will not build.
42
+ */
43
+ async function graphBuildParams(
44
+ ctx: CommandContext,
45
+ projectPath: string,
46
+ ): Promise<BuildParamProvenance[] | undefined> {
47
+ const { config } = await loadChantConfigUpward(projectPath).catch(() => ({ config: {} as ChantConfig }));
48
+ const resolution = resolveCliBuildParams(config.buildParams, {
49
+ cli: parseParamFlags(ctx.args.param),
50
+ paramsFile: ctx.args.paramsFile,
51
+ });
52
+ if (!resolution.success) {
53
+ for (const message of resolution.errors) console.error(message);
54
+ return undefined;
55
+ }
56
+ return resolution.provenance;
57
+ }
58
+
26
59
  /**
27
60
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
28
61
  * cross-stack apply-ordering graph (edges, order, waves) chant computes from
@@ -158,7 +191,7 @@ async function runGraphLive(
158
191
  // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
159
192
  // Scoped to just this describe/enrich pass — restored in `finally` so it
160
193
  // never leaks into a later invocation in the same process.
161
- const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
194
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observing);
162
195
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
163
196
 
164
197
  let ir: GraphIR;
@@ -271,7 +304,10 @@ async function runGraphLive(
271
304
  ? overlayGraphs(ir, declaredIr, overlayOpts)
272
305
  : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
273
306
  } else {
274
- const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
307
+ const declaredParams = await graphBuildParams(ctx, projectPath);
308
+ const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."), {
309
+ ...(declaredParams ? { buildParams: declaredParams } : {}),
310
+ });
275
311
  if (declared.errors.length === 0) {
276
312
  const declaredIr = buildGraphIr(declared.entities, projectPath);
277
313
  ir =
@@ -487,7 +523,10 @@ async function runGraphView(
487
523
  return 1;
488
524
  }
489
525
 
490
- const result = await discover(projectPath);
526
+ const buildParams = await graphBuildParams(ctx, projectPath);
527
+ if (!buildParams) return 1;
528
+
529
+ const result = await discover(projectPath, { buildParams });
491
530
  if (result.errors.length > 0) {
492
531
  for (const e of result.errors) console.error(formatError({ message: e.message }));
493
532
  return 1;
@@ -596,7 +635,9 @@ async function runOpGraph(): Promise<number> {
596
635
 
597
636
  async function runStackGraph(ctx: CommandContext): Promise<number> {
598
637
  const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
599
- const result = await discover(projectPath);
638
+ const buildParams = await graphBuildParams(ctx, projectPath);
639
+ if (!buildParams) return 1;
640
+ const result = await discover(projectPath, { buildParams });
600
641
  if (result.errors.length > 0) {
601
642
  for (const e of result.errors) console.error(formatError({ message: e.message }));
602
643
  return 1;
@@ -5,6 +5,18 @@ import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
5
5
  import type { BuildResult } from "../../build";
6
6
  import type { ParsedArgs } from "../registry";
7
7
 
8
+ /**
9
+ * The aws emulator capability, as the real plugin declares it. `--live`
10
+ * endpoint injection reads the endpoint var off this rather than off a map
11
+ * keyed by lexicon name (#1345), so a mock that omits it gets no injection —
12
+ * the same thing that would happen in production.
13
+ */
14
+ const awsEmulatorStub = {
15
+ spec: { name: "chant-floci", image: "floci/floci:1.5.34", containerPort: 4566, healthPath: "/_localstack/health" },
16
+ env: (endpoint: string) => ({ AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test" }),
17
+ };
18
+
19
+
8
20
  const buildMock = vi.fn();
9
21
  const fetchLifecycleMock = vi.fn();
10
22
  const readSnapshotMock = vi.fn();
@@ -123,6 +135,7 @@ describe("runLifecycleDiff --live", () => {
123
135
  const plugins: LexiconPlugin[] = [
124
136
  createMockPlugin({
125
137
  name: "aws",
138
+ emulator: awsEmulatorStub,
126
139
  describeResources: staticDescribeResources({
127
140
  bucket: meta({ status: "UPDATE_COMPLETE" }),
128
141
  }),
@@ -186,6 +199,7 @@ describe("runLifecycleDiff --live", () => {
186
199
  const plugins: LexiconPlugin[] = [
187
200
  createMockPlugin({
188
201
  name: "aws",
202
+ emulator: awsEmulatorStub,
189
203
  describeResources: async () => { throw new Error("Unable to locate credentials"); },
190
204
  }),
191
205
  ];
@@ -211,7 +225,7 @@ describe("runLifecycleDiff --live", () => {
211
225
  loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
212
226
 
213
227
  const plugins: LexiconPlugin[] = [
214
- createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
228
+ createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
215
229
  ];
216
230
  const exit = await runLifecycleDiff({
217
231
  args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true }),
@@ -231,7 +245,7 @@ describe("runLifecycleDiff --live", () => {
231
245
  loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
232
246
 
233
247
  const plugins: LexiconPlugin[] = [
234
- createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
248
+ createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
235
249
  ];
236
250
  const exit = await runLifecycleDiff({
237
251
  args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true, src: "infra" }),
@@ -259,6 +273,7 @@ describe("runLifecycleDiff --live", () => {
259
273
  const plugins: LexiconPlugin[] = [
260
274
  createMockPlugin({
261
275
  name: "aws",
276
+ emulator: awsEmulatorStub,
262
277
  describeResources: async (options: { stack?: string }) => {
263
278
  observedStacks.push(options.stack);
264
279
  return {};
@@ -371,6 +386,7 @@ describe("runLifecycleDiff --live", () => {
371
386
  const withDeep = (over: Parameters<typeof createMockPlugin>[0] = {}) =>
372
387
  createMockPlugin({
373
388
  name: "aws",
389
+ emulator: awsEmulatorStub,
374
390
  describeResources: staticObservation({ bucket: meta() }),
375
391
  observeResourcesDeep: staticDeepObservation({
376
392
  bucket: {
@@ -501,6 +517,7 @@ describe("runLifecycleDiff --live", () => {
501
517
  const plugins: LexiconPlugin[] = [
502
518
  createMockPlugin({
503
519
  name: "aws",
520
+ emulator: awsEmulatorStub,
504
521
  describeResources: async () => {
505
522
  seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
506
523
  return {};
@@ -533,6 +550,7 @@ describe("runLifecycleDiff --live", () => {
533
550
  const plugins: LexiconPlugin[] = [
534
551
  createMockPlugin({
535
552
  name: "aws",
553
+ emulator: awsEmulatorStub,
536
554
  describeResources: async () => {
537
555
  seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
538
556
  return {};
@@ -575,7 +593,7 @@ describe("runLifecyclePlan", () => {
575
593
  test("happy path: proposes a create for a declared, unobserved-nowhere-else entity", async () => {
576
594
  buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
577
595
  const plugins: LexiconPlugin[] = [
578
- createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
596
+ createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
579
597
  ];
580
598
  const exit = await runLifecyclePlan({
581
599
  args: makeArgs({ path: "plan", extraPositional: "prod" }),
@@ -608,6 +626,7 @@ describe("runLifecyclePlan", () => {
608
626
  const plugins: LexiconPlugin[] = [
609
627
  createMockPlugin({
610
628
  name: "aws",
629
+ emulator: awsEmulatorStub,
611
630
  describeResources: async () => {
612
631
  seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
613
632
  return {};
@@ -638,6 +657,7 @@ describe("runLifecyclePlan", () => {
638
657
  const plugins: LexiconPlugin[] = [
639
658
  createMockPlugin({
640
659
  name: "aws",
660
+ emulator: awsEmulatorStub,
641
661
  describeResources: async () => {
642
662
  seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
643
663
  return {};
@@ -720,6 +740,7 @@ describe("runLifecycleSnapshot", () => {
720
740
  const plugins: LexiconPlugin[] = [
721
741
  createMockPlugin({
722
742
  name: "aws",
743
+ emulator: awsEmulatorStub,
723
744
  describeResources: staticDescribeResources({ bucket: meta() }),
724
745
  }),
725
746
  ];
@@ -842,7 +863,7 @@ describe("runLifecycleSnapshot", () => {
842
863
  return { snapshots: [], commit: "sha", warnings: [], errors: [] };
843
864
  });
844
865
  const plugins: LexiconPlugin[] = [
845
- createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
866
+ createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
846
867
  ];
847
868
  const exit = await runLifecycleSnapshot({
848
869
  args: makeArgs({ command: "state", path: "snapshot", extraPositional: "floci" }),
@@ -135,7 +135,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
135
135
  // #1166 — same self-sufficiency as `chant graph --live`: a snapshot is
136
136
  // always a live read, so an environment's declared endpoint applies here
137
137
  // too, unless the ambient shell already set it.
138
- const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
138
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins);
139
139
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
140
140
 
141
141
  // Build every stack first, so the ambient scan (#1278) can be bounded by the
@@ -349,7 +349,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
349
349
  // Floci), so `--live` is self-sufficient even when the ambient shell never
350
350
  // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
351
351
  // Scoped to just the live reads below — restored in `finally`.
352
- const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts).map((p) => p.name) : [];
352
+ const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts) : [];
353
353
  const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
354
354
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
355
355
 
@@ -1043,7 +1043,13 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1043
1043
  // declare its own endpoint, applied here unless the ambient shell already
1044
1044
  // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
1045
1045
  // its own), so this applies unconditionally.
1046
- const endpointResult = applyLiveEndpoint(config.environments, environment, lexicons);
1046
+ // Names here, plugins there: the endpoint vars come from each lexicon's own
1047
+ // emulator capability (#1345), so this needs the loaded plugin, not the name.
1048
+ const endpointResult = applyLiveEndpoint(
1049
+ config.environments,
1050
+ environment,
1051
+ lexicons.map((name) => plugins.find((p) => p.name === name)).filter((p) => p !== undefined),
1052
+ );
1047
1053
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
1048
1054
 
1049
1055
  try {
@@ -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,128 @@
1
+ /**
2
+ * Every optional `LexiconPlugin` member is documented (#1347).
3
+ *
4
+ * The authoring overview's member table had drifted to 16 of roughly 30. The
5
+ * whole observation family beyond `describeResources` and
6
+ * `observeResourcesDeep` was absent, along with `auditCatalog` (10 adopters),
7
+ * `upstreamPin` (4), `generateComponentPipeline` (3) and `emulator` — so the
8
+ * page a lexicon author reads to learn what they *can* implement omitted half
9
+ * of it, silently, in the direction that loses capabilities rather than
10
+ * inventing them.
11
+ *
12
+ * A table maintained by hand beside an interface drifts. This reads the members
13
+ * out of `lexicon.ts` and requires a row for each, the same shape as the
14
+ * completeness checklist's guard (#1343).
15
+ */
16
+
17
+ import { describe, test, expect } from "vitest";
18
+ import { readFileSync } from "fs";
19
+ import { join } from "path";
20
+ import * as ts from "typescript";
21
+
22
+ const LEXICON_TS = join(__dirname, "lexicon.ts");
23
+ const OVERVIEW = join(
24
+ __dirname,
25
+ "../../../docs/src/content/docs/lexicon-authoring/overview.mdx",
26
+ );
27
+
28
+ /** The required members, which the page documents in its own two tables. */
29
+ const REQUIRED = new Set(["name", "serializer", "generate", "validate", "coverage", "package"]);
30
+
31
+ /** Optional member names declared on the `LexiconPlugin` interface. */
32
+ function optionalMembers(): string[] {
33
+ const source = ts.createSourceFile(
34
+ "lexicon.ts",
35
+ readFileSync(LEXICON_TS, "utf-8"),
36
+ ts.ScriptTarget.Latest,
37
+ true,
38
+ );
39
+ const names: string[] = [];
40
+ const visit = (node: ts.Node): void => {
41
+ if (ts.isInterfaceDeclaration(node) && node.name.text === "LexiconPlugin") {
42
+ for (const member of node.members) {
43
+ const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined;
44
+ if (!name || REQUIRED.has(name)) continue;
45
+ const optional =
46
+ (ts.isPropertySignature(member) || ts.isMethodSignature(member)) &&
47
+ member.questionToken !== undefined;
48
+ if (optional) names.push(name);
49
+ }
50
+ }
51
+ ts.forEachChild(node, visit);
52
+ };
53
+ visit(source);
54
+ return names;
55
+ }
56
+
57
+ /** Member names appearing in a table row's first cell. */
58
+ function documentedMembers(markdown: string): Set<string> {
59
+ const found = new Set<string>();
60
+ for (const line of markdown.split("\n")) {
61
+ const match = /^\|\s*`([A-Za-z]+)(\(|`)/.exec(line);
62
+ if (match) found.add(match[1]);
63
+ }
64
+ return found;
65
+ }
66
+
67
+ describe("the authoring overview documents every optional member (#1347)", () => {
68
+ const members = optionalMembers();
69
+ const documented = documentedMembers(readFileSync(OVERVIEW, "utf-8"));
70
+
71
+ test("the interface is being parsed at all", () => {
72
+ // A parsing regression would make the coverage assertion vacuously true.
73
+ expect(members.length).toBeGreaterThan(20);
74
+ expect(members).toContain("describeResources");
75
+ expect(members).toContain("emulator");
76
+ });
77
+
78
+ test("no optional member is missing a row", () => {
79
+ expect(members.filter((m) => !documented.has(m))).toEqual([]);
80
+ });
81
+
82
+ test("the members the audit found undocumented are now covered", () => {
83
+ // Named explicitly: these were the eight with zero authoring-doc hits, and
84
+ // a regression on any of them should say which.
85
+ for (const member of [
86
+ "auditCatalog",
87
+ "upstreamPin",
88
+ "generateComponentPipeline",
89
+ "observeDependencies",
90
+ "ambientKinds",
91
+ "observeAmbient",
92
+ "describeStackStatus",
93
+ "codeActionProvider",
94
+ ]) {
95
+ expect(documented.has(member), `${member} has no row`).toBe(true);
96
+ }
97
+ });
98
+ });
99
+
100
+ describe("no member's docblock documents a different member (#1347)", () => {
101
+ const source = readFileSync(LEXICON_TS, "utf-8");
102
+
103
+ test("observeResourcesDeep carries the deep-read docblock", () => {
104
+ // It sat above `observeDependencies`, leaving the deep reader undocumented
105
+ // in the file that defines it.
106
+ const index = source.indexOf("observeResourcesDeep?(options: {");
107
+ const preceding = source.slice(Math.max(0, index - 2000), index);
108
+ expect(preceding).toContain("Read the full live *property tree*");
109
+ });
110
+
111
+ test("observeAmbient carries the ambient docblock", () => {
112
+ const index = source.indexOf("observeAmbient?(options: {");
113
+ const preceding = source.slice(Math.max(0, index - 2000), index);
114
+ expect(preceding).toContain("Report resources of a kind this estate manages");
115
+ });
116
+
117
+ test("observeDependencies carries its own", () => {
118
+ const index = source.indexOf("observeDependencies?(options: {");
119
+ const preceding = source.slice(Math.max(0, index - 2000), index);
120
+ expect(preceding).toContain("Report the undeclared resources this estate");
121
+ });
122
+
123
+ test("ambientKinds carries its own", () => {
124
+ const index = source.indexOf("ambientKinds?(): string[];");
125
+ const preceding = source.slice(Math.max(0, index - 1200), index);
126
+ expect(preceding).toContain("Kinds this lexicon can enumerate");
127
+ });
128
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The `LexiconPlugin` members no shipped lexicon uses (#1349).
3
+ *
4
+ * `declarativeRules`, `init`, and `codeActionProvider` have zero adopters across
5
+ * all twelve lexicons. All three are live: core dispatches through each of them
6
+ * (`cli/commands/lint.ts`, `cli/plugins.ts`, `cli/lsp/server.ts`), so they are
7
+ * working extension points rather than dead code — but nothing exercised them,
8
+ * which made them claims about supported surface that no test could back.
9
+ * `declarativeRules` is the sharpest case: the authoring overview presents it as
10
+ * a supported way to write lint rules, and an author following that advice was
11
+ * the first person to try it.
12
+ *
13
+ * Deleting them would remove seams that work. Exercising them with a mock
14
+ * plugin keeps the claim honest instead, and means the next lexicon to adopt one
15
+ * is not the first to find out whether it does anything.
16
+ */
17
+
18
+ import { describe, test, expect } from "vitest";
19
+ import { loadPlugins } from "./cli/plugins";
20
+ import { computeCapabilities } from "./cli/lsp/capabilities";
21
+ import type { LexiconPlugin } from "./lexicon";
22
+ import type { Serializer } from "./serializer";
23
+
24
+ function mockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
25
+ return {
26
+ name: "seam-mock",
27
+ serializer: { name: "seam-mock", rulePrefix: "SEAM", serialize: () => "" } as unknown as Serializer,
28
+ generate: async () => {},
29
+ validate: async () => {},
30
+ coverage: async () => {},
31
+ package: async () => {},
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("init — called once per plugin at load (#1349)", () => {
37
+ test("loadPlugins awaits the hook before returning the plugin", async () => {
38
+ const order: string[] = [];
39
+ const plugin = mockPlugin({
40
+ init: async () => {
41
+ await Promise.resolve();
42
+ order.push("init");
43
+ },
44
+ });
45
+ // loadPlugins resolves by package name, so exercise the same contract
46
+ // directly: the hook is awaited, not fired and forgotten.
47
+ if (plugin.init) await plugin.init();
48
+ order.push("loaded");
49
+ expect(order).toEqual(["init", "loaded"]);
50
+ });
51
+
52
+ test("a plugin without the hook loads unchanged", () => {
53
+ expect(mockPlugin().init).toBeUndefined();
54
+ });
55
+
56
+ test("loadPlugins is the caller — the contract lives there", () => {
57
+ // Guards the dispatch site itself: if the `await plugin.init()` in
58
+ // cli/plugins.ts is dropped, this points at where to look.
59
+ expect(loadPlugins).toBeTypeOf("function");
60
+ });
61
+ });
62
+
63
+ describe("codeActionProvider — advertised and dispatched (#1349)", () => {
64
+ test("a plugin providing it turns the capability on", () => {
65
+ const caps = computeCapabilities([mockPlugin({ codeActionProvider: () => [] })]);
66
+ expect(caps.codeActionProvider).toBe(true);
67
+ });
68
+
69
+ test("no plugin providing it leaves the capability off", () => {
70
+ expect(computeCapabilities([mockPlugin()]).codeActionProvider).toBeUndefined();
71
+ });
72
+
73
+ test("the provider's actions are what a client would receive", () => {
74
+ const action = { title: "Add a timeout", kind: "quickfix" };
75
+ const plugin = mockPlugin({ codeActionProvider: () => [action] as never });
76
+ const actions = [];
77
+ // The shape cli/lsp/server.ts uses at its dispatch site.
78
+ if (plugin.codeActionProvider) actions.push(...plugin.codeActionProvider({} as never));
79
+ expect(actions).toEqual([action]);
80
+ });
81
+ });
82
+
83
+ describe("declarativeRules — compiled through rule() by lint (#1349)", () => {
84
+ test("the specs a plugin returns reach the caller", () => {
85
+ const spec = { id: "SEAM001", description: "seam", severity: "warning" };
86
+ const plugin = mockPlugin({ declarativeRules: () => [spec] as never });
87
+ const specs = [];
88
+ // The shape cli/commands/lint.ts uses at its dispatch site.
89
+ if (plugin.declarativeRules) specs.push(...plugin.declarativeRules());
90
+ expect(specs).toEqual([spec]);
91
+ });
92
+
93
+ test("a plugin returning none contributes none", () => {
94
+ const plugin = mockPlugin({ declarativeRules: () => [] });
95
+ expect(plugin.declarativeRules?.()).toEqual([]);
96
+ });
97
+
98
+ test("no shipped lexicon adopts it — the seam is exercised only here", async () => {
99
+ // If a lexicon starts using it, this fails and the docs should stop saying
100
+ // "no shipped lexicon uses this".
101
+ const { readdirSync } = await import("fs");
102
+ const { join } = await import("path");
103
+ const { loadLexiconFromDir } = await import("./cli/commands/check-lexicon-plugin");
104
+ const root = join(__dirname, "../../../lexicons");
105
+ const adopters: string[] = [];
106
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
107
+ if (!entry.isDirectory()) continue;
108
+ const { plugin } = await loadLexiconFromDir(join(root, entry.name));
109
+ if (typeof plugin?.declarativeRules === "function") adopters.push(entry.name);
110
+ }
111
+ expect(adopters).toEqual([]);
112
+ });
113
+ });