@intentius/chant 0.0.22 → 0.0.24

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
  3. package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
  4. package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
  5. package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
  6. package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
  7. package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
  8. package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
  9. package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
  10. package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
  11. package/src/cli/commands/init-lexicon.ts +12 -774
  12. package/src/cli/conflict-check.test.ts +43 -0
  13. package/src/cli/main.ts +1 -1
  14. package/src/cli/mcp/resource-handlers.ts +227 -0
  15. package/src/cli/mcp/server.ts +20 -409
  16. package/src/cli/mcp/state-tools.ts +138 -0
  17. package/src/cli/mcp/types.ts +45 -0
  18. package/src/codegen/docs-file-markers.ts +69 -0
  19. package/src/codegen/docs-rule-scanning.ts +159 -0
  20. package/src/codegen/docs-sections.ts +159 -0
  21. package/src/codegen/docs-sidebar.ts +56 -0
  22. package/src/codegen/docs-types.ts +79 -0
  23. package/src/codegen/docs.ts +9 -495
  24. package/src/composite.test.ts +75 -0
  25. package/src/composite.ts +37 -0
  26. package/src/discovery/collect.test.ts +34 -0
  27. package/src/discovery/collect.ts +12 -0
  28. package/src/lexicon-plugin-helpers.ts +130 -0
  29. package/src/toml-emit.ts +182 -0
  30. package/src/toml-parse.ts +370 -0
  31. package/src/toml-utils.ts +60 -0
  32. package/src/toml.ts +5 -602
