@intentius/chant 0.0.18 → 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 (87) hide show
  1. package/bin/chant +4 -1
  2. package/package.json +20 -1
  3. package/src/build.test.ts +4 -2
  4. package/src/build.ts +3 -0
  5. package/src/builder.test.ts +3 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
  7. package/src/cli/commands/build.ts +5 -12
  8. package/src/cli/commands/diff.test.ts +2 -1
  9. package/src/cli/commands/diff.ts +2 -1
  10. package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
  11. package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
  12. package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
  13. package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
  14. package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
  15. package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
  16. package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
  17. package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
  18. package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
  19. package/src/cli/commands/init-lexicon.test.ts +0 -9
  20. package/src/cli/commands/init-lexicon.ts +12 -868
  21. package/src/cli/commands/init.ts +2 -20
  22. package/src/cli/conflict-check.test.ts +43 -0
  23. package/src/cli/handlers/build.ts +3 -3
  24. package/src/cli/handlers/lint.ts +2 -2
  25. package/src/cli/handlers/spell.ts +396 -0
  26. package/src/cli/handlers/state.ts +230 -0
  27. package/src/cli/lsp/server.test.ts +4 -0
  28. package/src/cli/main.ts +37 -3
  29. package/src/cli/mcp/resource-handlers.ts +227 -0
  30. package/src/cli/mcp/server.test.ts +13 -9
  31. package/src/cli/mcp/server.ts +24 -199
  32. package/src/cli/mcp/state-tools.ts +138 -0
  33. package/src/cli/mcp/tools/build.ts +2 -1
  34. package/src/cli/mcp/types.ts +45 -0
  35. package/src/cli/plugins.ts +1 -1
  36. package/src/cli/reporters/stylish.test.ts +2 -2
  37. package/src/cli/reporters/stylish.ts +1 -1
  38. package/src/codegen/docs-file-markers.ts +69 -0
  39. package/src/codegen/docs-rule-scanning.ts +159 -0
  40. package/src/codegen/docs-sections.ts +159 -0
  41. package/src/codegen/docs-sidebar.ts +56 -0
  42. package/src/codegen/docs-types.ts +79 -0
  43. package/src/codegen/docs.ts +9 -495
  44. package/src/composite.test.ts +76 -1
  45. package/src/composite.ts +37 -0
  46. package/src/config.ts +4 -0
  47. package/src/declarable.test.ts +2 -1
  48. package/src/declarable.ts +1 -1
  49. package/src/discovery/collect.test.ts +34 -0
  50. package/src/discovery/collect.ts +12 -0
  51. package/src/discovery/graph.test.ts +40 -0
  52. package/src/discovery/import.test.ts +5 -5
  53. package/src/discovery/resolve.test.ts +20 -0
  54. package/src/discovery/resolve.ts +2 -2
  55. package/src/index.ts +2 -0
  56. package/src/lexicon-plugin-helpers.ts +130 -0
  57. package/src/lexicon.ts +24 -0
  58. package/src/lint/rule-options.test.ts +3 -3
  59. package/src/lint/rule-registry.test.ts +1 -1
  60. package/src/lint/rules/composite-scope.ts +1 -1
  61. package/src/serializer-walker.ts +2 -1
  62. package/src/spell/discovery.ts +183 -0
  63. package/src/spell/index.ts +3 -0
  64. package/src/spell/prompt.ts +133 -0
  65. package/src/spell/types.ts +89 -0
  66. package/src/state/digest.ts +88 -0
  67. package/src/state/git.ts +317 -0
  68. package/src/state/index.ts +4 -0
  69. package/src/state/snapshot.ts +179 -0
  70. package/src/state/types.ts +59 -0
  71. package/src/toml-emit.ts +182 -0
  72. package/src/toml-parse.ts +370 -0
  73. package/src/toml-utils.ts +60 -0
  74. package/src/toml.ts +5 -602
  75. package/src/types.ts +2 -1
  76. package/src/utils.test.ts +16 -3
  77. package/src/utils.ts +31 -1
  78. package/src/validation.test.ts +11 -0
  79. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  80. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  81. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  82. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  83. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  84. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  85. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  86. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  87. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -7,151 +7,18 @@
