@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,1998 @@
1
+ import * as ts from "typescript";
2
+ import { readFile } from "node:fs/promises";
3
+ import { existsSync, statSync, readFileSync, realpathSync } from "node:fs";
4
+ import { dirname, basename, join, sep, isAbsolute, resolve as resolvePath } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createRequire } from "node:module";
7
+ import { isDeclarable, type Declarable } from "../declarable";
8
+ import { isCompositeInstance, type CompositeInstance } from "../composite";
9
+ import { isAttrRefLike } from "../utils";
10
+ import {
11
+ collectConsts,
12
+ foldResource,
13
+ fold,
14
+ FoldError,
15
+ locate,
16
+ type FoldedResource,
17
+ type FoldedValue,
18
+ type FoldedIntrinsic,
19
+ type FoldedHelperCall,
20
+ type SymbolicValue,
21
+ } from "../fold/fold";
22
+ import { isChantOwnedSpecifier } from "../fold/foldable-helpers";
23
+ import { importModule } from "./import";
24
+ import type { IntrinsicDef } from "../lexicon";
25
+ import type { BuildParamValue } from "../build-params";
26
+
27
+ /**
28
+ * Bridges the static folder ({@link ../fold/fold}, #1026) into discovery
29
+ * (#1022/#1023, epic #1019): attempts to fold one source file into real
30
+ * `Declarable`/`CompositeInstance` instances with zero execution of the
31
+ * file's own top-level code, so `discover()` can skip `importModule` for it
32
+ * entirely.
33
+ *
34
+ * The folder only reduces expressions to plain values — it has no notion of
35
+ * lexicon resource classes or composites. This module supplies that missing
36
+ * piece: it reads the file's `import` declarations to learn which module
37
+ * each `new Type(...)` constructor or bare composite-factory call names,
38
+ * resolves and imports *that* module (a trusted lexicon/vendor module, not
39
+ * the file under fold), and constructs the real resource/composite instance
40
+ * from the folded props. Importing the lexicon module is not a regression on
41
+ * "no module execution" — the run path already imports it to get the same
42
+ * class/function; the only thing skipped here is executing the file's *own*
43
+ * statements.
44
+ *
45
+ * chant #1023 (epic #1019 Phase 5) extends this from leaf resources
46
+ * (`new Type(...)`) to composite factory calls — `SomeComposite({...})`,
47
+ * `propagate(SomeComposite({...}), {...})`, member access on the result
48
+ * (`web.deployment`), and destructuring (`const { a, b } = SomeComposite(...)`
49
+ * or `export const { a, b } = SomeComposite(...)`). A composite factory is a
50
+ * pure function of its props (EVL009/EVL010 guarantee its body only
51
+ * references props, sibling members, and imports), so — exactly like a
52
+ * resource constructor — it's safe to resolve through the file's imports and
53
+ * actually invoke with statically-folded props: no need to pre-verify "is
54
+ * this specifically a registered composite" via a shared registry (which
55
+ * would be unreliable across separately-loaded module instances of
56
+ * `@intentius/chant` anyway) — {@link resolveCallExpression} just resolves,
57
+ * invokes, and lets the RESULT speak. If it satisfies
58
+ * {@link isCompositeInstance} (or, for a plain resource-returning helper,
59
+ * {@link isDeclarable}), it's used. Nested composites and `propagate()`'d
60
+ * shared props need no special-casing: a nested composite is just another
61
+ * member the real factory call already produced (real JS execution inside a
62
+ * trusted module), and `propagate` is just another resolvable imported
63
+ * function that receives a live `CompositeInstance` plus folded shared props
64
+ * and returns it — `expandComposite()` (invoked downstream by
65
+ * `collectEntities`, unchanged) does the recursive expansion and the shared-
66
+ * prop merge exactly as it does for the run path.
67
+ */
68
+
69
+ /** One exported `const` name folded to a real, constructed `Declarable` or `CompositeInstance`. */
70
+ export type FoldedEntity = [name: string, entity: Declarable | CompositeInstance];
71
+
72
+ export type FoldFileResult =
73
+ | {
74
+ ok: true;
75
+ entities: FoldedEntity[];
76
+ /**
77
+ * chant #1020 — EVERY exported name's fully-resolved value, not just
78
+ * the `Declarable`/`CompositeInstance` ones already in `entities`: a
79
+ * plain value folds too (a string, a number, a plain object), it just
80
+ * contributes nothing to `entities` (see {@link applyResolvedValue}).
81
+ * This is the table another file's cross-file reference resolves
82
+ * against — see `buildExternals` below and the module doc on
83
+ * `planFoldTaint` for why a resource/composite value here MUST be the
84
+ * exact same object every referencing file sees.
85
+ */
86
+ exportedValues: Map<string, unknown>;
87
+ /**
88
+ * chant #1044 — the OTHER project files whose exported OBJECTS this
89
+ * fold consumed (a cross-file `Declarable`, composite instance, or any
90
+ * other non-primitive reached through `buildExternals`/a re-export).
91
+ *
92
+ * Object identity is the thing that cannot survive one side of the
93
+ * build folding while the other runs, so `planFoldTaint` needs to know
94
+ * who consumed whose objects: if a file here is forced back to run, the
95
+ * instance THIS file already captured is not the instance discovery
96
+ * will collect, and serialization fails on an entity with no logical
97
+ * name. A primitive (string, number, boolean, null) is never recorded —
98
+ * it has no identity to disagree about.
99
+ */
100
+ liveSources: ReadonlySet<string>;
101
+ }
102
+ | { ok: false; reason: string };
103
+
104
+ /**
105
+ * A per-build memo (chant #1020) so a project file imported by several
106
+ * others is folded exactly ONCE — every referrer resolves against the SAME
107
+ * `FoldFileResult`, and therefore the SAME constructed
108
+ * `Declarable`/`CompositeInstance` objects, no matter how many files
109
+ * cross-file-reference it or in what order discovery visits them. Also
110
+ * tracks the current resolution call chain (`stack`) so a genuine reference
111
+ * cycle (fileA needs fileB needs fileA) is DETECTED — rather than an
112
+ * infinite recursion / a promise awaiting itself forever — and reported as a
113
+ * located `FoldError` naming the cycle path.
114
+ *
115
+ * `discover()` creates exactly one session per `{ fold: true }` build and
116
+ * passes it to every top-level `tryFoldFile` call, so its own per-file loop
117
+ * and any cross-file reference reaching into the same file share the
118
+ * identical cache. Callers that don't care about cross-file sharing (unit
119
+ * tests exercising a single file in isolation) can omit it — {@link tryFoldFile}
120
+ * creates a private, single-call session on their behalf.
121
+ */
122
+ export interface FoldSession {
123
+ readonly intrinsics: readonly IntrinsicDef[];
124
+ readonly cache: Map<string, Promise<FoldFileResult>>;
125
+ readonly stack: string[];
126
+ /**
127
+ * Per-build memo (chant #1020 hang fix) for {@link importModule} itself —
128
+ * keyed by resolved absolute module path, shared session-wide exactly like
129
+ * {@link cache} above. Cross-file resolution means MANY files in one
130
+ * directory can each independently resolve the SAME constructor/composite-
131
+ * factory import (e.g. every file that constructs an AWS resource imports
132
+ * the same lexicon barrel) — before this, every one of those calls issued
133
+ * its own `await import(path)`, relying entirely on the runtime's own
134
+ * module cache to make the repeats cheap. That assumption holds for a
135
+ * plain `node`/`tsx` process, but NOT for a real dynamic import running
136
+ * inside a vitest worker: `vite-node`'s own SSR module graph can take a
137
+ * real, non-trivial amount of wall-clock time to re-resolve/re-register an
138
+ * already-loaded module on EVERY call, not just the first — harmless at
139
+ * single-digit call counts, but #1020's cross-file resolution can issue
140
+ * several times as many `importModule` calls for the same handful of large
141
+ * lexicon barrels within one `discover()` pass as the pre-#1020 single-file
142
+ * fold did. Memoizing the import itself (not just the path resolution)
143
+ * caps it at exactly one real `import()` per unique path per session,
144
+ * regardless of how many files reference it — this is what actually keeps
145
+ * a session-local retry (e.g. `sandbox-differential.test.ts`'s
146
+ * `vi.resetModules()` + rebuild path, which reruns fold from a cold
147
+ * module cache) from compounding into a multi-minute stall. Purely a cache
148
+ * over an idempotent operation (the same resolved path always yields the
149
+ * same module namespace object) — doesn't change what folds.
150
+ */
151
+ readonly importCache: Map<string, Promise<Record<string, unknown>>>;
152
+ /**
153
+ * Per-build memo (chant #1020 hang fix) for {@link resolveModulePath}'s
154
+ * RELATIVE/absolute-specifier branch only — keyed by
155
+ * `${dirname(fromFile)}\0${specifier}`. See {@link resolveModulePathMemoized}'s
156
+ * doc for the full story, including why bare (package) specifiers are
157
+ * memoized in a separate, process-wide cache instead of this session-
158
+ * scoped one: a relative specifier resolves against PROJECT source, which
159
+ * `chant build --watch` can legitimately change between rebuilds (a new
160
+ * sibling file appearing mid-session), so this cache is intentionally
161
+ * thrown away with the rest of the session at the end of every
162
+ * `discover()` call, unlike the bare-specifier one.
163
+ */
164
+ readonly resolvePathCache: Map<string, string>;
165
+ /**
166
+ * chant #1064 — this build's resolved build-time parameter values (see
167
+ * ../build-params.ts), consulted only by {@link buildExternals}'s one
168
+ * recognized bare-specifier case: a named `params` import resolving to
169
+ * ../params.ts. `undefined` when the build supplied none (no `chant.config.ts`
170
+ * `buildParams` declared, or the caller didn't pass any) — a project that
171
+ * doesn't use build-time parameters pays nothing extra here.
172
+ */
173
+ readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
174
+ }
175
+
176
+ /** Create a fresh, empty {@link FoldSession}. */
177
+ export function createFoldSession(
178
+ intrinsics: readonly IntrinsicDef[] = [],
179
+ buildParams?: Readonly<Record<string, BuildParamValue>>,
180
+ ): FoldSession {
181
+ return { intrinsics, cache: new Map(), stack: [], importCache: new Map(), resolvePathCache: new Map(), buildParams };
182
+ }
183
+
184
+ /**
185
+ * chant #1020 hang fix — memo for {@link resolveModulePath}'s BARE
186
+ * (package) specifier branch, deliberately PROCESS-WIDE rather than
187
+ * session-scoped (contrast {@link FoldSession.resolvePathCache}, used for
188
+ * relative specifiers). A bare specifier like `@intentius/chant-lexicon-aws`
189
+ * resolves via `createRequire(fromFile).resolve(specifier)` — which package
190
+ * a name refers to cannot change mid-process (Node's own module cache
191
+ * already assumes this: nothing invalidates `require.cache`/the dynamic
192
+ * `import()` cache either if `node_modules` changes under a running
193
+ * process), so caching the answer for the process's lifetime, across every
194
+ * `discover()` call/`FoldSession`, is exactly as safe as Node's own
195
+ * assumptions — unlike a relative specifier (a project file `chant build
196
+ * --watch` can legitimately add/remove between rebuilds), never unsafe to
197
+ * reuse.
198
+ *
199
+ * This is what actually fixes the hang, not just reduces it: profiling (a
200
+ * real `sample` during the observed multi-minute stall) traced the cost to
201
+ * `createRequire(fromFile).resolve(specifier)` ITSELF taking upwards of a
202
+ * minute — measured on this exact call, in this exact spot, nowhere else —
203
+ * specifically for the FIRST bare-specifier resolution inside a
204
+ * `FoldSession` created right after `vitest`'s `vi.resetModules()`
205
+ * (`sandbox-differential.test.ts`'s own retry path, pre-existing and
206
+ * unrelated to #1020 — confirmed on `main`, which hits the identical retry
207
+ * for the identical reason and stays fast). Two narrower versions of this
208
+ * fix (session-scoped; then process-wide but still keyed by directory) each
209
+ * still paid that cost at least once more: directory-scoping alone pays it
210
+ * once per NEW directory a corpus entry introduces, and #1020's cross-file
211
+ * resolution reaches a real constructor/composite-factory/intrinsic
212
+ * resolution — hence a real bare-specifier resolve — from strictly more
213
+ * directories across a 98-entry corpus than the pre-#1020 single-file fold
214
+ * ever did. Keyed by specifier ALONE (see {@link resolveModulePathMemoized}'s
215
+ * doc for the directory-independence assumption this relies on), the answer
216
+ * computed the first time ANY directory needed a given package is there for
217
+ * every directory after it, `vi.resetModules()` retry or not — the slow call
218
+ * never has to happen a second time in the same process, for any package.
219
+ */
220
+ const bareSpecifierPathCache = new Map<string, string>();
221
+
222
+ /**
223
+ * Memoized {@link resolveModulePath}. A relative/absolute specifier goes
224
+ * through the session-scoped `resolvePathCache`, keyed by DIRECTORY (not the
225
+ * full file path: `resolveModulePath` only ever consults `dirname(fromFile)`
226
+ * — the relative branch resolves against `dirname(fromFile)`, so the
227
+ * specific FILE within a directory never affects the answer) — a project
228
+ * file can legitimately be added/removed between builds (`chant build
229
+ * --watch`), so this cache is thrown away with the rest of the session.
230
+ *
231
+ * A bare (package) specifier goes through {@link bareSpecifierPathCache}
232
+ * instead, keyed by the specifier ALONE — process-wide, no directory
233
+ * component. `createRequire(fromFile).resolve(specifier)` technically CAN
234
+ * answer differently for the same specifier from two directories (nested
235
+ * `node_modules` with a version override), but `discover()`'s real usage is
236
+ * one `srcDir` per build, whose files overwhelmingly share one effective
237
+ * `node_modules` resolution — the same assumption bundlers (webpack,
238
+ * esbuild, vite) already make for their own module resolution caches. See
239
+ * {@link bareSpecifierPathCache}'s own doc for why process-wide (not just
240
+ * directory-scoped) is what actually fixes the hang: `examples/foo`'s
241
+ * directory and `lexicons/aws/examples/bar`'s directory both need
242
+ * `@intentius/chant-lexicon-aws`, and directory-scoping alone still pays the
243
+ * pathological cost once per NEW directory, not just once per package.
244
+ */
245
+ function resolveModulePathMemoized(
246
+ specifier: string,
247
+ fromFile: string,
248
+ resolvePathCache: Map<string, string>,
249
+ ): string {
250
+ const isBare = !specifier.startsWith(".") && !isAbsolute(specifier);
251
+ if (isBare) {
252
+ const cached = bareSpecifierPathCache.get(specifier);
253
+ if (cached !== undefined) return cached;
254
+ const resolved = resolveModulePath(specifier, fromFile);
255
+ bareSpecifierPathCache.set(specifier, resolved);
256
+ return resolved;
257
+ }
258
+ const key = `${dirname(fromFile)}\0${specifier}`;
259
+ const cached = resolvePathCache.get(key);
260
+ if (cached !== undefined) return cached;
261
+ const resolved = resolveModulePath(specifier, fromFile);
262
+ resolvePathCache.set(key, resolved);
263
+ return resolved;
264
+ }
265
+
266
+ /**
267
+ * chant #1020 hang fix — construct an `Error` for a routine, EXPECTED
268
+ * cross-file resolution failure (an unresolvable import, a non-function
269
+ * export, a shape this module doesn't support) WITHOUT V8's normal eager
270
+ * stack-frame capture. Every one of these is thrown, then caught a few
271
+ * frames up and reduced to `.message` — `tryFoldFileCore`'s own top-level
272
+ * catch, ultimately, same as {@link FoldError} (see its own doc in
273
+ * ../fold/fold.ts for the full mechanism and profiling evidence): `.stack` is
274
+ * never read on this path. Cheap for a shallow call stack, but this module's
275
+ * cross-file recursion (`foldFileMemoized` -> `buildExternals` ->
276
+ * `tryFoldFileCore` -> `resolveDeclaratorValue`/`resolveLiveValue` ->
277
+ * `resolveCallExpression`/`resolveImportedExport`, sometimes several files
278
+ * deep) makes the live stack deep enough, and hot enough for V8 to inline
279
+ * aggressively across a 98-entry corpus, that eager capture becomes the
280
+ * dominant cost — confirmed via `sample` during the observed multi-minute
281
+ * stall. Every throw site in this module that isn't a {@link FoldError}
282
+ * itself should use this instead of `new Error(...)`.
283
+ */
284
+ function cheapError(message: string): Error {
285
+ const prevStackTraceLimit = Error.stackTraceLimit;
286
+ Error.stackTraceLimit = 0;
287
+ try {
288
+ return new Error(message);
289
+ } finally {
290
+ Error.stackTraceLimit = prevStackTraceLimit;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Memoized {@link importModule} — see {@link FoldSession.importCache}'s doc
296
+ * for why this exists. Keyed by `modulePath` exactly as callers already pass
297
+ * it (the output of {@link resolveModulePath}, always an absolute path), so
298
+ * every caller resolving the same module shares the identical in-flight/
299
+ * settled promise instead of issuing its own `import()`.
300
+ */
301
+ function importModuleMemoized(
302
+ modulePath: string,
303
+ importCache: Map<string, Promise<Record<string, unknown>>>,
304
+ ): Promise<Record<string, unknown>> {
305
+ const cached = importCache.get(modulePath);
306
+ if (cached) return cached;
307
+ const promise = importModule(modulePath);
308
+ importCache.set(modulePath, promise);
309
+ return promise;
310
+ }
311
+
312
+ /** True when `node` carries the `export` modifier. */
313
+ function hasExportModifier(node: { modifiers?: ts.NodeArray<ts.ModifierLike> }): boolean {
314
+ return node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
315
+ }
316
+
317
+ /**
318
+ * True for any non-null, non-array object — a real `CompositeInstance`, a
319
+ * real `Declarable`, or (chant #1020) the synthetic plain object a
320
+ * namespace import's cross-file resolution builds from another file's own
321
+ * `exportedValues`. Property/member access on any of these is just a plain
322
+ * bracket index, so {@link resolveLiveValue} doesn't need to special-case
323
+ * which kind it is.
324
+ */
325
+ function isIndexableObject(value: unknown): value is Record<string, unknown> {
326
+ return typeof value === "object" && value !== null && !Array.isArray(value);
327
+ }
328
+
329
+ /** Extract a foldable object-binding-pattern element's source property key, or `undefined` if the shape isn't supported (rest element, nested pattern, default value, computed name). */
330
+ function bindingElementPropKey(el: ts.BindingElement): string | undefined {
331
+ if (el.dotDotDotToken || el.initializer || !ts.isIdentifier(el.name)) return undefined;
332
+ if (!el.propertyName) return el.name.text;
333
+ return ts.isIdentifier(el.propertyName) ? el.propertyName.text : undefined;
334
+ }
335
+
336
+ // ─────────────────────────────────────────────────────────────────────────
337
+ // Scan: classify every top-level statement, in file order.
338
+ // ─────────────────────────────────────────────────────────────────────────
339
+
340
+ /** A single exported `const name = new Type(...)` — the original (#1022) resource shape. */
341
+ interface ResourceDeclarator {
342
+ kind: "resource";
343
+ name: string;
344
+ node: ts.NewExpression;
345
+ }
346
+
347
+ /** A single exported `const name = <expr>` where `<expr>` isn't `new Type(...)` — #1023's composite-call shape (or a plain value, which still falls back). */
348
+ interface SingleDeclarator {
349
+ kind: "single";
350
+ name: string;
351
+ node: ts.Expression;
352
+ }
353
+
354
+ /** `export const { a, b: bAlias } = <expr>;` — #1023: destructuring a composite call's result at export time. */
355
+ interface DestructureDeclarator {
356
+ kind: "destructure";
357
+ node: ts.Expression;
358
+ elements: Array<{ propKey: string; bindingName: string }>;
359
+ }
360
+
361
+ /** `export { a, b as c };` — a LOCAL named-export list (no `moduleSpecifier`), referencing bindings declared earlier in the same file (#1023: commonly a composite call destructured into local `const`s first, then re-exported by name). Distinct from a genuine re-export (`export { a } from "./other"`), handled by {@link ReExportDeclarator} below. */
362
+ interface NamedExportDeclarator {
363
+ kind: "named-export";
364
+ elements: Array<{ localNameNode: ts.Identifier; exportedName: string }>;
365
+ }
366
+
367
+ /** `export { a, b as c } from "./other";` (chant #1020) — a genuine re-export: each element names an export of ANOTHER module, resolved cross-file exactly like an imported binding, then re-exported under (possibly) a different name. `export * from "./other"` is NOT this shape (no enumerable element list to resolve one-by-one) and still disqualifies the file, same as before #1020. */
368
+ interface ReExportDeclarator {
369
+ kind: "re-export";
370
+ specifier: string;
371
+ specifierNode: ts.StringLiteral;
372
+ elements: Array<{ imported: string; exportedName: string }>;
373
+ }
374
+
375
+ type ScanDeclarator =
376
+ | ResourceDeclarator
377
+ | SingleDeclarator
378
+ | DestructureDeclarator
379
+ | NamedExportDeclarator
380
+ | ReExportDeclarator;
381
+
382
+ interface ScanResult {
383
+ declarators: ScanDeclarator[];
384
+ unfoldableReason?: string;
385
+ }
386
+
387
+ /**
388
+ * Scan a source file's top-level statements for the exported shapes this
389
+ * module can fold: `export const X = new Type(...)` (resources, #1022),
390
+ * `export const X = <expr>` / `export const { a, b } = <expr>` (composite
391
+ * calls and member access on them, #1023), `export { a, b }` (a local
392
+ * named-export list), and `export { a, b } from "./other"` (a re-export
393
+ * chain, #1020). Any OTHER export construct (`export default`, `export *
394
+ * from`, an exported function/class, `let`/`var`, a destructured export with
395
+ * a rest/nested/defaulted element) makes the whole file ineligible: the
396
+ * module must run so that construct is actually evaluated. This mirrors the
397
+ * epic's hybrid design — fallback is per-module, not per-declaration,
398
+ * because an unfoldable export can itself reference or be referenced by a
399
+ * foldable one in ways only running proves safe.
400
+ */
401
+ function scanExports(sourceFile: ts.SourceFile): ScanResult {
402
+ const declarators: ScanDeclarator[] = [];
403
+
404
+ for (const statement of sourceFile.statements) {
405
+ if (ts.isExportAssignment(statement)) {
406
+ return { declarators, unfoldableReason: "`export default` is not foldable" };
407
+ }
408
+
409
+ if (ts.isExportDeclaration(statement)) {
410
+ if (statement.isTypeOnly) continue; // `export type { ... }` — erased, no runtime value to fold.
411
+ if (statement.moduleSpecifier) {
412
+ // chant #1020 — a genuine re-export (`export { a } from "./other"`).
413
+ // `export * from "./other"` has no `exportClause` at all — no
414
+ // enumerable element list to resolve one-by-one — and still
415
+ // disqualifies the file, same as before #1020.
416
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) {
417
+ return { declarators, unfoldableReason: "re-export declaration is not foldable" };
418
+ }
419
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
420
+ return { declarators, unfoldableReason: "re-export declaration is not foldable" };
421
+ }
422
+ const elements: ReExportDeclarator["elements"] = [];
423
+ for (const el of statement.exportClause.elements) {
424
+ if (el.isTypeOnly) continue;
425
+ const importedNameNode = el.propertyName ?? el.name;
426
+ if (!ts.isIdentifier(importedNameNode)) {
427
+ return { declarators, unfoldableReason: "re-export declaration is not foldable" };
428
+ }
429
+ elements.push({ imported: importedNameNode.text, exportedName: el.name.text });
430
+ }
431
+ declarators.push({
432
+ kind: "re-export",
433
+ specifier: statement.moduleSpecifier.text,
434
+ specifierNode: statement.moduleSpecifier,
435
+ elements,
436
+ });
437
+ continue;
438
+ }
439
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
440
+ return { declarators, unfoldableReason: "export declaration is not foldable" };
441
+ }
442
+ const elements: NamedExportDeclarator["elements"] = [];
443
+ for (const el of statement.exportClause.elements) {
444
+ if (el.isTypeOnly) continue;
445
+ const localNameNode = el.propertyName ?? el.name;
446
+ // `export { "string name" as foo }` (TS 4.5+ module-export-name
447
+ // syntax) isn't a plain identifier reference — not seen in practice
448
+ // for local (non-re-export) lists; fall back rather than guess.
449
+ if (!ts.isIdentifier(localNameNode)) {
450
+ return { declarators, unfoldableReason: "export declaration is not foldable" };
451
+ }
452
+ elements.push({ localNameNode, exportedName: el.name.text });
453
+ }
454
+ declarators.push({ kind: "named-export", elements });
455
+ continue;
456
+ }
457
+
458
+ if (ts.isFunctionDeclaration(statement) && hasExportModifier(statement)) {
459
+ return {
460
+ declarators,
461
+ unfoldableReason: `exported function declaration "${statement.name?.text ?? "<anonymous>"}" is not foldable`,
462
+ };
463
+ }
464
+ if (ts.isClassDeclaration(statement) && hasExportModifier(statement)) {
465
+ return {
466
+ declarators,
467
+ unfoldableReason: `exported class declaration "${statement.name?.text ?? "<anonymous>"}" is not foldable`,
468
+ };
469
+ }
470
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue;
471
+
472
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
473
+ return { declarators, unfoldableReason: "exported `let`/`var` declaration is not foldable" };
474
+ }
475
+
476
+ for (const decl of statement.declarationList.declarations) {
477
+ if (!decl.initializer) {
478
+ return { declarators, unfoldableReason: "exported destructured or uninitialized declaration is not foldable" };
479
+ }
480
+
481
+ if (ts.isIdentifier(decl.name)) {
482
+ if (ts.isNewExpression(decl.initializer)) {
483
+ declarators.push({ kind: "resource", name: decl.name.text, node: decl.initializer });
484
+ } else {
485
+ declarators.push({ kind: "single", name: decl.name.text, node: decl.initializer });
486
+ }
487
+ continue;
488
+ }
489
+
490
+ if (ts.isObjectBindingPattern(decl.name)) {
491
+ const elements: Array<{ propKey: string; bindingName: string }> = [];
492
+ let allSupported = true;
493
+ for (const el of decl.name.elements) {
494
+ const propKey = bindingElementPropKey(el);
495
+ if (propKey === undefined) {
496
+ allSupported = false;
497
+ break;
498
+ }
499
+ elements.push({ propKey, bindingName: el.name.getText() });
500
+ }
501
+ if (!allSupported) {
502
+ return { declarators, unfoldableReason: "exported destructured or uninitialized declaration is not foldable" };
503
+ }
504
+ declarators.push({ kind: "destructure", node: decl.initializer, elements });
505
+ continue;
506
+ }
507
+
508
+ // ArrayBindingPattern — not supported.
509
+ return { declarators, unfoldableReason: "exported destructured or uninitialized declaration is not foldable" };
510
+ }
511
+ }
512
+
513
+ return { declarators };
514
+ }
515
+
516
+ // ─────────────────────────────────────────────────────────────────────────
517
+ // Local (possibly non-exported) top-level bindings — needed to resolve
518
+ // `const web = WebApp({...}); export const x = web.member;` and
519
+ // `const { a } = WebApp({...}); export { a };` (#1023): the composite call
520
+ // itself is very often not the exported declaration.
521
+ // ─────────────────────────────────────────────────────────────────────────
522
+
523
+ /** Where a local top-level identifier's value comes from: either directly (`propKey` unset) or as one destructured member of another expression's result. */
524
+ interface LocalBinding {
525
+ source: ts.Expression;
526
+ propKey?: string;
527
+ }
528
+
529
+ function collectLocalBindings(sourceFile: ts.SourceFile): Map<string, LocalBinding> {
530
+ const bindings = new Map<string, LocalBinding>();
531
+
532
+ for (const statement of sourceFile.statements) {
533
+ if (!ts.isVariableStatement(statement)) continue;
534
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
535
+
536
+ for (const decl of statement.declarationList.declarations) {
537
+ if (!decl.initializer) continue;
538
+
539
+ if (ts.isIdentifier(decl.name)) {
540
+ bindings.set(decl.name.text, { source: decl.initializer });
541
+ } else if (ts.isObjectBindingPattern(decl.name)) {
542
+ for (const el of decl.name.elements) {
543
+ const propKey = bindingElementPropKey(el);
544
+ if (propKey === undefined) continue;
545
+ bindings.set(el.name.getText(), { source: decl.initializer, propKey });
546
+ }
547
+ }
548
+ // ArrayBindingPattern locals: not indexed — a later reference to one
549
+ // of its names simply won't be found below, which correctly falls
550
+ // back (same as referencing any other unresolved identifier).
551
+ }
552
+ }
553
+
554
+ return bindings;
555
+ }
556
+
557
+ // ─────────────────────────────────────────────────────────────────────────
558
+ // Import resolution — unchanged from #1022, shared by the resource and
559
+ // composite-call resolution paths.
560
+ // ─────────────────────────────────────────────────────────────────────────
561
+
562
+ /** Where an imported local identifier came from. */
563
+ interface ImportBinding {
564
+ specifier: string;
565
+ imported: string;
566
+ /** The `import ... from "specifier"` declaration's module-specifier
567
+ * string-literal node (chant #1020) — used to attach a source position to
568
+ * a cross-file resolution failure (in particular an import-cycle
569
+ * diagnostic) at the referencing site, not just the defining one. */
570
+ specifierNode: ts.StringLiteral;
571
+ }
572
+
573
+ /** Where a namespace import (`import * as ns from "specifier"`) came from — chant #1020. */
574
+ interface NamespaceImportBinding {
575
+ specifier: string;
576
+ specifierNode: ts.StringLiteral;
577
+ }
578
+
579
+ interface CollectedImports {
580
+ /** Default and named bindings (`import Foo from "x"`, `import { a, b as c } from "x"`). */
581
+ named: Map<string, ImportBinding>;
582
+ /** Namespace bindings (`import * as ns from "x"`) — chant #1020: indexed
583
+ * (unlike before) so a property-access chain rooted at `ns`
584
+ * (`ns.someExport`, e.g. `ecr.apiRepo.RepositoryUri`) can resolve
585
+ * cross-file. Still doesn't help a constructor/composite-factory CALLEE
586
+ * shaped as a dotted name (`new ns.Type(...)`, `ns.Foo(...)`) — that
587
+ * callee text is the literal string "ns.Type"/"ns.Foo", which simply
588
+ * misses `named` above, unaffected by this map's existence. */
589
+ namespaces: Map<string, NamespaceImportBinding>;
590
+ }
591
+
592
+ /** Map every top-level `import`-bound local identifier to its source module + export name. */
593
+ function collectImports(sourceFile: ts.SourceFile): CollectedImports {
594
+ const named = new Map<string, ImportBinding>();
595
+ const namespaces = new Map<string, NamespaceImportBinding>();
596
+
597
+ for (const statement of sourceFile.statements) {
598
+ if (!ts.isImportDeclaration(statement)) continue;
599
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
600
+ const clause = statement.importClause;
601
+ if (!clause) continue;
602
+ // A whole-statement `import type { ... } from "x"` is erased at runtime
603
+ // and never appears in expression position — indexing it would only
604
+ // ever manufacture a false cross-file dependency edge (taint, or worse,
605
+ // a phantom import-cycle) between files that share no real value
606
+ // dependency. Per-specifier `import { type Foo } from "x"` is filtered
607
+ // below, at the named-element level.
608
+ if (clause.isTypeOnly) continue;
609
+ const specifier = statement.moduleSpecifier.text;
610
+ const specifierNode = statement.moduleSpecifier;
611
+
612
+ if (clause.name) {
613
+ named.set(clause.name.text, { specifier, imported: "default", specifierNode });
614
+ }
615
+ if (clause.namedBindings) {
616
+ if (ts.isNamedImports(clause.namedBindings)) {
617
+ for (const element of clause.namedBindings.elements) {
618
+ if (element.isTypeOnly) continue;
619
+ const imported = element.propertyName?.text ?? element.name.text;
620
+ named.set(element.name.text, { specifier, imported, specifierNode });
621
+ }
622
+ } else if (ts.isNamespaceImport(clause.namedBindings)) {
623
+ namespaces.set(clause.namedBindings.name.text, { specifier, specifierNode });
624
+ }
625
+ }
626
+ }
627
+
628
+ return { named, namespaces };
629
+ }
630
+
631
+ /**
632
+ * chant #1020 hang fix — best-effort fast path for resolving a BARE
633
+ * specifier by walking `node_modules` directly with the same primitives
634
+ * (`existsSync`/`readFileSync`) the relative-specifier branch below already
635
+ * uses, reading `package.json`'s "exports"/"main" field by hand instead of
636
+ * calling `createRequire(fromFile).resolve(specifier)`. Returns `undefined`
637
+ * — never throws — for anything beyond the simple, single-target shape
638
+ * (a string `"exports"`, or an object whose `"."` entry is a string or a
639
+ * flat, string-valued condition map): the caller falls back to the slow,
640
+ * authoritative `createRequire().resolve()` path whenever this returns
641
+ * `undefined`, so a genuinely complex `package.json` (a conditions array, a
642
+ * self-reference, a `"."` entry the fast path doesn't recognize) is still
643
+ * resolved correctly, just not quickly.
644
+ *
645
+ * Why this exists at all: profiling traced the hang to
646
+ * `createRequire(fromFile).resolve(specifier)` ITSELF taking upwards of a
647
+ * minute — and, worse, growing (measured 60s, then 69s, then 361s for
648
+ * successive NEW packages later in the same run) — inside a vitest worker,
649
+ * specifically for the FIRST resolution of a given bare specifier in the
650
+ * process (session-level and even process-wide-by-specifier caching, see
651
+ * {@link bareSpecifierPathCache}, only avoid paying that cost a SECOND
652
+ * time). The relative-specifier branch just below, using these exact same
653
+ * `existsSync`/`statSync` primitives, never showed this slowdown anywhere
654
+ * in the same profiling — the cost is specific to Node's own
655
+ * `Module._resolveFilename` machinery, not to file-system access in
656
+ * general, so replacing just that one call with plain `existsSync`/
657
+ * `readFileSync` sidesteps it entirely for the common case every
658
+ * `@intentius/chant*` package (and most well-formed npm packages) ships.
659
+ */
660
+ function fastResolveBareSpecifier(specifier: string, fromFile: string): string | undefined {
661
+ let dir = dirname(fromFile);
662
+ for (;;) {
663
+ const packageDir = join(dir, "node_modules", specifier);
664
+ if (existsSync(packageDir)) {
665
+ const entry = fastResolvePackageEntry(packageDir);
666
+ if (entry === undefined) return undefined;
667
+ const resolved = resolvePath(packageDir, entry);
668
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) return undefined;
669
+ try {
670
+ return realpathSync(resolved);
671
+ } catch {
672
+ return undefined;
673
+ }
674
+ }
675
+ const parent = dirname(dir);
676
+ if (parent === dir) return undefined; // reached the filesystem root, unresolved
677
+ dir = parent;
678
+ }
679
+ }
680
+
681
+ /** Read `<packageDir>/package.json`'s "." export target — see {@link fastResolveBareSpecifier}'s doc for exactly which shapes this recognizes; anything else returns `undefined`. */
682
+ function fastResolvePackageEntry(packageDir: string): string | undefined {
683
+ const pkgJsonPath = join(packageDir, "package.json");
684
+ if (!existsSync(pkgJsonPath)) return undefined;
685
+ let pkg: unknown;
686
+ try {
687
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
688
+ } catch {
689
+ return undefined;
690
+ }
691
+ if (typeof pkg !== "object" || pkg === null) return undefined;
692
+ const exportsField = (pkg as Record<string, unknown>).exports;
693
+
694
+ if (exportsField !== undefined) {
695
+ if (typeof exportsField === "string") return exportsField;
696
+ if (typeof exportsField !== "object" || exportsField === null || Array.isArray(exportsField)) {
697
+ return undefined;
698
+ }
699
+ const exportsObj = exportsField as Record<string, unknown>;
700
+ // No "." key at all means the WHOLE object IS the "." export's own
701
+ // condition map (the shorthand form) — only when none of its OWN keys
702
+ // look like a subpath/condition-name ambiguity risk (a key starting
703
+ // with "." that isn't literally "." itself signals real subpath
704
+ // exports present, so bail rather than misparse).
705
+ const hasSubpathKeys = Object.keys(exportsObj).some((k) => k.startsWith(".") && k !== ".");
706
+ const target = "." in exportsObj ? exportsObj["."] : hasSubpathKeys ? undefined : exportsObj;
707
+ if (target === undefined) return undefined;
708
+ if (typeof target === "string") return target;
709
+ if (typeof target !== "object" || target === null || Array.isArray(target)) return undefined;
710
+ const conditions = target as Record<string, unknown>;
711
+ // Prefer "default" (present on every chant/lexicon package and the
712
+ // overwhelming majority of well-formed dual-mode npm packages); "node"/
713
+ // "import" as narrower fallbacks. Every chant package's own "default"
714
+ // and "development" conditions point at the identical source file, so
715
+ // which one Node's own algorithm would have picked never matters here.
716
+ for (const key of ["default", "node", "import"]) {
717
+ const val = conditions[key];
718
+ if (typeof val === "string") return val;
719
+ }
720
+ return undefined;
721
+ }
722
+
723
+ const main = (pkg as Record<string, unknown>).main;
724
+ if (main !== undefined) return typeof main === "string" ? main : undefined;
725
+ return "index.js";
726
+ }
727
+
728
+ /**
729
+ * Resolve an import specifier to an absolute module path, the way the
730
+ * declaring file's own `import` would — without depending on a TS-aware
731
+ * loader being active. Relative/absolute specifiers are probed against real
732
+ * TS/JS candidate files on disk; bare package specifiers try
733
+ * {@link fastResolveBareSpecifier} first, falling back to Node's own CJS
734
+ * algorithm from the declaring file's location (lexicon packages ship built
735
+ * JS, so this needs no `.ts` awareness) whenever that returns `undefined`.
736
+ */
737
+ function resolveModulePath(specifier: string, fromFile: string): string {
738
+ if (specifier.startsWith(".") || isAbsolute(specifier)) {
739
+ const base = specifier.startsWith(".") ? resolvePath(dirname(fromFile), specifier) : specifier;
740
+ const candidates = [
741
+ base,
742
+ `${base}.ts`,
743
+ `${base}.tsx`,
744
+ `${base}.js`,
745
+ `${base}.mjs`,
746
+ join(base, "index.ts"),
747
+ join(base, "index.js"),
748
+ ];
749
+ for (const candidate of candidates) {
750
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
751
+ }
752
+ // Nothing found on disk under any probed extension — hand back the bare
753
+ // base and let `import()` fail with its own, more specific error.
754
+ return base;
755
+ }
756
+
757
+ const fast = fastResolveBareSpecifier(specifier, fromFile);
758
+ if (fast !== undefined) return fast;
759
+ return createRequire(fromFile).resolve(specifier);
760
+ }
761
+
762
+ /**
763
+ * chant #1064 — the one real published subpath a project imports chant's
764
+ * build-time-parameters module from. {@link buildExternals} matches a bare
765
+ * specifier against this by TEXT ONLY, never by resolving it — see that
766
+ * function's own comment for why resolving an arbitrary bare specifier here
767
+ * would reintroduce the exact pathological cold-resolution cost chant#1020
768
+ * already fixed once (this module's other comments measure it at up to
769
+ * ~361s for the first resolution of a genuinely new bare specifier).
770
+ */
771
+ const PARAMS_BARE_SPECIFIER = "@intentius/chant/params";
772
+
773
+ /**
774
+ * chant #1064 — the absolute path of chant-core's OWN build-time-parameters
775
+ * runtime module (../params.ts), resolved lazily on first use, from THIS
776
+ * file's own location, via the exact same relative-specifier resolution
777
+ * {@link resolveModulePath} already applies to project files — cheap
778
+ * (`existsSync`/`statSync` candidate probing only, never Node's package
779
+ * resolution). Used by {@link buildExternals} to recognize a RELATIVE/
780
+ * ABSOLUTE import of the params module (this module's own test fixtures use
781
+ * an absolute path, matching the rest of this file's test convention) — a
782
+ * real project's bare `@intentius/chant/params` import is instead matched by
783
+ * {@link PARAMS_BARE_SPECIFIER}'s text alone, never through this path.
784
+ *
785
+ * Lazy and failure-safe, NOT a module-scope constant: this module is also
786
+ * bundled into the #1045 sandbox child, where module-init code runs inside
787
+ * `--permission` with a read allowlist. There `import.meta.url` is the
788
+ * bundle's temp-dir path, so an init-time probe reaches for `<tmp>/params` —
789
+ * outside the allowlist — and the `existsSync` throws `ERR_ACCESS_DENIED`,
790
+ * killing the child before it reports (a real, observed sandbox-vs-run error
791
+ * drift across six corpus entries). The child runs only the run path and
792
+ * never calls {@link buildExternals}, so deferring the probe to first fold
793
+ * use keeps it out of the sandbox entirely; if probing still fails there,
794
+ * `null` just disables the relative-path recognition rather than erroring.
795
+ */
796
+ let paramsModulePathMemo: string | null | undefined;
797
+ function paramsModulePath(): string | null {
798
+ if (paramsModulePathMemo === undefined) {
799
+ try {
800
+ paramsModulePathMemo = resolveModulePath("../params", fileURLToPath(import.meta.url));
801
+ } catch {
802
+ paramsModulePathMemo = null;
803
+ }
804
+ }
805
+ return paramsModulePathMemo;
806
+ }
807
+
808
+ // ─────────────────────────────────────────────────────────────────────────
809
+ // Resolution: given the scan + import map, compute the REAL runtime value
810
+ // (Declarable | CompositeInstance) each foldable export would have had if
811
+ // the file had actually run — without running the file.
812
+ // ─────────────────────────────────────────────────────────────────────────
813
+
814
+ /** Everything the recursive resolver needs, threaded through unchanged. */
815
+ interface ResolveCtx {
816
+ file: string;
817
+ consts: Map<string, ts.Expression>;
818
+ locals: Map<string, LocalBinding>;
819
+ imports: Map<string, ImportBinding>;
820
+ /** Namespace imports (chant #1020) — see {@link CollectedImports.namespaces}. */
821
+ namespaceImports: Map<string, NamespaceImportBinding>;
822
+ /** Memoizes by initializer node so a composite call referenced by several member accesses / destructured names is invoked exactly once — matching what actually running the file would do. */
823
+ memo: Map<ts.Expression, Promise<LiveResolution>>;
824
+ /** Lexicon-registered intrinsic tags (chant #1039) — passed through to
825
+ * {@link fold}/{@link foldResource} so a registered tagged template
826
+ * (e.g. AWS `Sub`\`...\`) folds instead of throwing "unregistered tagged
827
+ * template intrinsic". Empty when the caller (`discover()`) wasn't given any. */
828
+ intrinsics: readonly IntrinsicDef[];
829
+ /**
830
+ * chant #1020 — every relative-import binding of `file`'s that resolved,
831
+ * eagerly, to its real cross-file value: a plain value for an imported
832
+ * `const`, the real live `Declarable`/`CompositeInstance` for a name bound
833
+ * to a resource/composite in the defining module (see this module's own
834
+ * doc comment on `planFoldTaint` for why identity — not just equality —
835
+ * has to be preserved here), or a synthetic plain object of a namespace
836
+ * import's own `exportedValues` (so `ns.someExport` indexes it exactly
837
+ * like any other object). Consulted by {@link fold}/{@link foldResource}
838
+ * only when an identifier isn't one of `file`'s own `consts`, and by
839
+ * {@link resolveLiveValue}'s identifier branch only when it isn't one of
840
+ * `locals`. A name absent from this map (an unresolvable import, a bare
841
+ * package specifier, or a name a cross-file fold attempt didn't produce)
842
+ * falls through to the exact same "unresolved identifier"/"not foldable"
843
+ * failure as before #1020 — this is strictly additive.
844
+ */
845
+ externals: Map<string, unknown>;
846
+ /**
847
+ * chant #1020 — for a `file`-local import name whose cross-file
848
+ * resolution attempt failed, WHY (a located, human-readable reason —
849
+ * notably an import-cycle diagnostic naming the cycle path). Used only to
850
+ * enrich an otherwise-generic "unresolved identifier: X" failure message
851
+ * when X happens to be one of these names; purely cosmetic; resolution
852
+ * behavior doesn't depend on it.
853
+ */
854
+ crossFileFailures: Map<string, string>;
855
+ /**
856
+ * chant #1020 hang fix — session-wide {@link importModule} memo (see
857
+ * {@link FoldSession.importCache}'s doc). Every constructor/composite-
858
+ * factory/intrinsic import in this module goes through
859
+ * {@link importModuleMemoized} with this map, not a bare `importModule`
860
+ * call.
861
+ */
862
+ importCache: Map<string, Promise<Record<string, unknown>>>;
863
+ /**
864
+ * chant #1020 hang fix — session-wide {@link resolveModulePath} memo (see
865
+ * {@link FoldSession.resolvePathCache}'s doc). Every constructor/composite-
866
+ * factory/intrinsic/re-export resolution in this module goes through
867
+ * {@link resolveModulePathMemoized} with this map.
868
+ */
869
+ resolvePathCache: Map<string, string>;
870
+ }
871
+
872
+ /** `{ value }` when `node`'s shape was recognized and resolved (value may itself be `undefined`/`null` — e.g. an optional composite member that wasn't created); `undefined` when the shape isn't one the live resolver understands (a plain literal, etc.) — callers fall back to the original, unchanged handling for that shape. */
873
+ type LiveResolution = { value: unknown } | undefined;
874
+
875
+ function resolveMemoized(node: ts.Expression, ctx: ResolveCtx): Promise<LiveResolution> {
876
+ const cached = ctx.memo.get(node);
877
+ if (cached) return cached;
878
+ const promise = resolveLiveValue(node, ctx);
879
+ ctx.memo.set(node, promise);
880
+ return promise;
881
+ }
882
+
883
+ /**
884
+ * Attempt to resolve `node` to the real runtime value it would have if the
885
+ * file were executed. Understands the "spine" of composite consumption: a
886
+ * bare call (a composite factory, or a wrapper like `propagate()` — resolved
887
+ * through this file's imports, invoked for real with statically-folded
888
+ * arguments), a reference to an earlier top-level `const` bound to one of
889
+ * those (memoized — see {@link ResolveCtx.memo}), and dotted property access
890
+ * or `!` non-null assertion on the result (`web.deployment`, `web.pdb!`).
891
+ *
892
+ * Throws when the shape IS recognized (a call, a member access) but
893
+ * resolution genuinely fails — unresolved import, non-function import,
894
+ * folding a nested argument failed, member access on a non-composite value,
895
+ * the call itself threw. That failure should fall back the whole file to
896
+ * run, same as any other fold gap.
897
+ */
898
+ async function resolveLiveValue(node: ts.Expression, ctx: ResolveCtx): Promise<LiveResolution> {
899
+ if (
900
+ ts.isParenthesizedExpression(node) ||
901
+ ts.isNonNullExpression(node) ||
902
+ ts.isAsExpression(node) ||
903
+ ts.isSatisfiesExpression(node)
904
+ ) {
905
+ return resolveLiveValue(node.expression, ctx);
906
+ }
907
+
908
+ if (ts.isIdentifier(node)) {
909
+ const binding = ctx.locals.get(node.text);
910
+ if (!binding) {
911
+ // chant #1020 — not a same-file local; try a cross-file resolution
912
+ // (a plain value, or the real live Declarable/CompositeInstance a
913
+ // sibling file's own fold produced, or a namespace import's synthetic
914
+ // exports object). Absent from `externals` falls through to
915
+ // `undefined`, exactly the pre-#1020 behavior for any unbound name.
916
+ if (ctx.externals.has(node.text)) {
917
+ return { value: ctx.externals.get(node.text) };
918
+ }
919
+ return undefined;
920
+ }
921
+ const resolvedSource = await resolveMemoized(binding.source, ctx);
922
+ if (resolvedSource === undefined) return undefined;
923
+ if (binding.propKey === undefined) return resolvedSource;
924
+ if (!isIndexableObject(resolvedSource.value)) {
925
+ throw cheapError(`destructured member "${binding.propKey}" is not on a composite value`);
926
+ }
927
+ return { value: (resolvedSource.value as unknown as Record<string, unknown>)[binding.propKey] };
928
+ }
929
+
930
+ if (ts.isCallExpression(node)) {
931
+ return { value: await resolveCallExpression(node, ctx) };
932
+ }
933
+
934
+ if (ts.isPropertyAccessExpression(node)) {
935
+ const base = await resolveLiveValue(node.expression, ctx);
936
+ if (base === undefined) return undefined;
937
+ const key = node.name.text;
938
+ if (!isIndexableObject(base.value)) {
939
+ throw cheapError(`property access ".${key}" on a non-composite value is not foldable`);
940
+ }
941
+ return { value: (base.value as unknown as Record<string, unknown>)[key] };
942
+ }
943
+
944
+ return undefined;
945
+ }
946
+
947
+ /**
948
+ * Resolve and invoke a bare call expression — a composite factory call
949
+ * (`SomeComposite({...})`) or a wrapper that takes a composite instance and
950
+ * returns one (`propagate(SomeComposite({...}), {...})`). The callee must be
951
+ * a plain identifier bound by this file's own `import` (a namespace-import
952
+ * call like `ns.Foo(...)`, or a call to a function/composite DEFINED in this
953
+ * same file, can't be resolved without running the file — falls back, same
954
+ * as an unresolvable resource constructor).
955
+ *
956
+ * No pre-check verifies the resolved callee is "really" a composite: each
957
+ * argument is resolved (recursively, for a nested composite-call/member-
958
+ * access argument like `propagate`'s first one) or folded (for a plain props
959
+ * object literal via {@link fold}), the real function is invoked, and the
960
+ * RESULT is what matters to the caller — {@link resolveLiveValue}'s callers
961
+ * decide what shape they need (a `CompositeInstance` for member access, an
962
+ * `isDeclarable`/`isCompositeInstance` value for a top-level export).
963
+ */
964
+ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx): Promise<unknown> {
965
+ if (!ts.isIdentifier(node.expression)) {
966
+ throw cheapError(`call expression as a value is not foldable: ${node.expression.getText()}(...)`);
967
+ }
968
+ const calleeName = node.expression.text;
969
+ const binding = ctx.imports.get(calleeName);
970
+ if (!binding) {
971
+ throw cheapError(`call expression as a value is not foldable: ${calleeName}(...)`);
972
+ }
973
+
974
+ let modulePath: string;
975
+ try {
976
+ modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
977
+ } catch (err) {
978
+ throw cheapError(
979
+ `could not resolve import "${binding.specifier}" for "${calleeName}": ${err instanceof Error ? err.message : String(err)}`,
980
+ );
981
+ }
982
+
983
+ // Reuses the SAME import mechanism the run path uses ({@link importModule},
984
+ // ../discovery/import.ts — a plain-path `import()`) rather than a
985
+ // `pathToFileURL(...)`-wrapped one. For a resource constructor (a
986
+ // stateless lexicon class) the two would be interchangeable, but a
987
+ // composite-call resolution (#1023) can import a SIBLING PROJECT FILE
988
+ // (e.g. `import { network } from "./network"`, not just a lexicon
989
+ // package) that `discover()`'s own per-file loop ALSO independently
990
+ // processes — if that file falls back to run and imports the same
991
+ // sibling for a live value (`network.vpc.VpcId`), the two import calls
992
+ // MUST resolve to the identical cached module instance, or the run side
993
+ // ends up with a second, distinct `network`/`vpc` object whose AttrRefs
994
+ // can never be matched back to the entity fold already registered
995
+ // (`Cannot serialize AttrRef ...: logical name not set`).
996
+ let mod: Record<string, unknown>;
997
+ try {
998
+ mod = await importModuleMemoized(modulePath, ctx.importCache);
999
+ } catch (err) {
1000
+ throw cheapError(
1001
+ `could not import "${binding.specifier}" to resolve "${calleeName}": ${err instanceof Error ? err.message : String(err)}`,
1002
+ );
1003
+ }
1004
+
1005
+ const Fn = mod[binding.imported];
1006
+ if (typeof Fn !== "function") {
1007
+ throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a function`);
1008
+ }
1009
+
1010
+ const args: unknown[] = [];
1011
+ for (const argNode of node.arguments) {
1012
+ const live = await resolveLiveValue(argNode, ctx);
1013
+ // chant #1039 — a folded (non-live) argument may itself contain a
1014
+ // registered intrinsic tagged template; revive it into the real value
1015
+ // before the composite factory actually runs on it (see the "Intrinsic
1016
+ // revival" section below `resolveResourceEntity` uses the same way).
1017
+ args.push(
1018
+ live !== undefined
1019
+ ? live.value
1020
+ : await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx, false),
1021
+ );
1022
+ }
1023
+
1024
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1025
+ }
1026
+
1027
+ /**
1028
+ * Record one exported name's fully-resolved value: into `entities` when
1029
+ * it's a real `Declarable`/`CompositeInstance` (`collectEntities`, and
1030
+ * therefore serialization, only cares about these), and — chant #1020 —
1031
+ * unconditionally into `exportedValues` too, so a plain value (a string, a
1032
+ * number, a plain object, `undefined`/`null`) is still available for
1033
+ * ANOTHER file's cross-file reference to this export, exactly as it would
1034
+ * be if this file were actually imported and its real `exports` object read
1035
+ * directly. A plain value contributing nothing to `entities` isn't a
1036
+ * failure: the run path's real `exports` object would contain it too, and
1037
+ * `collectEntities` already silently ignores a non-Declarable/array/
1038
+ * CompositeInstance export the same way (see enumerateEntries, ../collect.ts).
1039
+ */
1040
+ function applyResolvedValue(
1041
+ name: string,
1042
+ value: unknown,
1043
+ entities: FoldedEntity[],
1044
+ exportedValues: Map<string, unknown>,
1045
+ ): void {
1046
+ exportedValues.set(name, value);
1047
+ if (isDeclarable(value) || isCompositeInstance(value)) {
1048
+ entities.push([name, value as Declarable | CompositeInstance]);
1049
+ }
1050
+ }
1051
+
1052
+ // ─────────────────────────────────────────────────────────────────────────
1053
+ // Intrinsic revival (chant #1039).
1054
+ //
1055
+ // `fold()`/`foldResource()` reduce a registered intrinsic tagged template
1056
+ // (e.g. AWS `Sub`\`...\`) to a symbolic `FoldedIntrinsic` node —
1057
+ // `{ __intrinsic, strings, values }` — and an unresolved external symbol
1058
+ // chain inside it (e.g. `AWS.StackName`) to a `SymbolicValue` —
1059
+ // `{ __symbol }` (../fold/fold.ts's own doc: "mirrors the runtime call shape
1060
+ // `Tag(strings, ...values)` so a later build path can replay it into the
1061
+ // real intrinsic object"). This IS that later build path: without it, the
1062
+ // symbolic envelope would be passed straight into the constructed entity's
1063
+ // props as an inert plain object — the entity would be built (no crash), but
1064
+ // its serialized output would silently diverge from the run path's real
1065
+ // `Sub`/`PseudoParameter` instances (caught by the #1025 differential the
1066
+ // moment intrinsics started actually folding). `reviveFoldedProps` walks the
1067
+ // folded props/attributes tree and, for each `{__intrinsic}`/`{__symbol}`
1068
+ // node, resolves the real tag function / pseudo-parameter through this
1069
+ // file's own imports (same mechanism `resolveResourceEntity` already uses
1070
+ // for the resource constructor itself) and invokes/accesses it for real —
1071
+ // exactly what running the original tagged template would have done.
1072
+ //
1073
+ // `{__attrRef}` (a same-file sibling-resource reference, ../fold/fold.ts's
1074
+ // `AttrRefValue`) is left untouched when it is NOT nested inside a revived
1075
+ // intrinsic: the serializer's generic walker already recognizes that plain
1076
+ // envelope structurally (see ../serializer-walker.ts) with no revival
1077
+ // needed. But an intrinsic's OWN implementation (e.g. `SubIntrinsic`) needs
1078
+ // a genuine `AttrRef` instance internally (`instanceof` checks), which would
1079
+ // require wiring a live `WeakRef` to the sibling entity — out of scope here,
1080
+ // same call as the existing "nested `new Type(...)` as a value" rejection
1081
+ // a few lines up: reject (fall back to run) rather than risk silently wrong
1082
+ // output.
1083
+ // ─────────────────────────────────────────────────────────────────────────
1084
+
1085
+ /** Resolve a bare name bound by this file's own `import` to its real, live export — the same two-step (resolve module path, then `importModule`) `resolveResourceEntity`/`resolveCallExpression` already use for constructors and composite factories. */
1086
+ async function resolveImportedExport(name: string, ctx: ResolveCtx): Promise<unknown> {
1087
+ const binding = ctx.imports.get(name);
1088
+ if (!binding) {
1089
+ throw cheapError(`"${name}" is not a resolvable import`);
1090
+ }
1091
+
1092
+ let modulePath: string;
1093
+ try {
1094
+ modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1095
+ } catch (err) {
1096
+ throw cheapError(
1097
+ `could not resolve import "${binding.specifier}" for "${name}": ${err instanceof Error ? err.message : String(err)}`,
1098
+ );
1099
+ }
1100
+
1101
+ let mod: Record<string, unknown>;
1102
+ try {
1103
+ mod = await importModuleMemoized(modulePath, ctx.importCache);
1104
+ } catch (err) {
1105
+ throw cheapError(
1106
+ `could not import "${binding.specifier}" to resolve "${name}": ${err instanceof Error ? err.message : String(err)}`,
1107
+ );
1108
+ }
1109
+
1110
+ return mod[binding.imported];
1111
+ }
1112
+
1113
+ /** A bare identifier, or simple dotted access chain rooted at one (`AWS.StackName`, `Azure.ResourceGroupName`) — the only shape {@link fold.ts}'s `isUnresolvedSymbolChain` actually produces `SymbolicValue` text for in practice (element access/non-null on a pseudo-parameter-style namespace isn't a real authoring pattern anywhere in a lexicon today). Anything else is rejected rather than guessed at. */
1114
+ const SIMPLE_DOTTED_CHAIN = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
1115
+
1116
+ /** Resolve a folded `SymbolicValue`'s raw source text (e.g. `"AWS.StackName"`) back to the real value it refers to, by resolving its root identifier through this file's own imports and then doing real property access down the rest of the chain — reproducing exactly what evaluating that expression at runtime would have done. */
1117
+ async function resolveSymbolicValue(text: string, ctx: ResolveCtx): Promise<unknown> {
1118
+ if (!SIMPLE_DOTTED_CHAIN.test(text)) {
1119
+ throw cheapError(`symbol "${text}" is not a simple dotted import reference`);
1120
+ }
1121
+ const [root, ...path] = text.split(".");
1122
+ let value = await resolveImportedExport(root, ctx);
1123
+ for (const key of path) {
1124
+ if (value === null || value === undefined) {
1125
+ throw cheapError(`symbol "${text}": "${root}" has no "${key}"`);
1126
+ }
1127
+ value = (value as Record<string, unknown>)[key];
1128
+ }
1129
+ return value;
1130
+ }
1131
+
1132
+ /**
1133
+ * Revive a folded value tree: replace any
1134
+ * `{__intrinsic}`/`{__helper}`/`{__symbol}` envelope with the real value it
1135
+ * represents.
1136
+ *
1137
+ * `requireLiveRefs` tracks whether the CURRENT node is (transitively) an
1138
+ * argument being handed to a real function that will inspect it — a
1139
+ * `{__intrinsic}`'s own interpolated `values`, or a `{__helper}` call's
1140
+ * arguments (chant #1082). In that position a symbolic `{__attrRef}` envelope
1141
+ * is rejected rather than passed along: the receiving implementation needs a
1142
+ * genuine `AttrRef` instance (`instanceof` checks, `WeakRef` derefs — see
1143
+ * `SubIntrinsic`, and `LexiconOutput`'s constructor in ../lexicon-output.ts),
1144
+ * and handing it a look-alike plain object produces output that is wrong
1145
+ * rather than absent. Everywhere else the envelope is left untouched, because
1146
+ * the serializer's generic walker already understands it — see the module-doc
1147
+ * note above.
1148
+ */
1149
+ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiveRefs: boolean): Promise<unknown> {
1150
+ if (value === null || typeof value !== "object") return value;
1151
+
1152
+ // chant #1020 — a REAL, already-constructed live object reached via
1153
+ // cross-file resolution (`ctx.externals`, e.g. `network.vpc.VpcId` folding
1154
+ // to a genuine `AttrRef` wired to the actual shared `vpc` instance, or a
1155
+ // bare cross-file identifier folding directly to the shared
1156
+ // Declarable/CompositeInstance itself). None of these have an own
1157
+ // `__symbol`/`__intrinsic`/`__attrRef`/`__resource` marker KEY — an AttrRef
1158
+ // instance's own enumerable fields are `parent`/`attribute`, not a nested
1159
+ // `__attrRef` object — so without this early return the generic object
1160
+ // walk below would silently reconstruct a plain-object copy of it,
1161
+ // destroying the very identity #1020 exists to preserve. Passed through
1162
+ // completely unchanged, exactly like `resolveCallExpression`'s own
1163
+ // `live.value` passthrough for a composite-call argument.
1164
+ if (isAttrRefLike(value) || isDeclarable(value) || isCompositeInstance(value)) {
1165
+ return value;
1166
+ }
1167
+
1168
+ if (Array.isArray(value)) {
1169
+ const revived: unknown[] = [];
1170
+ for (const el of value) revived.push(await reviveFoldedValue(el, ctx, requireLiveRefs));
1171
+ return revived;
1172
+ }
1173
+
1174
+ if ("__symbol" in value) {
1175
+ const symbolic = value as SymbolicValue;
1176
+ return resolveSymbolicValue(symbolic.__symbol, ctx);
1177
+ }
1178
+
1179
+ if ("__intrinsic" in value) {
1180
+ const intrinsic = value as FoldedIntrinsic;
1181
+ const Fn = await resolveImportedExport(intrinsic.__intrinsic, ctx);
1182
+ if (typeof Fn !== "function") {
1183
+ throw cheapError(`intrinsic "${intrinsic.__intrinsic}" did not resolve to a function`);
1184
+ }
1185
+ // Two authored forms, one envelope family (../fold/fold.ts's
1186
+ // `FoldedIntrinsic`): the tagged template replays as
1187
+ // `Name(strings, ...values)`, the plain call (chant #1044) as
1188
+ // `Name(...args)`. Both hand their interior to the REAL function the
1189
+ // file itself imported, with `requireLiveRefs` — an intrinsic inspects
1190
+ // what it is given (`SubIntrinsic`'s `instanceof` checks, `Ref`'s
1191
+ // `getLogicalName`), so a look-alike `{__attrRef}` envelope must be
1192
+ // rejected here rather than silently serialized as something else.
1193
+ const revived: unknown[] = [];
1194
+ if ("args" in intrinsic) {
1195
+ for (const a of intrinsic.args) revived.push(await reviveFoldedValue(a, ctx, true));
1196
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...revived);
1197
+ }
1198
+ for (const v of intrinsic.values) revived.push(await reviveFoldedValue(v, ctx, true));
1199
+ return (Fn as (...fnArgs: unknown[]) => unknown)(intrinsic.strings, ...revived);
1200
+ }
1201
+
1202
+ if ("__helper" in value) {
1203
+ return reviveHelperCall(value as FoldedHelperCall, ctx);
1204
+ }
1205
+
1206
+ if ("__attrRef" in value) {
1207
+ if (requireLiveRefs) {
1208
+ throw cheapError(
1209
+ "a same-file resource reference passed to a folded intrinsic or authoring helper is not foldable yet",
1210
+ );
1211
+ }
1212
+ return value;
1213
+ }
1214
+
1215
+ if ("__resource" in value) {
1216
+ // fold() itself already rejects a nested `new Type(...)` as a value
1217
+ // (see its own comment) — this is defensive, not a reachable path today.
1218
+ throw cheapError("nested resource as a value is not foldable");
1219
+ }
1220
+
1221
+ const revived: Record<string, unknown> = {};
1222
+ for (const [key, v] of Object.entries(value)) {
1223
+ revived[key] = await reviveFoldedValue(v as FoldedValue, ctx, requireLiveRefs);
1224
+ }
1225
+ return revived;
1226
+ }
1227
+
1228
+ /**
1229
+ * chant #1082 — the provenance half of the registered-authoring-helper check
1230
+ * (the shape/name half is `fold()`'s, see ../fold/foldable-helpers.ts's module
1231
+ * doc). Being in the allowlist is not permission to invoke whatever the name
1232
+ * happens to be bound to: the name must be bound by THIS FILE'S OWN `import`,
1233
+ * and that import must actually come from chant. Only then is the real
1234
+ * function called, with the folded arguments — the same function the run path
1235
+ * would have called, from the same module the source itself named, so fold
1236
+ * cannot diverge from run by construction.
1237
+ *
1238
+ * Anything short of that throws, which falls the whole file back to run: a
1239
+ * local `function phase(...)`, a `phase` imported from the project's own
1240
+ * helpers, a chant-owned import that turns out not to be a function.
1241
+ */
1242
+ async function reviveHelperCall(call: FoldedHelperCall, ctx: ResolveCtx): Promise<unknown> {
1243
+ const name = call.__helper;
1244
+ const binding = ctx.imports.get(name);
1245
+ if (!binding) {
1246
+ throw cheapError(`authoring helper "${name}(...)" is not a resolvable import`);
1247
+ }
1248
+ if (!isChantOwnedHelperBinding(binding, ctx)) {
1249
+ throw cheapError(
1250
+ `"${name}" is imported from "${binding.specifier}", which is not chant's own — ` +
1251
+ `only chant's registered authoring helpers fold as calls`,
1252
+ );
1253
+ }
1254
+
1255
+ const Fn = await resolveImportedExport(name, ctx);
1256
+ if (typeof Fn !== "function") {
1257
+ throw cheapError(`authoring helper "${name}" did not resolve to a function`);
1258
+ }
1259
+
1260
+ // `requireLiveRefs` — a helper receives its arguments as real values and may
1261
+ // inspect them (`output()` derefs the ref's `WeakRef` parent), so a symbolic
1262
+ // `{__attrRef}` envelope is rejected here rather than silently wrapped into
1263
+ // a wrong result. A ref that resolved cross-file to a genuine live `AttrRef`
1264
+ // passes straight through (see `reviveFoldedValue`'s early return).
1265
+ const args: unknown[] = [];
1266
+ for (const arg of call.args) args.push(await reviveFoldedValue(arg, ctx, true));
1267
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1268
+ }
1269
+
1270
+ /**
1271
+ * True when `binding` names a helper import chant itself owns: a published
1272
+ * chant package specifier ({@link isChantOwnedSpecifier}), or — for in-repo
1273
+ * and test callers, which import chant-core by relative/absolute path the way
1274
+ * this module's own fixtures do — a specifier that resolves to a file inside
1275
+ * chant-core's own tree.
1276
+ *
1277
+ * The path arm resolves only for a relative/absolute specifier, never a bare
1278
+ * one: resolving an arbitrary bare specifier here would reintroduce the
1279
+ * pathological cold-resolution cost chant#1020 measured (see
1280
+ * {@link fastResolveBareSpecifier}), and a bare specifier chant publishes is
1281
+ * already covered by the text arm.
1282
+ */
1283
+ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
1284
+ if (isChantOwnedSpecifier(binding.specifier)) return true;
1285
+ if (!isProjectFileSpecifier(binding.specifier)) return false;
1286
+ let targetPath: string;
1287
+ try {
1288
+ targetPath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1289
+ } catch {
1290
+ return false;
1291
+ }
1292
+ const root = chantCoreRoot();
1293
+ return targetPath === root || targetPath.startsWith(root + sep);
1294
+ }
1295
+
1296
+ /**
1297
+ * chant-core's own module root — `packages/core/src` in this repo,
1298
+ * `<pkg>/dist` in a published install — derived from THIS module's location.
1299
+ * Pure string arithmetic on `import.meta.url`, no filesystem access at all, so
1300
+ * it is safe in the #1045 sandbox child (which locks reads to an allowlist);
1301
+ * lazy and memoized purely to avoid doing it per call.
1302
+ */
1303
+ let chantCoreRootMemo: string | undefined;
1304
+ function chantCoreRoot(): string {
1305
+ // .../<root>/discovery/fold-import.ts -> .../<root>
1306
+ chantCoreRootMemo ??= dirname(dirname(fileURLToPath(import.meta.url)));
1307
+ return chantCoreRootMemo;
1308
+ }
1309
+
1310
+ /** Revive every value in a folded props/attributes object (see {@link reviveFoldedValue}). */
1311
+ async function reviveFoldedProps(
1312
+ props: { [key: string]: FoldedValue },
1313
+ ctx: ResolveCtx,
1314
+ ): Promise<Record<string, unknown>> {
1315
+ const revived: Record<string, unknown> = {};
1316
+ for (const [key, value] of Object.entries(props)) {
1317
+ revived[key] = await reviveFoldedValue(value, ctx, false);
1318
+ }
1319
+ return revived;
1320
+ }
1321
+
1322
+ // ─────────────────────────────────────────────────────────────────────────
1323
+ // Resource construction — unchanged from #1022 (folds the ctor call's props
1324
+ // via `fold()`, resolves the constructor through this file's imports,
1325
+ // constructs the real Declarable).
1326
+ // ─────────────────────────────────────────────────────────────────────────
1327
+
1328
+ async function resolveResourceEntity(
1329
+ name: string,
1330
+ node: ts.NewExpression,
1331
+ ctx: ResolveCtx,
1332
+ ): Promise<{ ok: true; entity: Declarable } | { ok: false; reason: string }> {
1333
+ let spec: FoldedResource;
1334
+ try {
1335
+ spec = foldResource(node, ctx.consts, ctx.intrinsics, ctx.externals);
1336
+ } catch (err) {
1337
+ if (err instanceof FoldError) {
1338
+ return { ok: false, reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}` };
1339
+ }
1340
+ throw err;
1341
+ }
1342
+
1343
+ // chant #1039 — replay any folded intrinsic/symbol envelopes into their
1344
+ // real runtime values before constructing the entity. A no-op walk when
1345
+ // this file used no registered intrinsics (the overwhelming majority of
1346
+ // cases today).
1347
+ //
1348
+ // chant #1082 — when `spec.args` is present the constructor's argument list
1349
+ // isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter`
1350
+ // is `(type, props)`), so revive the whole list and spread it below instead
1351
+ // of reviving `spec.props`, which in that case is only a view onto one of
1352
+ // its entries and would be double-counted.
1353
+ let ctorArgs: unknown[];
1354
+ try {
1355
+ if (spec.args) {
1356
+ ctorArgs = [];
1357
+ for (const arg of spec.args) ctorArgs.push(await reviveFoldedValue(arg, ctx, false));
1358
+ } else {
1359
+ const props = await reviveFoldedProps(spec.props, ctx);
1360
+ // The runtime constructor's optional second argument (`attributes` —
1361
+ // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
1362
+ // ../runtime.ts) is only present in `spec` when the source actually
1363
+ // passed one (see foldResource in ../fold/fold.ts). Passing `undefined`
1364
+ // when it's absent matches the run path's own default
1365
+ // (`attributes ?? {}` inside the constructor).
1366
+ ctorArgs = [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined];
1367
+ }
1368
+ } catch (err) {
1369
+ return {
1370
+ ok: false,
1371
+ reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}`,
1372
+ };
1373
+ }
1374
+
1375
+ const typeName = spec.__resource;
1376
+ const binding = ctx.imports.get(typeName);
1377
+ if (!binding) {
1378
+ return { ok: false, reason: `constructor "${typeName}" for "${name}" is not a resolvable import` };
1379
+ }
1380
+
1381
+ let modulePath: string;
1382
+ try {
1383
+ modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1384
+ } catch (err) {
1385
+ return {
1386
+ ok: false,
1387
+ reason: `could not resolve import "${binding.specifier}" for "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
1388
+ };
1389
+ }
1390
+
1391
+ // Same import mechanism as `resolveCallExpression` above — see its comment.
1392
+ let mod: Record<string, unknown>;
1393
+ try {
1394
+ mod = await importModuleMemoized(modulePath, ctx.importCache);
1395
+ } catch (err) {
1396
+ return {
1397
+ ok: false,
1398
+ reason: `could not import "${binding.specifier}" to resolve "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
1399
+ };
1400
+ }
1401
+
1402
+ const Ctor = mod[binding.imported];
1403
+ if (typeof Ctor !== "function") {
1404
+ return { ok: false, reason: `"${binding.imported}" from "${binding.specifier}" is not a constructor` };
1405
+ }
1406
+
1407
+ // Constructed with exactly the arguments the source wrote (see the revival
1408
+ // block above for how `ctorArgs` was built for each of the two shapes).
1409
+ const ResourceCtor = Ctor as new (...ctorArguments: unknown[]) => Declarable;
1410
+ const entity = new ResourceCtor(...ctorArgs);
1411
+ return { ok: true, entity };
1412
+ }
1413
+
1414
+ // ─────────────────────────────────────────────────────────────────────────
1415
+ // Cross-file resolution (chant #1020) — resolves an imported PROJECT-file
1416
+ // binding to its real, already-constructed value by recursively folding the
1417
+ // DEFINING module (in that module's own scope: its own consts, its own
1418
+ // imports), memoized per {@link FoldSession} so each cross-file export folds
1419
+ // exactly once and every referrer shares the identical object — see
1420
+ // `planFoldTaint`'s doc below for why that identity is the entire hard part.
1421
+ // A bare package specifier (a lexicon/vendor module) is left alone here: it
1422
+ // resolves through the pre-existing `importModule` mechanism at the point a
1423
+ // constructor/composite-factory/intrinsic tag is actually used, same as
1424
+ // before #1020 — this path exists only to avoid executing PROJECT files.
1425
+ // ─────────────────────────────────────────────────────────────────────────
1426
+
1427
+ const UNRESOLVED_IDENTIFIER_RE = /unresolved identifier: (\S+)$/;
1428
+
1429
+ /**
1430
+ * Enrich an otherwise-generic "unresolved identifier: X" failure when X is a
1431
+ * name whose OWN cross-file resolution was attempted and failed for a known
1432
+ * reason (most notably an import cycle) — purely cosmetic, never changes
1433
+ * whether something resolves. Every other failure shape passes through
1434
+ * unchanged.
1435
+ */
1436
+ function describeFoldFailure(err: unknown, ctx: ResolveCtx): string {
1437
+ if (!(err instanceof Error)) return String(err);
1438
+ const match = UNRESOLVED_IDENTIFIER_RE.exec(err.message);
1439
+ if (match) {
1440
+ const reason = ctx.crossFileFailures.get(match[1]);
1441
+ if (reason) return `${err.message} (${reason})`;
1442
+ }
1443
+ return err.message;
1444
+ }
1445
+
1446
+ /** Build a located `FoldError`'s formatted "line:col - message" string anchored at `node` — for a cross-file failure detected here in fold-import.ts (an import cycle, a name genuinely absent from the target module's exports) rather than inside `fold()` itself. */
1447
+ function locatedMessage(node: ts.Node, message: string): string {
1448
+ const { line, column } = locate(node);
1449
+ return new FoldError(message, line, column).message;
1450
+ }
1451
+
1452
+ /**
1453
+ * Fold `file` with session-wide memoization and cycle detection. The SAME
1454
+ * promise is handed back to every caller that asks for `file` within one
1455
+ * session — `discover()`'s own top-level per-file loop AND any cross-file
1456
+ * reference that reaches into `file` alike — so `file` is folded exactly
1457
+ * once and every referrer shares the identical constructed entities.
1458
+ */
1459
+ /**
1460
+ * A backstop, not a realistic limit: no legitimate project should have an
1461
+ * import chain this deep. Exists purely so a FUTURE regression that breaks
1462
+ * memoization (e.g. caching by the wrong key, or checking `stack` after
1463
+ * awaiting instead of before) fails loudly and immediately with a
1464
+ * diagnosable error, instead of recursing until the process runs out of
1465
+ * stack/memory or simply never terminates — a correctness bug in this exact
1466
+ * function is a termination hazard, not just a wrong-answer one, since
1467
+ * `foldFileMemoized` is the one place cross-file resolution can recurse.
1468
+ */
1469
+ const MAX_RESOLUTION_DEPTH = 200;
1470
+
1471
+ function foldFileMemoized(file: string, session: FoldSession): Promise<FoldFileResult> {
1472
+ const cycleStart = session.stack.indexOf(file);
1473
+ if (cycleStart !== -1) {
1474
+ // A file currently being resolved (an ancestor of this very call, still
1475
+ // on the stack) is needed again — a genuine reference cycle, not merely
1476
+ // two files that happen to import each other with no real value
1477
+ // dependency (that could never re-enter here for an ancestor). The
1478
+ // caller ({@link buildExternals}/the re-export declarator handler, which
1479
+ // has the referencing import's own specifier node) attaches a source
1480
+ // position to this via {@link locatedMessage}; here we only have the
1481
+ // file-level chain to name.
1482
+ const cycle = [...session.stack.slice(cycleStart), file].map((f) => basename(f));
1483
+ return Promise.resolve({ ok: false, reason: `import cycle: ${cycle.join(" -> ")}` });
1484
+ }
1485
+
1486
+ const cached = session.cache.get(file);
1487
+ if (cached) return cached;
1488
+
1489
+ if (session.stack.length >= MAX_RESOLUTION_DEPTH) {
1490
+ const chain = [...session.stack.slice(-5), file].map((f) => basename(f));
1491
+ return Promise.resolve({
1492
+ ok: false,
1493
+ reason:
1494
+ `cross-file resolution depth exceeded ${MAX_RESOLUTION_DEPTH} ` +
1495
+ `(...-> ${chain.join(" -> ")}) — this is almost certainly a resolution ` +
1496
+ `bug (memoization not taking effect), not a genuinely thousand-file-deep import chain`,
1497
+ });
1498
+ }
1499
+
1500
+ session.stack.push(file);
1501
+ const promise = tryFoldFileCore(file, session).finally(() => {
1502
+ const idx = session.stack.lastIndexOf(file);
1503
+ if (idx !== -1) session.stack.splice(idx, 1);
1504
+ });
1505
+ session.cache.set(file, promise);
1506
+ return promise;
1507
+ }
1508
+
1509
+ /** True for a relative or absolute specifier — a sibling PROJECT file, as opposed to a bare package specifier (an installed lexicon/vendor module). */
1510
+ function isProjectFileSpecifier(specifier: string): boolean {
1511
+ return specifier.startsWith(".") || isAbsolute(specifier);
1512
+ }
1513
+
1514
+ /**
1515
+ * Eagerly resolve every project-file import binding of `file`'s to its real
1516
+ * cross-file value (see this section's own doc above). A binding that isn't
1517
+ * a project-file specifier, doesn't resolve to a file on disk, or whose
1518
+ * target doesn't fold or doesn't actually export the requested name is
1519
+ * simply left OUT of `externals` — exactly like a name that was never
1520
+ * imported at all, `fold()`'s ordinary "unresolved identifier" failure still
1521
+ * fires if (and only if) that name is actually referenced. `failures`
1522
+ * records WHY, purely to enrich that later message (see
1523
+ * {@link describeFoldFailure}).
1524
+ */
1525
+ async function buildExternals(
1526
+ file: string,
1527
+ imports: Map<string, ImportBinding>,
1528
+ namespaceImports: Map<string, NamespaceImportBinding>,
1529
+ session: FoldSession,
1530
+ ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string>; liveSources: Set<string> }> {
1531
+ const externals = new Map<string, unknown>();
1532
+ const failures = new Map<string, string>();
1533
+ // chant #1044 — see `FoldFileResult.liveSources`.
1534
+ const liveSources = new Set<string>();
1535
+
1536
+ for (const [localName, binding] of imports) {
1537
+ // chant #1064 — a named `params` import that resolves to chant-core's own
1538
+ // build-time-parameters module (../params.ts) is substituted directly
1539
+ // from this build's already-resolved values, with NO import performed —
1540
+ // so `params.tier` folds to a LITERAL rather than a symbolic node
1541
+ // (contrast the `{__symbol}` deferral a pseudo-parameter namespace import
1542
+ // like `AWS.StackName` gets — that genuinely can't resolve until a real
1543
+ // module runs; a build parameter's value is already fully known here).
1544
+ //
1545
+ // A BARE specifier is recognized by an exact TEXT match against the one
1546
+ // real published subpath ({@link PARAMS_BARE_SPECIFIER}), NEVER by
1547
+ // resolving it: `resolveModulePathMemoized`'s bare-specifier branch falls
1548
+ // through to Node's own package resolution
1549
+ // (`createRequire(fromFile).resolve(specifier)`), which chant#1020's own
1550
+ // fix-history (see this module's other comments) measured at up to ~361s
1551
+ // for the FIRST resolution of a genuinely new bare specifier in a
1552
+ // process — a cost `buildExternals` previously never paid at all for
1553
+ // bare specifiers (the pre-#1064 code skipped them outright). Since
1554
+ // `binding.imported === "params"` alone says nothing about which package
1555
+ // a project actually imported from, resolving EVERY such bare specifier
1556
+ // to check it would reintroduce exactly that pathological cost for any
1557
+ // corpus/project file that happens to import a same-named binding from
1558
+ // an unrelated package — a real, measured regression this text-match
1559
+ // avoids entirely (no filesystem/package resolution for a bare
1560
+ // specifier, ever, in this branch).
1561
+ //
1562
+ // A RELATIVE/ABSOLUTE specifier is still resolved and path-compared
1563
+ // against {@link paramsModulePath} — that resolution is always cheap
1564
+ // (`existsSync`/`statSync` candidate probing, never Node's package
1565
+ // resolution), so it's safe for this module's own absolute-path test
1566
+ // fixtures to exercise the identical substitution a real bare import
1567
+ // takes, without the bare-specifier cost concern applying.
1568
+ if (session.buildParams && binding.imported === "params") {
1569
+ if (binding.specifier === PARAMS_BARE_SPECIFIER) {
1570
+ externals.set(localName, session.buildParams);
1571
+ continue;
1572
+ }
1573
+ if (isProjectFileSpecifier(binding.specifier)) {
1574
+ try {
1575
+ const targetPath = resolveModulePathMemoized(binding.specifier, file, session.resolvePathCache);
1576
+ if (targetPath === paramsModulePath()) {
1577
+ externals.set(localName, session.buildParams);
1578
+ continue;
1579
+ }
1580
+ } catch {
1581
+ // Unresolvable specifier — fall through to the ordinary handling
1582
+ // below, same as any other import this loop can't resolve.
1583
+ }
1584
+ }
1585
+ }
1586
+
1587
+ if (!isProjectFileSpecifier(binding.specifier)) {
1588
+ // Every other bare specifier (a lexicon/vendor package) is left alone
1589
+ // here, exactly as before #1064: it resolves lazily, through the
1590
+ // pre-existing `importModule` mechanism, only once a constructor/
1591
+ // composite-factory/intrinsic tag actually consumes it.
1592
+ continue;
1593
+ }
1594
+ let targetPath: string;
1595
+ try {
1596
+ targetPath = resolveModulePathMemoized(binding.specifier, file, session.resolvePathCache);
1597
+ } catch {
1598
+ continue;
1599
+ }
1600
+ const result = await foldFileMemoized(targetPath, session);
1601
+ if (!result.ok) {
1602
+ failures.set(localName, locatedMessage(binding.specifierNode, result.reason));
1603
+ continue;
1604
+ }
1605
+ if (result.exportedValues.has(binding.imported)) {
1606
+ const value = result.exportedValues.get(binding.imported);
1607
+ externals.set(localName, value);
1608
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
1609
+ } else {
1610
+ failures.set(
1611
+ localName,
1612
+ locatedMessage(binding.specifierNode, `"${binding.imported}" is not exported by "${binding.specifier}"`),
1613
+ );
1614
+ }
1615
+ }
1616
+
1617
+ for (const [localName, binding] of namespaceImports) {
1618
+ if (!isProjectFileSpecifier(binding.specifier)) continue;
1619
+ let targetPath: string;
1620
+ try {
1621
+ targetPath = resolveModulePathMemoized(binding.specifier, file, session.resolvePathCache);
1622
+ } catch {
1623
+ continue;
1624
+ }
1625
+ const result = await foldFileMemoized(targetPath, session);
1626
+ if (!result.ok) {
1627
+ failures.set(localName, locatedMessage(binding.specifierNode, result.reason));
1628
+ continue;
1629
+ }
1630
+ // A plain object wrapping the target's own exported values — property
1631
+ // access on it (`ns.someExport`) is then just an ordinary bracket index,
1632
+ // exactly like on a real composite instance (see `isIndexableObject`).
1633
+ externals.set(localName, Object.fromEntries(result.exportedValues));
1634
+ for (const value of result.exportedValues.values()) {
1635
+ if (hasObjectIdentity(value)) {
1636
+ liveSources.add(targetPath);
1637
+ break;
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ return { externals, failures, liveSources };
1643
+ }
1644
+
1645
+ /**
1646
+ * True when `value` is something whose IDENTITY matters across the
1647
+ * fold/run boundary — any object or function, as opposed to a primitive
1648
+ * (chant #1044). Deliberately coarse: an object that merely *contains* a
1649
+ * `Declarable` is as identity-bearing as the Declarable itself, and cheaply
1650
+ * treating every object as such avoids a deep walk whose only payoff would
1651
+ * be keeping a handful of extra files folded inside an entry that already
1652
+ * falls back. See {@link FoldFileResult.liveSources}.
1653
+ */
1654
+ function hasObjectIdentity(value: unknown): boolean {
1655
+ return value !== null && (typeof value === "object" || typeof value === "function");
1656
+ }
1657
+
1658
+ /**
1659
+ * Resolve a declarator's initializer to its final value: first via the
1660
+ * "live spine" ({@link resolveLiveValue} — same-file composite-call
1661
+ * navigation, and, chant #1020, a cross-file identifier/property-access
1662
+ * chain through `ctx.externals`); when that shape isn't recognized at all
1663
+ * (a plain literal, object/array literal, template, binary expression, …),
1664
+ * via the general reducer ({@link fold} + intrinsic revival) — chant #1020:
1665
+ * this is what lets `export const REGION = "us-east-1";` (and an IMPORTED
1666
+ * REGION used the same way) fold, not just a `new Type(...)`/composite-call
1667
+ * shape. Throws when a shape WAS recognized but resolution genuinely failed
1668
+ * (unresolved import, non-function import, a nested argument's own fold
1669
+ * failed, an unresolved identifier, …) — the caller falls the whole file
1670
+ * back to run, exactly as before #1020.
1671
+ */
1672
+ async function resolveDeclaratorValue(node: ts.Expression, ctx: ResolveCtx): Promise<{ value: unknown }> {
1673
+ const live = await resolveLiveValue(node, ctx);
1674
+ if (live !== undefined) return live;
1675
+ const folded = fold(node, ctx.consts, ctx.intrinsics, ctx.externals);
1676
+ return { value: await reviveFoldedValue(folded, ctx, false) };
1677
+ }
1678
+
1679
+ // ─────────────────────────────────────────────────────────────────────────
1680
+ // Entry point.
1681
+ // ─────────────────────────────────────────────────────────────────────────
1682
+
1683
+ /**
1684
+ * Attempt to fold one source file with zero execution of its own top-level
1685
+ * code, sharing `session`'s cross-file memoization (chant #1020) — see
1686
+ * {@link foldFileMemoized}. Internal; {@link tryFoldFile} is the public
1687
+ * entry point below.
1688
+ */
1689
+ async function tryFoldFileCore(file: string, session: FoldSession): Promise<FoldFileResult> {
1690
+ try {
1691
+ const source = await readFile(file, "utf-8");
1692
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
1693
+
1694
+ const scan = scanExports(sourceFile);
1695
+ if (scan.unfoldableReason) return { ok: false, reason: scan.unfoldableReason };
1696
+ if (scan.declarators.length === 0) return { ok: false, reason: "no foldable resource exports" };
1697
+
1698
+ const collected = collectImports(sourceFile);
1699
+ const { externals, failures, liveSources } = await buildExternals(file, collected.named, collected.namespaces, session);
1700
+
1701
+ const ctx: ResolveCtx = {
1702
+ file,
1703
+ consts: collectConsts(sourceFile),
1704
+ locals: collectLocalBindings(sourceFile),
1705
+ imports: collected.named,
1706
+ namespaceImports: collected.namespaces,
1707
+ memo: new Map(),
1708
+ intrinsics: session.intrinsics,
1709
+ externals,
1710
+ crossFileFailures: failures,
1711
+ importCache: session.importCache,
1712
+ resolvePathCache: session.resolvePathCache,
1713
+ };
1714
+
1715
+ const entities: FoldedEntity[] = [];
1716
+ const exportedValues = new Map<string, unknown>();
1717
+
1718
+ for (const decl of scan.declarators) {
1719
+ if (decl.kind === "resource") {
1720
+ const result = await resolveResourceEntity(decl.name, decl.node, ctx);
1721
+ if (!result.ok) return result;
1722
+ applyResolvedValue(decl.name, result.entity, entities, exportedValues);
1723
+ continue;
1724
+ }
1725
+
1726
+ if (decl.kind === "single") {
1727
+ let value: unknown;
1728
+ try {
1729
+ value = (await resolveDeclaratorValue(decl.node, ctx)).value;
1730
+ } catch (err) {
1731
+ return { ok: false, reason: `"${decl.name}" is not foldable: ${describeFoldFailure(err, ctx)}` };
1732
+ }
1733
+ applyResolvedValue(decl.name, value, entities, exportedValues);
1734
+ continue;
1735
+ }
1736
+
1737
+ if (decl.kind === "destructure") {
1738
+ let value: unknown;
1739
+ try {
1740
+ value = (await resolveDeclaratorValue(decl.node, ctx)).value;
1741
+ } catch (err) {
1742
+ return {
1743
+ ok: false,
1744
+ reason: `destructured export from "${decl.node.getText()}" is not foldable: ${describeFoldFailure(err, ctx)}`,
1745
+ };
1746
+ }
1747
+ if (!isIndexableObject(value)) {
1748
+ return {
1749
+ ok: false,
1750
+ reason: `destructured export from "${decl.node.getText()}" is not foldable (not a composite call or object)`,
1751
+ };
1752
+ }
1753
+ for (const { propKey, bindingName } of decl.elements) {
1754
+ applyResolvedValue(bindingName, value[propKey], entities, exportedValues);
1755
+ }
1756
+ continue;
1757
+ }
1758
+
1759
+ if (decl.kind === "named-export") {
1760
+ // Reuses resolveDeclaratorValue's own identifier handling
1761
+ // (ctx.locals/ctx.externals lookup + memoized destructured-member
1762
+ // extraction) rather than duplicating it here.
1763
+ for (const { localNameNode, exportedName } of decl.elements) {
1764
+ let value: unknown;
1765
+ try {
1766
+ value = (await resolveDeclaratorValue(localNameNode, ctx)).value;
1767
+ } catch (err) {
1768
+ return { ok: false, reason: `exported "${exportedName}" is not foldable: ${describeFoldFailure(err, ctx)}` };
1769
+ }
1770
+ applyResolvedValue(exportedName, value, entities, exportedValues);
1771
+ }
1772
+ continue;
1773
+ }
1774
+
1775
+ // decl.kind === "re-export" (chant #1020) — resolve each element
1776
+ // through the target module's OWN fold, memoized via the session
1777
+ // exactly like any other cross-file reference (see `foldFileMemoized`
1778
+ // above); a cycle surfaces here too since a re-export is just another
1779
+ // edge in the same module graph.
1780
+ let targetPath: string;
1781
+ try {
1782
+ targetPath = resolveModulePathMemoized(decl.specifier, file, session.resolvePathCache);
1783
+ } catch (err) {
1784
+ return {
1785
+ ok: false,
1786
+ reason: `could not resolve re-export "${decl.specifier}": ${err instanceof Error ? err.message : String(err)}`,
1787
+ };
1788
+ }
1789
+ const result = await foldFileMemoized(targetPath, session);
1790
+ if (!result.ok) {
1791
+ return {
1792
+ ok: false,
1793
+ reason: `re-export from "${decl.specifier}" is not foldable: ${locatedMessage(decl.specifierNode, result.reason)}`,
1794
+ };
1795
+ }
1796
+ for (const { imported, exportedName } of decl.elements) {
1797
+ if (!result.exportedValues.has(imported)) {
1798
+ return {
1799
+ ok: false,
1800
+ reason: locatedMessage(decl.specifierNode, `"${imported}" is not exported by "${decl.specifier}"`),
1801
+ };
1802
+ }
1803
+ const value = result.exportedValues.get(imported);
1804
+ // chant #1044 — a re-export hands another file's OBJECT straight
1805
+ // through under this file's name, so it is a live-identity edge
1806
+ // exactly like an imported binding is (see `liveSources`).
1807
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
1808
+ applyResolvedValue(exportedName, value, entities, exportedValues);
1809
+ }
1810
+ }
1811
+
1812
+ return { ok: true, entities, exportedValues, liveSources };
1813
+ } catch (err) {
1814
+ // Any unexpected failure degrades to "fall back to run" rather than
1815
+ // taking discovery down with it — fold is opt-in, not a new failure mode.
1816
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
1817
+ }
1818
+ }
1819
+
1820
+ /**
1821
+ * Attempt to fold one source file with zero execution of its own top-level
1822
+ * code. Returns the folded, instantiated entities on success, or a reason
1823
+ * to fall back to the run path (`importModule`) on the first construct
1824
+ * outside the fold subset.
1825
+ *
1826
+ * @param intrinsics - Lexicon-registered intrinsic tags (chant #1039), e.g.
1827
+ * AWS's `Sub`. Threaded down to {@link fold}/{@link foldResource} so a
1828
+ * registered tagged template folds instead of unconditionally throwing
1829
+ * "unregistered tagged template intrinsic". Defaults to none — the caller
1830
+ * (`discover()`, ultimately `chant build --fold`) is expected to pass the
1831
+ * target lexicons' combined `intrinsics()`. Ignored when `session` is
1832
+ * given (its own `intrinsics`, fixed at creation, apply instead).
1833
+ * @param session - chant #1020: share ONE {@link FoldSession} across every
1834
+ * file in a build (as `discover()` does) so a project file imported by
1835
+ * several others folds exactly once and every referrer shares the same
1836
+ * constructed entities — see {@link FoldSession}'s doc. Omit for a
1837
+ * standalone, single-file fold attempt (creates a private session scoped
1838
+ * to just this call — a cross-file reference reachable from `file` still
1839
+ * resolves, just without sharing its cache with any other top-level call).
1840
+ */
1841
+ export async function tryFoldFile(
1842
+ file: string,
1843
+ intrinsics: readonly IntrinsicDef[] = [],
1844
+ session?: FoldSession,
1845
+ ): Promise<FoldFileResult> {
1846
+ return foldFileMemoized(file, session ?? createFoldSession(intrinsics));
1847
+ }
1848
+
1849
+ // ─────────────────────────────────────────────────────────────────────────
1850
+ // Cross-file fold/run identity hazard (#1023).
1851
+ // ─────────────────────────────────────────────────────────────────────────
1852
+
1853
+ /**
1854
+ * A file that folds successfully in isolation must still be forced back to
1855
+ * run if some OTHER discovered file — one that itself falls back to run —
1856
+ * imports it (directly, or transitively through another sibling file it
1857
+ * imports). Composite folding is what makes this reachable: `network.ts`
1858
+ * exporting `export const network = VpcDefault({})` folds cleanly on its
1859
+ * own (no cross-file reference needed), landing its Declarables (`vpc`, its
1860
+ * subnets, …) in `entities` as ONE set of real objects, built by literally
1861
+ * invoking `VpcDefault` from inside `tryFoldFile` — bypassing `network.ts`
1862
+ * as a module entirely (that's the whole point of folding it).
1863
+ *
1864
+ * Before chant #1020, a sibling file like `alb.ts` that did
1865
+ * `import { network } from "./network"; ... vpcId: network.vpc.VpcId`
1866
+ * could never fold that cross-file reference and fell back to run — but
1867
+ * running `alb.ts` for real re-executes `import "./network"` for real too,
1868
+ * which (via Node's module cache) produces the SAME `network.ts` module
1869
+ * instance for every OTHER run-fallback file that imports it, but a
1870
+ * DIFFERENT one than the object `tryFoldFile("network.ts")` already built.
1871
+ * `alb.ts`'s AttrRef for `network.vpc.VpcId` then points at an object
1872
+ * that's never in the `entities` map (only the folded one is), so it can
1873
+ * never be assigned a logical name and serialization fails outright — not
1874
+ * drift, a crash.
1875
+ *
1876
+ * The fix: fold and run must never disagree about which object identity a
1877
+ * given file's exports have. Since `network.ts` itself doesn't need
1878
+ * anything cross-file to fold (only a file's own successful fold could ever
1879
+ * reach this taint — an unresolvable cross-file reference already fails
1880
+ * that file's OWN fold attempt), the safe rule is to force `network.ts`
1881
+ * back to run too, so both `alb.ts`'s real import and `network.ts`'s own
1882
+ * discovery entry resolve through the exact same `importModule` call and
1883
+ * share the exact same singleton module instance. This has to propagate
1884
+ * transitively (if `alb.ts` itself is only reachable by importing a file
1885
+ * that imports `network.ts`), so this is a forward-reachability walk over
1886
+ * the discovered files' relative-import graph, seeded from every file that
1887
+ * doesn't fold on its own.
1888
+ *
1889
+ * chant #1044 adds the OTHER half of the same hazard, in the opposite
1890
+ * direction along the same edges. Forward taint covers "a run file imports a
1891
+ * folded file"; it does not cover "a FOLDED file consumed the objects of a
1892
+ * file that later got forced to run". Once a plain-call intrinsic can fold,
1893
+ * that second case is easy to reach: in `lexicons/aws/examples/lambda-api`,
1894
+ * `health-api.ts` folds and captures `params.ts`'s real `Parameter` instance
1895
+ * through `Ref(environment)`, while `params.ts` itself is forced to run
1896
+ * because a DIFFERENT sibling (`data-bucket.ts`) imports it and falls back.
1897
+ * Discovery then collects the run instance and serializes the folded one —
1898
+ * the same "Logical name not set" crash described above, arriving from the
1899
+ * other side. So `liveSources` (see {@link FoldFileResult}) contributes
1900
+ * reverse edges here: a tainted file taints every folded file that captured
1901
+ * one of its objects. Only object identity propagates — a file that imported
1902
+ * a plain string from a tainted file has nothing to disagree about.
1903
+ *
1904
+ * chant #1020 changes the calculus but not this function: `alb.ts` can now
1905
+ * often fold `network.vpc.VpcId` too (see `buildExternals`/`foldFileMemoized`
1906
+ * above), by reusing THE EXACT SAME `tryFoldFile("network.ts")` call (memoized
1907
+ * per `FoldSession`) that `discover()`'s own per-file loop also uses — so
1908
+ * `alb.ts` and `network.ts` share one real `vpc` object without ever
1909
+ * disagreeing. This invariant is still needed for whatever STILL falls back
1910
+ * after #1020 (a call-as-a-value construct, #1044; a shape #1020 doesn't
1911
+ * cover): the edge collection below is unconditional — it doesn't care
1912
+ * whether an edge happens to ALSO be used for cross-file value resolution —
1913
+ * so the exact same forced-taint safety net still applies to that remaining
1914
+ * boundary, unchanged.
1915
+ */
1916
+ export async function planFoldTaint(
1917
+ files: readonly string[],
1918
+ wouldFold: ReadonlyMap<string, boolean>,
1919
+ liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
1920
+ ): Promise<Set<string>> {
1921
+ const fileSet = new Set(files);
1922
+
1923
+ // file -> set of OTHER discovered files it relatively imports OR re-exports
1924
+ // from, regardless of that file's own fold outcome (taint needs the full
1925
+ // edge set to propagate transitively). chant #1020 — this now ALSO counts
1926
+ // a namespace import (`import * as ns from "./x"`, previously skipped —
1927
+ // see `collectImports`) and a re-export (`export { x } from "./y"`, which
1928
+ // `collectImports` never saw at all, since it only looks at `import`
1929
+ // declarations): both are real cross-file value dependencies my own new
1930
+ // resolution follows, so both need the same identity guarantee an
1931
+ // ordinary named import already got.
1932
+ const edges = new Map<string, Set<string>>();
1933
+ for (const file of files) {
1934
+ const targets = new Set<string>();
1935
+ const addTarget = (specifier: string): void => {
1936
+ if (!isProjectFileSpecifier(specifier)) return; // package import, not a sibling source file
1937
+ let resolved: string;
1938
+ try {
1939
+ resolved = resolveModulePath(specifier, file);
1940
+ } catch {
1941
+ return;
1942
+ }
1943
+ if (fileSet.has(resolved)) targets.add(resolved);
1944
+ };
1945
+ try {
1946
+ const source = await readFile(file, "utf-8");
1947
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
1948
+ const collected = collectImports(sourceFile);
1949
+ for (const binding of collected.named.values()) addTarget(binding.specifier);
1950
+ for (const binding of collected.namespaces.values()) addTarget(binding.specifier);
1951
+ for (const statement of sourceFile.statements) {
1952
+ if (
1953
+ ts.isExportDeclaration(statement) &&
1954
+ !statement.isTypeOnly &&
1955
+ statement.moduleSpecifier &&
1956
+ ts.isStringLiteral(statement.moduleSpecifier)
1957
+ ) {
1958
+ addTarget(statement.moduleSpecifier.text);
1959
+ }
1960
+ }
1961
+ } catch {
1962
+ // Unreadable/unparseable — no edges contributed; tryFoldFile's own
1963
+ // top-level catch already turns this into a "run" decision for the
1964
+ // file itself, which is enough to seed it as tainted below.
1965
+ }
1966
+ edges.set(file, targets);
1967
+ }
1968
+
1969
+ // chant #1044 — reverse edges: consumed-file -> the folded files that
1970
+ // captured its objects. Same taint set, same fixpoint walk; see this
1971
+ // function's doc for the crash this closes.
1972
+ for (const [consumer, sources] of liveSources ?? []) {
1973
+ if (!fileSet.has(consumer)) continue;
1974
+ for (const source of sources) {
1975
+ if (!fileSet.has(source)) continue;
1976
+ let back = edges.get(source);
1977
+ if (!back) {
1978
+ back = new Set<string>();
1979
+ edges.set(source, back);
1980
+ }
1981
+ back.add(consumer);
1982
+ }
1983
+ }
1984
+
1985
+ const tainted = new Set<string>(files.filter((f) => wouldFold.get(f) !== true));
1986
+ const queue = [...tainted];
1987
+ while (queue.length > 0) {
1988
+ const current = queue.shift()!;
1989
+ for (const target of edges.get(current) ?? []) {
1990
+ if (!tainted.has(target)) {
1991
+ tainted.add(target);
1992
+ queue.push(target);
1993
+ }
1994
+ }
1995
+ }
1996
+
1997
+ return tainted;
1998
+ }