@intentius/chant 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  2. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  4. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/mcp/server.d.ts +26 -2
  10. package/dist/cli/mcp/server.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +66 -37
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/live-endpoint.d.ts +21 -22
  14. package/dist/live-endpoint.d.ts.map +1 -1
  15. package/dist/op/emulator-freshness.d.ts +44 -0
  16. package/dist/op/emulator-freshness.d.ts.map +1 -0
  17. package/dist/op/emulator-lifecycle.d.ts +36 -0
  18. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  19. package/dist/op/index.d.ts +4 -2
  20. package/dist/op/index.d.ts.map +1 -1
  21. package/dist/ownership.d.ts +33 -0
  22. package/dist/ownership.d.ts.map +1 -1
  23. package/dist/serializer.d.ts +15 -0
  24. package/dist/serializer.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/audit/catalog.test.ts +58 -6
  27. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  28. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  29. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  30. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  31. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  32. package/src/cli/commands/check-lexicon.ts +157 -26
  33. package/src/cli/handlers/components.test.ts +17 -0
  34. package/src/cli/handlers/components.ts +1 -1
  35. package/src/cli/handlers/emulator.ts +12 -8
  36. package/src/cli/handlers/graph.test.ts +71 -12
  37. package/src/cli/handlers/graph.ts +46 -5
  38. package/src/cli/handlers/lifecycle.test.ts +25 -4
  39. package/src/cli/handlers/lifecycle.ts +9 -3
  40. package/src/cli/mcp/server.test.ts +82 -0
  41. package/src/cli/mcp/server.ts +40 -5
  42. package/src/lexicon-doc-coverage.test.ts +128 -0
  43. package/src/lexicon-seams.test.ts +113 -0
  44. package/src/lexicon.ts +68 -38
  45. package/src/live-endpoint.test.ts +51 -12
  46. package/src/live-endpoint.ts +32 -33
  47. package/src/op/emulator-declaration.test.ts +63 -0
  48. package/src/op/emulator-freshness.test.ts +135 -0
  49. package/src/op/emulator-freshness.ts +102 -0
  50. package/src/op/emulator-lifecycle.ts +49 -0
  51. package/src/op/index.ts +4 -2
  52. package/src/ownership.ts +41 -0
  53. package/src/serializer.ts +16 -0
