@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.
- package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
- package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +26 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/lexicon.d.ts +66 -37
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +21 -22
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/op/emulator-freshness.d.ts +44 -0
- package/dist/op/emulator-freshness.d.ts.map +1 -0
- package/dist/op/emulator-lifecycle.d.ts +36 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/serializer.d.ts +15 -0
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +58 -6
- package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
- package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
- package/src/cli/commands/check-lexicon-mcp.ts +103 -0
- package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
- package/src/cli/commands/check-lexicon-plugin.ts +115 -0
- package/src/cli/commands/check-lexicon.ts +157 -26
- package/src/cli/handlers/components.test.ts +17 -0
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/emulator.ts +12 -8
- package/src/cli/handlers/graph.test.ts +71 -12
- package/src/cli/handlers/graph.ts +46 -5
- package/src/cli/handlers/lifecycle.test.ts +25 -4
- package/src/cli/handlers/lifecycle.ts +9 -3
- package/src/cli/mcp/server.test.ts +82 -0
- package/src/cli/mcp/server.ts +40 -5
- package/src/lexicon-doc-coverage.test.ts +128 -0
- package/src/lexicon-seams.test.ts +113 -0
- package/src/lexicon.ts +68 -38
- package/src/live-endpoint.test.ts +51 -12
- package/src/live-endpoint.ts +32 -33
- package/src/op/emulator-declaration.test.ts +63 -0
- package/src/op/emulator-freshness.test.ts +135 -0
- package/src/op/emulator-freshness.ts +102 -0
- package/src/op/emulator-lifecycle.ts +49 -0
- package/src/op/index.ts +4 -2
- package/src/ownership.ts +41 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
+
});
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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 =
|
|
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
|
+
});
|