7
7
  * (service grouping, resource type URLs, custom overview content).
8
8
  */
9
9
 
10
- import { readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from "fs";
10
+ import { readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
11
11
  import { join } from "path";
12
12
 
13
- /** Escape curly braces so MDX doesn't treat them as JSX expressions. */
14
- function escapeMdx(text: string): string {
15
- return text.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
16
- }
17
-
18
- // ── Types ──────────────────────────────────────────────────────────
19
-
20
- export interface DocsConfig {
21
- /** Lexicon name (used for page titles and paths) */
22
- name: string;
23
- /** Display name (e.g. "AWS CloudFormation") */
24
- displayName: string;
25
- /** Short description of what this lexicon targets */
26
- description: string;
27
- /** Path to dist/ directory containing manifest.json and meta.json */
28
- distDir: string;
29
- /** Output directory for generated .mdx files */
30
- outDir: string;
31
- /** Lexicon-specific overview content (markdown) */
32
- overview?: string;
33
- /** Output format description (e.g. "CloudFormation JSON template") */
34
- outputFormat?: string;
35
- /** Custom service grouping from resource type (e.g. "AWS::S3::Bucket" → "S3") */
36
- serviceFromType?: (resourceType: string) => string;
37
- /** Custom sections to append to overview page */
38
- extraSections?: Array<{ title: string; content: string }>;
39
- /** Standalone pages added to the sidebar after Overview */
40
- extraPages?: Array<{ slug: string; title: string; description?: string; content: string; sidebar?: boolean }>;
41
- /** Slugs of auto-generated pages to suppress (e.g. "pseudo-parameters") */
42
- suppressPages?: string[];
43
- /** Source directory for scanning rule files (defaults to srcDir sibling of distDir) */
44
- srcDir?: string;
45
- /** Base path for the generated Astro site (e.g. '/lexicons/aws/') */
46
- basePath?: string;
47
- /** Root directory for resolving {{file:...}} markers in extra page content */
48
- examplesDir?: string;
49
- /** Extra sidebar entries appended after extraPages (supports Starlight groups) */
50
- sidebarExtra?: Array<Record<string, unknown>>;
51
- }
52
-
53
- export interface DocsResult {
54
- pages: Map<string, string>;
55
- stats: {
56
- resources: number;
57
- properties: number;
58
- services: number;
59
- rules: number;
60
- intrinsics: number;
61
- };
62
- }
63
-
64
- interface ManifestJSON {
65
- name: string;
66
- version: string;
67
- namespace?: string;
68
- intrinsics?: Array<{
69
- name: string;
70
- description?: string;
71
- outputKey?: string;
72
- isTag?: boolean;
73
- }>;
74
- pseudoParameters?: Record<string, string>;
75
- }
76
-
77
- interface MetaEntry {
78
- resourceType: string;
79
- kind: "resource" | "property";
80
- lexicon: string;
81
- attrs?: Record<string, string>;
82
- propertyConstraints?: Record<string, unknown>;
83
- createOnly?: string[];
84
- writeOnly?: string[];
85
- primaryIdentifier?: string[];
86
- }
87
-
88
- interface RuleMeta {
89
- id: string;
90
- severity: string;
91
- category: string;
92
- description: string;
93
- type: "lint" | "post-synth";
94
- }
95
-
96
- // ── File marker interpolation ──────────────────────────────────────
97
-
98
- /**
99
- * Expand `{{file:path.ts}}` markers in content with fenced code blocks.
100
- *
101
- * Supported forms:
102
- * - `{{file:path.ts}}` — full file
103
- * - `{{file:path.ts:5-12}}` — lines 5–12 (1-based, inclusive)
104
- * - `{{file:path.ts|title=custom.ts}}` — override the code block title
105
- * - `{{file:path.ts:5-12|title=custom.ts}}` — both
106
- */
107
- export function expandFileMarkers(content: string, examplesDir: string): string {
108
- return content.replace(
109
- /\{\{file:([^}]+)\}\}/g,
110
- (_match, spec: string) => {
111
- // Parse options after |
112
- let filePart = spec;
113
- let title: string | undefined;
114
- const pipeIdx = spec.indexOf("|");
115
- if (pipeIdx !== -1) {
116
- filePart = spec.substring(0, pipeIdx);
117
- const opts = spec.substring(pipeIdx + 1);
118
- const titleMatch = opts.match(/title=([^\s|]+)/);
119
- if (titleMatch) title = titleMatch[1];
120
- }
13
+ import { expandFileMarkers } from "./docs-file-markers";
14
+ import { scanRules, generateRules } from "./docs-rule-scanning";
15
+ import { buildSidebar } from "./docs-sidebar";
16
+ import { generateOverview, generateIntrinsics, generatePseudoParameters, generateSerialization } from "./docs-sections";
17
+ import type { DocsConfig, DocsResult, ManifestJSON, MetaEntry } from "./docs-types";
121
18
 
122
- // Parse line range after :digits-digits at end of filePart
123
- let lineStart: number | undefined;
124
- let lineEnd: number | undefined;
125
- const rangeMatch = filePart.match(/^(.+):(\d+)-(\d+)$/);
126
- if (rangeMatch) {
127
- filePart = rangeMatch[1];
128
- lineStart = parseInt(rangeMatch[2], 10);
129
- lineEnd = parseInt(rangeMatch[3], 10);
130
- }
131
-
132
- const filePath = join(examplesDir, filePart);
133
- let fileContent: string;
134
- try {
135
- fileContent = readFileSync(filePath, "utf-8");
136
- } catch {
137
- throw new Error(`File marker {{file:${spec}}} — file not found: ${filePath}`);
138
- }
139
-
140
- // Extract line range if specified
141
- if (lineStart !== undefined && lineEnd !== undefined) {
142
- const lines = fileContent.split("\n");
143
- fileContent = lines.slice(lineStart - 1, lineEnd).join("\n");
144
- }
145
-
146
- // Determine language from extension
147
- const ext = filePart.substring(filePart.lastIndexOf(".") + 1);
148
- const lang = ext === "ts" || ext === "tsx" ? "typescript" : ext;
149
- const displayTitle = title ?? filePart.substring(filePart.lastIndexOf("/") + 1);
150
-
151
- return `\`\`\`${lang} title="${displayTitle}"\n${fileContent.trimEnd()}\n\`\`\``;
152
- },
153
- );
154
- }
19
+ // Re-export all public types and functions so existing importers continue to work.
20
+ export { expandFileMarkers } from "./docs-file-markers";
21
+ export type { DocsConfig, DocsResult } from "./docs-types";
155
22
 
156
23
  // ── Pipeline ───────────────────────────────────────────────────────
157
24
 
@@ -373,356 +240,3 @@ export default defineConfig({${config.basePath ? `\n base: '${config.basePath}'
373
240
  `,
374
241
  );
375
242
  }
376
-
377
- function buildSidebar(
378
- config: DocsConfig,
379
- result: DocsResult,
380
- ): Array<Record<string, unknown>> {
381
- // Starlight prepends basePath to every sidebar `link`, so a site-root-relative
382
- // path like "/chant/" becomes "/chant/lexicons/aws/chant/" — a 404. Instead
383
- // we use relative traversal: "../../" is prepended to become
384
- // "/chant/lexicons/aws/../../" which the browser resolves to "/chant/".
385
- const segments = (config.basePath ?? "/").replace(/^\/|\/$/g, "").split("/");
386
- const backLink = segments.length > 1 ? "../".repeat(segments.length - 1) : "/";
387
-
388
- const items: Array<Record<string, unknown>> = [
389
- { label: "← chant docs", link: backLink },
390
- { label: "Overview", slug: "index" },
391
- ];
392
-
393
- const suppress = new Set(config.suppressPages ?? []);
394
- const extraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
395
-
396
- // Extra pages from lexicon config (appear after Overview)
397
- if (config.extraPages) {
398
- for (const page of config.extraPages) {
399
- if (page.sidebar === false) continue;
400
- items.push({ label: page.title, slug: page.slug });
401
- }
402
- }
403
-
404
- if (!suppress.has("intrinsics") && !extraSlugs.has("intrinsics") && result.pages.has("intrinsics.mdx")) {
405
- items.push({ label: "Intrinsics", slug: "intrinsics" });
406
- }
407
-
408
- if (!suppress.has("pseudo-parameters") && !extraSlugs.has("pseudo-parameters") && result.pages.has("pseudo-parameters.mdx")) {
409
- items.push({ label: "Pseudo-Parameters", slug: "pseudo-parameters" });
410
- }
411
-
412
- if (!suppress.has("rules") && !extraSlugs.has("rules") && !extraSlugs.has("lint-rules") && result.pages.has("rules.mdx")) {
413
- items.push({ label: "Lint Rules", slug: "rules" });
414
- }
415
-
416
- if (!suppress.has("serialization") && !extraSlugs.has("serialization") && result.pages.has("serialization.mdx")) {
417
- items.push({ label: "Serialization", slug: "serialization" });
418
- }
419
-
420
- // Append raw sidebar entries (supports groups and nested items)
421
- if (config.sidebarExtra) {
422
- items.push(...config.sidebarExtra);
423
- }
424
-
425
- return items;
426
- }
427
-
428
- // ── Page generators ────────────────────────────────────────────────
429
-
430
- function generateOverview(
431
- config: DocsConfig,
432
- manifest: ManifestJSON,
433
- resources: Map<string, MetaEntry>,
434
- properties: Map<string, MetaEntry>,
435
- serviceGroups: Map<string, Map<string, MetaEntry>>,
436
- rules: RuleMeta[],
437
- ): string {
438
- const lines: string[] = [
439
- "---",
440
- `title: "${config.displayName}"`,
441
- `description: "${config.description}"`,
442
- "---",
443
- "",
444
- config.overview ?? `Reference documentation for the **${config.displayName}** lexicon.`,
445
- "",
446
- "## At a Glance",
447
- "",
448
- `| Metric | Count |`,
449
- `|--------|-------|`,
450
- `| Resources | ${resources.size} |`,
451
- `| Property types | ${properties.size} |`,
452
- `| Services | ${serviceGroups.size} |`,
453
- `| Intrinsic functions | ${manifest.intrinsics?.length ?? 0} |`,
454
- `| Pseudo-parameters | ${Object.keys(manifest.pseudoParameters ?? {}).length} |`,
455
- `| Lint rules | ${rules.length} |`,
456
- "",
457
- `**Lexicon version:** ${manifest.version} `,
458
- `**Namespace:** \`${manifest.namespace ?? manifest.name}\``,
459
- "",
460
- ];
461
-
462
- const suppress = new Set(config.suppressPages ?? []);
463
-
464
- // Extra pages listed first in reference links
465
- if (config.extraPages && config.extraPages.length > 0) {
466
- for (const page of config.extraPages) {
467
- if (page.sidebar === false) continue;
468
- lines.push(`- [${page.title}](./${page.slug})`);
469
- }
470
- }
471
-
472
- if (!suppress.has("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
473
- lines.push(
474
- `- [Intrinsic Functions](./intrinsics) — ${manifest.intrinsics.length} built-in functions`,
475
- );
476
- }
477
- if (
478
- !suppress.has("pseudo-parameters") &&
479
- manifest.pseudoParameters &&
480
- Object.keys(manifest.pseudoParameters).length > 0
481
- ) {
482
- lines.push(
483
- `- [Pseudo-Parameters](./pseudo-parameters) — ${Object.keys(manifest.pseudoParameters).length} pseudo-parameters`,
484
- );
485
- }
486
- const overviewExtraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
487
- if (!suppress.has("rules") && !overviewExtraSlugs.has("lint-rules") && rules.length > 0) {
488
- lines.push(`- [Lint Rules](./rules) — ${rules.length} rules`);
489
- }
490
- if (!suppress.has("serialization")) {
491
- lines.push(`- [Serialization](./serialization) — output format details`);
492
- }
493
-
494
- if (config.extraSections) {
495
- for (const section of config.extraSections) {
496
- lines.push("", `## ${section.title}`, "", section.content);
497
- }
498
- }
499
-
500
- lines.push("");
501
- return lines.join("\n");
502
- }
503
-
504
- function generateIntrinsics(
505
- config: DocsConfig,
506
- manifest: ManifestJSON,
507
- ): string {
508
- const intrinsics = manifest.intrinsics!;
509
- const lines: string[] = [
510
- "---",
511
- `title: "Intrinsic Functions"`,
512
- `description: "Built-in intrinsic functions for the ${config.displayName} lexicon"`,
513
- "---",
514
- "",
515
- `The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
516
- "",
517
- "| Function | Description | Output Key | Tag? |",
518
- "|----------|-------------|------------|------|",
519
- ];
520
-
521
- for (const fn of intrinsics) {
522
- const tag = fn.isTag ? "Yes" : "No";
523
- lines.push(
524
- `| \`${fn.name}\` | ${escapeMdx(fn.description ?? "—")} | \`${fn.outputKey ?? fn.name}\` | ${tag} |`,
525
- );
526
- }
527
-
528
- lines.push("");
529
- return lines.join("\n");
530
- }
531
-
532
- function generatePseudoParameters(
533
- config: DocsConfig,
534
- manifest: ManifestJSON,
535
- ): string {
536
- const params = manifest.pseudoParameters!;
537
- const entries = Object.entries(params);
538
- const lines: string[] = [
539
- "---",
540
- `title: "Pseudo-Parameters"`,
541
- `description: "Pseudo-parameters available in the ${config.displayName} lexicon"`,
542
- "---",
543
- "",
544
- `The ${config.displayName} lexicon provides **${entries.length}** pseudo-parameters — predefined values available in every stack without explicit declaration.`,
545
- "",
546
- "| Name | Value |",
547
- "|------|-------|",
548
- ];
549
-
550
- for (const [name, value] of entries) {
551
- lines.push(`| \`${name}\` | \`${value}\` |`);
552
- }
553
-
554
- lines.push("");
555
- return lines.join("\n");
556
- }
557
-
558
- function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
559
- const lintRules = rules.filter((r) => r.type === "lint");
560
- const postSynthRules = rules.filter((r) => r.type === "post-synth");
561
-
562
- const lines: string[] = [
563
- "---",
564
- `title: "Lint Rules"`,
565
- `description: "Lint rules and post-synth checks provided by the ${config.displayName} lexicon"`,
566
- "---",
567
- "",
568
- `The ${config.displayName} lexicon provides **${rules.length}** rules: ${lintRules.length} lint rules and ${postSynthRules.length} post-synth checks.`,
569
- "",
570
- ];
571
-
572
- if (lintRules.length > 0) {
573
- lines.push(
574
- "## Lint Rules",
575
- "",
576
- "| ID | Severity | Category | Description |",
577
- "|----|----------|----------|-------------|",
578
- );
579
- for (const rule of lintRules.sort((a, b) => a.id.localeCompare(b.id))) {
580
- lines.push(
581
- `| \`${rule.id}\` | ${rule.severity} | ${rule.category} | ${escapeMdx(rule.description)} |`,
582
- );
583
- }
584
- lines.push("");
585
- }
586
-
587
- if (postSynthRules.length > 0) {
588
- lines.push(
589
- "## Post-Synth Checks",
590
- "",
591
- "Post-synth checks validate the serialized output after the build pipeline completes.",
592
- "",
593
- "| ID | Description |",
594
- "|----|-------------|",
595
- );
596
- for (const rule of postSynthRules.sort((a, b) =>
597
- a.id.localeCompare(b.id),
598
- )) {
599
- lines.push(`| \`${rule.id}\` | ${escapeMdx(rule.description)} |`);
600
- }
601
- lines.push("");
602
- }
603
-
604
- return lines.join("\n");
605
- }
606
-
607
- function generateSerialization(config: DocsConfig): string {
608
- const lines: string[] = [
609
- "---",
610
- `title: "Serialization"`,
611
- `description: "Output format for the ${config.displayName} lexicon"`,
612
- "---",
613
- "",
614
- ];
615
-
616
- if (config.outputFormat) {
617
- lines.push(config.outputFormat);
618
- } else {
619
- lines.push(
620
- `The ${config.displayName} lexicon serializes resources into its native output format during the build step.`,
621
- "",
622
- "See the [Serialization](/serialization/output-formats) guide for general information about output formats in chant.",
623
- );
624
- }
625
-
626
- lines.push("");
627
- return lines.join("\n");
628
- }
629
-
630
- // ── Rule scanning ──────────────────────────────────────────────────
631
-
632
- /**
633
- * Scan lint rule and post-synth check source files to extract metadata.
634
- * Uses regex to find id, severity, category, and description from source.
635
- */
636
- function scanRules(srcDir: string): RuleMeta[] {
637
- const rules: RuleMeta[] = [];
638
-
639
- // Scan lint rules
640
- scanDir(join(srcDir, "lint", "rules"), "lint", rules);
641
-
642
- // Scan post-synth checks
643
- scanDir(join(srcDir, "lint", "post-synth"), "post-synth", rules);
644
-
645
- return rules;
646
- }
647
-
648
- function scanDir(dir: string, type: "lint" | "post-synth", out: RuleMeta[]): void {
649
- let entries: string[];
650
- try {
651
- entries = readdirSync(dir);
652
- } catch {
653
- return;
654
- }
655
-
656
- for (const entry of entries) {
657
- if (!entry.endsWith(".ts")) continue;
658
- if (entry.endsWith(".test.ts")) continue;
659
- if (entry === "index.ts") continue;
660
- // Skip utility files (no rule definitions)
661
- if (entry === "cf-refs.ts") continue;
662
-
663
- let content: string;
664
- try {
665
- content = readFileSync(join(dir, entry), "utf-8");
666
- } catch {
667
- continue;
668
- }
669
-
670
- if (type === "lint") {
671
- // Extract from LintRule objects: id, severity, category
672
- const idMatch = content.match(/id:\s*"([^"]+)"/);
673
- const severityMatch = content.match(/severity:\s*"([^"]+)"/);
674
- const categoryMatch = content.match(/category:\s*"([^"]+)"/);
675
-
676
- if (idMatch) {
677
- // Try to get description from JSDoc comment
678
- const descMatch = content.match(
679
- new RegExp(`\\*\\s*${idMatch[1]}:\\s*(.+?)\\n`),
680
- );
681
-
682
- out.push({
683
- id: idMatch[1],
684
- severity: severityMatch?.[1] ?? "warning",
685
- category: categoryMatch?.[1] ?? "general",
686
- description:
687
- descMatch?.[1]?.trim() ??
688
- extractDescriptionFromComment(content, idMatch[1]),
689
- type: "lint",
690
- });
691
- }
692
- } else {
693
- // Extract from PostSynthCheck objects: id, description
694
- const idMatch = content.match(/id:\s*"([^"]+)"/);
695
- const descMatch = content.match(/description:\s*"([^"]+)"/);
696
-
697
- if (idMatch) {
698
- out.push({
699
- id: idMatch[1],
700
- severity: "error",
701
- category: "post-synth",
702
- description: descMatch?.[1] ?? idMatch[1],
703
- type: "post-synth",
704
- });
705
- }
706
- }
707
- }
708
- }
709
-
710
- function extractDescriptionFromComment(
711
- content: string,
712
- ruleId: string,
713
- ): string {
714
- // Try to extract from the first line after the rule ID in JSDoc
715
- const pattern = new RegExp(
716
- `${ruleId}[:\\s]+([^\\n]+?)\\n\\s*\\*\\s*\\n\\s*\\*\\s*(.+?)\\n`,
717
- );
718
- const match = content.match(pattern);
719
- if (match) return match[1].trim();
720
-
721
- // Fallback: use text after "ruleId:" in JSDoc
722
- const simpleMatch = content.match(
723
- new RegExp(`\\*\\s*${ruleId}:\\s*(.+?)(?:\\n|\\*)`),
724
- );
725
- if (simpleMatch) return simpleMatch[1].trim();
726
-
727
- return ruleId;
728
- }
@@ -9,6 +9,7 @@ import {
9
9
  withDefaults,
10
10
  propagate,
11
11
  SHARED_PROPS,
12
+ mergeDefaults,
12
13
  } from "./composite";
