@intentius/chant 0.0.1

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 (271) hide show
  1. package/README.md +365 -0
  2. package/package.json +22 -0
  3. package/src/attrref.test.ts +148 -0
  4. package/src/attrref.ts +50 -0
  5. package/src/barrel.test.ts +157 -0
  6. package/src/barrel.ts +101 -0
  7. package/src/bench.test.ts +227 -0
  8. package/src/build.test.ts +437 -0
  9. package/src/build.ts +425 -0
  10. package/src/builder.test.ts +312 -0
  11. package/src/builder.ts +56 -0
  12. package/src/child-project.ts +44 -0
  13. package/src/cli/commands/__fixtures__/init-lexicon-output/README.md +26 -0
  14. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +14 -0
  15. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/package.json +16 -0
  16. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/index.mdx +8 -0
  17. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +7 -0
  18. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/tsconfig.json +10 -0
  19. package/src/cli/commands/__fixtures__/init-lexicon-output/examples/getting-started/.gitkeep +0 -0
  20. package/src/cli/commands/__fixtures__/init-lexicon-output/justfile +26 -0
  21. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +29 -0
  22. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/docs.ts +25 -0
  23. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +8 -0
  24. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +74 -0
  25. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/naming.ts +33 -0
  26. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/package.ts +25 -0
  27. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/rollback.ts +45 -0
  28. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +11 -0
  29. package/src/cli/commands/__fixtures__/init-lexicon-output/src/generated/.gitkeep +0 -0
  30. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +10 -0
  31. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +10 -0
  32. package/src/cli/commands/__fixtures__/init-lexicon-output/src/index.ts +9 -0
  33. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/rules/index.ts +1 -0
  34. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/rules/sample.ts +18 -0
  35. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lsp/completions.ts +14 -0
  36. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lsp/hover.ts +14 -0
  37. package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +110 -0
  38. package/src/cli/commands/__fixtures__/init-lexicon-output/src/serializer.ts +24 -0
  39. package/src/cli/commands/__fixtures__/init-lexicon-output/src/spec/fetch.ts +21 -0
  40. package/src/cli/commands/__fixtures__/init-lexicon-output/src/spec/parse.ts +25 -0
  41. package/src/cli/commands/__fixtures__/init-lexicon-output/src/validate-cli.ts +4 -0
  42. package/src/cli/commands/__fixtures__/init-lexicon-output/src/validate.ts +24 -0
  43. package/src/cli/commands/__fixtures__/init-lexicon-output/tsconfig.json +10 -0
  44. package/src/cli/commands/__fixtures__/sample-rule.ts +11 -0
  45. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +222 -0
  46. package/src/cli/commands/build.test.ts +149 -0
  47. package/src/cli/commands/build.ts +344 -0
  48. package/src/cli/commands/diff.test.ts +148 -0
  49. package/src/cli/commands/diff.ts +221 -0
  50. package/src/cli/commands/doctor.test.ts +239 -0
  51. package/src/cli/commands/doctor.ts +224 -0
  52. package/src/cli/commands/import.test.ts +379 -0
  53. package/src/cli/commands/import.ts +335 -0
  54. package/src/cli/commands/init-lexicon.test.ts +297 -0
  55. package/src/cli/commands/init-lexicon.ts +993 -0
  56. package/src/cli/commands/init.test.ts +317 -0
  57. package/src/cli/commands/init.ts +505 -0
  58. package/src/cli/commands/licenses.ts +165 -0
  59. package/src/cli/commands/lint.test.ts +332 -0
  60. package/src/cli/commands/lint.ts +408 -0
  61. package/src/cli/commands/list.test.ts +100 -0
  62. package/src/cli/commands/list.ts +108 -0
  63. package/src/cli/commands/update.test.ts +38 -0
  64. package/src/cli/commands/update.ts +207 -0
  65. package/src/cli/conflict-check.test.ts +255 -0
  66. package/src/cli/conflict-check.ts +89 -0
  67. package/src/cli/debug.ts +8 -0
  68. package/src/cli/format.test.ts +140 -0
  69. package/src/cli/format.ts +133 -0
  70. package/src/cli/handlers/build.ts +58 -0
  71. package/src/cli/handlers/dev.ts +38 -0
  72. package/src/cli/handlers/init.ts +46 -0
  73. package/src/cli/handlers/lint.ts +36 -0
  74. package/src/cli/handlers/misc.ts +57 -0
  75. package/src/cli/handlers/serve.ts +26 -0
  76. package/src/cli/index.ts +3 -0
  77. package/src/cli/lsp/capabilities.ts +46 -0
  78. package/src/cli/lsp/diagnostics.ts +52 -0
  79. package/src/cli/lsp/server.test.ts +618 -0
  80. package/src/cli/lsp/server.ts +393 -0
  81. package/src/cli/main.test.ts +257 -0
  82. package/src/cli/main.ts +224 -0
  83. package/src/cli/mcp/resources/context.ts +59 -0
  84. package/src/cli/mcp/server.test.ts +747 -0
  85. package/src/cli/mcp/server.ts +402 -0
  86. package/src/cli/mcp/tools/build.ts +117 -0
  87. package/src/cli/mcp/tools/import.ts +48 -0
  88. package/src/cli/mcp/tools/lint.ts +45 -0
  89. package/src/cli/plugins.test.ts +31 -0
  90. package/src/cli/plugins.ts +94 -0
  91. package/src/cli/registry.ts +73 -0
  92. package/src/cli/reporters/stylish.test.ts +282 -0
  93. package/src/cli/reporters/stylish.ts +186 -0
  94. package/src/cli/watch.test.ts +81 -0
  95. package/src/cli/watch.ts +101 -0
  96. package/src/codegen/case.test.ts +30 -0
  97. package/src/codegen/case.ts +11 -0
  98. package/src/codegen/coverage.ts +167 -0
  99. package/src/codegen/docs.ts +634 -0
  100. package/src/codegen/fetch.test.ts +119 -0
  101. package/src/codegen/fetch.ts +261 -0
  102. package/src/codegen/generate-registry.test.ts +118 -0
  103. package/src/codegen/generate-registry.ts +107 -0
  104. package/src/codegen/generate-runtime-index.test.ts +81 -0
  105. package/src/codegen/generate-runtime-index.ts +99 -0
  106. package/src/codegen/generate-typescript.test.ts +146 -0
  107. package/src/codegen/generate-typescript.ts +161 -0
  108. package/src/codegen/generate.ts +206 -0
  109. package/src/codegen/json-patch.test.ts +113 -0
  110. package/src/codegen/json-patch.ts +151 -0
  111. package/src/codegen/json-schema.test.ts +196 -0
  112. package/src/codegen/json-schema.ts +209 -0
  113. package/src/codegen/naming.ts +201 -0
  114. package/src/codegen/package.ts +161 -0
  115. package/src/codegen/rollback.test.ts +92 -0
  116. package/src/codegen/rollback.ts +115 -0
  117. package/src/codegen/topo-sort.test.ts +69 -0
  118. package/src/codegen/topo-sort.ts +46 -0
  119. package/src/codegen/typecheck.test.ts +37 -0
  120. package/src/codegen/typecheck.ts +74 -0
  121. package/src/codegen/validate.test.ts +86 -0
  122. package/src/codegen/validate.ts +143 -0
  123. package/src/composite.test.ts +426 -0
  124. package/src/composite.ts +243 -0
  125. package/src/config.test.ts +91 -0
  126. package/src/config.ts +87 -0
  127. package/src/declarable.test.ts +160 -0
  128. package/src/declarable.ts +47 -0
  129. package/src/detectLexicon.test.ts +236 -0
  130. package/src/detectLexicon.ts +37 -0
  131. package/src/discovery/cache.test.ts +78 -0
  132. package/src/discovery/cache.ts +86 -0
  133. package/src/discovery/collect.test.ts +269 -0
  134. package/src/discovery/collect.ts +51 -0
  135. package/src/discovery/cycles.test.ts +238 -0
  136. package/src/discovery/cycles.ts +107 -0
  137. package/src/discovery/files.test.ts +154 -0
  138. package/src/discovery/files.ts +61 -0
  139. package/src/discovery/graph.test.ts +476 -0
  140. package/src/discovery/graph.ts +150 -0
  141. package/src/discovery/import.test.ts +199 -0
  142. package/src/discovery/import.ts +20 -0
  143. package/src/discovery/index.test.ts +272 -0
  144. package/src/discovery/index.ts +132 -0
  145. package/src/discovery/resolve.test.ts +267 -0
  146. package/src/discovery/resolve.ts +54 -0
  147. package/src/errors.test.ts +138 -0
  148. package/src/errors.ts +86 -0
  149. package/src/import/base-parser.test.ts +67 -0
  150. package/src/import/base-parser.ts +48 -0
  151. package/src/import/generator.ts +21 -0
  152. package/src/import/ir-utils.test.ts +103 -0
  153. package/src/import/ir-utils.ts +87 -0
  154. package/src/import/parser.ts +41 -0
  155. package/src/index.ts +60 -0
  156. package/src/intrinsic-interpolation.test.ts +91 -0
  157. package/src/intrinsic-interpolation.ts +89 -0
  158. package/src/intrinsic.test.ts +69 -0
  159. package/src/intrinsic.ts +43 -0
  160. package/src/lexicon-integrity.test.ts +94 -0
  161. package/src/lexicon-integrity.ts +69 -0
  162. package/src/lexicon-manifest.test.ts +101 -0
  163. package/src/lexicon-manifest.ts +71 -0
  164. package/src/lexicon-output.test.ts +182 -0
  165. package/src/lexicon-output.ts +82 -0
  166. package/src/lexicon-schema.test.ts +239 -0
  167. package/src/lexicon-schema.ts +144 -0
  168. package/src/lexicon.ts +212 -0
  169. package/src/lint/config-overrides.test.ts +254 -0
  170. package/src/lint/config.test.ts +644 -0
  171. package/src/lint/config.ts +375 -0
  172. package/src/lint/declarative.test.ts +256 -0
  173. package/src/lint/declarative.ts +187 -0
  174. package/src/lint/engine.test.ts +465 -0
  175. package/src/lint/engine.ts +172 -0
  176. package/src/lint/named-checks.test.ts +37 -0
  177. package/src/lint/named-checks.ts +33 -0
  178. package/src/lint/parser.test.ts +129 -0
  179. package/src/lint/parser.ts +42 -0
  180. package/src/lint/post-synth.test.ts +113 -0
  181. package/src/lint/post-synth.ts +76 -0
  182. package/src/lint/presets/relaxed.json +19 -0
  183. package/src/lint/presets/strict.json +19 -0
  184. package/src/lint/rule-loader.test.ts +67 -0
  185. package/src/lint/rule-loader.ts +67 -0
  186. package/src/lint/rule-options.test.ts +141 -0
  187. package/src/lint/rule.test.ts +196 -0
  188. package/src/lint/rule.ts +98 -0
  189. package/src/lint/rules/barrel-import-style.test.ts +80 -0
  190. package/src/lint/rules/barrel-import-style.ts +59 -0
  191. package/src/lint/rules/composite-scope.ts +55 -0
  192. package/src/lint/rules/cor017-composite-name-match.test.ts +107 -0
  193. package/src/lint/rules/cor017-composite-name-match.ts +108 -0
  194. package/src/lint/rules/cor018-composite-prefer-lexicon-type.test.ts +172 -0
  195. package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +167 -0
  196. package/src/lint/rules/declarable-naming-convention.test.ts +69 -0
  197. package/src/lint/rules/declarable-naming-convention.ts +70 -0
  198. package/src/lint/rules/enforce-barrel-import.test.ts +169 -0
  199. package/src/lint/rules/enforce-barrel-import.ts +81 -0
  200. package/src/lint/rules/enforce-barrel-ref.test.ts +114 -0
  201. package/src/lint/rules/enforce-barrel-ref.ts +75 -0
  202. package/src/lint/rules/evl001-non-literal-expression.test.ts +158 -0
  203. package/src/lint/rules/evl001-non-literal-expression.ts +149 -0
  204. package/src/lint/rules/evl002-control-flow-resource.test.ts +110 -0
  205. package/src/lint/rules/evl002-control-flow-resource.ts +61 -0
  206. package/src/lint/rules/evl003-dynamic-property-access.test.ts +63 -0
  207. package/src/lint/rules/evl003-dynamic-property-access.ts +41 -0
  208. package/src/lint/rules/evl004-spread-non-const.test.ts +130 -0
  209. package/src/lint/rules/evl004-spread-non-const.ts +111 -0
  210. package/src/lint/rules/evl005-resource-block-body.test.ts +59 -0
  211. package/src/lint/rules/evl005-resource-block-body.ts +49 -0
  212. package/src/lint/rules/evl006-barrel-usage.test.ts +63 -0
  213. package/src/lint/rules/evl006-barrel-usage.ts +95 -0
  214. package/src/lint/rules/evl007-invalid-siblings.test.ts +87 -0
  215. package/src/lint/rules/evl007-invalid-siblings.ts +139 -0
  216. package/src/lint/rules/evl008-unresolvable-barrel-ref.test.ts +118 -0
  217. package/src/lint/rules/evl008-unresolvable-barrel-ref.ts +140 -0
  218. package/src/lint/rules/evl009-composite-no-constant.test.ts +162 -0
  219. package/src/lint/rules/evl009-composite-no-constant.ts +171 -0
  220. package/src/lint/rules/evl010-composite-no-transform.test.ts +121 -0
  221. package/src/lint/rules/evl010-composite-no-transform.ts +69 -0
  222. package/src/lint/rules/export-required.test.ts +213 -0
  223. package/src/lint/rules/export-required.ts +158 -0
  224. package/src/lint/rules/file-declarable-limit.test.ts +148 -0
  225. package/src/lint/rules/file-declarable-limit.ts +96 -0
  226. package/src/lint/rules/flat-declarations.test.ts +210 -0
  227. package/src/lint/rules/flat-declarations.ts +70 -0
  228. package/src/lint/rules/index.ts +99 -0
  229. package/src/lint/rules/no-cyclic-declarable-ref.test.ts +135 -0
  230. package/src/lint/rules/no-cyclic-declarable-ref.ts +178 -0
  231. package/src/lint/rules/no-redundant-type-import.test.ts +129 -0
  232. package/src/lint/rules/no-redundant-type-import.ts +85 -0
  233. package/src/lint/rules/no-redundant-value-cast.test.ts +51 -0
  234. package/src/lint/rules/no-redundant-value-cast.ts +46 -0
  235. package/src/lint/rules/no-string-ref.test.ts +100 -0
  236. package/src/lint/rules/no-string-ref.ts +66 -0
  237. package/src/lint/rules/no-unused-declarable-import.test.ts +74 -0
  238. package/src/lint/rules/no-unused-declarable-import.ts +103 -0
  239. package/src/lint/rules/no-unused-declarable.test.ts +134 -0
  240. package/src/lint/rules/no-unused-declarable.ts +118 -0
  241. package/src/lint/rules/prefer-namespace-import.test.ts +102 -0
  242. package/src/lint/rules/prefer-namespace-import.ts +63 -0
  243. package/src/lint/rules/single-concern-file.test.ts +156 -0
  244. package/src/lint/rules/single-concern-file.ts +98 -0
  245. package/src/lint/rules/stale-barrel-types.ts +60 -0
  246. package/src/lint/selectors.test.ts +113 -0
  247. package/src/lint/selectors.ts +188 -0
  248. package/src/lsp/lexicon-providers.ts +191 -0
  249. package/src/lsp/types.ts +79 -0
  250. package/src/mcp/types.ts +22 -0
  251. package/src/project/scan.test.ts +178 -0
  252. package/src/project/scan.ts +182 -0
  253. package/src/project/sync.test.ts +87 -0
  254. package/src/project/sync.ts +46 -0
  255. package/src/project-validation.test.ts +64 -0
  256. package/src/project-validation.ts +79 -0
  257. package/src/pseudo-parameter.test.ts +39 -0
  258. package/src/pseudo-parameter.ts +47 -0
  259. package/src/runtime.ts +68 -0
  260. package/src/serializer-walker.test.ts +124 -0
  261. package/src/serializer-walker.ts +83 -0
  262. package/src/serializer.ts +42 -0
  263. package/src/sort.test.ts +290 -0
  264. package/src/sort.ts +58 -0
  265. package/src/stack-output.ts +82 -0
  266. package/src/types.test.ts +307 -0
  267. package/src/types.ts +46 -0
  268. package/src/utils.test.ts +195 -0
  269. package/src/utils.ts +46 -0
  270. package/src/validation.test.ts +308 -0
  271. package/src/validation.ts +50 -0
