@intentius/chant 0.19.1 → 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.
- package/dist/build-params.d.ts +108 -0
- package/dist/build-params.d.ts.map +1 -0
- package/dist/build.d.ts +79 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +31 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts +54 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +27 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +16 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +10 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +12 -5
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts +62 -7
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts +12 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -0
- package/dist/components/sandbox/run.d.ts +42 -0
- package/dist/components/sandbox/run.d.ts.map +1 -0
- package/dist/composite.d.ts +5 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/config.d.ts +71 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +166 -0
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
- package/dist/discovery/entity-wire.d.ts +50 -0
- package/dist/discovery/entity-wire.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +210 -0
- package/dist/discovery/fold-import.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +74 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.d.ts +18 -0
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
- package/dist/discovery/sandbox/child-errors.d.ts +15 -0
- package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +13 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +69 -0
- package/dist/discovery/sandbox/run.d.ts.map +1 -0
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +226 -0
- package/dist/fold/fold.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +102 -0
- package/dist/fold/subset.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +7 -2
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -2
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +81 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +11 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +7 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
- package/dist/params.d.ts +60 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/provenance.d.ts +21 -0
- package/dist/provenance.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/build-params.test.ts +144 -0
- package/src/build-params.ts +207 -0
- package/src/build.test.ts +38 -0
- package/src/build.ts +144 -7
- package/src/cli/commands/build.test.ts +220 -2
- package/src/cli/commands/build.ts +111 -3
- package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
- package/src/cli/commands/check-lexicon-examples.ts +103 -0
- package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
- package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
- package/src/cli/commands/check-lexicon.test.ts +34 -0
- package/src/cli/commands/check-lexicon.ts +119 -1
- package/src/cli/commands/lint.ts +31 -3
- package/src/cli/commands/onboard.ts +1 -1
- package/src/cli/component-security-boundary.test.ts +170 -0
- package/src/cli/handlers/build.ts +24 -3
- package/src/cli/handlers/components.ts +9 -2
- package/src/cli/handlers/dev.ts +1 -1
- package/src/cli/handlers/graph.ts +7 -5
- package/src/cli/handlers/lifecycle.ts +2 -2
- package/src/cli/handlers/lint.ts +2 -0
- package/src/cli/handlers/misc.ts +2 -2
- package/src/cli/handlers/run-client.ts +1 -1
- package/src/cli/handlers/run.ts +20 -5
- package/src/cli/main.test.ts +22 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +20 -4
- package/src/cli/registry.ts +10 -0
- package/src/cli/security-boundary.test.ts +135 -0
- package/src/codegen/docs-sections.test.ts +61 -0
- package/src/codegen/docs-sections.ts +7 -3
- package/src/components/cli-support.ts +22 -10
- package/src/components/discover.ts +127 -25
- package/src/components/sandbox/driver.ts +114 -0
- package/src/components/sandbox/run.test.ts +185 -0
- package/src/components/sandbox/run.ts +177 -0
- package/src/composite.test.ts +21 -0
- package/src/composite.ts +20 -1
- package/src/config.ts +81 -0
- package/src/discovery/collect.ts +17 -3
- package/src/discovery/entity-wire-codec.ts +485 -0
- package/src/discovery/entity-wire.test.ts +240 -0
- package/src/discovery/entity-wire.ts +67 -0
- package/src/discovery/fold-import.test.ts +1026 -0
- package/src/discovery/fold-import.ts +1805 -0
- package/src/discovery/index.test.ts +191 -1
- package/src/discovery/index.ts +233 -1
- package/src/discovery/sandbox/bundle.ts +218 -0
- package/src/discovery/sandbox/child-errors.ts +65 -0
- package/src/discovery/sandbox/driver.ts +147 -0
- package/src/discovery/sandbox/run.test.ts +179 -0
- package/src/discovery/sandbox/run.ts +196 -0
- package/src/errors.ts +9 -1
- package/src/fold/fold.test.ts +535 -0
- package/src/fold/fold.ts +648 -0
- package/src/fold/subset-doc-parity.test.ts +183 -0
- package/src/fold/subset.test.ts +241 -0
- package/src/fold/subset.ts +302 -0
- package/src/index.ts +2 -0
- package/src/lexicon-output.ts +7 -2
- package/src/lexicon-schema.test.ts +14 -0
- package/src/lexicon-schema.ts +3 -1
- package/src/lexicon.ts +82 -1
- package/src/lifecycle/git.test.ts +10 -5
- package/src/lifecycle/release-ledger.test.ts +28 -0
- package/src/lifecycle/release-ledger.ts +11 -0
- package/src/lint/component-checks.ts +8 -1
- package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
- package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
- package/src/params.test.ts +22 -0
- package/src/params.ts +66 -0
- package/src/provenance.ts +22 -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
|
+
}
|