13
14
  import { DECLARABLE_MARKER, type Declarable } from "./declarable";
14
15
  import { AttrRef } from "./attrref";
@@ -116,7 +117,7 @@ describe("Composite", () => {
116
117
  });
117
118
 
118
119
  const instance = MyComp({});
119
- const roleProps = instance.members.role.props as Record<string, unknown>;
120
+ const roleProps = (instance.members.role as MockResource).props;
120
121
  expect(roleProps.bucketArn).toBeInstanceOf(AttrRef);
121
122
  // The AttrRef's parent should be the bucket instance
122
123
  expect((roleProps.bucketArn as AttrRef).attribute).toBe("Arn");
@@ -491,3 +492,77 @@ describe("propagate", () => {
491
492
  expect((expanded.get("sBucket") as any).props.name).toBe("data");
492
493
  });
493
494
  });
495
+
496
+ describe("mergeDefaults", () => {
497
+ test("returns base unchanged when no overrides", () => {
498
+ const base = { a: 1, b: "hello" };
499
+ expect(mergeDefaults(base)).toBe(base);
500
+ expect(mergeDefaults(base, undefined)).toBe(base);
501
+ });
502
+
503
+ test("scalar override wins", () => {
504
+ const result = mergeDefaults({ a: 1, b: 2 }, { a: 10 });
505
+ expect(result).toEqual({ a: 10, b: 2 });
506
+ });
507
+
508
+ test("undefined values in overrides are skipped", () => {
509
+ const result = mergeDefaults({ a: 1, b: 2 }, { a: undefined });
510
+ expect(result).toEqual({ a: 1, b: 2 });
511
+ });
512
+
513
+ test("arrays are concatenated (base + overrides)", () => {
514
+ const result = mergeDefaults(
515
+ { tags: [{ key: "env", value: "prod" }] },
516
+ { tags: [{ key: "team", value: "alpha" }] },
517
+ );
518
+ expect(result.tags).toEqual([
519
+ { key: "env", value: "prod" },
520
+ { key: "team", value: "alpha" },
521
+ ]);
522
+ });
523
+
524
+ test("object override deep merges nested objects", () => {
525
+ const result = mergeDefaults(
526
+ { config: { a: 1, b: 2 } },
527
+ { config: { a: 10 } as any },
528
+ );
529
+ expect(result.config).toEqual({ a: 10, b: 2 } as any);
530
+ });
531
+
532
+ test("new keys from overrides are added", () => {
533
+ const result = mergeDefaults(
534
+ { a: 1 } as Record<string, unknown>,
535
+ { b: 2 },
536
+ );
537
+ expect(result).toEqual({ a: 1, b: 2 });
538
+ });
539
+
540
+ test("does not mutate the base object", () => {
541
+ const base = { a: 1, b: [1, 2] };
542
+ const result = mergeDefaults(base, { a: 10, b: [3] });
543
+ expect(base.a).toBe(1);
544
+ expect(base.b).toEqual([1, 2]);
545
+ expect(result.a).toBe(10);
546
+ expect(result.b).toEqual([1, 2, 3]);
547
+ });
548
+
549
+ test("empty overrides returns a shallow copy", () => {
550
+ const base = { a: 1 };
551
+ const result = mergeDefaults(base, {});
552
+ expect(result).toEqual({ a: 1 });
553
+ expect(result).not.toBe(base);
554
+ });
555
+
556
+ test("mixed: some scalars overridden, some arrays concatenated, some unchanged", () => {
557
+ const result = mergeDefaults(
558
+ { name: "orig", count: 5, tags: ["a"], config: { x: 1 } },
559
+ { name: "new", tags: ["b"], config: undefined },
560
+ );
561
+ expect(result).toEqual({
562
+ name: "new",
563
+ count: 5,
564
+ tags: ["a", "b"],
565
+ config: { x: 1 },
566
+ });
567
+ });
568
+ });
package/src/composite.ts CHANGED
@@ -243,3 +243,40 @@ export function resource<T extends Declarable, P>(
243
243
  ): T {
244
244
  return new Type(props, attributes);
245
245
  }