@@ -78,4 +78,37 @@ export declare function tagArrayToMap(tags: ReadonlyArray<{
78
78
  * undefined when the managed-by marker is absent.
79
79
  */
80
80
  export declare function readOwnership(tagsOrLabels: Record<string, unknown> | undefined, keys: ChannelKeys): OwnershipMarker | undefined;
81
+ /**
82
+ * A read path that can resolve an ownership verdict from the marker (#1348).
83
+ *
84
+ * Per-path rather than per-lexicon because the answer genuinely differs by
85
+ * path: aws stamps tags at synthesis and reads them on the deep observation and
86
+ * on live export, but `describeResources` is sourced from
87
+ * `describe-stack-resources`, which returns no tags at all — so an `owned: true`
88
+ * thin read against aws can only answer `unknown`.
89
+ */
90
+ export type OwnershipReadPath = "describeResources" | "observeResourcesDeep" | "exportResources";
91
+ /**
92
+ * Where a lexicon can stamp and read chant's ownership marker (#1348).
93
+ *
94
+ * `ResourceMetadata.ownership` documents an obligation — a lexicon with no
95
+ * marker channel on a path must stamp `unknown` rather than degrade silently,
96
+ * because the change set never escalates `unknown` to a delete — and that
97
+ * obligation had no type, no declaration, and no check. A caller could not
98
+ * learn whether `owned: true` was answerable except by asking and reading a
99
+ * warning on stderr afterwards, which is invisible to `lifecycle plan`, which
100
+ * is where the wrong delete gets proposed.
101
+ *
102
+ * Absent means the lexicon has no marker channel at all: every verdict it
103
+ * returns must be `unknown`. Declaring one is a claim the conformance suite
104
+ * checks — on a declared path, verdicts must be `owned` or `foreign`.
105
+ */
106
+ export interface OwnershipChannel {
107
+ /** The provider-native keys this lexicon stamps into. */
108
+ readonly keys: ChannelKeys;
109
+ /** The read paths that resolve a verdict from the marker. */
110
+ readonly reads: readonly OwnershipReadPath[];
111
+ }
112
+ /** Whether this lexicon resolves a real verdict on `path`, or can only say `unknown`. */
113
+ export declare function resolvesOwnershipOn(channel: OwnershipChannel | undefined, path: OwnershipReadPath): boolean;
81
114
  //# sourceMappingURL=ownership.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ownership.d.ts","sourceRoot":"","sources":["../src/ownership.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,4DAA4D;AAC5D,eAAO,MAAM,0BAA0B,UAAU,CAAC;AAElD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAIlC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,eAAe,GACtB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOxB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,OAAO,CAGT;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,OAAO,GAAG,SAAS,CAErB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,CAAC;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,SAAS,GACjE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,eAAe,GAAG,SAAS,CAQ7B"}
1
+ {"version":3,"file":"ownership.d.ts","sourceRoot":"","sources":["../src/ownership.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,4DAA4D;AAC5D,eAAO,MAAM,0BAA0B,UAAU,CAAC;AAElD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAIlC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,eAAe,GACtB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOxB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,OAAO,CAGT;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,OAAO,GAAG,SAAS,CAErB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,CAAC;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,SAAS,GACjE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACjD,IAAI,EAAE,WAAW,GAChB,eAAe,GAAG,SAAS,CAQ7B;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,mBAAmB,GAAG,sBAAsB,GAAG,iBAAiB,CAAC;AAEjG;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,SAAS,iBAAiB,EAAE,CAAC;CAC9C;AAED,yFAAyF;AACzF,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,gBAAgB,GAAG,SAAS,EACrC,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAET"}
@@ -46,6 +46,21 @@ export interface Serializer {
46
46
  * Prefix used for rules in this lexicon
47
47
  */
48
48
  rulePrefix: string;
49
+ /**
50
+ * Further id families this lexicon owns, beyond {@link rulePrefix} (#1349).
51
+ *
52
+ * The prefix exists so ids do not collide when several lexicons are loaded
53
+ * together — forgejo wraps github's rules as `WFJ-GHA0xx` for exactly that
54
+ * reason. It was declared and checked by nothing, and k8s quietly shipped
55
+ * five `ARGO0xx` checks outside its own `WK8`.
56
+ *
57
+ * A second family is sometimes right: Argo CD is a distinct product surface
58
+ * that happens to be covered by the k8s lexicon, and renaming published ids
59
+ * would break every `chant-disable ARGO001` in the wild. Declaring it keeps
60
+ * the collision guarantee while allowing the split — an undeclared family is
61
+ * a tier-1 failure.
62
+ */
63
+ extraRulePrefixes?: readonly string[];
49
64
  /**
50
65
  * Serializes the entities to a string representation
51
66
  * @param entities - Map of entity name to Declarable entity
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAE5B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+BAA+B;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB;;OAEG;IACH,IAAI,EAAE,MAAM,CAAC;IAEb;;OAEG;IACH,UAAU,EAAE,MAAM,CAAC;IAEnB;;;;;OAKG;IACH,SAAS,CACP,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EACjC,OAAO,CAAC,EAAE,aAAa,EAAE,EACzB,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,GAAG,gBAAgB,CAAC;IAE7B;;;;;OAKG;IACH,iBAAiB,CAAC,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC;CACpD"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAE5B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+BAA+B;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB;;OAEG;IACH,IAAI,EAAE,MAAM,CAAC;IAEb;;OAEG;IACH,UAAU,EAAE,MAAM,CAAC;IAEnB;;;;;;;;;;;;;OAaG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAEtC;;;;;OAKG;IACH,SAAS,CACP,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EACjC,OAAO,CAAC,EAAE,aAAa,EAAE,EACzB,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,GAAG,gBAAgB,CAAC;IAE7B;;;;;OAKG;IACH,iBAAiB,CAAC,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC;CACpD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -1,10 +1,24 @@
1
1
  import { describe, test, expect } from "vitest";
2
+ import { readdirSync } from "fs";
3
+ import { join } from "path";
2
4
  import { RULE_CATALOG, RULE_CATEGORY, auditRule, resolveAuditCatalog } from "./catalog";
3
5
  import { loadPlugins } from "../cli/plugins";
4
6
 
5
- const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm"];
6
-
7
- /** All post-synth check ids the audit can actually surface, from the lexicons. */
7
+ /**
8
+ * Every lexicon in the repo, enumerated rather than listed (#1346).
9
+ *
10
+ * This was nine hardcoded names. It missed fountain, which contributes a
11
+ * catalog, and could not notice fly and temporal, which did not — so their six
12
+ * post-synth checks reached `chant audit` with no title, tier, fix kind, or
13
+ * category, and the guard meant to catch exactly that was blind to them. A
14
+ * thirteenth lexicon would have been invisible to it on arrival.
15
+ */
16
+ const AUDIT_LEXICONS = readdirSync(join(__dirname, "../../../../lexicons"), { withFileTypes: true })
17
+ .filter((e) => e.isDirectory())
18
+ .map((e) => e.name)
19
+ .sort();
20
+
21
+ /** Post-synth check ids — what the catalog is required to cover. */
8
22
  async function realCheckIds(): Promise<Set<string>> {
9
23
  const plugins = await loadPlugins(AUDIT_LEXICONS);
10
24
  const ids = new Set<string>();
@@ -16,6 +30,27 @@ async function realCheckIds(): Promise<Set<string>> {
16
30
  return ids;
17
31
  }
18
32
 
33
+ /**
34
+ * Every rule id a lexicon ships, post-synth or lint — what a catalog entry is
35
+ * allowed to name.
36
+ *
37
+ * Wider than {@link realCheckIds} on purpose. A catalog must cover the
38
+ * post-synth checks, because those are what `chant audit` surfaces, but an
39
+ * entry for a lint rule is not stale: fountain documents FTN001, a lint rule,
40
+ * because a reader hitting it wants the same metadata. Requiring the reverse —
41
+ * an entry for every lint rule in every lexicon — would be a much larger claim
42
+ * than the audit makes.
43
+ */
44
+ async function shippedRuleIds(): Promise<Set<string>> {
45
+ const plugins = await loadPlugins(AUDIT_LEXICONS);
46
+ const ids = new Set<string>();
47
+ for (const plugin of plugins) {
48
+ for (const check of plugin.postSynthChecks?.() ?? []) ids.add(check.id);
49
+ for (const rule of plugin.lintRules?.() ?? []) ids.add(rule.id);
50
+ }
51
+ return ids;
52
+ }
53
+
19
54
  /** The effective catalog the auditor uses — core static + every lexicon's contributed entries (#687). */
20
55
  const aggregate = () => resolveAuditCatalog(AUDIT_LEXICONS);
21
56
 
@@ -26,12 +61,29 @@ describe("RULE_CATALOG (aggregated: core static + lexicon-contributed, #687)", (
26
61
  expect(missing).toEqual([]);
27
62
  });
28
63
 
29
- test("has no stale entries that aren't real checks", async () => {
30
- const [real, catalog] = [await realCheckIds(), await aggregate()];
31
- const stale = Object.keys(catalog).filter((id) => !real.has(id)).sort();
64
+ test("has no stale entries that aren't real rules", async () => {
65
+ const [shipped, catalog] = [await shippedRuleIds(), await aggregate()];
66
+ const stale = Object.keys(catalog)
67
+ .filter((id) => !shipped.has(id))
68
+ // Core's own cross-cutting ids are not any one lexicon's to ship.
69
+ .filter((id) => !(id in RULE_CATALOG))
70
+ .sort();
32
71
  expect(stale).toEqual([]);
33
72
  });
34
73
 
74
+ test("every lexicon that ships post-synth checks contributes a catalog for them", async () => {
75
+ const plugins = await loadPlugins(AUDIT_LEXICONS);
76
+ const uncatalogued = plugins
77
+ .map((plugin) => {
78
+ const checks = (plugin.postSynthChecks?.() ?? []).map((c) => c.id);
79
+ const catalog = plugin.auditCatalog?.() ?? {};
80
+ const missing = checks.filter((id) => !(id in catalog) && !(id in RULE_CATALOG));
81
+ return missing.length > 0 ? `${plugin.name}: ${missing.join(", ")}` : undefined;
82
+ })
83
+ .filter((entry): entry is string => entry !== undefined);
84
+ expect(uncatalogued).toEqual([]);
85
+ });
86
+
35
87
  test("every entry has a title, remediation, and valid tier/fixKind/category", async () => {
36
88
  for (const [id, m] of Object.entries(await aggregate())) {
37
89
  expect(m.id, `${id} id matches key`).toBe(id);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The completeness checklist and the tool it describes must agree (#1343).
3
+ *
4
+ * They did not. The doc listed 16/16/14 checks where the tool ran 18/14/9 —
5
+ * seven rows described checks that do not exist anywhere in `check-lexicon.ts`
6
+ * (`default-labels.test.ts`, `coverage.test.ts`, the three `import/*` tests,
7
+ * `exportResources()` implemented, and the ownership marker), and two real
8
+ * tier-1 checks were documented nowhere. The two missing ones were the damaging
9
+ * direction: an author reading the checklist would have believed live export and
10
+ * ownership marking were verified somewhere, and nothing verifies either.
11
+ *
12
+ * A hand-maintained parallel description of a tool drifts. This test makes the
13
+ * doc's first column the check's own `name`, so drift is a build failure rather
14
+ * than something a reviewer has to notice.
15
+ */
16
+
17
+ import { describe, test, expect } from "vitest";
18
+ import { readFileSync } from "fs";
19
+ import { join } from "path";
20
+ import { checkLexicon } from "./check-lexicon";
21
+
22
+ const CHECKLIST = join(
23
+ __dirname,
24
+ "../../../../../docs/src/content/docs/lexicon-authoring/completeness-checklist.mdx",
25
+ );
26
+
27
+ /** The check names listed under `## Tier <n>` in the checklist. */
28
+ function documentedChecks(markdown: string, tier: 1 | 2 | 3): string[] {
29
+ const section = markdown.split(new RegExp(`^## Tier ${tier}\\b`, "m"))[1];
30
+ if (section === undefined) throw new Error(`no "## Tier ${tier}" section in the checklist`);
31
+ const body = section.split(/^## /m)[0];
32
+ const names: string[] = [];
33
+ for (const line of body.split("\n")) {
34
+ // A table row whose first cell is a backticked check name.
35
+ const match = /^\|\s*`(.+?)`\s*\|/.exec(line);
36
+ if (match) names.push(match[1]);
37
+ }
38
+ return names;
39
+ }
40
+
41
+ describe("the completeness checklist matches check-lexicon (#1343)", () => {
42
+ const markdown = readFileSync(CHECKLIST, "utf-8");
43
+
44
+ // aws is the reference lexicon; the set of checks run is the same for any
45
+ // directory, since every check is pushed unconditionally.
46
+ const resultPromise = checkLexicon(join(__dirname, "../../../../../lexicons/aws"));
47
+
48
+ for (const tier of [1, 2, 3] as const) {
49
+ test(`tier ${tier} documents exactly the checks the tool runs`, async () => {
50
+ const executed = (await resultPromise).items.filter((i) => i.tier === tier).map((i) => i.name);
51
+ const documented = documentedChecks(markdown, tier);
52
+
53
+ const undocumented = executed.filter((n) => !documented.includes(n));
54
+ const phantom = documented.filter((n) => !executed.includes(n));
55
+
56
+ expect({ undocumented, phantom }).toEqual({ undocumented: [], phantom: [] });
57
+ expect(documented).toHaveLength(executed.length);
58
+ });
59
+ }
60
+
61
+ test("the checklist lists the checks in the order the tool reports them", async () => {
62
+ for (const tier of [1, 2, 3] as const) {
63
+ const executed = (await resultPromise).items.filter((i) => i.tier === tier).map((i) => i.name);
64
+ expect(documentedChecks(markdown, tier)).toEqual(executed);
65
+ }
66
+ });
67
+
68
+ test("no tier section is empty — a parsing regression would silently pass the set comparison", () => {
69
+ for (const tier of [1, 2, 3] as const) {
70
+ expect(documentedChecks(markdown, tier).length).toBeGreaterThan(0);
71
+ }
72
+ });
73
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { mcpNameViolations, lexiconNameFor } from "./check-lexicon-mcp";
6
+
7
+ const tools = (...names: string[]) => names.map((name) => ({ name }));
8
+ const resources = (...uris: string[]) => uris.map((uri) => ({ uri }));
9
+
10
+ describe("mcpNameViolations (#1341)", () => {
11
+ test("a bare tool name is fine — core supplies the namespace", () => {
12
+ expect(mcpNameViolations("gitlab", tools("migrate"), [])).toEqual([]);
13
+ });
14
+
15
+ test("a self-prefixed tool name is fine — core does not double it", () => {
16
+ expect(mcpNameViolations("gitlab", tools("gitlab:diff"), [])).toEqual([]);
17
+ });
18
+
19
+ test("a name carrying another lexicon's prefix is a violation", () => {
20
+ // This is the shape the doubling bug produced: `gitlab:gitlab:diff`.
21
+ const found = mcpNameViolations("git", tools("gitlab:diff"), []);
22
+ expect(found).toHaveLength(1);
23
+ expect(found[0]).toContain("git:gitlab:diff");
24
+ });
25
+
26
+ test("an uppercase or spaced tool name is a violation", () => {
27
+ expect(mcpNameViolations("aws", tools("Diff Resources"), [])).toHaveLength(1);
28
+ expect(mcpNameViolations("aws", tools("Diff"), [])).toHaveLength(1);
29
+ });
30
+
31
+ test("hyphenated verbs are allowed", () => {
32
+ expect(mcpNameViolations("github", tools("github:workflow-yaml"), [])).toEqual([]);
33
+ });
34
+
35
+ test("a bare resource path is fine", () => {
36
+ expect(mcpNameViolations("aws", [], resources("examples/s3-bucket"))).toEqual([]);
37
+ });
38
+
39
+ test("the colon form the shared catalog helper emits is fine", () => {
40
+ expect(mcpNameViolations("aws", [], resources("aws:resource-catalog"))).toEqual([]);
41
+ });
42
+
43
+ test("the chant://lexicon/<name>/ form the authoring docs taught is fine once normalized", () => {
44
+ expect(mcpNameViolations("azure", [], resources("chant://lexicon/azure/catalog"))).toEqual([]);
45
+ });
46
+
47
+ test("a uri carrying a foreign scheme is a violation", () => {
48
+ // azure shipped `chant://azure/chant://lexicon/azure/catalog` this way when
49
+ // the lexicon name in the uri did not match the lexicon being registered.
50
+ const found = mcpNameViolations("aws", [], resources("chant://lexicon/azure/catalog"));
51
+ expect(found).toHaveLength(1);
52
+ expect(found[0]).toContain("chant://aws/chant://lexicon/azure/catalog");
53
+ });
54
+
55
+ test("an already-registered uri passes through", () => {
56
+ expect(mcpNameViolations("aws", [], resources("chant://aws/catalog"))).toEqual([]);
57
+ });
58
+
59
+ test("tools and resources are reported together", () => {
60
+ expect(mcpNameViolations("aws", tools("Bad Name"), resources("chant://other/x"))).toHaveLength(2);
61
+ });
62
+
63
+ test("a lexicon contributing nothing has nothing to violate", () => {
64
+ expect(mcpNameViolations("fly", [], [])).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("lexiconNameFor", () => {
69
+ function fixture(pkg?: Record<string, unknown>): string {
70
+ const dir = mkdtempSync(join(tmpdir(), "chant-mcp-name-"));
71
+ mkdirSync(dir, { recursive: true });
72
+ if (pkg) writeFileSync(join(dir, "package.json"), JSON.stringify(pkg));
73
+ return dir;
74
+ }
75
+
76
+ test("reads the lexicon name out of the package name", () => {
77
+ expect(lexiconNameFor(fixture({ name: "@intentius/chant-lexicon-aws" }))).toBe("aws");
78
+ });
79
+
80
+ test("handles a hyphenated lexicon name", () => {
81
+ expect(lexiconNameFor(fixture({ name: "@intentius/chant-lexicon-my-cloud" }))).toBe("my-cloud");
82
+ });
83
+
84
+ test("falls back to the directory name when package.json is missing", () => {
85
+ const dir = fixture();
86
+ expect(lexiconNameFor(dir)).toBe(dir.split("/").pop());
87
+ });
88
+
89
+ test("falls back when the package name is not a lexicon package", () => {
90
+ const dir = fixture({ name: "@intentius/chant" });
91
+ expect(lexiconNameFor(dir)).toBe(dir.split("/").pop());
92
+ });
93
+ });
@@ -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
+ });