@intentius/chant 0.19.1 → 0.21.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 (164) 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 +71 -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/codegen/docs-types.d.ts +2 -0
  28. package/dist/codegen/docs-types.d.ts.map +1 -1
  29. package/dist/components/cli-support.d.ts +12 -5
  30. package/dist/components/cli-support.d.ts.map +1 -1
  31. package/dist/components/discover.d.ts +62 -7
  32. package/dist/components/discover.d.ts.map +1 -1
  33. package/dist/components/sandbox/driver.d.ts +12 -0
  34. package/dist/components/sandbox/driver.d.ts.map +1 -0
  35. package/dist/components/sandbox/run.d.ts +42 -0
  36. package/dist/components/sandbox/run.d.ts.map +1 -0
  37. package/dist/composite.d.ts +5 -0
  38. package/dist/composite.d.ts.map +1 -1
  39. package/dist/config.d.ts +71 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/declarable.d.ts +16 -0
  42. package/dist/declarable.d.ts.map +1 -1
  43. package/dist/discovery/collect.d.ts.map +1 -1
  44. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  45. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  46. package/dist/discovery/entity-wire.d.ts +50 -0
  47. package/dist/discovery/entity-wire.d.ts.map +1 -0
  48. package/dist/discovery/fold-import.d.ts +239 -0
  49. package/dist/discovery/fold-import.d.ts.map +1 -0
  50. package/dist/discovery/index.d.ts +74 -1
  51. package/dist/discovery/index.d.ts.map +1 -1
  52. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  53. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  55. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  56. package/dist/discovery/sandbox/driver.d.ts +13 -0
  57. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  58. package/dist/discovery/sandbox/run.d.ts +69 -0
  59. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  60. package/dist/errors.d.ts +9 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/fold/fold.d.ts +299 -0
  63. package/dist/fold/fold.d.ts.map +1 -0
  64. package/dist/fold/foldable-helpers.d.ts +121 -0
  65. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  66. package/dist/fold/subset.d.ts +134 -0
  67. package/dist/fold/subset.d.ts.map +1 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/lexicon-output.d.ts +7 -2
  71. package/dist/lexicon-output.d.ts.map +1 -1
  72. package/dist/lexicon-schema.d.ts +4 -2
  73. package/dist/lexicon-schema.d.ts.map +1 -1
  74. package/dist/lexicon.d.ts +131 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/lifecycle/release-ledger.d.ts +11 -0
  77. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  78. package/dist/lint/component-checks.d.ts +7 -1
  79. package/dist/lint/component-checks.d.ts.map +1 -1
  80. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  81. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  82. package/dist/params.d.ts +60 -0
  83. package/dist/params.d.ts.map +1 -0
  84. package/dist/provenance.d.ts +21 -0
  85. package/dist/provenance.d.ts.map +1 -1
  86. package/dist/runtime.d.ts +10 -1
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/package.json +2 -1
  89. package/src/build-params.test.ts +144 -0
  90. package/src/build-params.ts +207 -0
  91. package/src/build.test.ts +38 -0
  92. package/src/build.ts +144 -7
  93. package/src/cli/commands/build.test.ts +220 -2
  94. package/src/cli/commands/build.ts +111 -3
  95. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  96. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  97. package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
  98. package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
  99. package/src/cli/commands/check-lexicon.test.ts +34 -0
  100. package/src/cli/commands/check-lexicon.ts +137 -1
  101. package/src/cli/commands/lint.ts +31 -3
  102. package/src/cli/commands/onboard.ts +1 -1
  103. package/src/cli/component-security-boundary.test.ts +170 -0
  104. package/src/cli/handlers/build.ts +24 -3
  105. package/src/cli/handlers/components.ts +9 -2
  106. package/src/cli/handlers/dev.ts +1 -1
  107. package/src/cli/handlers/graph.ts +7 -5
  108. package/src/cli/handlers/lifecycle.ts +2 -2
  109. package/src/cli/handlers/lint.ts +2 -0
  110. package/src/cli/handlers/misc.ts +2 -2
  111. package/src/cli/handlers/run-client.ts +1 -1
  112. package/src/cli/handlers/run.ts +20 -5
  113. package/src/cli/main.test.ts +22 -0
  114. package/src/cli/main.ts +39 -0
  115. package/src/cli/plugins.ts +20 -4
  116. package/src/cli/registry.ts +10 -0
  117. package/src/cli/security-boundary.test.ts +135 -0
  118. package/src/codegen/docs-sections.test.ts +67 -0
  119. package/src/codegen/docs-sections.ts +7 -3
  120. package/src/codegen/docs-types.ts +2 -0
  121. package/src/components/cli-support.ts +22 -10
  122. package/src/components/discover.ts +127 -25
  123. package/src/components/sandbox/driver.ts +114 -0
  124. package/src/components/sandbox/run.test.ts +185 -0
  125. package/src/components/sandbox/run.ts +177 -0
  126. package/src/composite.test.ts +21 -0
  127. package/src/composite.ts +20 -1
  128. package/src/config.ts +81 -0
  129. package/src/declarable.ts +20 -0
  130. package/src/discovery/collect.ts +17 -3
  131. package/src/discovery/entity-wire-codec.ts +487 -0
  132. package/src/discovery/entity-wire.test.ts +240 -0
  133. package/src/discovery/entity-wire.ts +67 -0
  134. package/src/discovery/fold-import.test.ts +1598 -0
  135. package/src/discovery/fold-import.ts +1998 -0
  136. package/src/discovery/index.test.ts +191 -1
  137. package/src/discovery/index.ts +242 -1
  138. package/src/discovery/sandbox/bundle.ts +218 -0
  139. package/src/discovery/sandbox/child-errors.ts +65 -0
  140. package/src/discovery/sandbox/driver.ts +147 -0
  141. package/src/discovery/sandbox/run.test.ts +179 -0
  142. package/src/discovery/sandbox/run.ts +196 -0
  143. package/src/errors.ts +9 -1
  144. package/src/fold/fold.test.ts +812 -0
  145. package/src/fold/fold.ts +805 -0
  146. package/src/fold/foldable-helpers.ts +171 -0
  147. package/src/fold/subset-doc-parity.test.ts +210 -0
  148. package/src/fold/subset.test.ts +352 -0
  149. package/src/fold/subset.ts +383 -0
  150. package/src/index.ts +2 -0
  151. package/src/lexicon-output.ts +7 -2
  152. package/src/lexicon-schema.test.ts +57 -0
  153. package/src/lexicon-schema.ts +8 -1
  154. package/src/lexicon.ts +132 -1
  155. package/src/lifecycle/git.test.ts +10 -5
  156. package/src/lifecycle/release-ledger.test.ts +28 -0
  157. package/src/lifecycle/release-ledger.ts +11 -0
  158. package/src/lint/component-checks.ts +8 -1
  159. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  160. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  161. package/src/params.test.ts +22 -0
  162. package/src/params.ts +66 -0
  163. package/src/provenance.ts +22 -0
  164. package/src/runtime.ts +11 -2