246
+
247
+ /**
248
+ * Shallow-merges override values into a base props object.
249
+ *
250
+ * Merge semantics:
251
+ * - `undefined` values in overrides are skipped
252
+ * - Arrays: concatenated (base + overrides), matching `propagate()` semantics
253
+ * - Scalars / objects: override wins
254
+ *
255
+ * No deep merge — too dangerous with IaC props where nested objects
256
+ * (e.g. policy documents) should be replaced wholesale.
257
+ */
258
+ export function mergeDefaults<T extends Record<string, unknown>>(
259
+ base: T,
260
+ overrides?: Partial<T>,
261
+ ): T {
262
+ if (!overrides) return base;
263
+ const result = { ...base };
264
+ for (const [key, value] of Object.entries(overrides)) {
265
+ if (value === undefined) continue;
266
+ const existing = result[key as keyof T];
267
+ if (Array.isArray(existing) && Array.isArray(value)) {
268
+ (result as any)[key] = [...existing, ...value];
269
+ } else if (
270
+ existing != null && typeof existing === "object" && !Array.isArray(existing) &&
271
+ value != null && typeof value === "object" && !Array.isArray(value)
272
+ ) {
273
+ (result as any)[key] = mergeDefaults(
274
+ existing as Record<string, unknown>,
275
+ value as Record<string, unknown>,
276
+ );
277
+ } else {
278
+ (result as any)[key] = value;
279
+ }
280
+ }
281
+ return result;
282
+ }
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ import type { LintConfig } from "./lint/config";
9
9
  export const ChantConfigSchema = z.object({
10
10
  runtime: z.enum(["bun", "node"]).optional(),
11
11
  lexicons: z.array(z.string().min(1)).optional(),
12
+ environments: z.array(z.string().min(1)).optional(),
12
13
  lint: z.record(z.string(), z.unknown()).optional(),
13
14
  }).passthrough();
14
15
 
@@ -24,6 +25,9 @@ export interface ChantConfig {
24
25
  /** Lexicon package names to load (e.g. ["aws"]) */
25
26
  lexicons?: string[];
26
27
 
28
+ /** Environment names (e.g. ["staging", "prod"]) */
29
+ environments?: string[];
30
+
27
31
  /** Lint configuration (rules, extends, overrides, plugins) */
28
32
  lint?: LintConfig;
29
33
  }
@@ -12,7 +12,8 @@ describe("DECLARABLE_MARKER", () => {
12
12
  });
13
13
 
14
14
  test("uses Symbol.for for global registry", () => {
15
- expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable"));
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable") as any);
16
17
  });
17
18
  });
18
19
 
package/src/declarable.ts CHANGED
@@ -9,7 +9,7 @@ export const DECLARABLE_MARKER = Symbol.for("chant.declarable");
9
9
  export interface Declarable {
10
10
  readonly lexicon: string;
11
11
  readonly entityType: string;
12
- readonly kind?: "resource" | "property";
12
+ readonly kind?: "resource" | "property" | "output";
13
13
  readonly [DECLARABLE_MARKER]: true;
14
14
  }
15
15