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