@intentius/chant 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +54 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +12 -5
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/discover.d.ts +62 -7
  30. package/dist/components/discover.d.ts.map +1 -1
  31. package/dist/components/sandbox/driver.d.ts +12 -0
  32. package/dist/components/sandbox/driver.d.ts.map +1 -0
  33. package/dist/components/sandbox/run.d.ts +42 -0
  34. package/dist/components/sandbox/run.d.ts.map +1 -0
  35. package/dist/composite.d.ts +5 -0
  36. package/dist/composite.d.ts.map +1 -1
  37. package/dist/config.d.ts +71 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/discovery/collect.d.ts.map +1 -1
  40. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  41. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  42. package/dist/discovery/entity-wire.d.ts +50 -0
  43. package/dist/discovery/entity-wire.d.ts.map +1 -0
  44. package/dist/discovery/fold-import.d.ts +210 -0
  45. package/dist/discovery/fold-import.d.ts.map +1 -0
  46. package/dist/discovery/index.d.ts +74 -1
  47. package/dist/discovery/index.d.ts.map +1 -1
  48. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  49. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  50. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  51. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  52. package/dist/discovery/sandbox/driver.d.ts +13 -0
  53. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/run.d.ts +69 -0
  55. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  56. package/dist/errors.d.ts +9 -1
  57. package/dist/errors.d.ts.map +1 -1
  58. package/dist/fold/fold.d.ts +226 -0
  59. package/dist/fold/fold.d.ts.map +1 -0
  60. package/dist/fold/subset.d.ts +102 -0
  61. package/dist/fold/subset.d.ts.map +1 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/lexicon-output.d.ts +7 -2
  65. package/dist/lexicon-output.d.ts.map +1 -1
  66. package/dist/lexicon-schema.d.ts +2 -2
  67. package/dist/lexicon-schema.d.ts.map +1 -1
  68. package/dist/lexicon.d.ts +81 -1
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/release-ledger.d.ts +11 -0
  71. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  72. package/dist/lint/component-checks.d.ts +7 -1
  73. package/dist/lint/component-checks.d.ts.map +1 -1
  74. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  75. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  76. package/dist/params.d.ts +60 -0
  77. package/dist/params.d.ts.map +1 -0
  78. package/dist/provenance.d.ts +21 -0
  79. package/dist/provenance.d.ts.map +1 -1
  80. package/dist/terraform/aws-resources.d.ts.map +1 -1
  81. package/package.json +2 -1
  82. package/src/build-params.test.ts +144 -0
  83. package/src/build-params.ts +207 -0
  84. package/src/build.test.ts +38 -0
  85. package/src/build.ts +144 -7
  86. package/src/cli/commands/build.test.ts +220 -2
  87. package/src/cli/commands/build.ts +111 -3
  88. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  89. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  90. package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
  91. package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
  92. package/src/cli/commands/check-lexicon.test.ts +34 -0
  93. package/src/cli/commands/check-lexicon.ts +119 -1
  94. package/src/cli/commands/lint.ts +31 -3
  95. package/src/cli/commands/onboard.ts +1 -1
  96. package/src/cli/component-security-boundary.test.ts +170 -0
  97. package/src/cli/handlers/build.ts +24 -3
  98. package/src/cli/handlers/components.ts +9 -2
  99. package/src/cli/handlers/dev.ts +1 -1
  100. package/src/cli/handlers/graph.ts +7 -5
  101. package/src/cli/handlers/lifecycle.ts +2 -2
  102. package/src/cli/handlers/lint.ts +2 -0
  103. package/src/cli/handlers/misc.ts +2 -2
  104. package/src/cli/handlers/run-client.ts +1 -1
  105. package/src/cli/handlers/run.ts +20 -5
  106. package/src/cli/main.test.ts +22 -0
  107. package/src/cli/main.ts +39 -0
  108. package/src/cli/plugins.ts +20 -4
  109. package/src/cli/registry.ts +10 -0
  110. package/src/cli/security-boundary.test.ts +135 -0
  111. package/src/codegen/docs-sections.test.ts +61 -0
  112. package/src/codegen/docs-sections.ts +7 -3
  113. package/src/components/cli-support.ts +22 -10
  114. package/src/components/discover.ts +127 -25
  115. package/src/components/sandbox/driver.ts +114 -0
  116. package/src/components/sandbox/run.test.ts +185 -0
  117. package/src/components/sandbox/run.ts +177 -0
  118. package/src/composite.test.ts +21 -0
  119. package/src/composite.ts +20 -1
  120. package/src/config.ts +81 -0
  121. package/src/discovery/collect.ts +17 -3
  122. package/src/discovery/entity-wire-codec.ts +485 -0
  123. package/src/discovery/entity-wire.test.ts +240 -0
  124. package/src/discovery/entity-wire.ts +67 -0
  125. package/src/discovery/fold-import.test.ts +1026 -0
  126. package/src/discovery/fold-import.ts +1805 -0
  127. package/src/discovery/index.test.ts +191 -1
  128. package/src/discovery/index.ts +233 -1
  129. package/src/discovery/sandbox/bundle.ts +218 -0
  130. package/src/discovery/sandbox/child-errors.ts +65 -0
  131. package/src/discovery/sandbox/driver.ts +147 -0
  132. package/src/discovery/sandbox/run.test.ts +179 -0
  133. package/src/discovery/sandbox/run.ts +196 -0
  134. package/src/errors.ts +9 -1
  135. package/src/fold/fold.test.ts +535 -0
  136. package/src/fold/fold.ts +648 -0
  137. package/src/fold/subset-doc-parity.test.ts +183 -0
  138. package/src/fold/subset.test.ts +241 -0
  139. package/src/fold/subset.ts +302 -0
  140. package/src/index.ts +2 -0
  141. package/src/lexicon-output.ts +7 -2
  142. package/src/lexicon-schema.test.ts +14 -0
  143. package/src/lexicon-schema.ts +3 -1
  144. package/src/lexicon.ts +82 -1
  145. package/src/lifecycle/git.test.ts +10 -5
  146. package/src/lifecycle/release-ledger.test.ts +28 -0
  147. package/src/lifecycle/release-ledger.ts +11 -0
  148. package/src/lint/component-checks.ts +8 -1
  149. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  150. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  151. package/src/params.test.ts +22 -0
  152. package/src/params.ts +66 -0
  153. package/src/provenance.ts +22 -0
  154. package/src/terraform/adopt-state.test.ts +1 -1
  155. package/src/terraform/aws-resources.test.ts +11 -4
  156. package/src/terraform/aws-resources.ts +60 -0
