@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.
Files changed (130) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  5. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  6. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  7. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  8. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/graph.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  13. package/dist/cli/handlers/search.d.ts +30 -1
  14. package/dist/cli/handlers/search.d.ts.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/mcp/server.d.ts +26 -2
  17. package/dist/cli/mcp/server.d.ts.map +1 -1
  18. package/dist/cli/plugins.d.ts +20 -0
  19. package/dist/cli/plugins.d.ts.map +1 -1
  20. package/dist/codegen/registry.d.ts +23 -0
  21. package/dist/codegen/registry.d.ts.map +1 -0
  22. package/dist/components/sandbox/driver.d.ts.map +1 -1
  23. package/dist/composite.d.ts +23 -4
  24. package/dist/composite.d.ts.map +1 -1
  25. package/dist/deep-observation.d.ts +11 -0
  26. package/dist/deep-observation.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/kubectl-context.d.ts.map +1 -1
  34. package/dist/lexicon-config.d.ts +61 -0
  35. package/dist/lexicon-config.d.ts.map +1 -0
  36. package/dist/lexicon.d.ts +85 -37
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/deep-diff.d.ts +11 -0
  39. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/digest.d.ts.map +1 -1
  41. package/dist/lifecycle/identity.d.ts +52 -0
  42. package/dist/lifecycle/identity.d.ts.map +1 -0
  43. package/dist/lifecycle/observe.d.ts +5 -0
  44. package/dist/lifecycle/observe.d.ts.map +1 -1
  45. package/dist/lifecycle/replay.d.ts.map +1 -1
  46. package/dist/lifecycle/types.d.ts +30 -0
  47. package/dist/lifecycle/types.d.ts.map +1 -1
  48. package/dist/live-endpoint.d.ts +21 -22
  49. package/dist/live-endpoint.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/dist/op/emulator-freshness.d.ts +44 -0
  53. package/dist/op/emulator-freshness.d.ts.map +1 -0
  54. package/dist/op/emulator-lifecycle.d.ts +36 -0
  55. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +4 -2
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/ownership.d.ts +33 -0
  59. package/dist/ownership.d.ts.map +1 -1
  60. package/dist/serializer.d.ts +15 -0
  61. package/dist/serializer.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/audit/catalog.test.ts +58 -6
  64. package/src/build.ts +24 -0
  65. package/src/cli/commands/build.ts +10 -0
  66. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  67. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  68. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  69. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  70. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  71. package/src/cli/commands/check-lexicon.ts +176 -26
  72. package/src/cli/handlers/components.test.ts +17 -0
  73. package/src/cli/handlers/components.ts +1 -1
  74. package/src/cli/handlers/emulator.ts +12 -8
  75. package/src/cli/handlers/graph.test.ts +71 -12
  76. package/src/cli/handlers/graph.ts +46 -5
  77. package/src/cli/handlers/lifecycle.test.ts +25 -4
  78. package/src/cli/handlers/lifecycle.ts +19 -3
  79. package/src/cli/handlers/run-client.ts +3 -1
  80. package/src/cli/handlers/search-kind.test.ts +45 -0
  81. package/src/cli/handlers/search.ts +102 -4
  82. package/src/cli/main.ts +32 -10
  83. package/src/cli/mcp/server.test.ts +82 -0
  84. package/src/cli/mcp/server.ts +40 -5
  85. package/src/cli/param-flag-scope.test.ts +69 -0
  86. package/src/cli/plugins.test.ts +33 -1
  87. package/src/cli/plugins.ts +55 -0
  88. package/src/codegen/registry.test.ts +56 -0
  89. package/src/codegen/registry.ts +69 -0
  90. package/src/components/SPRAWL-VALIDATION.md +5 -5
  91. package/src/components/sandbox/driver.test.ts +27 -0
  92. package/src/components/sandbox/driver.ts +12 -0
  93. package/src/composite.ts +33 -4
  94. package/src/deep-observation.ts +11 -0
  95. package/src/discovery/sandbox/driver.test.ts +34 -0
  96. package/src/discovery/sandbox/driver.ts +19 -0
  97. package/src/graph-declared.test.ts +86 -0
  98. package/src/graph-declared.ts +14 -2
  99. package/src/graph-ir.ts +32 -8
  100. package/src/graph-refs.test.ts +56 -0
  101. package/src/graph-refs.ts +37 -1
  102. package/src/kubectl-context.ts +4 -1
  103. package/src/lexicon-config.test.ts +111 -0
  104. package/src/lexicon-config.ts +92 -0
  105. package/src/lexicon-doc-coverage.test.ts +128 -0
  106. package/src/lexicon-seams.test.ts +113 -0
  107. package/src/lexicon.ts +88 -38
  108. package/src/lifecycle/deep-diff.test.ts +48 -1
  109. package/src/lifecycle/deep-diff.ts +16 -0
  110. package/src/lifecycle/digest.test.ts +81 -0
  111. package/src/lifecycle/digest.ts +34 -3
  112. package/src/lifecycle/identity.test.ts +39 -0
  113. package/src/lifecycle/identity.ts +61 -0
  114. package/src/lifecycle/observe.test.ts +75 -1
  115. package/src/lifecycle/observe.ts +28 -2
  116. package/src/lifecycle/replay.test.ts +251 -0
  117. package/src/lifecycle/replay.ts +67 -19
  118. package/src/lifecycle/types.ts +26 -0
  119. package/src/live-endpoint.test.ts +51 -12
  120. package/src/live-endpoint.ts +32 -33
  121. package/src/managed-fields.test.ts +50 -0
  122. package/src/managed-fields.ts +25 -6
  123. package/src/meta/peer-deps.test.ts +111 -14
  124. package/src/op/emulator-declaration.test.ts +63 -0
  125. package/src/op/emulator-freshness.test.ts +135 -0
  126. package/src/op/emulator-freshness.ts +102 -0
  127. package/src/op/emulator-lifecycle.ts +49 -0
  128. package/src/op/index.ts +4 -2
  129. package/src/ownership.ts +41 -0
  130. 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: "src/plugin.ts exists",
