@intentius/chant 0.37.0 → 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/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +11 -0
- package/dist/codegen/docs.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/codegen/docs-rule-scanning.ts +12 -3
- package/src/codegen/docs-sections.ts +5 -3
- package/src/codegen/docs-sidebar.ts +8 -2
- package/src/codegen/docs.ts +19 -0
- 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,
|
|
@@ -109,17 +109,26 @@ function extractDescriptionFromComment(
|
|
|
109
109
|
return ruleId;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function plural(n: number, noun: string): string {
|
|
113
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
export function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
|
|
113
117
|
const lintRules = rules.filter((r) => r.type === "lint");
|
|
114
118
|
const postSynthRules = rules.filter((r) => r.type === "post-synth");
|
|
115
119
|
|
|
116
120
|
const lines: string[] = [
|
|
117
121
|
"---",
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// "All Rules", not "Lint Rules": most lexicons also ship a hand-written
|
|
123
|
+
// page under the latter title that covers a selected subset, and two pages
|
|
124
|
+
// with the same title reads as a duplicate rather than a complete table.
|
|
125
|
+
`title: "All Rules"`,
|
|
126
|
+
`description: "Every lint rule and post-synth check provided by the ${config.displayName} lexicon"`,
|
|
120
127
|
"---",
|
|
121
128
|
"",
|
|
122
|
-
`The ${config.displayName} lexicon provides **${rules.length}** rules:
|
|
129
|
+
`The ${config.displayName} lexicon provides **${rules.length}** rules: ` +
|
|
130
|
+
`${plural(lintRules.length, "lint rule")} and ` +
|
|
131
|
+
`${plural(postSynthRules.length, "post-synth check")}.`,
|
|
123
132
|
"",
|
|
124
133
|
];
|
|
125
134
|
|
|
@@ -64,9 +64,11 @@ export function generateOverview(
|
|
|
64
64
|
`- [Pseudo-Parameters](./pseudo-parameters) — ${Object.keys(manifest.pseudoParameters).length} pseudo-parameters`,
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// Same reasoning as the sidebar's rules entry: link the complete table on
|
|
68
|
+
// every lexicon, under the label the sidebar uses, whether or not a prose
|
|
69
|
+
// `lint-rules` page also exists.
|
|
70
|
+
if (!suppress.has("rules") && rules.length > 0) {
|
|
71
|
+
lines.push(`- [All Rules](./rules) — ${rules.length} rules`);
|
|
70
72
|
}
|
|
71
73
|
if (!suppress.has("serialization")) {
|
|
72
74
|
lines.push(`- [Serialization](./serialization) — output format details`);
|
|
@@ -39,8 +39,14 @@ export function buildSidebar(
|
|
|
39
39
|
items.push({ label: "Pseudo-Parameters", slug: "pseudo-parameters" });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Every lexicon links its generated rules table, whether or not it also
|
|
43
|
+
// ships a prose `lint-rules` page. Skipping it when one existed was how gcp
|
|
44
|
+
// ended up emitting a page nothing pointed at (#1312), and it left readers
|
|
45
|
+
// with no complete list on the lexicons whose prose covers only part of the
|
|
46
|
+
// set — aws documented 26 of 50 that way. The label distinguishes the
|
|
47
|
+
// generated table from a prose page rather than competing with it.
|
|
48
|
+
if (!suppress.has("rules") && !extraSlugs.has("rules") && result.pages.has("rules.mdx")) {
|
|
49
|
+
items.push({ label: "All Rules", slug: "rules" });
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
if (!suppress.has("serialization") && !extraSlugs.has("serialization") && result.pages.has("serialization.mdx")) {
|
package/src/codegen/docs.ts
CHANGED
|
@@ -177,6 +177,25 @@ function withGeneratedMarker(config: DocsConfig, content: string): string {
|
|
|
177
177
|
return `${marker}\n\n${content}`;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Render the complete rules table for a lexicon that has no {@link docsPipeline}
|
|
182
|
+
* site of its own.
|
|
183
|
+
*
|
|
184
|
+
* The docker lexicon hand-authors its docs, which left its rule table the only
|
|
185
|
+
* one in the repo that could drift from source without anything noticing
|
|
186
|
+
* (#1312). This is the one page worth generating even when the rest of a site
|
|
187
|
+
* is hand-written; the caller writes the result to `rules.mdx` and links it.
|
|
188
|
+
* Returns null when the lexicon declares no rules.
|
|
189
|
+
*/
|
|
190
|
+
export function generateRulesPage(
|
|
191
|
+
config: DocsConfig,
|
|
192
|
+
srcDir: string,
|
|
193
|
+
): string | null {
|
|
194
|
+
const rules = scanRules(srcDir);
|
|
195
|
+
if (rules.length === 0) return null;
|
|
196
|
+
return withGeneratedMarker(config, generateRules(config, rules));
|
|
197
|
+
}
|
|
198
|
+
|
|
180
199
|
/**
|
|
181
200
|
* Marks a page as pipeline output. Used both to warn readers off editing the
|
|
182
201
|
* file and, in {@link writeDocsSite}, to tell a page this pipeline owns from a
|
|
@@ -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
|
+
});
|