package/src/build.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import type { Declarable } from "./declarable";
2
2
  import type { Serializer, SerializerResult } from "./serializer";
3
3
  import type { OwnershipMarker } from "./ownership";
4
- import type { DiscoveryError, BuildError } from "./errors";
5
- import { BuildError as BuildErrorClass } from "./errors";
4
+ import type { BuildError, DiscoveryErrorType } from "./errors";
5
+ import type { IntrinsicDef } from "./lexicon";
6
+ import type { BuildParamProvenance } from "./provenance";
7
+ import { DiscoveryError, BuildError as BuildErrorClass } from "./errors";
6
8
  import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
7
9
  import { AttrRef } from "./attrref";
8
10
  import { isChildProject, type ChildProjectInstance } from "./child-project";
9
- import { discover } from "./discovery/index";
11
+ import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/index";
12
+ import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
13
+ import { buildDependencyGraph } from "./discovery/graph";
10
14
  import { topologicalSort } from "./sort";
11
15
  import { resolve } from "node:path";
12
16
 
@@ -149,6 +153,45 @@ export interface BuildOptions {
149
153
  * {@link SerializeContext} so a dialect can read its lexicon-scoped settings.
150
154
  */
151
155
  config?: Record<string, unknown>;
156
+
157
+ /**
158
+ * chant #1022 (epic #1019) — opt-in: fold source modules statically
159
+ * instead of importing/running them, falling back to run per-file for
160
+ * anything the folder can't represent. Default `false` (unchanged
161
+ * behavior). See {@link DiscoveryOptions.fold} in `./discovery/index`.
162
+ */
163
+ fold?: boolean;
164
+
165
+ /**
166
+ * chant #1039 — lexicon-registered intrinsic tags (e.g. AWS's `Sub`) to
167
+ * recognize while folding. Passed straight through to
168
+ * {@link DiscoveryOptions.intrinsics}; ignored unless {@link fold} is set.
169
+ * The CLI populates this from `options.plugins.flatMap(p => p.intrinsics?.() ?? [])`.
170
+ */
171
+ intrinsics?: IntrinsicDef[];
172
+
173
+ /**
174
+ * chant #1045 Phase 2 — opt-in: run-fallback files (or, when {@link fold}
175
+ * isn't set, every file) execute together, isolated, in one sandboxed
176
+ * child process instead of in-process. Passed straight through to
177
+ * {@link DiscoveryOptions.sandbox} in `./discovery/index`. Default `false`
178
+ * (unchanged behavior/performance).
179
+ */
180
+ sandbox?: boolean;
181
+
182
+ /**
183
+ * chant #1064 — this build's resolved build-time parameter values (see
184
+ * ./build-params.ts's `resolveBuildParams`, driven by the CLI's
185
+ * `--param`/`--params-file`/declared `env` mapping/`chant.config.ts`
186
+ * `buildParams` defaults). Threaded through to `discover()`, which
187
+ * populates `./params.ts`'s shared `params` object before any project file
188
+ * is imported or folded, and into the fold session so a `params.<name>`
189
+ * reference resolves to a literal. Passed through verbatim onto
190
+ * {@link BuildResult.buildParams} — `build()` itself does no
191
+ * declaration/validation (that's the CLI/config layer's job); it only
192
+ * carries the already-resolved records for provenance.
193
+ */
194
+ buildParams?: BuildParamProvenance[];
152
195
  }