101
+ name: "The plugin exposes a Serializer with a name and rule prefix",
78
102
  tier: 1,
79
- pass: existsSync(join(dir, "src/plugin.ts")),
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: "src/serializer.ts exists",
113
+ name: "lintRules() returns at least 1 rule",
84
114
  tier: 1,
85
- pass: existsSync(join(dir, "src/serializer.ts")),
115
+ pass: lintRules.items.length > 0,
116
+ detail: lintRules.error ? `threw: ${lintRules.error}` : `${lintRules.items.length} rule(s)`,
86
117
  });
87
118
 
88
- const ruleFiles = listTsFiles(join(dir, "src/lint/rules"), ["index.ts"]);
119
+ const postSynthChecks = safeList(plugin?.postSynthChecks?.bind(plugin));
89
120
  items.push({
90
- name: "At least 1 lint rule in src/lint/rules/",
121
+ name: "postSynthChecks() returns at least 1 check",
91
122
  tier: 1,
92
- pass: ruleFiles.length > 0,
93
- detail: ruleFiles.length > 0 ? `${ruleFiles.length} rule(s)` : undefined,
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: "At least 1 post-synth check",
158
+ name: "The plugin registers completionProvider",
100
159
  tier: 1,
101
- pass: postSynthFiles.length > 0,
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: "src/lsp/completions.ts exists",
164
+ name: "The plugin registers hoverProvider",
107
165
  tier: 1,
108
- pass: existsSync(join(dir, "src/lsp/completions.ts")),
166
+ pass: registers(plugin, "hoverProvider"),
109
167
  });
110
168
 
111
169
  items.push({
112
- name: "src/lsp/hover.ts exists",
170
+ name: "The plugin registers docs()",
113
171
  tier: 1,
114
- pass: existsSync(join(dir, "src/lsp/hover.ts")),
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.ts has uncommented ${method}`,
386
+ name: `The plugin registers ${method}`,
315
387
  tier: 2,
316
- pass: hasUncommented,
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
  ];