@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit of the names a lexicon's MCP contributions are registered under (#1341).
|
|
3
|
+
*
|
|
4
|
+
* Core namespaces every contribution — `<lexicon>:<verb>` for a tool,
|
|
5
|
+
* `chant://<lexicon>/<path>` for a resource — but core is not the only place
|
|
6
|
+
* that applies a prefix. `createDiffTool`/`createCatalogResource`
|
|
7
|
+
* (../../lexicon-plugin-helpers.ts) emit `<lexicon>:diff` and
|
|
8
|
+
* `<lexicon>:resource-catalog`, eleven lexicons write the prefix into the name
|
|
9
|
+
* by hand, and `lexicon-authoring/lsp-mcp.mdx` taught a third form for URIs.
|
|
10
|
+
* Applying the namespace unconditionally shipped `gitlab:gitlab:diff`,
|
|
11
|
+
* `aws:aws:diff`, and `chant://azure/chant://lexicon/azure/catalog` in every
|
|
12
|
+
* `chant serve mcp` session, while every doc named the single-prefixed form.
|
|
13
|
+
*
|
|
14
|
+
* The rule this checks is the one an agent experiences, not the one a lexicon
|
|
15
|
+
* author typed: whatever the declaration style, the registered name must be a
|
|
16
|
+
* single well-formed namespaced identifier. That keeps the check indifferent to
|
|
17
|
+
* which of the three authored forms a lexicon uses, and still fails the moment a
|
|
18
|
+
* name doubles or carries an embedded scheme.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from "fs";
|
|
22
|
+
import { basename, join } from "path";
|
|
23
|
+
import { namespacedToolName, namespacedResourceUri } from "../mcp/server";
|
|
24
|
+
|
|
25
|
+
export interface McpNameAudit {
|
|
26
|
+
/** How many tools + resources were examined. */
|
|
27
|
+
checked: number;
|
|
28
|
+
/** One human-readable line per malformed registered name. */
|
|
29
|
+
violations: string[];
|
|
30
|
+
/** False when the lexicon could not be loaded — the audit is then vacuous. */
|
|
31
|
+
loaded: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `@intentius/chant-lexicon-aws` → `aws`; falls back to the directory name. */
|
|
35
|
+
export function lexiconNameFor(dir: string): string {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { name?: string };
|
|
38
|
+
const match = /chant-lexicon-([a-z0-9-]+)$/.exec(pkg.name ?? "");
|
|
39
|
+
if (match) return match[1];
|
|
40
|
+
} catch {
|
|
41
|
+
// fall through to the directory name
|
|
42
|
+
}
|
|
43
|
+
return basename(dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toolPattern = (lexicon: string): RegExp =>
|
|
47
|
+
new RegExp(`^${lexicon}:[a-z0-9][a-z0-9-]*$`);
|
|
48
|
+
|
|
49
|
+
const resourcePattern = (lexicon: string): RegExp =>
|
|
50
|
+
new RegExp(`^chant://${lexicon}/[A-Za-z0-9][A-Za-z0-9._/-]*$`);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The malformed registered names among a lexicon's contributions. Pure, so the
|
|
54
|
+
* rule can be tested without loading a lexicon package.
|
|
55
|
+
*/
|
|
56
|
+
export function mcpNameViolations(
|
|
57
|
+
lexicon: string,
|
|
58
|
+
tools: Array<{ name: string }>,
|
|
59
|
+
resources: Array<{ uri: string }>,
|
|
60
|
+
): string[] {
|
|
61
|
+
const violations: string[] = [];
|
|
62
|
+
for (const tool of tools) {
|
|
63
|
+
const registered = namespacedToolName(lexicon, tool.name);
|
|
64
|
+
if (!toolPattern(lexicon).test(registered)) {
|
|
65
|
+
violations.push(`tool ${JSON.stringify(tool.name)} registers as ${JSON.stringify(registered)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const resource of resources) {
|
|
69
|
+
const registered = namespacedResourceUri(lexicon, resource.uri);
|
|
70
|
+
if (!resourcePattern(lexicon).test(registered)) {
|
|
71
|
+
violations.push(`resource ${JSON.stringify(resource.uri)} registers as ${JSON.stringify(registered)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return violations;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register this lexicon's MCP contributions the way the server does and check
|
|
79
|
+
* the resulting names.
|
|
80
|
+
*/
|
|
81
|
+
export async function auditMcpNames(dir: string): Promise<McpNameAudit> {
|
|
82
|
+
const lexicon = lexiconNameFor(dir);
|
|
83
|
+
let plugin: {
|
|
84
|
+
mcpTools?(): Array<{ name: string }>;
|
|
85
|
+
mcpResources?(): Array<{ uri: string }>;
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const { loadPlugins } = await import("../plugins");
|
|
89
|
+
const plugins = await loadPlugins([lexicon]);
|
|
90
|
+
if (plugins.length === 0) return { checked: 0, violations: [], loaded: false };
|
|
91
|
+
plugin = plugins[0];
|
|
92
|
+
} catch {
|
|
93
|
+
return { checked: 0, violations: [], loaded: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tools = plugin.mcpTools?.() ?? [];
|
|
97
|
+
const resources = plugin.mcpResources?.() ?? [];
|
|
98
|
+
return {
|
|
99
|
+
checked: tools.length + resources.length,
|
|
100
|
+
violations: mcpNameViolations(lexicon, tools, resources),
|
|
101
|
+
loaded: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { loadLexiconFromDir, pluginEntryFor, registers, safeList } from "./check-lexicon-plugin";
|
|
6
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
7
|
+
|
|
8
|
+
const roots: string[] = [];
|
|
9
|
+
|
|
10
|
+
function lexiconDir(files: Record<string, string>): string {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-lexicon-load-"));
|
|
12
|
+
roots.push(dir);
|
|
13
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
14
|
+
const path = join(dir, rel);
|
|
15
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
16
|
+
writeFileSync(path, content);
|
|
17
|
+
}
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A module source exporting a minimally valid LexiconPlugin. */
|
|
22
|
+
const pluginSource = (name: string, extra = "") => `
|
|
23
|
+
export const plugin = {
|
|
24
|
+
name: ${JSON.stringify(name)},
|
|
25
|
+
serializer: { name: ${JSON.stringify(name)}, rulePrefix: "XYZ", serialize: () => "" },
|
|
26
|
+
generate: async () => {},
|
|
27
|
+
validate: async () => {},
|
|
28
|
+
coverage: async () => {},
|
|
29
|
+
package: async () => {},
|
|
30
|
+
${extra}
|
|
31
|
+
};
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("pluginEntryFor", () => {
|
|
39
|
+
test("prefers the entry the package declares", () => {
|
|
40
|
+
const dir = lexiconDir({
|
|
41
|
+
"package.json": JSON.stringify({ exports: { ".": { default: "./src/index.ts" } } }),
|
|
42
|
+
"src/index.ts": "",
|
|
43
|
+
"src/plugin.ts": "",
|
|
44
|
+
});
|
|
45
|
+
expect(pluginEntryFor(dir)).toBe(join(dir, "src/index.ts"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("falls back to src/index.ts when no entry is declared", () => {
|
|
49
|
+
const dir = lexiconDir({ "src/index.ts": "" });
|
|
50
|
+
expect(pluginEntryFor(dir)).toBe(join(dir, "src/index.ts"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("falls back to src/plugin.ts when there is no index", () => {
|
|
54
|
+
const dir = lexiconDir({ "src/plugin.ts": "" });
|
|
55
|
+
expect(pluginEntryFor(dir)).toBe(join(dir, "src/plugin.ts"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("skips a declared entry that does not exist", () => {
|
|
59
|
+
const dir = lexiconDir({
|
|
60
|
+
"package.json": JSON.stringify({ exports: { ".": { default: "./dist/index.js" } } }),
|
|
61
|
+
"src/index.ts": "",
|
|
62
|
+
});
|
|
63
|
+
expect(pluginEntryFor(dir)).toBe(join(dir, "src/index.ts"));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns undefined when nothing is importable", () => {
|
|
67
|
+
expect(pluginEntryFor(lexiconDir({ "README.md": "" }))).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("loadLexiconFromDir", () => {
|
|
72
|
+
test("finds the plugin among the module's exports", async () => {
|
|
73
|
+
const dir = lexiconDir({ "src/index.ts": pluginSource("mock") });
|
|
74
|
+
const loaded = await loadLexiconFromDir(dir);
|
|
75
|
+
expect(loaded.error).toBeUndefined();
|
|
76
|
+
expect(loaded.plugin?.name).toBe("mock");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reports a directory with no importable entry rather than throwing", async () => {
|
|
80
|
+
const loaded = await loadLexiconFromDir(lexiconDir({ "README.md": "" }));
|
|
81
|
+
expect(loaded.plugin).toBeUndefined();
|
|
82
|
+
expect(loaded.error).toContain("no importable entry point");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reports a module that exports no plugin", async () => {
|
|
86
|
+
const dir = lexiconDir({ "src/index.ts": "export const notAPlugin = { name: 'x' };" });
|
|
87
|
+
const loaded = await loadLexiconFromDir(dir);
|
|
88
|
+
expect(loaded.plugin).toBeUndefined();
|
|
89
|
+
expect(loaded.error).toBe("the module exports no LexiconPlugin");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("reports an import failure as a finding, not a crash", async () => {
|
|
93
|
+
const dir = lexiconDir({ "src/index.ts": "this is not valid typescript ((((" });
|
|
94
|
+
const loaded = await loadLexiconFromDir(dir);
|
|
95
|
+
expect(loaded.plugin).toBeUndefined();
|
|
96
|
+
expect(loaded.error).toContain("import failed");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a plugin missing a required lifecycle method is not a LexiconPlugin", async () => {
|
|
100
|
+
const dir = lexiconDir({
|
|
101
|
+
"src/index.ts": `
|
|
102
|
+
export const plugin = {
|
|
103
|
+
name: "half",
|
|
104
|
+
serializer: { name: "half", serialize: () => "" },
|
|
105
|
+
generate: async () => {},
|
|
106
|
+
};
|
|
107
|
+
`,
|
|
108
|
+
});
|
|
109
|
+
expect((await loadLexiconFromDir(dir)).error).toBe("the module exports no LexiconPlugin");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("registers", () => {
|
|
114
|
+
const plugin = { name: "x", hoverProvider: () => undefined } as unknown as LexiconPlugin;
|
|
115
|
+
|
|
116
|
+
test("true for a function member", () => {
|
|
117
|
+
expect(registers(plugin, "hoverProvider")).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("false for an absent member — the helm case", () => {
|
|
121
|
+
expect(registers(plugin, "completionProvider")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("false when there is no plugin at all", () => {
|
|
125
|
+
expect(registers(undefined, "hoverProvider")).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("safeList", () => {
|
|
130
|
+
test("returns the list", () => {
|
|
131
|
+
expect(safeList(() => [1, 2])).toEqual({ items: [1, 2] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an absent member is an empty list, not an error", () => {
|
|
135
|
+
expect(safeList<number>(undefined)).toEqual({ items: [] });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a member that throws is reported rather than aborting the run", () => {
|
|
139
|
+
const result = safeList<number>(() => {
|
|
140
|
+
throw new Error("no rules directory");
|
|
141
|
+
});
|
|
142
|
+
expect(result.items).toEqual([]);
|
|
143
|
+
expect(result.error).toBe("no rules directory");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a member returning undefined is an empty list", () => {
|
|
147
|
+
expect(safeList(() => undefined as unknown as number[])).toEqual({ items: [] });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading a lexicon's plugin so completeness checks can look at what it
|
|
3
|
+
* actually registers (#1342).
|
|
4
|
+
*
|
|
5
|
+
* `check-lexicon`'s checks were almost all existence assertions over the
|
|
6
|
+
* directory: `src/lsp/completions.ts exists`, `At least 1 lint rule in
|
|
7
|
+
* src/lint/rules/`. A file with the right name is not the contract — the
|
|
8
|
+
* contract is the member the plugin exposes, because that is what core
|
|
9
|
+
* dispatches through. helm is the proof: it ships `src/lsp/completions.ts` and
|
|
10
|
+
* `src/lsp/hover.ts`, both with passing tests, exports `helmCompletions` and
|
|
11
|
+
* `helmHover` from its index, and never sets `completionProvider` or
|
|
12
|
+
* `hoverProvider` on the plugin. `cli/lsp/server.ts` dispatches through exactly
|
|
13
|
+
* those fields, so helm's LSP support is unreachable in an editor — while tier 1
|
|
14
|
+
* (the files) and tier 2 (the tests) both passed.
|
|
15
|
+
*
|
|
16
|
+
* Resolution is directory-local rather than by package name: `chant dev
|
|
17
|
+
* check-lexicon <dir>` should work on a lexicon that is not installed, and
|
|
18
|
+
* `loadPlugin` in ../plugins.ts imports `@intentius/chant-lexicon-<name>`,
|
|
19
|
+
* which requires it to be.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "fs";
|
|
23
|
+
import { isAbsolute, join, resolve } from "path";
|
|
24
|
+
import { pathToFileURL } from "url";
|
|
25
|
+
import { isLexiconPlugin, type LexiconPlugin } from "../../lexicon";
|
|
26
|
+
|
|
27
|
+
export interface LoadedLexicon {
|
|
28
|
+
/** The plugin, when the package exported one. */
|
|
29
|
+
plugin?: LexiconPlugin;
|
|
30
|
+
/** Why loading failed, for a check's `detail`. */
|
|
31
|
+
error?: string;
|
|
32
|
+
/** The entry point that was imported, for diagnostics. */
|
|
33
|
+
entry?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The module a lexicon package presents to consumers.
|
|
38
|
+
*
|
|
39
|
+
* Tier 1 requires `exports["."].default` to be `./src/index.ts`, so that is the
|
|
40
|
+
* first choice — it is the module core itself imports. The fallbacks keep this
|
|
41
|
+
* usable on a lexicon that has not reached that check yet.
|
|
42
|
+
*/
|
|
43
|
+
export function pluginEntryFor(dir: string): string | undefined {
|
|
44
|
+
const candidates: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as {
|
|
47
|
+
exports?: { "."?: { default?: string } };
|
|
48
|
+
main?: string;
|
|
49
|
+
};
|
|
50
|
+
for (const declared of [pkg.exports?.["."]?.default, pkg.main]) {
|
|
51
|
+
if (typeof declared === "string") candidates.push(declared);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// no package.json, or unreadable — fall through to the conventional paths
|
|
55
|
+
}
|
|
56
|
+
candidates.push("./src/index.ts", "./src/plugin.ts");
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const path = isAbsolute(candidate) ? candidate : resolve(dir, candidate);
|
|
60
|
+
if (existsSync(path)) return path;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Import a lexicon directory and return the `LexiconPlugin` it exports.
|
|
67
|
+
*
|
|
68
|
+
* Never throws: a lexicon that cannot be loaded is a finding, not a crash, and
|
|
69
|
+
* every caller reports it as a failed check rather than aborting the run.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadLexiconFromDir(dir: string): Promise<LoadedLexicon> {
|
|
72
|
+
const entry = pluginEntryFor(dir);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
return { error: "no importable entry point (looked for package.json exports, src/index.ts, src/plugin.ts)" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let mod: Record<string, unknown>;
|
|
78
|
+
try {
|
|
79
|
+
mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown>;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { entry, error: `import failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const value of Object.values(mod)) {
|
|
85
|
+
if (isLexiconPlugin(value)) return { plugin: value, entry };
|
|
86
|
+
}
|
|
87
|
+
// A default export whose own members are the plugin (some lexicons re-export
|
|
88
|
+
// a namespace rather than the object itself).
|
|
89
|
+
const fallback = (mod.default ?? {}) as Record<string, unknown>;
|
|
90
|
+
for (const value of Object.values(fallback)) {
|
|
91
|
+
if (isLexiconPlugin(value)) return { plugin: value, entry };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { entry, error: "the module exports no LexiconPlugin" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the plugin exposes a callable member under `name`. */
|
|
98
|
+
export function registers(plugin: LexiconPlugin | undefined, name: keyof LexiconPlugin): boolean {
|
|
99
|
+
return typeof plugin?.[name] === "function";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Call a plugin member that returns a list, treating a throw as an empty list.
|
|
104
|
+
*
|
|
105
|
+
* A member that throws is not a registration — it is worse than an absent one,
|
|
106
|
+
* and the check that counts its results should fail rather than the whole run.
|
|
107
|
+
*/
|
|
108
|
+
export function safeList<T>(fn: (() => T[]) | undefined): { items: T[]; error?: string } {
|
|
109
|
+
if (typeof fn !== "function") return { items: [] };
|
|
110
|
+
try {
|
|
111
|
+
return { items: fn() ?? [] };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { items: [], error: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -3,6 +3,9 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { auditIntrinsics } from "./check-lexicon-intrinsics";
|
|
4
4
|
import { checkExamplesBuild } from "./check-lexicon-examples";
|
|
5
5
|
import { auditDocsReachability } from "./check-lexicon-docs";
|
|
6
|
+
import { auditMcpNames, lexiconNameFor } from "./check-lexicon-mcp";
|
|
7
|
+
import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
|
|
8
|
+
import { RULE_CATALOG } from "../../audit/catalog";
|
|
6
9
|
|
|
7
10
|
// ── Types ────────────────────────────────────────────────────────────
|
|
8
11
|
|
|
@@ -73,45 +76,100 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
73
76
|
|
|
74
77
|
// ── Tier 1: Required ───────────────────────────────────────────
|
|
75
78
|
|
|
79
|
+
// #1342 — a capability is present when the plugin exposes it, not when a file
|
|
80
|
+
// with the right name sits on disk. The checks below used to be existence
|
|
81
|
+
// assertions, which is how helm shipped `src/lsp/completions.ts` and
|
|
82
|
+
// `src/lsp/hover.ts` (with tests) while registering neither provider, and
|
|
83
|
+
// passed every tier. `cli/lsp/server.ts` dispatches through the plugin
|
|
84
|
+
// members, so those files were unreachable in an editor.
|
|
85
|
+
const loaded = await loadLexiconFromDir(dir);
|
|
86
|
+
const plugin = loaded.plugin;
|
|
87
|
+
|
|
88
|
+
items.push({
|
|
89
|
+
name: "The package exports a LexiconPlugin",
|
|
90
|
+
tier: 1,
|
|
91
|
+
pass: plugin !== undefined,
|
|
92
|
+
detail: loaded.error ?? (loaded.entry ? `exported by ${basename(loaded.entry)}` : undefined),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const serializer = plugin?.serializer as { name?: unknown; rulePrefix?: unknown; serialize?: unknown } | undefined;
|
|
96
|
+
const serializerOk =
|
|
97
|
+
typeof serializer?.name === "string" &&
|
|
98
|
+
typeof serializer?.rulePrefix === "string" &&
|
|
99
|
+
typeof serializer?.serialize === "function";
|
|
76
100
|
items.push({
|
|
77
|
-
name: "
|
|
101
|
+
name: "The plugin exposes a Serializer with a name and rule prefix",
|
|
78
102
|
tier: 1,
|
|
79
|
-
pass:
|
|
103
|
+
pass: serializerOk,
|
|
104
|
+
detail: serializerOk
|
|
105
|
+
? `${String(serializer?.name)} (${String(serializer?.rulePrefix)})`
|
|
106
|
+
: plugin
|
|
107
|
+
? "serializer is missing name, rulePrefix, or serialize"
|
|
108
|
+
: undefined,
|
|
80
109
|
});
|
|
81
110
|
|
|
111
|
+
const lintRules = safeList(plugin?.lintRules?.bind(plugin));
|
|
82
112
|
items.push({
|
|
83
|
-
name: "
|
|
113
|
+
name: "lintRules() returns at least 1 rule",
|
|
84
114
|
tier: 1,
|
|
85
|
-
pass:
|
|
115
|
+
pass: lintRules.items.length > 0,
|
|
116
|
+
detail: lintRules.error ? `threw: ${lintRules.error}` : `${lintRules.items.length} rule(s)`,
|
|
86
117
|
});
|
|
87
118
|
|
|
88
|
-
const
|
|
119
|
+
const postSynthChecks = safeList(plugin?.postSynthChecks?.bind(plugin));
|
|
89
120
|
items.push({
|
|
90
|
-
name: "
|
|
121
|
+
name: "postSynthChecks() returns at least 1 check",
|
|
91
122
|
tier: 1,
|
|
92
|
-
pass:
|
|
93
|
-
detail:
|
|
123
|
+
pass: postSynthChecks.items.length > 0,
|
|
124
|
+
detail: postSynthChecks.error
|
|
125
|
+
? `threw: ${postSynthChecks.error}`
|
|
126
|
+
: `${postSynthChecks.items.length} check(s)`,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// #1349 — `rulePrefix` exists so ids do not collide when several lexicons are
|
|
130
|
+
// loaded together (forgejo wraps github's rules as `WFJ-GHA0xx` for exactly
|
|
131
|
+
// that reason), and it was checked by nothing. k8s shipped five `ARGO0xx`
|
|
132
|
+
// checks outside its declared `WK8`. Core's cross-cutting ids are exempt:
|
|
133
|
+
// they belong to core, not to whichever lexicon surfaces them.
|
|
134
|
+
const declaredPrefixes = [
|
|
135
|
+
typeof serializer?.rulePrefix === "string" ? serializer.rulePrefix : "",
|
|
136
|
+
...((plugin?.serializer as { extraRulePrefixes?: readonly string[] } | undefined)?.extraRulePrefixes ?? []),
|
|
137
|
+
].filter((p) => p.length > 0);
|
|
138
|
+
const allRuleIds = [
|
|
139
|
+
...lintRules.items.map((r) => (r as { id?: string }).id),
|
|
140
|
+
...postSynthChecks.items.map((c) => (c as { id?: string }).id),
|
|
141
|
+
].filter((id): id is string => typeof id === "string");
|
|
142
|
+
const offPrefix = allRuleIds.filter(
|
|
143
|
+
(id) => !(id in RULE_CATALOG) && !declaredPrefixes.some((p) => id.startsWith(p)),
|
|
144
|
+
);
|
|
145
|
+
items.push({
|
|
146
|
+
name: "Every rule id starts with a declared rule prefix",
|
|
147
|
+
tier: 1,
|
|
148
|
+
pass: declaredPrefixes.length > 0 && offPrefix.length === 0,
|
|
149
|
+
detail:
|
|
150
|
+
offPrefix.length > 0
|
|
151
|
+
? `${offPrefix.length} outside ${declaredPrefixes.join("/")}: ${[...new Set(offPrefix)].slice(0, 6).join(", ")}`
|
|
152
|
+
: declaredPrefixes.length > 0
|
|
153
|
+
? `${allRuleIds.length} id(s) under ${declaredPrefixes.join("/")}`
|
|
154
|
+
: "no rule prefix declared",
|
|
94
155
|
});
|
|
95
156
|
|
|
96
|
-
const postSynthFiles = listTsFiles(join(dir, "src/lint/post-synth"), ["index.ts"])
|
|
97
|
-
.filter((f) => !f.endsWith("-helpers.ts") && f !== "helpers.ts");
|
|
98
157
|
items.push({
|
|
99
|
-
name: "
|
|
158
|
+
name: "The plugin registers completionProvider",
|
|
100
159
|
tier: 1,
|
|
101
|
-
pass:
|
|
102
|
-
detail: postSynthFiles.length > 0 ? `${postSynthFiles.length} check(s)` : undefined,
|
|
160
|
+
pass: registers(plugin, "completionProvider"),
|
|
103
161
|
});
|
|
104
162
|
|
|
105
163
|
items.push({
|
|
106
|
-
name: "
|
|
164
|
+
name: "The plugin registers hoverProvider",
|
|
107
165
|
tier: 1,
|
|
108
|
-
pass:
|
|
166
|
+
pass: registers(plugin, "hoverProvider"),
|
|
109
167
|
});
|
|
110
168
|
|
|
111
169
|
items.push({
|
|
112
|
-
name: "
|
|
170
|
+
name: "The plugin registers docs()",
|
|
113
171
|
tier: 1,
|
|
114
|
-
pass:
|
|
172
|
+
pass: registers(plugin, "docs"),
|
|
115
173
|
});
|
|
116
174
|
|
|
117
175
|
items.push({
|
|
@@ -262,6 +320,24 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
262
320
|
: undefined,
|
|
263
321
|
});
|
|
264
322
|
|
|
323
|
+
// #1341 — core namespaces MCP contributions, and so do the shared helpers and
|
|
324
|
+
// most lexicons, so the names agents actually saw were `gitlab:gitlab:diff`
|
|
325
|
+
// and `chant://azure/chant://lexicon/azure/catalog`. The check is on the
|
|
326
|
+
// registered name rather than the declared one: three authored forms are in
|
|
327
|
+
// use and all of them are fine, but only one registered shape is.
|
|
328
|
+
const mcpNames = await auditMcpNames(dir);
|
|
329
|
+
items.push({
|
|
330
|
+
name: "MCP tools and resources register under one well-formed namespace",
|
|
331
|
+
tier: 1,
|
|
332
|
+
pass: mcpNames.violations.length === 0,
|
|
333
|
+
detail:
|
|
334
|
+
mcpNames.violations.length > 0
|
|
335
|
+
? mcpNames.violations.join(" | ")
|
|
336
|
+
: mcpNames.loaded
|
|
337
|
+
? `${mcpNames.checked} contribution(s) checked`
|
|
338
|
+
: "lexicon could not be loaded — not checked",
|
|
339
|
+
});
|
|
340
|
+
|
|
265
341
|
const hasPluginTest = findFiles(join(dir, "src"), (n) => n === "plugin.test.ts").length > 0;
|
|
266
342
|
items.push({
|
|
267
343
|
name: "plugin.test.ts exists",
|
|
@@ -302,21 +378,95 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
302
378
|
|
|
303
379
|
const pluginContent = readOr(join(dir, "src/plugin.ts"));
|
|
304
380
|
|
|
381
|
+
// #1342 — these were a regex over plugin.ts source text, which passed on a
|
|
382
|
+
// method declared in a form the regex happened to match and on one that
|
|
383
|
+
// throws when called. Ask the plugin instead.
|
|
305
384
|
for (const method of ["mcpTools", "mcpResources", "skills", "detectTemplate", "initTemplates"] as const) {
|
|
306
|
-
// Check for uncommented method: line starts with optional whitespace, then the method name
|
|
307
|
-
// Exclude lines that start with // or * (comment blocks)
|
|
308
|
-
const lines = pluginContent.split("\n");
|
|
309
|
-
const hasUncommented = lines.some((line) => {
|
|
310
|
-
const trimmed = line.trim();
|
|
311
|
-
return trimmed.startsWith(`${method}(`) || trimmed.startsWith(`${method} (`);
|
|
312
|
-
});
|
|
313
385
|
items.push({
|
|
314
|
-
name: `plugin
|
|
386
|
+
name: `The plugin registers ${method}`,
|
|
315
387
|
tier: 2,
|
|
316
|
-
pass:
|
|
388
|
+
pass: registers(plugin, method),
|
|
317
389
|
});
|
|
318
390
|
}
|
|
319
391
|
|
|
392
|
+
// #1346 — `resolveAuditCatalog` contributes nothing for a lexicon that omits
|
|
393
|
+
// the method, silently, so its checks surface in `chant audit` with no title,
|
|
394
|
+
// tier, fix kind, or category. Tier 2 rather than tier 1: the lexicon builds
|
|
395
|
+
// and lints correctly without it; what suffers is one command's output.
|
|
396
|
+
const auditCatalog = (() => {
|
|
397
|
+
try {
|
|
398
|
+
return plugin?.auditCatalog?.() ?? {};
|
|
399
|
+
} catch {
|
|
400
|
+
return {};
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
const uncatalogued = postSynthChecks.items
|
|
404
|
+
.map((c) => (c as { id?: string }).id)
|
|
405
|
+
.filter((id): id is string => typeof id === "string" && !(id in auditCatalog) && !(id in RULE_CATALOG));
|
|
406
|
+
items.push({
|
|
407
|
+
name: "auditCatalog() covers every post-synth check",
|
|
408
|
+
tier: 2,
|
|
409
|
+
pass: uncatalogued.length === 0,
|
|
410
|
+
detail:
|
|
411
|
+
uncatalogued.length > 0
|
|
412
|
+
? `${uncatalogued.length} without metadata: ${uncatalogued.slice(0, 6).join(", ")}`
|
|
413
|
+
: `${Object.keys(auditCatalog).length} entry/entries`,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// #1348 — the marker channel is a claim: declaring `reads: ["exportResources"]`
|
|
417
|
+
// while not implementing `exportResources` promises a verdict from a path
|
|
418
|
+
// that does not exist. The behavioral half lives in the observation
|
|
419
|
+
// conformance suite, which holds a declared path to a real verdict and an
|
|
420
|
+
// undeclared one to `unknown`; this is the static half.
|
|
421
|
+
const channel = plugin?.ownershipChannel;
|
|
422
|
+
const channelProblems: string[] = [];
|
|
423
|
+
if (channel) {
|
|
424
|
+
for (const path of channel.reads) {
|
|
425
|
+
if (!registers(plugin, path as keyof typeof plugin)) {
|
|
426
|
+
channelProblems.push(`declares a marker channel on ${path}, which the plugin does not implement`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const keys = channel.keys as { managedBy?: unknown; stack?: unknown; env?: unknown } | undefined;
|
|
430
|
+
for (const key of ["managedBy", "stack", "env"] as const) {
|
|
431
|
+
if (typeof keys?.[key] !== "string" || (keys[key] as string).length === 0) {
|
|
432
|
+
channelProblems.push(`marker keys are missing ${key}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (channel.reads.length === 0) {
|
|
436
|
+
channelProblems.push("declares marker keys but no read path — nothing can resolve a verdict");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
items.push({
|
|
440
|
+
name: "Any declared ownership channel names paths the plugin implements",
|
|
441
|
+
tier: 2,
|
|
442
|
+
pass: channelProblems.length === 0,
|
|
443
|
+
detail:
|
|
444
|
+
channelProblems.length > 0
|
|
445
|
+
? channelProblems.join("; ")
|
|
446
|
+
: channel
|
|
447
|
+
? `marker on ${channel.reads.join(", ")}`
|
|
448
|
+
: "no marker channel — every verdict must be unknown",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// #1344 — a lexicon that reads its own `chant.config.ts` namespace should
|
|
452
|
+
// declare its shape, or a typo inside that namespace is accepted and silently
|
|
453
|
+
// ignored: `forgejo: { runnerLabel: … }` left the dialect on its defaults with
|
|
454
|
+
// nothing said. Source scan, because the read happens deep in a serializer
|
|
455
|
+
// rather than anywhere the plugin object can be asked.
|
|
456
|
+
const lexiconName = lexiconNameFor(dir);
|
|
457
|
+
const readsOwnNamespace = findFiles(join(dir, "src"), (n) => n.endsWith(".ts") && !n.endsWith(".test.ts"))
|
|
458
|
+
.some((file) => new RegExp(`config\\s*\\??\\.\\s*${lexiconName}\\b`).test(readOr(file)));
|
|
459
|
+
items.push({
|
|
460
|
+
name: "Declares a configSchema if it reads its own config namespace",
|
|
461
|
+
tier: 2,
|
|
462
|
+
pass: !readsOwnNamespace || plugin?.configSchema !== undefined,
|
|
463
|
+
detail: readsOwnNamespace
|
|
464
|
+
? plugin?.configSchema
|
|
465
|
+
? `config.${lexiconName} is declared and validated`
|
|
466
|
+
: `reads config.${lexiconName} but declares no schema — unknown keys there are silently ignored`
|
|
467
|
+
: "reads no config namespace of its own",
|
|
468
|
+
});
|
|
469
|
+
|
|
320
470
|
const compositeFiles = listTsFiles(join(dir, "src/composites"), ["index.ts"]);
|
|
321
471
|
items.push({
|
|
322
472
|
name: "At least 1 composite in src/composites/",
|
|
@@ -4,6 +4,18 @@ import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
|
|
|
4
4
|
import type { BuildResult } from "../../build";
|
|
5
5
|
import type { ParsedArgs } from "../registry";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The aws emulator capability, as the real plugin declares it. `--live`
|
|
9
|
+
* endpoint injection reads the endpoint var off this rather than off a map
|
|
10
|
+
* keyed by lexicon name (#1345), so a mock that omits it gets no injection —
|
|
11
|
+
* the same thing that would happen in production.
|
|
12
|
+
*/
|
|
13
|
+
const awsEmulatorStub = {
|
|
14
|
+
spec: { name: "chant-floci", image: "floci/floci:1.5.34", containerPort: 4566, healthPath: "/_localstack/health" },
|
|
15
|
+
env: (endpoint: string) => ({ AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test" }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
|
|
7
19
|
const getHeadCommitMock = vi.fn();
|
|
8
20
|
const fetchLifecycleMock = vi.fn();
|
|
9
21
|
const pushLifecycleMock = vi.fn();
|
|
@@ -256,6 +268,7 @@ describe("components handlers", () => {
|
|
|
256
268
|
const plugins: LexiconPlugin[] = [
|
|
257
269
|
createMockPlugin({
|
|
258
270
|
name: "aws",
|
|
271
|
+
emulator: awsEmulatorStub,
|
|
259
272
|
describeResources: staticDescribeResources({ svc: meta() }),
|
|
260
273
|
}),
|
|
261
274
|
];
|
|
@@ -306,6 +319,7 @@ describe("components handlers", () => {
|
|
|
306
319
|
const plugins: LexiconPlugin[] = [
|
|
307
320
|
createMockPlugin({
|
|
308
321
|
name: "aws",
|
|
322
|
+
emulator: awsEmulatorStub,
|
|
309
323
|
describeResources: async () => {
|
|
310
324
|
seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
|
|
311
325
|
return { svc: meta() };
|
|
@@ -339,6 +353,7 @@ describe("components handlers", () => {
|
|
|
339
353
|
const plugins: LexiconPlugin[] = [
|
|
340
354
|
createMockPlugin({
|
|
341
355
|
name: "aws",
|
|
356
|
+
emulator: awsEmulatorStub,
|
|
342
357
|
describeResources: staticDescribeResources({ mystery: meta() }),
|
|
343
358
|
}),
|
|
344
359
|
];
|
|
@@ -391,6 +406,7 @@ describe("components handlers", () => {
|
|
|
391
406
|
const plugins: LexiconPlugin[] = [
|
|
392
407
|
createMockPlugin({
|
|
393
408
|
name: "aws",
|
|
409
|
+
emulator: awsEmulatorStub,
|
|
394
410
|
describeResources: staticDescribeResources({ "search-service-v2": meta() }),
|
|
395
411
|
}),
|
|
396
412
|
];
|
|
@@ -434,6 +450,7 @@ describe("components handlers", () => {
|
|
|
434
450
|
const plugins: LexiconPlugin[] = [
|
|
435
451
|
createMockPlugin({
|
|
436
452
|
name: "aws",
|
|
453
|
+
emulator: awsEmulatorStub,
|
|
437
454
|
describeResources: staticDescribeResources({ svc: meta() }),
|
|
438
455
|
}),
|
|
439
456
|
];
|