153
196
 
154
197
  export interface BuildResult {
@@ -166,6 +209,20 @@ export interface BuildResult {
166
209
  manifest: BuildManifest;
167
210
  /** Number of source files processed */
168
211
  sourceFileCount: number;
212
+ /**
213
+ * Per-file fold-vs-run decisions (#1022). Empty unless
214
+ * {@link BuildOptions.fold} was set.
215
+ */
216
+ foldDecisions: FoldDecision[];
217
+
218
+ /**
219
+ * This build's resolved build-time parameters (#1064) — the build
220
+ * provenance record for `params.<name>` values, alongside the existing
221
+ * entity-level provenance (./provenance.ts). Passed through verbatim from
222
+ * {@link BuildOptions.buildParams}; empty when the project declares/
223
+ * supplies none.
224
+ */
225
+ buildParams: BuildParamProvenance[];
169
226
  }
170
227
 
171
228
  /**
@@ -451,13 +508,43 @@ export async function build(
451
508
  serializers: Serializer[],
452
509
  parentBuildStack?: Set<string>,
453
510
  options?: BuildOptions,
511
+ ): Promise<BuildResult> {
512
+ // Step 1: Discover entities and dependencies
513
+ const discoveryResult = await discover(path, {
514
+ fold: options?.fold,
515
+ intrinsics: options?.intrinsics,
516
+ sandbox: options?.sandbox,
517
+ buildParams: options?.buildParams,
518
+ });
519
+
520
+ return buildFromDiscoveryResult(discoveryResult, path, serializers, parentBuildStack, options);
521
+ }
522
+
523
+ /**
524
+ * chant #1045 (Phase 1) — build from a discovery result produced OUTSIDE the
525
+ * normal `discover(path)` call, i.e. decoded from {@link DiscoveredEntitiesJson}
526
+ * (see {@link buildFromEntitiesJson}). Everything from here on (topological
527
+ * sort, recursive child-project builds, partitioning, output detection,
528
+ * serialization, manifest) is exactly what `build()` already does after its
529
+ * own `discover()` call — extracted so the JSON path reuses it verbatim
530
+ * instead of forking it.
531
+ *
532
+ * @param resolvedPathForChildStack - Used only to seed the circular-nested-
533
+ * stack detection (`buildStack`); the JSON path has no single directory a
534
+ * decoded entity set came from, so callers without one may pass any stable
535
+ * label (child projects aren't supported by the JSON boundary yet — see
536
+ * `discovery/entity-wire.ts` — so this is inert for that path today).
537
+ */
538
+ async function buildFromDiscoveryResult(
539
+ discoveryResult: DiscoveryResult,
540
+ resolvedPathForChildStack: string,
541
+ serializers: Serializer[],
542
+ parentBuildStack?: Set<string>,
543
+ options?: BuildOptions,
454
544
  ): Promise<BuildResult> {
455
545
  const warnings: string[] = [];
456
546
  const errors: Array<DiscoveryError | BuildError> = [];
457
547
 
458
- // Step 1: Discover entities and dependencies
459
- const discoveryResult = await discover(path);
460
-
461
548
  // Collect discovery errors
462
549
  errors.push(...discoveryResult.errors);
463
550
 
@@ -486,7 +573,7 @@ export async function build(
486
573
  }
487
574
 
488
575
  // Step 4: Recursively build child projects
489
- const resolvedPath = resolve(path);
576
+ const resolvedPath = resolve(resolvedPathForChildStack);
490
577
  const buildStack = parentBuildStack
491
578
  ? new Set(parentBuildStack)
492
579
  : new Set<string>();
@@ -596,5 +683,55 @@ export async function build(
596
683
  errors,
597
684
  manifest,
598
685
  sourceFileCount: discoveryResult.sourceFiles.length,
686
+ foldDecisions: discoveryResult.foldDecisions,
687
+ buildParams: options?.buildParams ?? [],
599
688
  };
600
689
  }
690
+
691
+ /**
692
+ * chant #1045 (Phase 1) — build directly from a JSON-encoded discovery
693
+ * result (see {@link discoverEntitySetJson} in `./discovery/entity-wire.ts`)
694
+ * instead of pointing `build()` at a directory.
695
+ *
696
+ * Decodes the wire entity set back into a live entities map — see
697
+ * `decodeEntitySet`'s doc for why the decoded entities are functionally
698
+ * indistinguishable from what `discover()` produces in-process (real
699
+ * `AttrRef` instances, whole-entity identity preserved by reference, not by
700
+ * clone) — then runs the exact same post-discovery pipeline `build()` uses
701
+ * ({@link buildFromDiscoveryResult}), so partitioning, output detection,
702
+ * serialization, and the manifest are the SAME code path, not a fork of it.
703
+ *
704
+ * Dependencies aren't part of the wire format: unlike entities, a dependency
705
+ * graph is plain name-to-name data with no identity problem, so it's cheaper
706
+ * and more honest to recompute it from the decoded entities via the same
707
+ * `buildDependencyGraph()` `discover()` itself uses than to carry a second,
708
+ * redundant wire shape across the boundary.
709
+ *
710
+ * @param label - Used only to seed circular-nested-stack detection; a JSON
711
+ * entity set has no single source directory the way a `build(path, …)`
712
+ * call does. Inert today — child projects (`nestedStack()`) aren't
713
+ * supported by the JSON boundary yet (see `discovery/entity-wire.ts`).
714
+ */
715
+ export async function buildFromEntitiesJson(
716
+ json: DiscoveredEntitiesJson,
717
+ serializers: Serializer[],
718
+ label = "<json-entity-set>",
719
+ parentBuildStack?: Set<string>,
720
+ options?: BuildOptions,
721
+ ): Promise<BuildResult> {
722
+ const entities = decodeEntitySet(json.entitySet);
723
+ const dependencies = buildDependencyGraph(entities);
724
+ const errors: DiscoveryError[] = json.errors.map(
725
+ (e) => new DiscoveryError(e.file, e.message, e.type as DiscoveryErrorType),
726
+ );
727
+
728
+ const discoveryResult: DiscoveryResult = {
729
+ entities,
730
+ dependencies,
731
+ sourceFiles: json.sourceFiles,
732
+ errors,
733
+ foldDecisions: json.foldDecisions,
734
+ };
735
+
736
+ return buildFromDiscoveryResult(discoveryResult, label, serializers, parentBuildStack, options);
737
+ }
@@ -1,11 +1,12 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { buildCommand, resolveBuildFormat, type BuildOptions } from "./build";
3
3
  import type { Serializer } from "../../serializer";
4
4
  import { parseYAML } from "../../yaml";
5
5
  import { mkdir, rm, writeFile } from "node:fs/promises";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
- import { join } from "node:path";
7
+ import { join, dirname, resolve as resolvePath } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
+ import { fileURLToPath } from "node:url";
9
10
 
10
11
  describe("buildCommand", () => {
11
12
  let testDir: string;
@@ -133,6 +134,223 @@ export const testEntity = {
133
134
  expect(result.fileCount).toBeDefined();
134
135
  });
135
136
 
137
+ test("#1022 — --fold folds a leaf-only module and logs the fold decision", async () => {
138
+ const thisDir = dirname(fileURLToPath(import.meta.url));
139
+ const runtimePath = resolvePath(thisDir, "../../runtime");
140
+
141
+ await writeFile(
142
+ join(testDir, "resources.ts"),
143
+ `
144
+ import { createResource } from ${JSON.stringify(runtimePath)};
145
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
146
+ `
147
+ );
148
+ await writeFile(
149
+ join(testDir, "main.ts"),
150
+ `
151
+ import { Bucket } from "./resources";
152
+ throw new Error("must never execute — sentinel for #1022 fold verification");
153
+ export const bucket = new Bucket({ name: "my-bucket" });
154
+ `
155
+ );
156
+
157
+ const awsSerializer: Serializer = {
158
+ name: "aws",
159
+ rulePrefix: "TEST",
160
+ serialize: (entities) => JSON.stringify([...entities.keys()]),
161
+ };
162
+
163
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
164
+ try {
165
+ const result = await buildCommand({
166
+ path: testDir,
167
+ format: "json",
168
+ serializers: [awsSerializer],
169
+ fold: true,
170
+ });
171
+
172
+ expect(result.success).toBe(true);
173
+ expect(result.resourceCount).toBe(1);
174
+
175
+ const loggedFoldLine = errorSpy.mock.calls
176
+ .map((call) => String(call[0]))
177
+ .some((line) => line.includes("[fold:fold]") && line.includes("main.ts"));
178
+ expect(loggedFoldLine).toBe(true);
179
+ } finally {
180
+ errorSpy.mockRestore();
181
+ }
182
+ });
183
+
184
+ test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
185
+ const thisDir = dirname(fileURLToPath(import.meta.url));
186
+ const runtimePath = resolvePath(thisDir, "../../runtime");
187
+ const paramsPath = resolvePath(thisDir, "../../params");
188
+
189
+ await writeFile(
190
+ join(testDir, "chant.config.ts"),
191
+ `
192
+ export default {
193
+ buildParams: {
194
+ tier: { type: "string", enum: ["light", "production"], default: "light" },
195
+ },
196
+ };
197
+ `,
198
+ );
199
+ await writeFile(
200
+ join(testDir, "resources.ts"),
201
+ `
202
+ import { createResource } from ${JSON.stringify(runtimePath)};
203
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
204
+ `,
205
+ );
206
+ await writeFile(
207
+ join(testDir, "main.ts"),
208
+ `
209
+ import { Bucket } from "./resources";
210
+ import { params } from ${JSON.stringify(paramsPath)};
211
+ throw new Error("must never execute — sentinel for #1064 fold verification");
212
+ export const bucket = new Bucket({ name: params.tier });
213
+ `,
214
+ );
215
+
216
+ const awsSerializer: Serializer = {
217
+ name: "aws",
218
+ rulePrefix: "TEST",
219
+ serialize: (entities) => JSON.stringify([...entities.values()].map((e) => (e as unknown as { props: unknown }).props)),
220
+ };
221
+
222
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
223
+ try {
224
+ const result = await buildCommand({
225
+ path: testDir,
226
+ format: "json",
227
+ serializers: [awsSerializer],
228
+ fold: true,
229
+ params: { tier: "production" },
230
+ });
231
+
232
+ expect(result.success).toBe(true);
233
+ expect(result.resourceCount).toBe(1);
234
+ expect(result.buildParams).toEqual([{ name: "tier", value: "production", source: "cli" }]);
235
+
236
+ const loggedParamLine = errorSpy.mock.calls
237
+ .map((call) => String(call[0]))
238
+ .some((line) => line.includes("[param] tier") && line.includes("production") && line.includes("cli"));
239
+ expect(loggedParamLine).toBe(true);
240
+ } finally {
241
+ errorSpy.mockRestore();
242
+ }
243
+ });
244
+
245
+ test("#1064 — an unresolved required build-time parameter is a build error naming the parameter, not a thrown error", async () => {
246
+ await writeFile(
247
+ join(testDir, "chant.config.ts"),
248
+ `
249
+ export default {
250
+ buildParams: { tier: { type: "string" } },
251
+ };
252
+ `,
253
+ );
254
+
255
+ const result = await buildCommand({
256
+ path: testDir,
257
+ format: "json",
258
+ serializers: [mockSerializer],
259
+ });
260
+
261
+ expect(result.success).toBe(false);
262
+ expect(result.errors.some((e) => e.includes('"tier"') && e.includes("--param"))).toBe(true);
263
+ });
264
+
265
+ test("#1064 — an enum violation is a build error naming the parameter and the allowed values", async () => {
266
+ await writeFile(
267
+ join(testDir, "chant.config.ts"),
268
+ `
269
+ export default {
270
+ buildParams: { tier: { type: "string", enum: ["light", "production"] } },
271
+ };
272
+ `,
273
+ );
274
+
275
+ const result = await buildCommand({
276
+ path: testDir,
277
+ format: "json",
278
+ serializers: [mockSerializer],
279
+ params: { tier: "bogus" },
280
+ });
281
+
282
+ expect(result.success).toBe(false);
283
+ expect(result.errors.some((e) => e.includes('"tier"') && e.includes("bogus"))).toBe(true);
284
+ });
285
+
286
+ test("#1064 — --params-file supplies values from a JSON file, second precedence after --param", async () => {
287
+ await writeFile(
288
+ join(testDir, "chant.config.ts"),
289
+ `
290
+ export default {
291
+ buildParams: {
292
+ tier: { type: "string", default: "light" },
293
+ env: { type: "string", default: "dev" },
294
+ },
295
+ };
296
+ `,
297
+ );
298
+ await writeFile(
299
+ join(testDir, "test.infra.ts"),
300
+ `
301
+ export const testEntity = {
302
+ lexicon: "test",
303
+ entityType: "TestEntity",
304
+ [Symbol.for("chant.declarable")]: true,
305
+ };
306
+ `,
307
+ );
308
+ const paramsFilePath = join(testDir, "params.json");
309
+ await writeFile(paramsFilePath, JSON.stringify({ tier: "production", env: "from-file" }));
310
+
311
+ const result = await buildCommand({
312
+ path: testDir,
313
+ format: "json",
314
+ serializers: [mockSerializer],
315
+ params: { tier: "from-cli" },
316
+ paramsFile: paramsFilePath,
317
+ });
318
+
319
+ expect(result.success).toBe(true);
320
+ const byName = new Map((result.buildParams ?? []).map((p) => [p.name, p]));
321
+ expect(byName.get("tier")).toEqual({ name: "tier", value: "from-cli", source: "cli" });
322
+ expect(byName.get("env")).toEqual({ name: "env", value: "from-file", source: "params-file" });
323
+ });
324
+
325
+ test("--fold is opt-in: omitting it builds via the unchanged run path", async () => {
326
+ await writeFile(
327
+ join(testDir, "test.infra.ts"),
328
+ `
329
+ export const testEntity = {
330
+ lexicon: "test",
331
+ entityType: "TestEntity",
332
+ [Symbol.for("chant.declarable")]: true,
333
+ };
334
+ `
335
+ );
336
+
337
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
338
+ try {
339
+ const result = await buildCommand({
340
+ path: testDir,
341
+ format: "json",
342
+ serializers: [mockSerializer],
343
+ });
344
+
345
+ expect(result.success).toBe(true);
346
+ expect(result.resourceCount).toBe(1);
347
+ const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
348
+ expect(anyFoldLine).toBe(false);
349
+ } finally {
350
+ errorSpy.mockRestore();
351
+ }
352
+ });
353
+
136
354
  test("creates parent directories for the primary output path (#38)", async () => {
137
355
  // Write into a nested temp path whose parent dirs don't yet exist —
138
356
  // chant build should mkdir -p the parents rather than fail with ENOENT.
@@ -1,13 +1,14 @@
1
1
  import { build } from "../../build";
2
- import { loadChantConfig, resolveOwnershipMarker } from "../../config";
2
+ import { loadChantConfig, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
3
+ import { resolveBuildParams } from "../../build-params";
3
4
  import type { Serializer, SerializerResult } from "../../serializer";
4
5
  import type { LexiconPlugin } from "../../lexicon";
5
6
  import { runPostSynthChecks } from "../../lint/post-synth";
6
7
  import { loadPolicyChecks } from "../../lint/policy";
7
8
  import { sortedJsonReplacer } from "../../utils";
8
9
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
9
- import { writeFileSync, mkdirSync } from "fs";
10
- import { resolve, dirname, join } from "path";
10
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
11
+ import { resolve, dirname, join, relative } from "path";
11
12
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
12
13
 
13
14
  /**
@@ -32,6 +33,38 @@ export interface BuildOptions {
32
33
  * policy can branch on environment.
33
34
  */
34
35
  env?: string;
36
+ /**
37
+ * chant #1022 (epic #1019) — opt-in: fold source modules statically
38
+ * instead of importing/running them (`chant build --fold`). Falls back to
39
+ * run per-file for anything the folder can't represent. Merged with the
40
+ * project's `chant.config.ts` `build.fold` via {@link resolveFoldEnabled}
41
+ * — this flag, when true, always wins for the invocation.
42
+ */
43
+ fold?: boolean;
44
+
45
+ /**
46
+ * chant #1045 Phase 2 — opt-in: run-fallback source files (or, without
47
+ * `fold`, every file) execute together, isolated, in one sandboxed child
48
+ * process (`chant build --sandbox`). Merged with the project's
49
+ * `chant.config.ts` `build.sandbox` via {@link resolveSandboxEnabled} —
50
+ * this flag, when true, always wins for the invocation.
51
+ */
52
+ sandbox?: boolean;
53
+
54
+ /**
55
+ * chant #1064 — `--param name=value` flags (repeatable), parsed to a flat
56
+ * `{ name: value }` record of raw (unvalidated) strings. Highest
57
+ * precedence in {@link resolveBuildParams}'s resolution against the
58
+ * project's declared `chant.config.ts` `buildParams`.
59
+ */
60
+ params?: Record<string, string>;
61
+
62
+ /**
63
+ * chant #1064 — `--params-file <path>`: a JSON file of `{ "name": value }`
64
+ * build-time parameter values, read and parsed here. Second precedence,
65
+ * after {@link params}.
66
+ */
67
+ paramsFile?: string;
35
68
  }
36
69
 
37
70
  /**
@@ -81,6 +114,8 @@ export interface BuildResult {
81
114
  errors: string[];
82
115
  /** Warning messages */
83
116
  warnings: string[];
117
+ /** This build's resolved build-time parameters (#1064) — see `BuildResult.buildParams` (../../build.ts). Empty when the project declares/supplies none. */
118
+ buildParams?: import("../../provenance").BuildParamProvenance[];
84
119
  }
85
120
 
86
121
  /**
@@ -110,12 +145,84 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
110
145
  ? await loadPolicyChecks(config.lint.policies, configDir)
111
146
  : [];
112
147
 
148
+ // #1022 — opt-in fold path: the CLI flag wins over `chant.config.ts`'s
149
+ // `build.fold`, which wins over the (unchanged) default of running every
150
+ // module.
151
+ const fold = resolveFoldEnabled(config, options.fold);
152
+
153
+ // #1045 Phase 2 — opt-in sandboxed execution of run-fallback files (or,
154
+ // without --fold, every file). Same CLI-flag-wins-over-config precedence
155
+ // as fold, resolved independently.
156
+ const sandbox = resolveSandboxEnabled(config, options.sandbox);
157
+
158
+ // #1064 — resolve declared build-time parameters (chant.config.ts's
159
+ // buildParams) against this invocation's --param/--params-file/declared
160
+ // env mapping, BEFORE calling build() — a resolution failure (an unknown
161
+ // name, a missing required value, a type/enum mismatch) is reported as a
162
+ // chant build error naming the parameter, never a thrown error from inside
163
+ // user source (which is what loomster's hand-rolled `tierFromEnv()`-style
164
+ // validators did before migrating to this mechanism).
165
+ let paramsFileContent: Record<string, unknown> | undefined;
166
+ if (options.paramsFile) {
167
+ try {
168
+ paramsFileContent = JSON.parse(readFileSync(resolve(options.paramsFile), "utf-8"));
169
+ } catch (err) {
170
+ errors.push(
171
+ formatError({
172
+ message: `Failed to read/parse --params-file "${options.paramsFile}": ${err instanceof Error ? err.message : String(err)}`,
173
+ }),
174
+ );
175
+ }
176
+ }
177
+ const paramsResolution = resolveBuildParams(config.buildParams, {
178
+ cli: options.params,
179
+ fromFile: paramsFileContent,
180
+ env: process.env,
181
+ });
182
+ for (const message of paramsResolution.errors) {
183
+ errors.push(formatError({ message }));
184
+ }
185
+ if (errors.length > 0) {
186
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
187
+ }
188
+ // #1064 — build-provenance visibility: report every resolved build-time
189
+ // parameter (name, value, and which source won it) the same
190
+ // unconditional-log-not-gated-on---verbose way #1022's fold decisions are
191
+ // reported just below, so a build's environment-varying inputs are as
192
+ // visible as its fold-vs-run choices.
193
+ for (const p of paramsResolution.provenance) {
194
+ console.error(formatInfo(`[param] ${p.name} = ${JSON.stringify(p.value)} (${p.source})`));
195
+ }
196
+
197
+ // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
198
+ // `Sub`) through to the fold path, so a file using a registered intrinsic
199
+ // tagged template folds instead of unconditionally falling back to run.
200
+ // `intrinsics` is an optional plugin extension (not every lexicon defines
201
+ // any), hence the guard.
202
+ const intrinsics = options.plugins?.flatMap((plugin) => plugin.intrinsics?.() ?? []) ?? [];
203
+
113
204
  // Run the build
114
205
  const result = await build(infraPath, options.serializers, undefined, {
115
206
  ownership,
116
207
  config: config as unknown as Record<string, unknown>,
208
+ fold,
209
+ sandbox,
210
+ intrinsics,
211
+ buildParams: paramsResolution.provenance,
117
212
  });
118
213
 
214
+ // #1022 — report per-file fold vs run so it's visible what still runs.
215
+ if (fold) {
216
+ for (const decision of result.foldDecisions) {
217
+ const rel = relative(infraPath, decision.file) || decision.file;
218
+ const detail =
219
+ decision.mode === "fold"
220
+ ? `${decision.resourceCount ?? 0} resource(s), no module execution`
221
+ : (decision.reason ?? "fell back to run");
222
+ console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
223
+ }
224
+ }
225
+
119
226
  // Format errors
120
227
  for (const error of result.errors) {
121
228
  const formatted = formatError({
@@ -359,6 +466,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
359
466
  fileCount,
360
467
  errors,
361
468
  warnings,
469
+ buildParams: paramsResolution.provenance,
362
470
  };
363
471
  }
364
472
 
@@ -0,0 +1,92 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { checkExamplesBuild } from "./check-lexicon-examples";
6
+
7
+ /**
8
+ * chant #1067 — proof that "every shipped example builds" actually builds
9
+ * one, rather than counting example directories the way every prior
10
+ * check-lexicon.ts check did. The fixture reproduces the exact defect
11
+ * found in `lexicons/aws/examples/core-concepts`: two files in the same
12
+ * example independently exporting a top-level binding of the same name,
13
+ * which is fine on its own but a "Duplicate export name" discovery error
14
+ * the moment the whole `src/` directory is built as one project — which is
15
+ * exactly what each example's own `npm run build` does.
16
+ *
17
+ * These fixtures import the real, already-installed `@intentius/chant-lexicon-aws`
18
+ * package (workspace-linked), so no lexicon plugin needs to be faked.
19
+ */
20
+
21
+ function writeLexiconDirWithExample(
22
+ lexiconDir: string,
23
+ exampleName: string,
24
+ files: Record<string, string>,
25
+ ): void {
26
+ const srcDir = join(lexiconDir, "examples", exampleName, "src");
27
+ mkdirSync(srcDir, { recursive: true });
28
+ for (const [name, content] of Object.entries(files)) {
29
+ writeFileSync(join(srcDir, name), content);
30
+ }
31
+ }
32
+
33
+ describe("checkExamplesBuild", () => {
34
+ let dir: string;
35
+
36
+ beforeAll(() => {
37
+ dir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-examples-"));
38
+ });
39
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
40
+
41
+ test("a clean example builds", async () => {
42
+ writeLexiconDirWithExample(dir, "clean", {
43
+ "bucket.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
44
+
45
+ export const appBucket = new Bucket({
46
+ BucketName: "my-app-bucket",
47
+ });
48
+ `,
49
+ });
50
+
51
+ const results = await checkExamplesBuild(dir);
52
+ const clean = results.find((r) => r.example === "clean");
53
+ expect(clean).toMatchObject({ ok: true });
54
+ });
55
+
56
+ test("two files independently exporting the same top-level name fails to build (the core-concepts defect)", async () => {
57
+ writeLexiconDirWithExample(dir, "broken", {
58
+ "a.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
59
+
60
+ export const dataBucket = new Bucket({
61
+ BucketName: "a-bucket",
62
+ });
63
+ `,
64
+ "b.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
65
+
66
+ export const dataBucket = new Bucket({
67
+ BucketName: "b-bucket",
68
+ });
69
+ `,
70
+ });
71
+
72
+ const results = await checkExamplesBuild(dir);
73
+ const broken = results.find((r) => r.example === "broken");
74
+ expect(broken?.ok).toBe(false);
75
+ expect(broken?.detail).toMatch(/Duplicate export name "dataBucket" found/);
76
+ });
77
+
78
+ test("an empty src/ directory is skipped, not reported as a failure", async () => {
79
+ writeLexiconDirWithExample(dir, "empty", {});
80
+ const results = await checkExamplesBuild(dir);
81
+ expect(results.find((r) => r.example === "empty")).toBeUndefined();
82
+ });
83
+
84
+ test("returns [] when there is no examples/ directory at all", async () => {
85
+ const noExamplesDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-examples-none-"));
86
+ try {
87
+ expect(await checkExamplesBuild(noExamplesDir)).toEqual([]);
88
+ } finally {
89
+ rmSync(noExamplesDir, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });