@intentius/chant 0.19.0 → 0.20.0

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