@@ -0,0 +1,69 @@
1
+ /**
2
+ * File marker interpolation and MDX escaping utilities.
3
+ */
4
+
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ /** Escape curly braces so MDX doesn't treat them as JSX expressions. */
9
+ export function escapeMdx(text: string): string {
10
+ return text.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
11
+ }
12
+
13
+ /**
14
+ * Expand `{{file:path.ts}}` markers in content with fenced code blocks.
15
+ *
16
+ * Supported forms:
17
+ * - `{{file:path.ts}}` — full file
18
+ * - `{{file:path.ts:5-12}}` — lines 5–12 (1-based, inclusive)
19
+ * - `{{file:path.ts|title=custom.ts}}` — override the code block title
20
+ * - `{{file:path.ts:5-12|title=custom.ts}}` — both
21
+ */
22
+ export function expandFileMarkers(content: string, examplesDir: string): string {
23
+ return content.replace(
24
+ /\{\{file:([^}]+)\}\}/g,
25
+ (_match, spec: string) => {
26
+ // Parse options after |
27
+ let filePart = spec;
28
+ let title: string | undefined;
29
+ const pipeIdx = spec.indexOf("|");
30
+ if (pipeIdx !== -1) {
31
+ filePart = spec.substring(0, pipeIdx);
32
+ const opts = spec.substring(pipeIdx + 1);
33
+ const titleMatch = opts.match(/title=([^\s|]+)/);
34
+ if (titleMatch) title = titleMatch[1];
35
+ }
36
+
37
+ // Parse line range after :digits-digits at end of filePart
38
+ let lineStart: number | undefined;
39
+ let lineEnd: number | undefined;
40
+ const rangeMatch = filePart.match(/^(.+):(\d+)-(\d+)$/);
41
+ if (rangeMatch) {
42
+ filePart = rangeMatch[1];
43
+ lineStart = parseInt(rangeMatch[2], 10);
44
+ lineEnd = parseInt(rangeMatch[3], 10);
45
+ }
46
+
47
+ const filePath = join(examplesDir, filePart);
48
+ let fileContent: string;
49
+ try {
50
+ fileContent = readFileSync(filePath, "utf-8");
51
+ } catch {
52
+ throw new Error(`File marker {{file:${spec}}} — file not found: ${filePath}`);
53
+ }
54
+
55
+ // Extract line range if specified
56
+ if (lineStart !== undefined && lineEnd !== undefined) {
57
+ const lines = fileContent.split("\n");
58
+ fileContent = lines.slice(lineStart - 1, lineEnd).join("\n");
59
+ }
60
+
61
+ // Determine language from extension
62
+ const ext = filePart.substring(filePart.lastIndexOf(".") + 1);
63
+ const lang = ext === "ts" || ext === "tsx" ? "typescript" : ext;
64
+ const displayTitle = title ?? filePart.substring(filePart.lastIndexOf("/") + 1);
65
+
66
+ return `\`\`\`${lang} title="${displayTitle}"\n${fileContent.trimEnd()}\n\`\`\``;
67
+ },
68
+ );
69
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Rule scanning and rule page generation for lexicon documentation.
3
+ *
4
+ * Scans lint rule and post-synth check source files to extract metadata,
5
+ * and generates an MDX page listing them.
6
+ */
7
+
8
+ import { readFileSync, readdirSync } from "fs";
9
+ import { join } from "path";
10
+
11
+ import { escapeMdx } from "./docs-file-markers";
12
+ import type { DocsConfig, RuleMeta } from "./docs-types";
13
+
14
+ /**
15
+ * Scan lint rule and post-synth check source files to extract metadata.
16
+ * Uses regex to find id, severity, category, and description from source.
17
+ */
18
+ export function scanRules(srcDir: string): RuleMeta[] {
19
+ const rules: RuleMeta[] = [];
20
+
21
+ // Scan lint rules
22
+ scanDir(join(srcDir, "lint", "rules"), "lint", rules);
23
+
24
+ // Scan post-synth checks
25
+ scanDir(join(srcDir, "lint", "post-synth"), "post-synth", rules);
26
+
27
+ return rules;
28
+ }
29
+
30
+ function scanDir(dir: string, type: "lint" | "post-synth", out: RuleMeta[]): void {
31
+ let entries: string[];
32
+ try {
33
+ entries = readdirSync(dir);
34
+ } catch {
35
+ return;
36
+ }
37
+
38
+ for (const entry of entries) {
39
+ if (!entry.endsWith(".ts")) continue;
40
+ if (entry.endsWith(".test.ts")) continue;
41
+ if (entry === "index.ts") continue;
42
+ // Skip utility files (no rule definitions)
43
+ if (entry === "cf-refs.ts") continue;
44
+
45
+ let content: string;
46
+ try {
47
+ content = readFileSync(join(dir, entry), "utf-8");
48
+ } catch {
49
+ continue;
50
+ }
51
+
52
+ if (type === "lint") {
53
+ // Extract from LintRule objects: id, severity, category
54
+ const idMatch = content.match(/id:\s*"([^"]+)"/);
55
+ const severityMatch = content.match(/severity:\s*"([^"]+)"/);
56
+ const categoryMatch = content.match(/category:\s*"([^"]+)"/);
57
+
58
+ if (idMatch) {
59
+ // Try to get description from JSDoc comment
60
+ const descMatch = content.match(
61
+ new RegExp(`\\*\\s*${idMatch[1]}:\\s*(.+?)\\n`),
62
+ );
63
+
64
+ out.push({
65
+ id: idMatch[1],
66
+ severity: severityMatch?.[1] ?? "warning",
67
+ category: categoryMatch?.[1] ?? "general",
68
+ description:
69
+ descMatch?.[1]?.trim() ??
70
+ extractDescriptionFromComment(content, idMatch[1]),
71
+ type: "lint",
72
+ });
73
+ }
74
+ } else {
75
+ // Extract from PostSynthCheck objects: id, description
76
+ const idMatch = content.match(/id:\s*"([^"]+)"/);
77
+ const descMatch = content.match(/description:\s*"([^"]+)"/);
78
+
79
+ if (idMatch) {
80
+ out.push({
81
+ id: idMatch[1],
82
+ severity: "error",
83
+ category: "post-synth",
84
+ description: descMatch?.[1] ?? idMatch[1],
85
+ type: "post-synth",
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ function extractDescriptionFromComment(
93
+ content: string,
94
+ ruleId: string,
95
+ ): string {
96
+ // Try to extract from the first line after the rule ID in JSDoc
97
+ const pattern = new RegExp(
98
+ `${ruleId}[:\\s]+([^\\n]+?)\\n\\s*\\*\\s*\\n\\s*\\*\\s*(.+?)\\n`,
99
+ );
100
+ const match = content.match(pattern);
101
+ if (match) return match[1].trim();
102
+
103
+ // Fallback: use text after "ruleId:" in JSDoc
104
+ const simpleMatch = content.match(
105
+ new RegExp(`\\*\\s*${ruleId}:\\s*(.+?)(?:\\n|\\*)`),
106
+ );
107
+ if (simpleMatch) return simpleMatch[1].trim();
108
+
109
+ return ruleId;
110
+ }
111
+
112
+ export function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
113
+ const lintRules = rules.filter((r) => r.type === "lint");
114
+ const postSynthRules = rules.filter((r) => r.type === "post-synth");
115
+
116
+ const lines: string[] = [
117
+ "---",
118
+ `title: "Lint Rules"`,
119
+ `description: "Lint rules and post-synth checks provided by the ${config.displayName} lexicon"`,
120
+ "---",
121
+ "",
122
+ `The ${config.displayName} lexicon provides **${rules.length}** rules: ${lintRules.length} lint rules and ${postSynthRules.length} post-synth checks.`,
123
+ "",
124
+ ];
125
+
126
+ if (lintRules.length > 0) {
127
+ lines.push(
128
+ "## Lint Rules",
129
+ "",
130
+ "| ID | Severity | Category | Description |",
131
+ "|----|----------|----------|-------------|",
132
+ );
133
+ for (const rule of lintRules.sort((a, b) => a.id.localeCompare(b.id))) {
134
+ lines.push(
135
+ `| \`${rule.id}\` | ${rule.severity} | ${rule.category} | ${escapeMdx(rule.description)} |`,
136
+ );
137
+ }
138
+ lines.push("");
139
+ }
140
+
141
+ if (postSynthRules.length > 0) {
142
+ lines.push(
143
+ "## Post-Synth Checks",
144
+ "",
145
+ "Post-synth checks validate the serialized output after the build pipeline completes.",
146
+ "",
147
+ "| ID | Description |",
148
+ "|----|-------------|",
149
+ );
150
+ for (const rule of postSynthRules.sort((a, b) =>
151
+ a.id.localeCompare(b.id),
152
+ )) {
153
+ lines.push(`| \`${rule.id}\` | ${escapeMdx(rule.description)} |`);
154
+ }
155
+ lines.push("");
156
+ }
157
+
158
+ return lines.join("\n");
159
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Section content generators for lexicon documentation pages.
3
+ *
4
+ * Each function produces a complete MDX page string with frontmatter.
5
+ */
6
+
7
+ import { escapeMdx } from "./docs-file-markers";
8
+ import type { DocsConfig, ManifestJSON, MetaEntry, RuleMeta } from "./docs-types";
9
+
10
+ export function generateOverview(
11
+ config: DocsConfig,
12
+ manifest: ManifestJSON,
13
+ resources: Map<string, MetaEntry>,
14
+ properties: Map<string, MetaEntry>,
15
+ serviceGroups: Map<string, Map<string, MetaEntry>>,
16
+ rules: RuleMeta[],
17
+ ): string {
18
+ const lines: string[] = [
19
+ "---",
20
+ `title: "${config.displayName}"`,
21
+ `description: "${config.description}"`,
22
+ "---",
23
+ "",
24
+ config.overview ?? `Reference documentation for the **${config.displayName}** lexicon.`,
25
+ "",
26
+ "## At a Glance",
27
+ "",
28
+ `| Metric | Count |`,
29
+ `|--------|-------|`,
30
+ `| Resources | ${resources.size} |`,
31
+ `| Property types | ${properties.size} |`,
32
+ `| Services | ${serviceGroups.size} |`,
33
+ `| Intrinsic functions | ${manifest.intrinsics?.length ?? 0} |`,
34
+ `| Pseudo-parameters | ${Object.keys(manifest.pseudoParameters ?? {}).length} |`,
35
+ `| Lint rules | ${rules.length} |`,
36
+ "",
37
+ `**Lexicon version:** ${manifest.version} `,
38
+ `**Namespace:** \`${manifest.namespace ?? manifest.name}\``,
39
+ "",
40
+ ];
41
+
42
+ const suppress = new Set(config.suppressPages ?? []);
43
+
44
+ // Extra pages listed first in reference links
45
+ if (config.extraPages && config.extraPages.length > 0) {
46
+ for (const page of config.extraPages) {
47
+ if (page.sidebar === false) continue;
48
+ lines.push(`- [${page.title}](./${page.slug})`);
49
+ }
50
+ }
51
+
52
+ if (!suppress.has("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
53
+ lines.push(
54
+ `- [Intrinsic Functions](./intrinsics) — ${manifest.intrinsics.length} built-in functions`,
55
+ );
56
+ }
57
+ if (
58
+ !suppress.has("pseudo-parameters") &&
59
+ manifest.pseudoParameters &&
60
+ Object.keys(manifest.pseudoParameters).length > 0
61
+ ) {
62
+ lines.push(
63
+ `- [Pseudo-Parameters](./pseudo-parameters) — ${Object.keys(manifest.pseudoParameters).length} pseudo-parameters`,
64
+ );
65
+ }
66
+ const overviewExtraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
67
+ if (!suppress.has("rules") && !overviewExtraSlugs.has("lint-rules") && rules.length > 0) {
68
+ lines.push(`- [Lint Rules](./rules) — ${rules.length} rules`);
69
+ }
70
+ if (!suppress.has("serialization")) {
71
+ lines.push(`- [Serialization](./serialization) — output format details`);
72
+ }
73
+
74
+ if (config.extraSections) {
75
+ for (const section of config.extraSections) {
76
+ lines.push("", `## ${section.title}`, "", section.content);
77
+ }
78
+ }
79
+
80
+ lines.push("");
81
+ return lines.join("\n");
82
+ }
83
+
84
+ export function generateIntrinsics(
85
+ config: DocsConfig,
86
+ manifest: ManifestJSON,
87
+ ): string {
88
+ const intrinsics = manifest.intrinsics!;
89
+ const lines: string[] = [
90
+ "---",
91
+ `title: "Intrinsic Functions"`,
92
+ `description: "Built-in intrinsic functions for the ${config.displayName} lexicon"`,
93
+ "---",
94
+ "",
95
+ `The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
96
+ "",
97
+ "| Function | Description | Output Key | Tag? |",
98
+ "|----------|-------------|------------|------|",
99
+ ];
100
+
101
+ for (const fn of intrinsics) {
102
+ const tag = fn.isTag ? "Yes" : "No";
103
+ lines.push(
104
+ `| \`${fn.name}\` | ${escapeMdx(fn.description ?? "—")} | \`${fn.outputKey ?? fn.name}\` | ${tag} |`,
105
+ );
106
+ }
107
+
108
+ lines.push("");
109
+ return lines.join("\n");
110
+ }
111
+
112
+ export function generatePseudoParameters(
113
+ config: DocsConfig,
114
+ manifest: ManifestJSON,
115
+ ): string {
116
+ const params = manifest.pseudoParameters!;
117
+ const entries = Object.entries(params);
118
+ const lines: string[] = [
119
+ "---",
120
+ `title: "Pseudo-Parameters"`,
121
+ `description: "Pseudo-parameters available in the ${config.displayName} lexicon"`,
122
+ "---",
123
+ "",
124
+ `The ${config.displayName} lexicon provides **${entries.length}** pseudo-parameters — predefined values available in every stack without explicit declaration.`,
125
+ "",
126
+ "| Name | Value |",
127
+ "|------|-------|",
128
+ ];
129
+
130
+ for (const [name, value] of entries) {
131
+ lines.push(`| \`${name}\` | \`${value}\` |`);
132
+ }
133
+
134
+ lines.push("");
135
+ return lines.join("\n");
136
+ }
137
+
138
+ export function generateSerialization(config: DocsConfig): string {
139
+ const lines: string[] = [
140
+ "---",
141
+ `title: "Serialization"`,
142
+ `description: "Output format for the ${config.displayName} lexicon"`,
143
+ "---",
144
+ "",
145
+ ];
146
+
147
+ if (config.outputFormat) {
148
+ lines.push(config.outputFormat);
149
+ } else {
150
+ lines.push(
151
+ `The ${config.displayName} lexicon serializes resources into its native output format during the build step.`,
152
+ "",
153
+ "See the [Serialization](/serialization/output-formats) guide for general information about output formats in chant.",
154
+ );
155
+ }
156
+
157
+ lines.push("");
158
+ return lines.join("\n");
159
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Sidebar generation for Starlight docs sites.
3
+ */
4
+
5
+ import type { DocsConfig, DocsResult } from "./docs-types";
6
+
7
+ export function buildSidebar(
8
+ config: DocsConfig,
9
+ result: DocsResult,
10
+ ): Array<Record<string, unknown>> {
11
+ // Starlight prepends basePath to every sidebar `link`, so a site-root-relative
12
+ // path like "/chant/" becomes "/chant/lexicons/aws/chant/" — a 404. Instead
13
+ // we use relative traversal: "../../" is prepended to become
14
+ // "/chant/lexicons/aws/../../" which the browser resolves to "/chant/".
15
+ const segments = (config.basePath ?? "/").replace(/^\/|\/$/g, "").split("/");
16
+ const backLink = segments.length > 1 ? "../".repeat(segments.length - 1) : "/";
17
+
18
+ const items: Array<Record<string, unknown>> = [
19
+ { label: "← chant docs", link: backLink },
20
+ { label: "Overview", slug: "index" },
21
+ ];
22
+
23
+ const suppress = new Set(config.suppressPages ?? []);
24
+ const extraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
25
+
26
+ // Extra pages from lexicon config (appear after Overview)
27
+ if (config.extraPages) {
28
+ for (const page of config.extraPages) {
29
+ if (page.sidebar === false) continue;
30
+ items.push({ label: page.title, slug: page.slug });
31
+ }
32
+ }
33
+
34
+ if (!suppress.has("intrinsics") && !extraSlugs.has("intrinsics") && result.pages.has("intrinsics.mdx")) {
35
+ items.push({ label: "Intrinsics", slug: "intrinsics" });
36
+ }
37
+
38
+ if (!suppress.has("pseudo-parameters") && !extraSlugs.has("pseudo-parameters") && result.pages.has("pseudo-parameters.mdx")) {
39
+ items.push({ label: "Pseudo-Parameters", slug: "pseudo-parameters" });
40
+ }
41
+
42
+ if (!suppress.has("rules") && !extraSlugs.has("rules") && !extraSlugs.has("lint-rules") && result.pages.has("rules.mdx")) {
43
+ items.push({ label: "Lint Rules", slug: "rules" });
44
+ }
45
+
46
+ if (!suppress.has("serialization") && !extraSlugs.has("serialization") && result.pages.has("serialization.mdx")) {
47
+ items.push({ label: "Serialization", slug: "serialization" });
48
+ }
49
+
50
+ // Append raw sidebar entries (supports groups and nested items)
51
+ if (config.sidebarExtra) {
52
+ items.push(...config.sidebarExtra);
53
+ }
54
+
55
+ return items;
56
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Type definitions for the documentation pipeline.
3
+ */
4
+
5
+ export interface DocsConfig {
6
+ /** Lexicon name (used for page titles and paths) */
7
+ name: string;
8
+ /** Display name (e.g. "AWS CloudFormation") */
9
+ displayName: string;
10
+ /** Short description of what this lexicon targets */
11
+ description: string;
12
+ /** Path to dist/ directory containing manifest.json and meta.json */
13
+ distDir: string;
14
+ /** Output directory for generated .mdx files */
15
+ outDir: string;
16
+ /** Lexicon-specific overview content (markdown) */
17
+ overview?: string;
18
+ /** Output format description (e.g. "CloudFormation JSON template") */
19
+ outputFormat?: string;
20
+ /** Custom service grouping from resource type (e.g. "AWS::S3::Bucket" → "S3") */
21
+ serviceFromType?: (resourceType: string) => string;
22
+ /** Custom sections to append to overview page */
23
+ extraSections?: Array<{ title: string; content: string }>;
24
+ /** Standalone pages added to the sidebar after Overview */
25
+ extraPages?: Array<{ slug: string; title: string; description?: string; content: string; sidebar?: boolean }>;
26
+ /** Slugs of auto-generated pages to suppress (e.g. "pseudo-parameters") */
27
+ suppressPages?: string[];
28
+ /** Source directory for scanning rule files (defaults to srcDir sibling of distDir) */
29
+ srcDir?: string;
30
+ /** Base path for the generated Astro site (e.g. '/lexicons/aws/') */
31
+ basePath?: string;
32
+ /** Root directory for resolving {{file:...}} markers in extra page content */
33
+ examplesDir?: string;
34
+ /** Extra sidebar entries appended after extraPages (supports Starlight groups) */
35
+ sidebarExtra?: Array<Record<string, unknown>>;
36
+ }
37
+
38
+ export interface DocsResult {
39
+ pages: Map<string, string>;
40
+ stats: {
41
+ resources: number;
42
+ properties: number;
43
+ services: number;
44
+ rules: number;
45
+ intrinsics: number;
46
+ };
47
+ }
48
+
49
+ export interface ManifestJSON {
50
+ name: string;
51
+ version: string;
52
+ namespace?: string;
53
+ intrinsics?: Array<{
54
+ name: string;
55
+ description?: string;
56
+ outputKey?: string;
57
+ isTag?: boolean;
58
+ }>;
59
+ pseudoParameters?: Record<string, string>;
60
+ }
61
+
62
+ export interface MetaEntry {
63
+ resourceType: string;
64
+ kind: "resource" | "property";
65
+ lexicon: string;
66
+ attrs?: Record<string, string>;
67
+ propertyConstraints?: Record<string, unknown>;
68
+ createOnly?: string[];
69
+ writeOnly?: string[];
70
+ primaryIdentifier?: string[];
71
+ }
72
+
73
+ export interface RuleMeta {
74
+ id: string;
75
+ severity: string;
76
+ category: string;
77
+ description: string;
78
+ type: "lint" | "post-synth";
79
+ }