@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,149 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { buildCommand, type BuildOptions } from "./build";
3
+ import type { Serializer } from "../../serializer";
4
+ import { mkdir, rm, writeFile } from "node:fs/promises";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ describe("buildCommand", () => {
10
+ let testDir: string;
11
+ let outputFile: string;
12
+
13
+ const mockSerializer: Serializer = {
14
+ name: "test",
15
+ rulePrefix: "TEST",
16
+ serialize: (entities) => {
17
+ const result: Record<string, unknown> = {};
18
+ for (const [name, entity] of entities) {
19
+ result[name] = { type: entity.entityType };
20
+ }
21
+ return JSON.stringify({ resources: result }, null, 2);
22
+ },
23
+ };
24
+
25
+ beforeEach(async () => {
26
+ testDir = join(tmpdir(), `chant-cli-test-${Date.now()}-${Math.random()}`);
27
+ outputFile = join(testDir, "output.json");
28
+ await mkdir(testDir, { recursive: true });
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(testDir, { recursive: true, force: true });
33
+ });
34
+
35
+ test("builds empty directory successfully", async () => {
36
+ const options: BuildOptions = {
37
+ path: testDir,
38
+ format: "json",
39
+ serializers: [mockSerializer],
40
+ };
41
+
42
+ const result = await buildCommand(options);
43
+
44
+ expect(result.success).toBe(true);
45
+ expect(result.errors).toHaveLength(0);
46
+ });
47
+
48
+ test("builds directory with entities", async () => {
49
+ // Create a test infrastructure file
50
+ const infraFile = join(testDir, "test.infra.ts");
51
+ await writeFile(
52
+ infraFile,
53
+ `
54
+ export const testEntity = {
55
+ lexicon: "test",
56
+ entityType: "TestEntity",
57
+ [Symbol.for("chant.declarable")]: true,
58
+ };
59
+ `
60
+ );
61
+
62
+ const options: BuildOptions = {
63
+ path: testDir,
64
+ format: "json",
65
+ serializers: [mockSerializer],
66
+ };
67
+
68
+ const result = await buildCommand(options);
69
+
70
+ expect(result.success).toBe(true);
71
+ expect(result.resourceCount).toBe(1);
72
+ });
73
+
74
+ test("writes output to file when specified", async () => {
75
+ const options: BuildOptions = {
76
+ path: testDir,
77
+ output: outputFile,
78
+ format: "json",
79
+ serializers: [mockSerializer],
80
+ };
81
+
82
+ const result = await buildCommand(options);
83
+
84
+ expect(result.success).toBe(true);
85
+ expect(existsSync(outputFile)).toBe(true);
86
+
87
+ const content = readFileSync(outputFile, "utf-8");
88
+ expect(() => JSON.parse(content)).not.toThrow();
89
+ });
90
+
91
+ test("returns errors for invalid files", async () => {
92
+ // Create a broken TypeScript file
93
+ const infraFile = join(testDir, "broken.infra.ts");
94
+ await writeFile(infraFile, "this is not valid typescript {{{");
95
+
96
+ const options: BuildOptions = {
97
+ path: testDir,
98
+ format: "json",
99
+ serializers: [mockSerializer],
100
+ };
101
+
102
+ const result = await buildCommand(options);
103
+
104
+ // Should still complete but may have errors
105
+ expect(result.errors.length).toBeGreaterThanOrEqual(0);
106
+ });
107
+
108
+ test("handles yaml format option", async () => {
109
+ const options: BuildOptions = {
110
+ path: testDir,
111
+ output: outputFile.replace(".json", ".yaml"),
112
+ format: "yaml",
113
+ serializers: [mockSerializer],
114
+ };
115
+
116
+ const result = await buildCommand(options);
117
+
118
+ expect(result.success).toBe(true);
119
+ expect(existsSync(outputFile.replace(".json", ".yaml"))).toBe(true);
120
+ });
121
+
122
+ test("result includes resource and file counts", async () => {
123
+ const options: BuildOptions = {
124
+ path: testDir,
125
+ format: "json",
126
+ serializers: [mockSerializer],
127
+ };
128
+
129
+ const result = await buildCommand(options);
130
+
131
+ expect(result.resourceCount).toBeDefined();
132
+ expect(result.fileCount).toBeDefined();
133
+ });
134
+
135
+ test("handles invalid output path", async () => {
136
+ const options: BuildOptions = {
137
+ path: testDir,
138
+ output: "/nonexistent/directory/file.json",
139
+ format: "json",
140
+ serializers: [mockSerializer],
141
+ };
142
+
143
+ const result = await buildCommand(options);
144
+
145
+ // Should report error about output file
146
+ expect(result.errors.length).toBeGreaterThan(0);
147
+ expect(result.errors.some((e) => e.includes("output"))).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,344 @@
1
+ import { build } from "../../build";
2
+ import type { Serializer, SerializerResult } from "../../serializer";
3
+ import type { LexiconPlugin } from "../../lexicon";
4
+ import { runPostSynthChecks } from "../../lint/post-synth";
5
+ import type { PostSynthCheck } from "../../lint/post-synth";
6
+ import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
7
+ import { writeFileSync } from "fs";
8
+ import { resolve } from "path";
9
+ import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
10
+
11
+ /**
12
+ * Build command options
13
+ */
14
+ export interface BuildOptions {
15
+ /** Path to infrastructure directory */
16
+ path: string;
17
+ /** Output file path (undefined = stdout) */
18
+ output?: string;
19
+ /** Output format */
20
+ format: "json" | "yaml";
21
+ /** Serializers to use for serialization */
22
+ serializers: Serializer[];
23
+ /** Lexicon plugins (for post-synth checks) */
24
+ plugins?: LexiconPlugin[];
25
+ /** Print summary to stderr */
26
+ verbose?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Build command result
31
+ */
32
+ export interface BuildResult {
33
+ /** Whether the build succeeded */
34
+ success: boolean;
35
+ /** Number of resources built */
36
+ resourceCount: number;
37
+ /** Number of source files processed */
38
+ fileCount: number;
39
+ /** Error messages */
40
+ errors: string[];
41
+ /** Warning messages */
42
+ warnings: string[];
43
+ }
44
+
45
+ /**
46
+ * Execute the build command
47
+ */
48
+ export async function buildCommand(options: BuildOptions): Promise<BuildResult> {
49
+ const errors: string[] = [];
50
+ const warnings: string[] = [];
51
+
52
+ // Resolve the path
53
+ const infraPath = resolve(options.path);
54
+
55
+ // Run the build
56
+ const result = await build(infraPath, options.serializers);
57
+
58
+ // Format errors
59
+ for (const error of result.errors) {
60
+ const formatted = formatError({
61
+ file: "file" in error ? (error as unknown as Record<string, unknown>).file as string | undefined : undefined,
62
+ line: "line" in error ? (error as unknown as Record<string, unknown>).line as number | undefined : undefined,
63
+ column: "column" in error ? (error as unknown as Record<string, unknown>).column as number | undefined : undefined,
64
+ message: error.message,
65
+ name: error.name,
66
+ });
67
+ errors.push(formatted);
68
+ }
69
+
70
+ // Format warnings
71
+ for (const warning of result.warnings) {
72
+ warnings.push(formatWarning({ message: warning }));
73
+ }
74
+
75
+ // Run post-synth checks from plugins
76
+ if (result.errors.length === 0 && options.plugins) {
77
+ const postSynthChecks: PostSynthCheck[] = [];
78
+ for (const plugin of options.plugins) {
79
+ if (plugin.postSynthChecks) {
80
+ postSynthChecks.push(...plugin.postSynthChecks());
81
+ }
82
+ }
83
+
84
+ if (postSynthChecks.length > 0) {
85
+ const postDiags = runPostSynthChecks(postSynthChecks, result);
86
+ for (const diag of postDiags) {
87
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
88
+ const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
89
+ if (diag.severity === "error") {
90
+ errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
91
+ } else {
92
+ warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ // Handle output
99
+ if (result.errors.length === 0 && errors.length === 0) {
100
+ // Extract primary content and collect additional files from SerializerResult
101
+ const additionalFiles = new Map<string, string>();
102
+
103
+ function getPrimaryContent(raw: string | SerializerResult): string {
104
+ if (typeof raw === "string") return raw;
105
+ if (raw.files) {
106
+ for (const [filename, content] of Object.entries(raw.files)) {
107
+ additionalFiles.set(filename, content);
108
+ }
109
+ }
110
+ return raw.primary;
111
+ }
112
+
113
+ // Try to parse content as JSON; return raw string if not JSON.
114
+ function tryParseJson(content: string): { json: unknown } | { raw: string } {
115
+ try {
116
+ return { json: JSON.parse(content) };
117
+ } catch {
118
+ return { raw: content };
119
+ }
120
+ }
121
+
122
+ // Single lexicon: output the template directly
123
+ // Multiple lexicons: wrap in lexicon keys
124
+ let output: string = "{}";
125
+ if (result.outputs.size === 1) {
126
+ const [, raw] = [...result.outputs.entries()][0];
127
+ const content = getPrimaryContent(raw);
128
+ const parsed = tryParseJson(content);
129
+ if ("json" in parsed) {
130
+ output = JSON.stringify(parsed.json, sortedJsonReplacer, 2);
131
+ if (options.format === "yaml") {
132
+ output = jsonToYaml(JSON.parse(output));
133
+ }
134
+ } else {
135
+ output = parsed.raw;
136
+ }
137
+ } else {
138
+ // Multiple lexicons: JSON outputs get combined under lexicon keys,
139
+ // non-JSON outputs (e.g. YAML) are appended after a separator.
140
+ const combined: Record<string, unknown> = {};
141
+ const nonJsonSections: string[] = [];
142
+ const sortedLexiconNames = [...result.outputs.keys()].sort();
143
+ for (const lexiconName of sortedLexiconNames) {
144
+ const content = getPrimaryContent(result.outputs.get(lexiconName)!);
145
+ const parsed = tryParseJson(content);
146
+ if ("json" in parsed) {
147
+ combined[lexiconName] = parsed.json;
148
+ } else {
149
+ nonJsonSections.push(`# --- ${lexiconName} ---\n${parsed.raw}`);
150
+ }
151
+ }
152
+
153
+ const parts: string[] = [];
154
+ if (Object.keys(combined).length > 0) {
155
+ let jsonOutput = JSON.stringify(combined, sortedJsonReplacer, 2);
156
+ if (options.format === "yaml") {
157
+ jsonOutput = jsonToYaml(JSON.parse(jsonOutput));
158
+ }
159
+ parts.push(jsonOutput);
160
+ }
161
+ parts.push(...nonJsonSections);
162
+ if (parts.length > 0) {
163
+ output = parts.join("\n\n");
164
+ }
165
+ }
166
+
167
+ if (options.output) {
168
+ // Write to file
169
+ try {
170
+ const outputPath = resolve(options.output);
171
+ writeFileSync(outputPath, output);
172
+
173
+ // Write additional files (e.g. nested stack templates) alongside the primary output
174
+ if (additionalFiles.size > 0) {
175
+ const { dirname, join } = require("path");
176
+ const outputDir = dirname(outputPath);
177
+ for (const [filename, content] of additionalFiles) {
178
+ let fileContent = content;
179
+ // Format additional files consistently
180
+ try {
181
+ const fileParsed = JSON.parse(content);
182
+ fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
183
+ if (options.format === "yaml") {
184
+ fileContent = jsonToYaml(JSON.parse(fileContent));
185
+ }
186
+ } catch {
187
+ // If not JSON, write as-is
188
+ }
189
+ writeFileSync(join(outputDir, filename), fileContent);
190
+ }
191
+ }
192
+ } catch (err) {
193
+ errors.push(
194
+ formatError({
195
+ message: `Failed to write output file: ${err instanceof Error ? err.message : String(err)}`,
196
+ })
197
+ );
198
+ }
199
+ } else {
200
+ // Print to stdout
201
+ console.log(output);
202
+ // Log additional files to stderr if any
203
+ for (const [filename, content] of additionalFiles) {
204
+ console.error(`\n--- ${filename} ---`);
205
+ console.error(content);
206
+ }
207
+ }
208
+ }
209
+
210
+ const resourceCount = result.entities.size;
211
+ const fileCount = result.sourceFileCount;
212
+
213
+ if (options.verbose && errors.length === 0) {
214
+ console.error(
215
+ formatSuccess(
216
+ `Built ${formatBold(String(resourceCount))} resources successfully`
217
+ )
218
+ );
219
+ }
220
+
221
+ return {
222
+ success: errors.length === 0,
223
+ resourceCount,
224
+ fileCount,
225
+ errors,
226
+ warnings,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * JSON.stringify replacer that sorts object keys for deterministic output
232
+ */
233
+ function sortedJsonReplacer(_key: string, value: unknown): unknown {
234
+ if (value && typeof value === "object" && !Array.isArray(value)) {
235
+ return Object.fromEntries(
236
+ Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))
237
+ );
238
+ }
239
+ return value;
240
+ }
241
+
242
+ /**
243
+ * Simple JSON to YAML converter
244
+ */
245
+ function jsonToYaml(obj: unknown, indent = 0): string {
246
+ const spaces = " ".repeat(indent);
247
+
248
+ if (obj === null) return "null";
249
+ if (obj === undefined) return "~";
250
+ if (typeof obj === "boolean") return obj ? "true" : "false";
251
+ if (typeof obj === "number") return String(obj);
252
+ if (typeof obj === "string") {
253
+ // Quote strings that need it
254
+ if (obj.includes("\n") || obj.includes(":") || obj.includes("#")) {
255
+ return `"${obj.replace(/"/g, '\\"')}"`;
256
+ }
257
+ return obj;
258
+ }
259
+
260
+ if (Array.isArray(obj)) {
261
+ if (obj.length === 0) return "[]";
262
+ return obj
263
+ .map((item) => `${spaces}- ${jsonToYaml(item, indent + 1).trimStart()}`)
264
+ .join("\n");
265
+ }
266
+
267
+ if (typeof obj === "object") {
268
+ const entries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b));
269
+ if (entries.length === 0) return "{}";
270
+ return entries
271
+ .map(([key, value]) => {
272
+ const yamlValue = jsonToYaml(value, indent + 1);
273
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
274
+ return `${spaces}${key}:\n${yamlValue}`;
275
+ }
276
+ return `${spaces}${key}: ${yamlValue.trimStart()}`;
277
+ })
278
+ .join("\n");
279
+ }
280
+
281
+ return String(obj);
282
+ }
283
+
284
+ /**
285
+ * Print errors to stderr
286
+ */
287
+ export function printErrors(errors: string[]): void {
288
+ for (const error of errors) {
289
+ console.error(error);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Print warnings to stderr
295
+ */
296
+ export function printWarnings(warnings: string[]): void {
297
+ for (const warning of warnings) {
298
+ console.error(warning);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Run build in watch mode. Runs an initial build, then watches for changes
304
+ * and triggers rebuilds. Returns a cleanup function.
305
+ */
306
+ export function buildCommandWatch(
307
+ options: BuildOptions,
308
+ onRebuild?: (result: BuildResult) => void,
309
+ ): () => void {
310
+ const infraPath = resolve(options.path);
311
+
312
+ console.error(formatInfo(`[${formatTimestamp()}] Watching for changes...`));
313
+
314
+ // Run initial build
315
+ buildCommand(options).then((result) => {
316
+ printWarnings(result.warnings);
317
+ printErrors(result.errors);
318
+ onRebuild?.(result);
319
+ console.error(formatInfo(`[${formatTimestamp()}] Waiting for changes...`));
320
+ });
321
+
322
+ // Watch for changes and trigger rebuilds
323
+ const cleanup = watchDirectory(infraPath, async (changedFiles) => {
324
+ console.error("");
325
+ console.error(
326
+ formatInfo(
327
+ `[${formatTimestamp()}] Changes detected: ${formatChangedFiles(changedFiles, infraPath)}`,
328
+ ),
329
+ );
330
+
331
+ try {
332
+ const result = await buildCommand(options);
333
+ printWarnings(result.warnings);
334
+ printErrors(result.errors);
335
+ onRebuild?.(result);
336
+ } catch (err) {
337
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
338
+ }
339
+
340
+ console.error(formatInfo(`[${formatTimestamp()}] Waiting for changes...`));
341
+ });
342
+
343
+ return cleanup;
344
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { diffCommand, type DiffOptions } from "./diff";
3
+ import type { Serializer } from "../../serializer";
4
+ import { mkdir, rm, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ describe("diffCommand", () => {
9
+ let testDir: string;
10
+
11
+ const mockSerializer: Serializer = {
12
+ name: "test",
13
+ rulePrefix: "TEST",
14
+ serialize: (entities) => {
15
+ const result: Record<string, unknown> = {};
16
+ for (const [name, entity] of entities) {
17
+ result[name] = { type: entity.entityType };
18
+ }
19
+ return JSON.stringify({ resources: result }, null, 2);
20
+ },
21
+ };
22
+
23
+ beforeEach(async () => {
24
+ testDir = join(tmpdir(), `chant-diff-test-${Date.now()}-${Math.random()}`);
25
+ await mkdir(testDir, { recursive: true });
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await rm(testDir, { recursive: true, force: true });
30
+ });
31
+
32
+ test("shows no changes for empty directory with no previous output", async () => {
33
+ const options: DiffOptions = {
34
+ path: testDir,
35
+ serializers: [mockSerializer],
36
+ };
37
+
38
+ const result = await diffCommand(options);
39
+
40
+ expect(result.success).toBe(true);
41
+ // Empty build vs empty previous — no meaningful changes beyond the empty object
42
+ });
43
+
44
+ test("shows additions when no previous output file exists", async () => {
45
+ const infraFile = join(testDir, "test.infra.ts");
46
+ await writeFile(
47
+ infraFile,
48
+ `
49
+ export const myBucket = {
50
+ lexicon: "test",
51
+ entityType: "TestBucket",
52
+ [Symbol.for("chant.declarable")]: true,
53
+ };
54
+ `
55
+ );
56
+
57
+ const options: DiffOptions = {
58
+ path: testDir,
59
+ output: join(testDir, "nonexistent.json"),
60
+ serializers: [mockSerializer],
61
+ };
62
+
63
+ const result = await diffCommand(options);
64
+
65
+ expect(result.success).toBe(true);
66
+ expect(result.hasChanges).toBe(true);
67
+ expect(result.diff).toContain("+");
68
+ });
69
+
70
+ test("shows no changes when output matches", async () => {
71
+ const infraFile = join(testDir, "test.infra.ts");
72
+ await writeFile(
73
+ infraFile,
74
+ `
75
+ export const myBucket = {
76
+ lexicon: "test",
77
+ entityType: "TestBucket",
78
+ [Symbol.for("chant.declarable")]: true,
79
+ };
80
+ `
81
+ );
82
+
83
+ // First, build to get the expected output
84
+ const { build } = await import("@intentius/chant/build");
85
+ const buildResult = await build(join(testDir), [mockSerializer]);
86
+ const combined: Record<string, unknown> = {};
87
+ const sortedSerializerNames = [...buildResult.outputs.keys()].sort();
88
+ for (const serializerName of sortedSerializerNames) {
89
+ combined[serializerName] = JSON.parse(buildResult.outputs.get(serializerName)!);
90
+ }
91
+
92
+ // Sort keys to match diffCommand behavior
93
+ const sortedReplacer = (_key: string, value: unknown): unknown => {
94
+ if (value && typeof value === "object" && !Array.isArray(value)) {
95
+ return Object.fromEntries(
96
+ Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))
97
+ );
98
+ }
99
+ return value;
100
+ };
101
+ const expectedOutput = JSON.stringify(combined, sortedReplacer, 2);
102
+
103
+ const outputFile = join(testDir, "output.json");
104
+ await writeFile(outputFile, expectedOutput);
105
+
106
+ const options: DiffOptions = {
107
+ path: testDir,
108
+ output: outputFile,
109
+ serializers: [mockSerializer],
110
+ };
111
+
112
+ const result = await diffCommand(options);
113
+
114
+ expect(result.success).toBe(true);
115
+ expect(result.hasChanges).toBe(false);
116
+ expect(result.diff).toBe("");
117
+ });
118
+
119
+ test("shows diff when output differs", async () => {
120
+ const infraFile = join(testDir, "test.infra.ts");
121
+ await writeFile(
122
+ infraFile,
123
+ `
124
+ export const myBucket = {
125
+ lexicon: "test",
126
+ entityType: "TestBucket",
127
+ [Symbol.for("chant.declarable")]: true,
128
+ };
129
+ `
130
+ );
131
+
132
+ const outputFile = join(testDir, "output.json");
133
+ await writeFile(outputFile, '{\n "test": {\n "resources": {}\n }\n}');
134
+
135
+ const options: DiffOptions = {
136
+ path: testDir,
137
+ output: outputFile,
138
+ serializers: [mockSerializer],
139
+ };
140
+
141
+ const result = await diffCommand(options);
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(result.hasChanges).toBe(true);
145
+ expect(result.diff).toContain("---");
146
+ expect(result.diff).toContain("+++");
147
+ });
148
+ });