@@ -0,0 +1,207 @@
1
+ import type { BuildParamProvenance } from "./provenance";
2
+
3
+ /**
4
+ * Build-time parameter declaration + resolution (chant #1064, follow-up to
5
+ * epic #1019's fold work — see issue #1064's "DECISION: option 1" comment).
6
+ *
7
+ * A build-time parameter is declared in `chant.config.ts`'s `buildParams`
8
+ * (name, type, optional `default`/`enum`/`env` mapping), supplied to `chant
9
+ * build` (a `--param name=value` flag, a `--params-file` JSON file, or a
10
+ * declared `env` var), and referenced from source as `params.<name>` (see
11
+ * ./params.ts) — never as an ambient `process.env` read. This module owns
12
+ * declaration + precedence + validation; ./params.ts is the plain runtime
13
+ * object source references; ../discovery/fold-import.ts is what makes a
14
+ * `params.<name>` reference fold to a literal instead of a symbolic node.
15
+ *
16
+ * This is NOT the deploy-time `Parameter` class (`lexicons/aws/src/parameter.ts`)
17
+ * — that resolves when a CloudFormation stack deploys; this resolves before
18
+ * the template is even synthesized, so its value can change which resources
19
+ * are produced at all. See ./params.ts's module doc for the full distinction.
20
+ */
21
+
22
+ /** A build-time parameter's resolved (and declared-default/enum) value — always a scalar. */
23
+ export type BuildParamValue = string | number | boolean;
24
+
25
+ /**
26
+ * One declared build-time parameter (`chant.config.ts`'s `buildParams.<name>`).
27
+ */
28
+ export interface BuildParamDef {
29
+ /** The value's declared type — supplied strings (CLI flags, env vars, JSON-file strings) are coerced to it. */
30
+ type: "string" | "number" | "boolean";
31
+ /** Value used when no `--param`/`--params-file`/declared `env` var supplies one. Omit to require an explicit value every build. */
32
+ default?: BuildParamValue;
33
+ /**
34
+ * Allowed values — a resolved value outside this list is a build error
35
+ * naming the parameter (never a thrown error from user source). Replaces
36
+ * the hand-written `if (!VALID.includes(raw)) throw ...` pattern loomster's
37
+ * `params.ts` files used before migrating to this mechanism.
38
+ */
39
+ enum?: readonly BuildParamValue[];
40
+ /**
41
+ * Opt-in, EXPLICITLY declared environment-variable fallback — e.g. `env:
42
+ * "LOOM_TIER"`. This is the only place an env var may feed a build-time
43
+ * parameter: reading `process.env` directly from project source is never
44
+ * supported (see ./params.ts's module doc and ../fold/fold.ts's pointed
45
+ * error for a bare `process` reference). Consulted only when no
46
+ * `--param`/`--params-file` value was supplied for this parameter.
47
+ */
48
+ env?: string;
49
+ /**
50
+ * Set `false` to make an unresolved value NOT a build error: `params.<name>`
51
+ * is simply omitted (reads as plain JS `undefined` — a normal, un-erroring
52
+ * property access on an object missing that key), instead of the default
53
+ * behavior of requiring every declared parameter to resolve to something.
54
+ * For a value that is genuinely optional with no meaningful default (an ARN
55
+ * that references an existing resource only on some deploys, a CIDR
56
+ * override, a JSON blob) — the same "unset means the composite decides"
57
+ * shape a hand-written `process.env.X || undefined` used to express.
58
+ * Default `true` (a declared parameter must resolve to a value).
59
+ */
60
+ required?: boolean;
61
+ /** Human-readable description, surfaced in error messages and docs generation. */
62
+ description?: string;
63
+ }
64
+
65
+ /** A project's full set of declared build-time parameters, keyed by name. */
66
+ export type BuildParamsConfig = Record<string, BuildParamDef>;
67
+
68
+ /** Raw, not-yet-validated inputs {@link resolveBuildParams} resolves against a project's declared {@link BuildParamsConfig}. */
69
+ export interface BuildParamsInput {
70
+ /** `--param name=value` flags, repeated — highest precedence. */
71
+ cli?: Record<string, string>;
72
+ /** Parsed contents of a `--params-file` JSON file — second precedence. */
73
+ fromFile?: Record<string, unknown>;
74
+ /** The process environment, consulted only for a parameter that declares an `env` mapping, and only once `cli`/`fromFile` have no value for it. */
75
+ env?: Record<string, string | undefined>;
76
+ }
77
+
78
+ /** Result of resolving a project's declared parameters against one build invocation's inputs. */
79
+ export interface BuildParamsResolution {
80
+ /** Every successfully resolved parameter — see {@link BuildParamProvenance}. Empty when the project declares none. */
81
+ provenance: BuildParamProvenance[];
82
+ /**
83
+ * Validation failures, each naming the offending parameter — an unknown
84
+ * `--param`/`--params-file` key, a missing required value, a type mismatch,
85
+ * or a value outside a declared `enum`. Reported by the CLI as a build
86
+ * error (chant #1064's acceptance criterion: "not a thrown error inside
87
+ * user source"); never thrown from here.
88
+ */
89
+ errors: string[];
90
+ }
91
+
92
+ function formatValue(value: unknown): string {
93
+ return typeof value === "string" ? `"${value}"` : String(value);
94
+ }
95
+
96
+ /** Coerce `raw` to `def.type`, appending a located error and returning `undefined` on failure. */
97
+ function coerce(name: string, raw: BuildParamValue, def: BuildParamDef, errors: string[]): BuildParamValue | undefined {
98
+ if (def.type === "string") return String(raw);
99
+
100
+ if (def.type === "number") {
101
+ const n = typeof raw === "number" ? raw : Number(raw);
102
+ if (Number.isNaN(n)) {
103
+ errors.push(`build parameter "${name}" must be a number, got ${formatValue(raw)}`);
104
+ return undefined;
105
+ }
106
+ return n;
107
+ }
108
+
109
+ // def.type === "boolean"
110
+ if (typeof raw === "boolean") return raw;
111
+ if (raw === "true") return true;
112
+ if (raw === "false") return false;
113
+ errors.push(`build parameter "${name}" must be a boolean ("true" or "false"), got ${formatValue(raw)}`);
114
+ return undefined;
115
+ }
116
+
117
+ /**
118
+ * Resolve a project's declared build-time parameters against one build's
119
+ * supplied inputs. Precedence per parameter, most to least specific:
120
+ * `cli` (`--param name=value`) > `fromFile` (`--params-file`) > the
121
+ * parameter's own declared `env` mapping (only if set) > `def.default`.
122
+ *
123
+ * A parameter with no declared `default` and no value from any source is a
124
+ * build error, not a silently-`undefined` value — a build-time parameter
125
+ * exists specifically so a project never has an invisible dependency on
126
+ * ambient state; leaving one unresolved would just reintroduce that under a
127
+ * different name. Every failure is collected (not thrown), each naming the
128
+ * offending parameter, so a single invocation reports every problem at once.
129
+ *
130
+ * The one opt-out is `def.required: false` — for a parameter that is
131
+ * genuinely optional with no meaningful default (an ARN that only applies to
132
+ * a reference-existing deploy, a CIDR override), an unresolved value is
133
+ * simply omitted from `provenance`/`params` rather than an error; source
134
+ * reads it as plain `undefined`, same as before migrating off
135
+ * `process.env.X || undefined`.
136
+ */
137
+ export function resolveBuildParams(defs: BuildParamsConfig | undefined, input: BuildParamsInput): BuildParamsResolution {
138
+ const declared = defs ?? {};
139
+ const cli = input.cli ?? {};
140
+ const fromFile = input.fromFile ?? {};
141
+ const env = input.env ?? {};
142
+ const errors: string[] = [];
143
+ const provenance: BuildParamProvenance[] = [];
144
+
145
+ for (const key of Object.keys(cli)) {
146
+ if (!(key in declared)) {
147
+ errors.push(`unknown build parameter "${key}" (from --param) — not declared in chant.config.ts's buildParams`);
148
+ }
149
+ }
150
+ for (const key of Object.keys(fromFile)) {
151
+ if (!(key in declared)) {
152
+ errors.push(`unknown build parameter "${key}" (from --params-file) — not declared in chant.config.ts's buildParams`);
153
+ }
154
+ }
155
+
156
+ for (const [name, def] of Object.entries(declared)) {
157
+ let raw: BuildParamValue | undefined;
158
+ let source: BuildParamProvenance["source"] | undefined;
159
+
160
+ if (name in cli) {
161
+ raw = cli[name];
162
+ source = "cli";
163
+ } else if (name in fromFile) {
164
+ const fileValue = fromFile[name];
165
+ if (typeof fileValue !== "string" && typeof fileValue !== "number" && typeof fileValue !== "boolean") {
166
+ errors.push(`build parameter "${name}" (from --params-file) must be a string, number, or boolean`);
167
+ continue;
168
+ }
169
+ raw = fileValue;
170
+ source = "params-file";
171
+ } else if (def.env && env[def.env] !== undefined) {
172
+ raw = env[def.env];
173
+ source = "env";
174
+ } else if (def.default !== undefined) {
175
+ raw = def.default;
176
+ source = "default";
177
+ }
178
+
179
+ if (raw === undefined || source === undefined) {
180
+ if (def.required === false) continue; // omitted entirely — params.<name> reads as undefined, not an error
181
+ const envHint = def.env ? `, set ${def.env}` : "";
182
+ errors.push(
183
+ `build parameter "${name}" has no value — pass --param ${name}=<value>, use --params-file${envHint}, or add a default in chant.config.ts's buildParams`,
184
+ );
185
+ continue;
186
+ }
187
+
188
+ const value = coerce(name, raw, def, errors);
189
+ if (value === undefined) continue;
190
+
191
+ if (def.enum && !def.enum.includes(value)) {
192
+ errors.push(
193
+ `build parameter "${name}" must be one of ${def.enum.map(formatValue).join(", ")}, got ${formatValue(value)}`,
194
+ );
195
+ continue;
196
+ }
197
+
198
+ provenance.push({ name, value, source });
199
+ }
200
+
201
+ return { provenance, errors };
202
+ }
203
+
204
+ /** Project the resolved provenance records down to a plain `{ name: value }` map — what actually gets bound to `params.<name>` (see ./params.ts). */
205
+ export function buildParamValues(provenance: readonly BuildParamProvenance[]): Record<string, BuildParamValue> {
206
+ return Object.fromEntries(provenance.map((p) => [p.name, p.value]));
207
+ }
package/src/build.test.ts CHANGED
@@ -64,6 +64,44 @@ export const testEntity = {
64
64
  expect(result.entities.size).toBe(1);
65
65
  expect(result.entities.has("testEntity")).toBe(true);
66
66
  expect(result.errors.length).toBe(0);
67
+ expect(result.foldDecisions).toEqual([]);
68
+ });
69
+
70
+ test("#1022 — --fold folds a leaf-only module and surfaces foldDecisions", async () => {
71
+ const { dirname, resolve: resolvePath } = await import("node:path");
72
+ const { fileURLToPath } = await import("node:url");
73
+ const thisDir = dirname(fileURLToPath(import.meta.url));
74
+ const runtimePath = resolvePath(thisDir, "runtime");
75
+
76
+ await writeFile(
77
+ join(testDir, "resources.ts"),
78
+ `
79
+ import { createResource } from ${JSON.stringify(runtimePath)};
80
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
81
+ `
82
+ );
83
+ await writeFile(
84
+ join(testDir, "main.ts"),
85
+ `
86
+ import { Bucket } from "./resources";
87
+ throw new Error("must never execute — sentinel for #1022 fold verification");
88
+ export const bucket = new Bucket({ name: "my-bucket" });
89
+ `
90
+ );
91
+
92
+ const mockSerializer: Serializer = {
93
+ name: "aws",
94
+ rulePrefix: "TEST",
95
+ serialize: (entities) => JSON.stringify([...entities.keys()]),
96
+ };
97
+
98
+ const result = await build(testDir, [mockSerializer], undefined, { fold: true });
99
+
100
+ expect(result.errors).toEqual([]);
101
+ expect(result.entities.size).toBe(1);
102
+ expect(result.entities.has("bucket")).toBe(true);
103
+ const mainDecision = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
104
+ expect(mainDecision?.mode).toBe("fold");
67
105
  });
68
106
 
69
107
  test("handles discovery errors", async () => {
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.