@@ -0,0 +1,426 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import {
3
+ Composite,
4
+ isCompositeInstance,
5
+ expandComposite,
6
+ CompositeRegistry,
7
+ COMPOSITE_MARKER,
8
+ resource,
9
+ withDefaults,
10
+ propagate,
11
+ SHARED_PROPS,
12
+ } from "./composite";
13
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
14
+ import { AttrRef } from "./attrref";
15
+
16
+ function mockDeclarable(type = "TestEntity", lexicon = "test"): Declarable {
17
+ return {
18
+ lexicon,
19
+ entityType: type,
20
+ kind: "resource",
21
+ [DECLARABLE_MARKER]: true,
22
+ } as Declarable;
23
+ }
24
+
25
+ class MockResource implements Declarable {
26
+ readonly [DECLARABLE_MARKER] = true as const;
27
+ readonly lexicon = "test";
28
+ readonly entityType: string;
29
+ readonly kind = "resource" as const;
30
+ readonly arn: AttrRef;
31
+ readonly props: Record<string, unknown>;
32
+
33
+ constructor(props: Record<string, unknown> = {}) {
34
+ this.entityType = (props.type as string) ?? "TestResource";
35
+ this.props = props;
36
+ this.arn = new AttrRef(this, "Arn");
37
+ }
38
+ }
39
+
40
+ describe("Composite", () => {
41
+ beforeEach(() => {
42
+ CompositeRegistry.clear();
43
+ });
44
+
45
+ test("creates a callable composite definition", () => {
46
+ const MyComp = Composite<{ name: string }>((props) => ({
47
+ item: mockDeclarable(props.name),
48
+ }));
49
+
50
+ expect(typeof MyComp).toBe("function");
51
+ expect(MyComp.compositeName).toBe("anonymous");
52
+ });
53
+
54
+ test("returns CompositeInstance with marker", () => {
55
+ const MyComp = Composite<{}>(() => ({
56
+ item: mockDeclarable(),
57
+ }));
58
+
59
+ const instance = MyComp({});
60
+ expect(instance[COMPOSITE_MARKER]).toBe(true);
61
+ expect(instance.members).toBeDefined();
62
+ });
63
+
64
+ test("members contain the Declarables from the factory", () => {
65
+ const MyComp = Composite<{ name: string }>((props) => ({
66
+ bucket: mockDeclarable(`Bucket-${props.name}`),
67
+ role: mockDeclarable(`Role-${props.name}`),
68
+ }));
69
+
70
+ const instance = MyComp({ name: "test" });
71
+ expect(Object.keys(instance.members)).toEqual(["bucket", "role"]);
72
+ expect(instance.members.bucket.entityType).toBe("Bucket-test");
73
+ expect(instance.members.role.entityType).toBe("Role-test");
74
+ });
75
+
76
+ test("props are forwarded to the factory", () => {
77
+ let receivedProps: { x: number } | undefined;
78
+ const MyComp = Composite<{ x: number }>((props) => {
79
+ receivedProps = props;
80
+ return { item: mockDeclarable() };
81
+ });
82
+
83
+ MyComp({ x: 42 });
84
+ expect(receivedProps).toEqual({ x: 42 });
85
+ });
86
+
87
+ test("members are accessible as top-level properties", () => {
88
+ const MyComp = Composite<{}>(() => ({
89
+ bucket: mockDeclarable("Bucket"),
90
+ role: mockDeclarable("Role"),
91
+ }));
92
+
93
+ const instance = MyComp({});
94
+ expect(instance.bucket.entityType).toBe("Bucket");
95
+ expect(instance.role.entityType).toBe("Role");
96
+ });
97
+
98
+ test("throws if factory returns a non-Declarable member", () => {
99
+ const BadComp = Composite<{}>(() => ({
100
+ notDeclarable: "oops" as unknown as Declarable,
101
+ }));
102
+
103
+ expect(() => BadComp({})).toThrow('member "notDeclarable" is not a Declarable');
104
+ });
105
+
106
+ test("sets compositeName when provided", () => {
107
+ const Named = Composite<{}>(() => ({ item: mockDeclarable() }), "MyComposite");
108
+ expect(Named.compositeName).toBe("MyComposite");
109
+ });
110
+
111
+ test("supports AttrRef cross-references between members", () => {
112
+ const MyComp = Composite<{}>(() => {
113
+ const bucket = new MockResource({ type: "Bucket" });
114
+ const role = new MockResource({ type: "Role", bucketArn: bucket.arn });
115
+ return { bucket, role };
116
+ });
117
+
118
+ const instance = MyComp({});
119
+ const roleProps = instance.members.role.props as Record<string, unknown>;
120
+ expect(roleProps.bucketArn).toBeInstanceOf(AttrRef);
121
+ // The AttrRef's parent should be the bucket instance
122
+ expect((roleProps.bucketArn as AttrRef).attribute).toBe("Arn");
123
+ });
124
+ });
125
+
126
+ describe("isCompositeInstance", () => {
127
+ test("returns true for a valid CompositeInstance", () => {
128
+ const MyComp = Composite<{}>(() => ({ item: mockDeclarable() }));
129
+ expect(isCompositeInstance(MyComp({}))).toBe(true);
130
+ });
131
+
132
+ test("returns false for a plain Declarable", () => {
133
+ expect(isCompositeInstance(mockDeclarable())).toBe(false);
134
+ });
135
+
136
+ test("returns false for null, undefined, primitives, plain objects", () => {
137
+ expect(isCompositeInstance(null)).toBe(false);
138
+ expect(isCompositeInstance(undefined)).toBe(false);
139
+ expect(isCompositeInstance(42)).toBe(false);
140
+ expect(isCompositeInstance("string")).toBe(false);
141
+ expect(isCompositeInstance({ members: {} })).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("expandComposite", () => {
146
+ test("expands a simple composite into prefixed entities", () => {
147
+ const MyComp = Composite<{}>(() => ({
148
+ bucket: mockDeclarable("Bucket"),
149
+ role: mockDeclarable("Role"),
150
+ }));
151
+
152
+ const expanded = expandComposite("storage", MyComp({}));
153
+ expect(expanded.size).toBe(2);
154
+ expect(expanded.get("storage_bucket")?.entityType).toBe("Bucket");
155
+ expect(expanded.get("storage_role")?.entityType).toBe("Role");
156
+ });
157
+
158
+ test("handles nested composites", () => {
159
+ const Inner = Composite<{}>(() => ({
160
+ table: mockDeclarable("Table"),
161
+ }));
162
+
163
+ const Outer = Composite<{}>(() => ({
164
+ bucket: mockDeclarable("Bucket"),
165
+ nested: Inner({}) as unknown as Declarable,
166
+ }));
167
+
168
+ const expanded = expandComposite("app", Outer({}));
169
+ expect(expanded.size).toBe(2);
170
+ expect(expanded.get("app_bucket")?.entityType).toBe("Bucket");
171
+ expect(expanded.get("app_nested_table")?.entityType).toBe("Table");
172
+ });
173
+
174
+ test("preserves Declarable identity (same object reference)", () => {
175
+ const bucket = mockDeclarable("Bucket");
176
+ const MyComp = Composite<{}>(() => ({ bucket }));
177
+
178
+ const expanded = expandComposite("s", MyComp({}));
179
+ expect(expanded.get("s_bucket")).toBe(bucket);
180
+ });
181
+
182
+ test("handles empty composite", () => {
183
+ const Empty = Composite<{}>(() => ({} as Record<string, Declarable>));
184
+ const expanded = expandComposite("e", Empty({}));
185
+ expect(expanded.size).toBe(0);
186
+ });
187
+ });
188
+
189
+ describe("CompositeRegistry", () => {
190
+ beforeEach(() => {
191
+ CompositeRegistry.clear();
192
+ });
193
+
194
+ test("auto-registers when Composite() is called", () => {
195
+ expect(CompositeRegistry.size).toBe(0);
196
+ Composite<{}>(() => ({ item: mockDeclarable() }));
197
+ expect(CompositeRegistry.size).toBe(1);
198
+ });
199
+
200
+ test("getAll returns all registered definitions", () => {
201
+ Composite<{}>(() => ({ a: mockDeclarable() }), "A");
202
+ Composite<{}>(() => ({ b: mockDeclarable() }), "B");
203
+ const all = CompositeRegistry.getAll();
204
+ expect(all).toHaveLength(2);
205
+ expect(all.map((d) => d.compositeName)).toContain("A");
206
+ expect(all.map((d) => d.compositeName)).toContain("B");
207
+ });
208
+
209
+ test("clear empties the registry", () => {
210
+ Composite<{}>(() => ({ item: mockDeclarable() }));
211
+ expect(CompositeRegistry.size).toBe(1);
212
+ CompositeRegistry.clear();
213
+ expect(CompositeRegistry.size).toBe(0);
214
+ });
215
+ });
216
+
217
+ describe("resource() helper", () => {
218
+ test("returns an instance of the given class", () => {
219
+ const instance = resource(MockResource, { type: "Bucket" });
220
+ expect(instance).toBeInstanceOf(MockResource);
221
+ expect(instance.entityType).toBe("Bucket");
222
+ });
223
+
224
+ test("passes props correctly", () => {
225
+ const instance = resource(MockResource, { type: "Lambda", memory: 512 });
226
+ expect(instance.props.memory).toBe(512);
227
+ });
228
+
229
+ test("returned instance has AttrRef attributes", () => {
230
+ const instance = resource(MockResource, {});
231
+ expect(instance.arn).toBeInstanceOf(AttrRef);
232
+ });
233
+ });
234
+
235
+ function mockDeclarableWithProps(type: string, props: Record<string, unknown>): Declarable {
236
+ const d = {
237
+ lexicon: "test",
238
+ entityType: type,
239
+ kind: "resource" as const,
240
+ [DECLARABLE_MARKER]: true,
241
+ props,
242
+ } as Declarable;
243
+ Object.defineProperty(d, "props", { value: props, enumerable: false, configurable: true });
244
+ return d;
245
+ }
246
+
247
+ describe("withDefaults", () => {
248
+ beforeEach(() => {
249
+ CompositeRegistry.clear();
250
+ });
251
+
252
+ test("defaulted props become optional, non-defaulted stay required", () => {
253
+ const Base = Composite<{ name: string; timeout: number }>((props) => ({
254
+ item: mockDeclarable(props.name),
255
+ }), "Base");
256
+
257
+ const Wrapped = withDefaults(Base, { timeout: 30 });
258
+ // Can call without timeout
259
+ const instance = Wrapped({ name: "test" });
260
+ expect(instance.members.item.entityType).toBe("test");
261
+ });
262
+
263
+ test("caller can override a default", () => {
264
+ let received: { timeout: number } | undefined;
265
+ const Base = Composite<{ timeout: number }>((props) => {
266
+ received = props;
267
+ return { item: mockDeclarable() };
268
+ });
269
+
270
+ const Wrapped = withDefaults(Base, { timeout: 30 });
271
+ Wrapped({ timeout: 60 });
272
+ expect(received!.timeout).toBe(60);
273
+ });
274
+
275
+ test("default value is used when prop is not provided", () => {
276
+ let received: { timeout: number } | undefined;
277
+ const Base = Composite<{ timeout: number }>((props) => {
278
+ received = props;
279
+ return { item: mockDeclarable() };
280
+ });
281
+
282
+ const Wrapped = withDefaults(Base, { timeout: 30 });
283
+ Wrapped({});
284
+ expect(received!.timeout).toBe(30);
285
+ });
286
+
287
+ test("composition: withDefaults(withDefaults(base, d1), d2) works", () => {
288
+ let received: { a: number; b: number; c: number } | undefined;
289
+ const Base = Composite<{ a: number; b: number; c: number }>((props) => {
290
+ received = props;
291
+ return { item: mockDeclarable() };
292
+ });
293
+
294
+ const Step1 = withDefaults(Base, { a: 1 });
295
+ const Step2 = withDefaults(Step1, { b: 2 });
296
+ Step2({ c: 3 });
297
+ expect(received).toEqual({ a: 1, b: 2, c: 3 });
298
+ });
299
+
300
+ test("wrapped definition shares _id and compositeName", () => {
301
+ const Base = Composite<{ x: number }>(() => ({ item: mockDeclarable() }), "Named");
302
+ const Wrapped = withDefaults(Base, { x: 1 });
303
+
304
+ expect(Wrapped._id).toBe(Base._id);
305
+ expect(Wrapped.compositeName).toBe("Named");
306
+ });
307
+
308
+ test("withDefaults does not add a new registry entry", () => {
309
+ const Base = Composite<{ x: number }>(() => ({ item: mockDeclarable() }));
310
+ expect(CompositeRegistry.size).toBe(1);
311
+
312
+ withDefaults(Base, { x: 1 });
313
+ expect(CompositeRegistry.size).toBe(1);
314
+ });
315
+
316
+ test("expandComposite works identically on defaulted composites", () => {
317
+ const Base = Composite<{ name: string; timeout: number }>((props) => ({
318
+ fn: mockDeclarable(`Fn-${props.name}`),
319
+ role: mockDeclarable(`Role-${props.name}`),
320
+ }));
321
+
322
+ const Wrapped = withDefaults(Base, { timeout: 30 });
323
+ const expanded = expandComposite("api", Wrapped({ name: "test" }));
324
+
325
+ expect(expanded.size).toBe(2);
326
+ expect(expanded.get("api_fn")?.entityType).toBe("Fn-test");
327
+ expect(expanded.get("api_role")?.entityType).toBe("Role-test");
328
+ });
329
+ });
330
+
331
+ describe("propagate", () => {
332
+ beforeEach(() => {
333
+ CompositeRegistry.clear();
334
+ });
335
+
336
+ test("shared props appear on all expanded members", () => {
337
+ const MyComp = Composite<{}>(() => ({
338
+ bucket: mockDeclarableWithProps("Bucket", { bucketName: "data" }),
339
+ role: mockDeclarableWithProps("Role", { roleName: "admin" }),
340
+ }));
341
+
342
+ const instance = propagate(MyComp({}), { env: "prod" });
343
+ const expanded = expandComposite("s", instance);
344
+
345
+ const bucketProps = (expanded.get("s_bucket") as any).props;
346
+ const roleProps = (expanded.get("s_role") as any).props;
347
+ expect(bucketProps.env).toBe("prod");
348
+ expect(roleProps.env).toBe("prod");
349
+ });
350
+
351
+ test("array merge: member tags + shared tags are concatenated", () => {
352
+ const MyComp = Composite<{}>(() => ({
353
+ bucket: mockDeclarableWithProps("Bucket", {
354
+ tags: [{ key: "team", value: "alpha" }],
355
+ }),
356
+ }));
357
+
358
+ const instance = propagate(MyComp({}), {
359
+ tags: [{ key: "env", value: "prod" }],
360
+ });
361
+ const expanded = expandComposite("s", instance);
362
+ const tags = (expanded.get("s_bucket") as any).props.tags;
363
+
364
+ expect(tags).toEqual([
365
+ { key: "env", value: "prod" },
366
+ { key: "team", value: "alpha" },
367
+ ]);
368
+ });
369
+
370
+ test("scalar: member-specific value wins over shared", () => {
371
+ const MyComp = Composite<{}>(() => ({
372
+ bucket: mockDeclarableWithProps("Bucket", { region: "us-west-2" }),
373
+ }));
374
+
375
+ const instance = propagate(MyComp({}), { region: "eu-west-1" });
376
+ const expanded = expandComposite("s", instance);
377
+ expect((expanded.get("s_bucket") as any).props.region).toBe("us-west-2");
378
+ });
379
+
380
+ test("undefined values in shared props are stripped", () => {
381
+ const MyComp = Composite<{}>(() => ({
382
+ bucket: mockDeclarableWithProps("Bucket", { name: "data" }),
383
+ }));
384
+
385
+ const instance = propagate(MyComp({}), { name: undefined, extra: "yes" });
386
+ const expanded = expandComposite("s", instance);
387
+ const props = (expanded.get("s_bucket") as any).props;
388
+ expect(props.name).toBe("data");
389
+ expect(props.extra).toBe("yes");
390
+ });
391
+
392
+ test("nested composites: shared props propagate into nested members", () => {
393
+ const Inner = Composite<{}>(() => ({
394
+ table: mockDeclarableWithProps("Table", { tableName: "items" }),
395
+ }));
396
+
397
+ const Outer = Composite<{}>(() => ({
398
+ bucket: mockDeclarableWithProps("Bucket", { bucketName: "data" }),
399
+ nested: Inner({}) as unknown as Declarable,
400
+ }));
401
+
402
+ const instance = propagate(Outer({}), { env: "prod" });
403
+ const expanded = expandComposite("app", instance);
404
+
405
+ expect((expanded.get("app_bucket") as any).props.env).toBe("prod");
406
+ expect((expanded.get("app_nested_table") as any).props.env).toBe("prod");
407
+ });
408
+
409
+ test("expanded declarables are same object references", () => {
410
+ const bucket = mockDeclarableWithProps("Bucket", { name: "data" });
411
+ const MyComp = Composite<{}>(() => ({ bucket }));
412
+
413
+ const instance = propagate(MyComp({}), { env: "prod" });
414
+ const expanded = expandComposite("s", instance);
415
+ expect(expanded.get("s_bucket")).toBe(bucket);
416
+ });
417
+
418
+ test("composites without propagate work unchanged", () => {
419
+ const MyComp = Composite<{}>(() => ({
420
+ bucket: mockDeclarableWithProps("Bucket", { name: "data" }),
421
+ }));
422
+
423
+ const expanded = expandComposite("s", MyComp({}));
424
+ expect((expanded.get("s_bucket") as any).props.name).toBe("data");
425
+ });
426
+ });
@@ -0,0 +1,243 @@
1
+ import { isDeclarable, type Declarable } from "./declarable";
2
+
3
+ /**
4
+ * Marker symbol for Composite type identification.
5
+ */
6
+ export const COMPOSITE_MARKER = Symbol.for("chant.composite");
7
+
8
+ /**
9
+ * A record of named Declarable members produced by a composite factory.
10
+ */
11
+ export type CompositeMembers = Record<string, Declarable>;
12
+
13
+ /**
14
+ * The result of instantiating a composite — contains the marker and expanded members.
15
+ */
16
+ export interface CompositeInstance<M extends CompositeMembers = CompositeMembers> {
17
+ readonly [COMPOSITE_MARKER]: true;
18
+ readonly members: M;
19
+ readonly _definition: CompositeDefinition<any, M>;
20
+ }
21
+
22
+ /**
23
+ * A composite definition: a callable that produces a CompositeInstance.
24
+ */
25
+ export interface CompositeDefinition<P, M extends CompositeMembers = CompositeMembers> {
26
+ (props: P): CompositeInstance<M> & M;
27
+ readonly compositeName: string;
28
+ readonly _id: symbol;
29
+ }
30
+
31
+ /**
32
+ * Type guard: is this value a CompositeInstance?
33
+ */
34
+ export function isCompositeInstance(value: unknown): value is CompositeInstance {
35
+ return (
36
+ typeof value === "object" &&
37
+ value !== null &&
38
+ COMPOSITE_MARKER in value &&
39
+ (value as Record<symbol, unknown>)[COMPOSITE_MARKER] === true
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Global registry of composite definitions.
45
+ */
46
+ export class CompositeRegistry {
47
+ private static definitions = new Map<symbol, CompositeDefinition<unknown>>();
48
+
49
+ static register(definition: CompositeDefinition<unknown>): void {
50
+ this.definitions.set(definition._id, definition);
51
+ }
52
+
53
+ static getAll(): CompositeDefinition<unknown>[] {
54
+ return Array.from(this.definitions.values());
55
+ }
56
+
57
+ static clear(): void {
58
+ this.definitions.clear();
59
+ }
60
+
61
+ static get size(): number {
62
+ return this.definitions.size;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Creates a composite definition from a factory closure.
68
+ *
69
+ * Usage:
70
+ * ```ts
71
+ * const SecureStorage = Composite<{ name: string }>((props) => {
72
+ * const bucket = new Bucket({ bucketName: props.name });
73
+ * const role = new Role({ policies: [{ resource: bucket.arn }] });
74
+ * return { bucket, role };
75
+ * });
76
+ * export const storage = SecureStorage({ name: "data" });
77
+ * ```
78
+ */
79
+ export function Composite<P, M extends CompositeMembers = CompositeMembers>(
80
+ factory: (props: P) => M,
81
+ name?: string,
82
+ ): CompositeDefinition<P, M> {
83
+ const id = Symbol();
84
+ const compositeName = name ?? "anonymous";
85
+
86
+ const definition = ((props: P): CompositeInstance<M> & M => {
87
+ const members = factory(props);
88
+
89
+ for (const [key, value] of Object.entries(members)) {
90
+ if (!isDeclarable(value) && !isCompositeInstance(value)) {
91
+ throw new Error(
92
+ `Composite "${compositeName}" member "${key}" is not a Declarable or CompositeInstance`,
93
+ );
94
+ }
95
+ }
96
+
97
+ const instance: CompositeInstance<M> = {
98
+ [COMPOSITE_MARKER]: true,
99
+ members,
100
+ _definition: definition,
101
+ };
102
+
103
+ return Object.assign(instance, members) as CompositeInstance<M> & M;
104
+ }) as CompositeDefinition<P, M>;
105
+
106
+ Object.defineProperty(definition, "compositeName", { value: compositeName, writable: false });
107
+ Object.defineProperty(definition, "_id", { value: id, writable: false });
108
+
109
+ CompositeRegistry.register(definition as CompositeDefinition<unknown>);
110
+
111
+ return definition;
112
+ }
113
+
114
+ /**
115
+ * Expands a CompositeInstance into a flat Map of prefixed entity names to Declarables.
116
+ * Handles nested composites recursively.
117
+ */
118
+ export function expandComposite(
119
+ prefix: string,
120
+ instance: CompositeInstance,
121
+ ): Map<string, Declarable> {
122
+ const result = new Map<string, Declarable>();
123
+ const shared = (instance as any)[SHARED_PROPS] as Record<string, unknown> | undefined;
124
+
125
+ for (const [memberName, member] of Object.entries(instance.members)) {
126
+ const fullName = `${prefix}_${memberName}`;
127
+
128
+ if (isCompositeInstance(member)) {
129
+ const nested = expandComposite(fullName, member);
130
+ for (const [nestedName, nestedEntity] of nested) {
131
+ result.set(nestedName, nestedEntity);
132
+ }
133
+ } else {
134
+ result.set(fullName, member as Declarable);
135
+ }
136
+ }
137
+
138
+ if (shared) {
139
+ for (const entity of result.values()) {
140
+ if ("props" in entity) {
141
+ const existing = entity.props as Record<string, unknown>;
142
+ const merged: Record<string, unknown> = {};
143
+ for (const [k, v] of Object.entries(shared)) {
144
+ if (v !== undefined) {
145
+ merged[k] = v;
146
+ }
147
+ }
148
+ for (const [k, v] of Object.entries(existing)) {
149
+ if (v !== undefined) {
150
+ if (Array.isArray(v) && Array.isArray(merged[k])) {
151
+ merged[k] = [...(merged[k] as unknown[]), ...v];
152
+ } else {
153
+ merged[k] = v;
154
+ }
155
+ }
156
+ }
157
+ Object.defineProperty(entity, "props", {
158
+ value: merged, enumerable: false, configurable: true,
159
+ });
160
+ }
161
+ }
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Type helpers for withDefaults.
169
+ */
170
+ type PartialByDefault<P, D extends Partial<P>> =
171
+ Omit<P, keyof D> & Partial<Pick<P, keyof D & keyof P>>;
172
+ type Simplify<T> = { [K in keyof T]: T[K] };
173
+
174
+ /**
175
+ * Wraps a CompositeDefinition with pre-applied default values.
176
+ * Props that have defaults become optional in the returned type.
177
+ *
178
+ * ```ts
179
+ * const SecureApi = withDefaults(LambdaApi, { runtime: "nodejs20.x", timeout: 10 });
180
+ * const api = SecureApi({ name: "myApi", code: "./dist" }); // runtime and timeout are optional
181
+ * ```
182
+ */
183
+ export function withDefaults<P, M extends CompositeMembers, D extends Partial<P>>(
184
+ definition: CompositeDefinition<P, M>,
185
+ defaults: D,
186
+ ): CompositeDefinition<Simplify<PartialByDefault<P, D>>, M> {
187
+ const wrapped = ((props: Simplify<PartialByDefault<P, D>>) => {
188
+ return definition({ ...defaults, ...props } as P);
189
+ }) as CompositeDefinition<Simplify<PartialByDefault<P, D>>, M>;
190
+
191
+ Object.defineProperty(wrapped, "compositeName", {
192
+ value: definition.compositeName, writable: false,
193
+ });
194
+ Object.defineProperty(wrapped, "_id", {
195
+ value: definition._id, writable: false,
196
+ });
197
+
198
+ return wrapped;
199
+ }
200
+
201
+ /**
202
+ * Symbol key for shared props attached by propagate().
203
+ */
204
+ export const SHARED_PROPS = Symbol.for("chant.composite.shared");
205
+
206
+ /**
207
+ * Attaches shared properties to a composite instance.
208
+ * During expandComposite(), shared props are merged into every member's props.
209
+ *
210
+ * Merge semantics:
211
+ * - Scalars: member-specific value wins
212
+ * - Arrays (e.g. tags): concatenate shared + member-specific
213
+ * - undefined values in shared props are stripped
214
+ *
215
+ * ```ts
216
+ * export const storage = propagate(
217
+ * SecureStorage({ name: "data" }),
218
+ * { tags: [{ key: "env", value: "prod" }] },
219
+ * );
220
+ * ```
221
+ */
222
+ export function propagate<M extends CompositeMembers>(
223
+ instance: CompositeInstance<M> & M,
224
+ sharedProps: Record<string, unknown>,
225
+ ): CompositeInstance<M> & M {
226
+ Object.defineProperty(instance, SHARED_PROPS, {
227
+ value: sharedProps,
228
+ enumerable: false,
229
+ });
230
+ return instance;
231
+ }
232
+
233
+ /**
234
+ * Marker function for resource declarations within composites.
235
+ * At runtime, simply calls `new Type(props)` and returns the result.
236
+ * Exists so lint tooling can validate composite member construction (EVL005).
237
+ */
238
+ export function resource<T extends Declarable, P>(
239
+ Type: new (props: P) => T,
240
+ props: P,
241
+ ): T {
242
+ return new Type(props);
243
+ }