@intentius/chant 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -5,13 +5,14 @@ import { dirname, basename, join, sep, isAbsolute, resolve as resolvePath } from
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createRequire } from "node:module";
7
7
  import { isDeclarable, type Declarable } from "../declarable";
8
- import { isCompositeInstance, type CompositeInstance } from "../composite";
8
+ import { Composite, isCompositeInstance, type CompositeInstance, type CompositeMembers } from "../composite";
9
9
  import { isAttrRefLike } from "../utils";
10
10
  import { isIntrinsic } from "../intrinsic";
11
11
  import {
12
12
  collectConsts,
13
13
  foldResource,
14
14
  fold,
15
+ propName,
15
16
  FoldError,
16
17
  locate,
17
18
  type FoldedResource,
@@ -21,7 +22,18 @@ import {
21
22
  type SymbolicValue,
22
23
  } from "../fold/fold";
23
24
  import { isChantOwnedSpecifier, isFoldableHelperName } from "../fold/foldable-helpers";
24
- import { briefNodeText, callExpressionMessage } from "../fold/subset";
25
+ import {
26
+ briefNodeText,
27
+ callExpressionMessage,
28
+ computedPropertyNameMessage,
29
+ isLiteralElementKey,
30
+ unsupportedBinaryMessage,
31
+ unsupportedExpressionMessage,
32
+ UNSUPPORTED_OBJECT_MEMBER_MESSAGE,
33
+ UNSUPPORTED_UNARY_MESSAGE,
34
+ SUPPORTED_BINARY_OPERATORS,
35
+ SUPPORTED_UNARY_OPERATORS,
36
+ } from "../fold/subset";
25
37
  import { importModule } from "./import";
26
38
  import type { IntrinsicDef } from "../lexicon";
27
39
  import type { BuildParamValue } from "../build-params";
@@ -44,28 +56,37 @@ import type { BuildParamValue } from "../build-params";
44
56
  * class/function; the only thing skipped here is executing the file's *own*
45
57
  * statements.
46
58
  *
47
- * chant #1023 (epic #1019 Phase 5) extends this from leaf resources
48
- * (`new Type(...)`) to composite factory calls — `SomeComposite({...})`,
59
+ * chant #1022 extends this from leaf resources (`new Type(...)`) to composite
60
+ * factory calls — `SomeComposite({...})`,
49
61
  * `propagate(SomeComposite({...}), {...})`, member access on the result
50
62
  * (`web.deployment`), and destructuring (`const { a, b } = SomeComposite(...)`
51
63
  * or `export const { a, b } = SomeComposite(...)`). A composite factory is a
52
64
  * pure function of its props (EVL009/EVL010 guarantee its body only
53
65
  * references props, sibling members, and imports), so — exactly like a
54
- * resource constructor — it's safe to resolve through the file's imports and
55
- * actually invoke with statically-folded props: no need to pre-verify "is
56
- * this specifically a registered composite" via a shared registry (which
57
- * would be unreliable across separately-loaded module instances of
58
- * `@intentius/chant` anyway) {@link resolveCallExpression} just resolves,
59
- * invokes, and lets the RESULT speak. If it satisfies
60
- * {@link isCompositeInstance} (or, for a plain resource-returning helper,
61
- * {@link isDeclarable}), it's used. Nested composites and `propagate()`'d
62
- * shared props need no special-casing: a nested composite is just another
63
- * member the real factory call already produced (real JS execution inside a
64
- * trusted module), and `propagate` is just another resolvable imported
65
- * function that receives a live `CompositeInstance` plus folded shared props
66
- * and returns it `expandComposite()` (invoked downstream by
67
- * `collectEntities`, unchanged) does the recursive expansion and the shared-
68
- * prop merge exactly as it does for the run path.
66
+ * resource constructor — it is resolved through the file's imports and
67
+ * INVOKED with statically-folded props, and the RESULT is what matters: if it
68
+ * satisfies {@link isCompositeInstance} (or, for a plain resource-returning
69
+ * helper, {@link isDeclarable}), it's used. Nested composites and
70
+ * `propagate()`'d shared props need no special-casing there: a nested
71
+ * composite is just another member the real factory call already produced,
72
+ * and `propagate` is just another resolvable imported function that receives a
73
+ * live `CompositeInstance` plus folded shared props and returns it —
74
+ * `expandComposite()` (invoked downstream by `collectEntities`, unchanged)
75
+ * does the recursive expansion and the shared-prop merge exactly as it does
76
+ * for the run path.
77
+ *
78
+ * chant #1023 (epic #1019 Phase 5) removes that invocation where it can. When
79
+ * the callee is a composite defined in a PROJECT file as
80
+ * `Composite(<fn>, "<name>")` and `<fn>`'s body stays inside a closed,
81
+ * documented subset, its body is INTERPRETED instead — the defining module is
82
+ * never imported, and the members are built here, by the lexicon's own
83
+ * constructors, from the same folded props. That closes the last place a file
84
+ * reported as `[fold:fold]` could still execute project-authored code in the
85
+ * CLI's process (#1093), and lets such a file fold under `--sandbox` rather
86
+ * than being demoted to the child (#1111). A factory outside the subset keeps
87
+ * invoking, unchanged. See the contract block above
88
+ * {@link resolveInterpretableFactory} for the five admissibility rules and
89
+ * what interpretation preserves.
69
90
  */
70
91
 
71
92
  /** One exported `const` name folded to a real, constructed `Declarable` or `CompositeInstance`. */
@@ -217,6 +238,25 @@ export interface FoldSession {
217
238
  * only the fold half would buy nothing there.
218
239
  */
219
240
  readonly sandbox: boolean;
241
+ /**
242
+ * chant #1023 — per-build memo for {@link readFactoryModule}, keyed by the
243
+ * resolved absolute path of a module that DEFINES a composite. A composite
244
+ * defined once and called from a dozen sibling files is parsed, and its
245
+ * imports resolved, exactly once per build — the same reason
246
+ * {@link FoldSession.cache} exists for the files discovery folds directly.
247
+ *
248
+ * Separate from `cache` because the two ask different questions of the same
249
+ * file: `cache` asks "what are this module's exported VALUES" (and fails
250
+ * outright for a module that exports a function declaration, which a
251
+ * composite-defining module very often does); this asks "what is this
252
+ * module's static SCOPE" — its consts, its imports, and those imports'
253
+ * already-resolved values — which is well-defined even when the module as a
254
+ * whole doesn't fold. `lexicons/aws/examples/lambda-api/src/lambda-api.ts`
255
+ * is exactly that case: it exports two plain functions alongside its
256
+ * `Composite`, so it never folds, and its `LambdaApi` is interpretable
257
+ * regardless.
258
+ */
259
+ readonly factoryModules: Map<string, Promise<FactoryModuleScope | undefined>>;
220
260
  }
221
261
 
222
262
  /**
@@ -256,6 +296,7 @@ export function createFoldSession(
256
296
  buildParams,
257
297
  lexiconPackages: new Set(lexicons.map(lexiconPackageName)),
258
298
  sandbox,
299
+ factoryModules: new Map(),
259
300
  };
260
301
  }
261
302
 
@@ -392,6 +433,60 @@ function hasExportModifier(node: { modifiers?: ts.NodeArray<ts.ModifierLike> }):
392
433
  return node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
393
434
  }
394
435
 
436
+ // ─────────────────────────────────────────────────────────────────────────
437
+ // Execution accounting (chant #1023).
438
+ // ─────────────────────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * What the fold pass actually EXECUTED, and what it interpreted instead — the
442
+ * number chant #1023 is measured by, and the one the #1093/#1111
443
+ * execution-boundary report could not state before: `--sandbox` proves
444
+ * *nothing project-owned ran in this process*, but plain `--fold` still
445
+ * invoked composite factories in-process, and nothing counted them.
446
+ *
447
+ * Purely observational. Two integer increments on paths that were already
448
+ * about to perform a dynamic `import()` or parse a module, so it costs
449
+ * nothing measurable and changes no decision.
450
+ */
451
+ export interface FoldExecutionCounts {
452
+ /**
453
+ * Composite-factory / wrapper calls {@link resolveCallExpression} resolved
454
+ * by importing the defining module and CALLING it in this process.
455
+ */
456
+ factoryInvocations: number;
457
+ /**
458
+ * Of {@link factoryInvocations}, the ones whose callee came from a PROJECT
459
+ * FILE (a relative/absolute specifier) rather than a lexicon package or
460
+ * chant's own — i.e. the ones that execute project-authored code here. Text
461
+ * only ({@link isProjectFileSpecifier}); no resolution is performed to
462
+ * classify.
463
+ */
464
+ projectFactoryInvocations: number;
465
+ /**
466
+ * Composite factory bodies {@link interpretCompositeFactory} evaluated
467
+ * statically instead — each one an invocation that did NOT happen.
468
+ */
469
+ factoryInterpretations: number;
470
+ }
471
+
472
+ const executionCounts: FoldExecutionCounts = {
473
+ factoryInvocations: 0,
474
+ projectFactoryInvocations: 0,
475
+ factoryInterpretations: 0,
476
+ };
477
+
478
+ /** A snapshot of {@link FoldExecutionCounts}. Process-wide and monotonic — a caller wanting a per-build figure calls {@link resetFoldExecutionCounts} first. */
479
+ export function foldExecutionCounts(): Readonly<FoldExecutionCounts> {
480
+ return { ...executionCounts };
481
+ }
482
+
483
+ /** Zero {@link foldExecutionCounts}, for a caller measuring one build. */
484
+ export function resetFoldExecutionCounts(): void {
485
+ executionCounts.factoryInvocations = 0;
486
+ executionCounts.projectFactoryInvocations = 0;
487
+ executionCounts.factoryInterpretations = 0;
488
+ }
489
+
395
490
  /**
396
491
  * True for any non-null, non-array object — a real `CompositeInstance`, a
397
492
  * real `Declarable`, or (chant #1020) the synthetic plain object a
@@ -1049,6 +1144,23 @@ interface ResolveCtx {
1049
1144
  lexiconPackages: ReadonlySet<string>;
1050
1145
  /** chant #1093 — see {@link FoldSession.sandbox}. */
1051
1146
  sandbox: boolean;
1147
+ /**
1148
+ * chant #1023 — the whole build session, for the two things composite-factory
1149
+ * interpretation needs that a per-file context cannot carry: the
1150
+ * {@link FoldSession.factoryModules} memo (a composite's defining module is
1151
+ * parsed once per BUILD, not once per calling file) and
1152
+ * {@link FoldSession.stack} (so a factory whose module is already being
1153
+ * resolved further up the same chain is a detected cycle, not a hang).
1154
+ */
1155
+ session: FoldSession;
1156
+ /**
1157
+ * chant #1023 — how many composite factory bodies are being interpreted
1158
+ * around this context, 0 at a file's own top level. A composite that
1159
+ * (directly or through its members) calls itself has no fixpoint and no file
1160
+ * boundary for {@link FoldSession.stack} to notice, so this is what
1161
+ * terminates it — see {@link MAX_INTERPRETATION_DEPTH}.
1162
+ */
1163
+ interpretDepth: number;
1052
1164
  }
1053
1165
 
1054
1166
  /** `{ 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. */
@@ -1127,7 +1239,7 @@ async function resolveLiveValue(node: ts.Expression, ctx: ResolveCtx): Promise<L
1127
1239
  }
1128
1240
 
1129
1241
  /**
1130
- * Resolve and invoke a bare call expression — a composite factory call
1242
+ * Resolve a bare call expression — a composite factory call
1131
1243
  * (`SomeComposite({...})`) or a wrapper that takes a composite instance and
1132
1244
  * returns one (`propagate(SomeComposite({...}), {...})`). The callee must be
1133
1245
  * a plain identifier bound by this file's own `import` (a namespace-import
@@ -1135,13 +1247,25 @@ async function resolveLiveValue(node: ts.Expression, ctx: ResolveCtx): Promise<L
1135
1247
  * same file, can't be resolved without running the file — falls back, same
1136
1248
  * as an unresolvable resource constructor).
1137
1249
  *
1138
- * No pre-check verifies the resolved callee is "really" a composite: each
1139
- * argument is resolved (recursively, for a nested composite-call/member-
1140
- * access argument like `propagate`'s first one) or folded (for a plain props
1141
- * object literal via {@link fold}), the real function is invoked, and the
1142
- * RESULT is what matters to the caller — {@link resolveLiveValue}'s callers
1143
- * decide what shape they need (a `CompositeInstance` for member access, an
1144
- * `isDeclarable`/`isCompositeInstance` value for a top-level export).
1250
+ * Two ways to get the value, tried in that order:
1251
+ *
1252
+ * 1. **Interpretation** (chant #1023) the callee is an interpretable
1253
+ * registered `Composite` defined in a PROJECT file, so its body is
1254
+ * evaluated statically and nothing is imported or run. See
1255
+ * {@link resolveInterpretableFactory} for the exact admissible subset.
1256
+ * 2. **Invocation** (chant #1022) everything else. Each argument is
1257
+ * resolved (recursively, for a nested composite-call/member-access
1258
+ * argument like `propagate`'s first one) or folded (for a plain props
1259
+ * object literal via {@link fold}), the real function is imported and
1260
+ * invoked, and the RESULT is what matters to the caller —
1261
+ * {@link resolveLiveValue}'s callers decide what shape they need (a
1262
+ * `CompositeInstance` for member access, an
1263
+ * `isDeclarable`/`isCompositeInstance` value for a top-level export).
1264
+ * No pre-check verifies the resolved callee is "really" a composite.
1265
+ *
1266
+ * Arm 2 is untouched by #1023, including for a factory arm 1 STARTED and then
1267
+ * declined on: interpretation is all-or-nothing per call, and a decline is not
1268
+ * a fold failure — it lands exactly where the code landed before this existed.
1145
1269
  */
1146
1270
  async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx): Promise<unknown> {
1147
1271
  // chant #1054 — reuses ../fold/subset's `callExpressionMessage` (the SAME
@@ -1158,6 +1282,43 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
1158
1282
  throw cheapError(callExpressionMessage(node));
1159
1283
  }
1160
1284
 
1285
+ // chant #1023 — is this callee a composite whose body can be INTERPRETED
1286
+ // rather than run? Purely a static question (does the defining module parse,
1287
+ // does it declare this export as `Composite(<fn>, "<name>")`, is `<fn>`'s
1288
+ // body inside the admissible subset) — no argument has been evaluated yet
1289
+ // and nothing has been imported, so a `undefined` here has cost nothing and
1290
+ // changed nothing.
1291
+ const factory = await resolveInterpretableFactory(binding, ctx);
1292
+
1293
+ if (factory) {
1294
+ const args = await resolveCallArguments(node, calleeName, ctx);
1295
+ const interpreted = await interpretCompositeFactory(factory, args, ctx);
1296
+ if (interpreted) return interpreted.value;
1297
+ // Declined — the body's shape passed but something in it did not resolve
1298
+ // (an identifier the module's own imports don't reach, a constructor
1299
+ // --sandbox refuses). Fall through to arm 2 with the arguments ALREADY
1300
+ // evaluated, so a nested composite-call argument is not built twice.
1301
+ return invokeImportedCallee(node, calleeName, binding, ctx, args);
1302
+ }
1303
+
1304
+ return invokeImportedCallee(node, calleeName, binding, ctx);
1305
+ }
1306
+
1307
+ /**
1308
+ * Arm 2 — import the module the callee came from and call it, in this process.
1309
+ * Unchanged from #1022 in both behavior and ORDER (refusal, resolve, import,
1310
+ * callable check, arguments, call); #1023 only factored it out so the
1311
+ * interpretation arm above can fall back into it.
1312
+ *
1313
+ * @param args - Already-evaluated arguments, when the caller has them.
1314
+ */
1315
+ async function invokeImportedCallee(
1316
+ node: ts.CallExpression,
1317
+ calleeName: string,
1318
+ binding: ImportBinding,
1319
+ ctx: ResolveCtx,
1320
+ args?: readonly unknown[],
1321
+ ): Promise<unknown> {
1161
1322
  // chant #1093 — THE gap this check exists for. Invoking the callee runs
1162
1323
  // project code (the factory body, and its whole module's top level) in the
1163
1324
  // CLI's own process; under --sandbox that has to happen in the child
@@ -1202,21 +1363,43 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
1202
1363
  throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a function`);
1203
1364
  }
1204
1365
 
1205
- // chant #1112 ONE rule for a registered authoring helper's arguments,
1206
- // applied at BOTH sites that can invoke one. `reviveHelperCall` (the
1207
- // nested-value site, #1082) already revives a helper's arguments with
1208
- // `requireLiveRefs`, because a helper reads THROUGH its ref (`output()`
1209
- // derefs the `WeakRef` parent) and a look-alike `{__attrRef}` envelope
1210
- // makes it produce a wrong result rather than none. This site — a
1211
- // top-level `export const oArn = output(bucket.Arn, "oArn")` — took the
1212
- // composite-factory rule (`false`) instead, which is right for a factory
1213
- // (its props keep the envelope, and the serializer's own walker resolves
1214
- // it) and wrong for a helper. It went unnoticed while the resulting
1215
- // `LexiconOutput` was being discarded anyway; with the export namespace
1216
- // now collected in full, a same-file `output(...)` would reach the
1217
- // serializer holding an inert envelope where the run path has a real
1218
- // reference. Rejected here instead, which falls the file back to run
1219
- // absent output, never a wrong one.
1366
+ const callArgs = args ?? (await resolveCallArguments(node, calleeName, ctx));
1367
+
1368
+ executionCounts.factoryInvocations += 1;
1369
+ if (isProjectFileSpecifier(binding.specifier)) executionCounts.projectFactoryInvocations += 1;
1370
+
1371
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...callArgs);
1372
+ }
1373
+
1374
+ /**
1375
+ * Resolve a call's arguments to the real values the callee would receive —
1376
+ * factored out of {@link resolveCallExpression} (chant #1023) so the
1377
+ * interpretation and invocation arms consume ONE evaluation of them. Anything
1378
+ * else would evaluate a nested composite-call argument twice and hand the
1379
+ * callee the second instance while the first was already wired into an
1380
+ * `AttrRef`.
1381
+ *
1382
+ * chant #1112 — ONE rule for a registered authoring helper's arguments,
1383
+ * applied at BOTH sites that can invoke one. `reviveHelperCall` (the
1384
+ * nested-value site, #1082) already revives a helper's arguments with
1385
+ * `requireLiveRefs`, because a helper reads THROUGH its ref (`output()`
1386
+ * derefs the `WeakRef` parent) and a look-alike `{__attrRef}` envelope
1387
+ * makes it produce a wrong result rather than none. This site — a
1388
+ * top-level `export const oArn = output(bucket.Arn, "oArn")` — took the
1389
+ * composite-factory rule (`false`) instead, which is right for a factory
1390
+ * (its props keep the envelope, and the serializer's own walker resolves
1391
+ * it) and wrong for a helper. It went unnoticed while the resulting
1392
+ * `LexiconOutput` was being discarded anyway; with the export namespace
1393
+ * now collected in full, a same-file `output(...)` would reach the
1394
+ * serializer holding an inert envelope where the run path has a real
1395
+ * reference. Rejected here instead, which falls the file back to run —
1396
+ * absent output, never a wrong one.
1397
+ */
1398
+ async function resolveCallArguments(
1399
+ node: ts.CallExpression,
1400
+ calleeName: string,
1401
+ ctx: ResolveCtx,
1402
+ ): Promise<unknown[]> {
1220
1403
  const helperArgs = isFoldableHelperName(calleeName);
1221
1404
  const args: unknown[] = [];
1222
1405
  for (const argNode of node.arguments) {
@@ -1231,8 +1414,773 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
1231
1414
  : await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx, helperArgs),
1232
1415
  );
1233
1416
  }
1417
+ return args;
1418
+ }
1234
1419
 
1235
- return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1420
+ // ─────────────────────────────────────────────────────────────────────────
1421
+ // Composite factory interpretation (chant #1023, epic #1019 Phase 5).
1422
+ //
1423
+ // THE CONTRACT — the admissible factory subset, in ../fold/subset.ts's style.
1424
+ //
1425
+ // A composite is a factory function, and #1022 got its value the only way it
1426
+ // could: by importing the defining module and CALLING it. That is the last
1427
+ // place `chant build --fold` executes project-authored code in the CLI's own
1428
+ // process (chant #1093), and the reason `--sandbox` has to demote such a file
1429
+ // to the sandboxed child instead of folding it (chant #1111). This section
1430
+ // removes the call for the factories whose bodies can be evaluated instead.
1431
+ //
1432
+ // A call is interpreted when ALL of the following hold. Every one of them is
1433
+ // checked BEFORE any argument is evaluated or anything is constructed, so a
1434
+ // factory outside the subset costs a parse and nothing else:
1435
+ //
1436
+ // 1. **The callee is bound by an `import` from a PROJECT FILE** (a
1437
+ // relative/absolute specifier). A lexicon-package composite is
1438
+ // deliberately NOT interpreted, for two independent reasons: an installed
1439
+ // lexicon ships compiled JS, so whether its factory bodies were
1440
+ // interpretable would depend on whether the package happened to ship
1441
+ // `.ts` — fold coverage must not vary with a dependency's build shape —
1442
+ // and a lexicon package is on {@link isTrustedExecutableBinding}'s
1443
+ // allowlist already, so calling it is exactly as safe under `--sandbox` as
1444
+ // the `loadPlugins` import the CLI performed before discovery began. There
1445
+ // is nothing to buy and a build-shape dependency to lose.
1446
+ // 2. **The defining module declares that export as `Composite(<fn>, "<name>")`**
1447
+ // — a top-level `export const <name> = Composite(...)`, where `Composite`
1448
+ // is bound in THAT module to an import of chant's own
1449
+ // ({@link isChantOwnedHelperBinding}, the same provenance question #1082
1450
+ // asks of an authoring helper, and the same answer). This is #1023's
1451
+ // "recognize the callee is a registered Composite": a bare call to
1452
+ // anything else — a project helper function, a `withDefaults(...)`
1453
+ // wrapper, a re-exported binding — is not recognized and is not
1454
+ // interpreted.
1455
+ // 3. **`<fn>` is an arrow/function expression taking at most one parameter**,
1456
+ // bound as a plain identifier or a simple object binding pattern (no
1457
+ // default, rest, or nested pattern).
1458
+ // 4. **Its body is a single expression, or a block of `const` declarations
1459
+ // followed by one `return`** — and nothing else. No `if`, no `throw`, no
1460
+ // loop, no `let`/`var`, no nested function declaration, no bare expression
1461
+ // statement. This is the line loomster's `composites/*.ts` fall outside
1462
+ // (module-level `buildXxx()` helpers with `if`/`throw` and `.map()`), and
1463
+ // they are meant to: they keep invoking, exactly as before.
1464
+ // 5. **Every expression in it is inside the fold subset, extended with the
1465
+ // two things a factory body exists to do**: `new Type(...)` in ANY value
1466
+ // position (a member, a nested property object, an array element), and a
1467
+ // call through a bare identifier. Both are shape-admissible here and
1468
+ // resolution-checked at evaluation, the same permissive-shape /
1469
+ // strict-evaluation split ../fold/subset.ts documents for identifier
1470
+ // binding and helper provenance.
1471
+ //
1472
+ // Everything else is out: a computed key, a method call (`naming.name(...)` —
1473
+ // the callee is a property access, not an identifier), an array method, an
1474
+ // operator `fold()` doesn't implement, `await`, a class expression, a template
1475
+ // with a computed tag.
1476
+ //
1477
+ // WHY THIS IS NOT IN ../fold/subset.ts. That module is the shared, shape-only
1478
+ // predicate `fold()` and EVL both read, and its own contract is that it may
1479
+ // only ever be PERMISSIVE relative to `fold()`, never stricter. Admissibility
1480
+ // here is not a shape question at all: rules 1 and 2 need the module graph —
1481
+ // which file a name came from, and what that file declares — exactly like
1482
+ // #1082's authoring-helper provenance, which subset.ts documents (point 2b)
1483
+ // as deliberately out for the same reason. Putting rules 3-5 there alone would
1484
+ // describe a subset no caller could act on without also answering 1 and 2, and
1485
+ // putting all five there would mean teaching a syntax-only lint rule to
1486
+ // resolve imports. The shape half stays checkable by anything that wants it —
1487
+ // {@link findFactorySubsetViolation} takes a lone `ts` node — but the shared
1488
+ // predicate is not made stricter, or wider, by this issue.
1489
+ //
1490
+ // WHAT IS PRESERVED. Interpretation is not a second construction path:
1491
+ //
1492
+ // - **The instances are real, and made by the lexicon's own constructors.**
1493
+ // A `new Role({...})` in the body resolves `Role` through the DEFINING
1494
+ // module's imports and calls that class, the same class the run path would
1495
+ // have called from the same resolved module path.
1496
+ // - **Sibling references are live, not symbolic.** `role.Arn` reads the
1497
+ // attribute off the `Role` instance this interpretation just built, so it
1498
+ // is a genuine `AttrRef` wired to a genuine parent — identical to running
1499
+ // the factory, and strictly better than a top-level fold's `{__attrRef}`
1500
+ // envelope (an intrinsic that inspects its argument, `Sub`/`Ref`, gets what
1501
+ // it expects here).
1502
+ // - **The instance is assembled by `Composite()` itself.** The members go
1503
+ // back through chant's own {@link Composite} (../composite.ts) rather than
1504
+ // through a hand-built look-alike, so member validation, the non-enumerable
1505
+ // `members`/`_definition` layout `propagate()` and `expandComposite()`
1506
+ // depend on, and provenance stamping are the run path's, by construction
1507
+ // and not by resemblance.
1508
+ // - **`propagate()` still works, untouched.** It is a chant-owned import, so
1509
+ // `propagate(SomeComposite({...}), {...})` invokes the real `propagate` on
1510
+ // the interpreted instance and mutates it in place exactly as it does a run
1511
+ // one — which is why instance identity has to be a real `CompositeInstance`
1512
+ // and not a copy (chant #1097).
1513
+ // ─────────────────────────────────────────────────────────────────────────
1514
+
1515
+ /**
1516
+ * A backstop for a composite that calls itself, directly or through a member.
1517
+ * {@link FoldSession.stack} cannot see it — the recursion happens entirely
1518
+ * inside one module's source, crossing no file boundary — so this is what
1519
+ * terminates it. Deliberately small: real composite nesting is 2-3 deep, and a
1520
+ * chain past this is a bug, not a design.
1521
+ */
1522
+ const MAX_INTERPRETATION_DEPTH = 16;
1523
+
1524
+ /**
1525
+ * The static scope of a module that DEFINES composites — everything
1526
+ * interpreting one of its factory bodies needs, computed once per module per
1527
+ * build (see {@link FoldSession.factoryModules}).
1528
+ *
1529
+ * Note what is NOT here: the module's exported VALUES. Interpreting a factory
1530
+ * never needs them, which is the whole point — a module can define a perfectly
1531
+ * interpretable composite and still be unfoldable as a module (an exported
1532
+ * function declaration alongside it, say), and those two facts are
1533
+ * independent.
1534
+ */
1535
+ interface FactoryModuleScope {
1536
+ file: string;
1537
+ sourceFile: ts.SourceFile;
1538
+ /**
1539
+ * The module's top-level `const`s, MINUS every one that resolves to a
1540
+ * `new Type(...)` resource.
1541
+ *
1542
+ * The exclusion is the load-bearing part. `fold()` turns a property access
1543
+ * on a resource-valued const into a symbolic `{__attrRef, entity: "<the
1544
+ * const's name>"}` — a name resolved much later, against the entity table of
1545
+ * the file being COLLECTED, which is the calling file and not this one. A
1546
+ * module-level resource shared by every call of a factory is also a
1547
+ * singleton whose identity the run path shares and interpretation would not.
1548
+ * Dropping those consts makes any reference to one an ordinary "unresolved
1549
+ * identifier" failure, which declines the interpretation and invokes
1550
+ * instead — the answer that is right on both counts.
1551
+ */
1552
+ consts: Map<string, ts.Expression>;
1553
+ imports: Map<string, ImportBinding>;
1554
+ namespaceImports: Map<string, NamespaceImportBinding>;
1555
+ /**
1556
+ * This module's own imports, resolved to their real cross-file values (see
1557
+ * {@link ResolveCtx.externals}) — LAZILY, and memoized here once built.
1558
+ *
1559
+ * Laziness is not an optimization detail, it is what keeps this issue from
1560
+ * paying #1020's cost all over again. `resolveCallExpression` reaches
1561
+ * {@link resolveInterpretableFactory} for EVERY call through a project-file
1562
+ * import, the overwhelming majority of which are not composites at all. Only
1563
+ * the parse is spent finding that out; resolving a module's whole import
1564
+ * graph — which recursively folds every project file it names — is spent
1565
+ * only by a call that is actually about to be interpreted.
1566
+ */
1567
+ resolved?: Promise<{ externals: Map<string, unknown>; failures: Map<string, string> }>;
1568
+ }
1569
+
1570
+ /** An admissible composite factory: where it lives, what it is called, and the function to interpret. */
1571
+ interface InterpretableFactory {
1572
+ scope: FactoryModuleScope;
1573
+ fn: ts.ArrowFunction | ts.FunctionExpression;
1574
+ /** `Composite()`'s second argument, or `"anonymous"` when it has none — matching {@link Composite}'s own default. */
1575
+ compositeName: string;
1576
+ }
1577
+
1578
+ /**
1579
+ * Parse a composite-defining module into its static scope, memoized per build.
1580
+ * Returns `undefined` when the file can't be read or parsed — a decline, never
1581
+ * a throw: the caller falls through to the invocation arm, which will produce
1582
+ * its own (identical, pre-#1023) error if the module is genuinely broken.
1583
+ *
1584
+ * Reads and parses only. Nothing is imported, nothing is executed, and the
1585
+ * module's own imports are not resolved yet — see
1586
+ * {@link FactoryModuleScope.resolved}.
1587
+ */
1588
+ function readFactoryModule(modulePath: string, session: FoldSession): Promise<FactoryModuleScope | undefined> {
1589
+ const cached = session.factoryModules.get(modulePath);
1590
+ if (cached) return cached;
1591
+ const promise = readFactoryModuleCore(modulePath);
1592
+ session.factoryModules.set(modulePath, promise);
1593
+ return promise;
1594
+ }
1595
+
1596
+ async function readFactoryModuleCore(modulePath: string): Promise<FactoryModuleScope | undefined> {
1597
+ let sourceFile: ts.SourceFile;
1598
+ try {
1599
+ const source = await readFile(modulePath, "utf-8");
1600
+ sourceFile = ts.createSourceFile(modulePath, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
1601
+ } catch {
1602
+ // Not a readable TypeScript source — an installed package's compiled
1603
+ // entry point reached through a path specifier, a missing file. Decline.
1604
+ return undefined;
1605
+ }
1606
+
1607
+ const collected = collectImports(sourceFile);
1608
+ const consts = collectConsts(sourceFile);
1609
+ for (const [name] of [...consts]) {
1610
+ if (constResolvesToResource(consts, name, new Set())) consts.delete(name);
1611
+ }
1612
+
1613
+ return {
1614
+ file: modulePath,
1615
+ sourceFile,
1616
+ consts,
1617
+ imports: collected.named,
1618
+ namespaceImports: collected.namespaces,
1619
+ };
1620
+ }
1621
+
1622
+ /**
1623
+ * Resolve a composite-defining module's own imports, once per module per
1624
+ * build. Guarded by {@link FoldSession.stack}, exactly like
1625
+ * {@link foldFileMemoized}: a module already being resolved further up the
1626
+ * same chain is a genuine cycle, and gets an empty scope (every identifier
1627
+ * then reads as unresolved, which declines the interpretation) rather than a
1628
+ * re-entry.
1629
+ */
1630
+ function factoryModuleScopeResolved(
1631
+ scope: FactoryModuleScope,
1632
+ session: FoldSession,
1633
+ ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string> }> {
1634
+ if (scope.resolved) return scope.resolved;
1635
+ if (session.stack.includes(scope.file) || session.stack.length >= MAX_RESOLUTION_DEPTH) {
1636
+ return Promise.resolve({ externals: new Map(), failures: new Map() });
1637
+ }
1638
+ session.stack.push(scope.file);
1639
+ scope.resolved = buildExternals(scope.file, scope.imports, scope.namespaceImports, session).finally(() => {
1640
+ const idx = session.stack.lastIndexOf(scope.file);
1641
+ if (idx !== -1) session.stack.splice(idx, 1);
1642
+ });
1643
+ return scope.resolved;
1644
+ }
1645
+
1646
+ /**
1647
+ * True when a module-level `const` is (transitively) bound to a
1648
+ * `new Type(...)`. Mirrors `fold()`'s own `resolvesToResource`, but follows an
1649
+ * identifier chain (`const a = new T(); const b = a;`) so aliasing can't smuggle
1650
+ * a module-level resource into a factory body — see
1651
+ * {@link FactoryModuleScope.consts}.
1652
+ */
1653
+ function constResolvesToResource(
1654
+ consts: Map<string, ts.Expression>,
1655
+ name: string,
1656
+ seen: Set<string>,
1657
+ ): boolean {
1658
+ if (seen.has(name)) return false;
1659
+ seen.add(name);
1660
+ const init = consts.get(name);
1661
+ if (init === undefined) return false;
1662
+ if (ts.isNewExpression(init)) return true;
1663
+ if (ts.isIdentifier(init)) return constResolvesToResource(consts, init.text, seen);
1664
+ return false;
1665
+ }
1666
+
1667
+ /**
1668
+ * Find `export const <exportName> = Composite(<fn>, "<name>")` at the top level
1669
+ * of `scope`'s module, verifying that `Composite` really is chant's own in that
1670
+ * module. Returns `undefined` for every other shape — see rule 2 of the
1671
+ * contract above.
1672
+ */
1673
+ function findCompositeDefinition(
1674
+ scope: FactoryModuleScope,
1675
+ exportName: string,
1676
+ ctx: ResolveCtx,
1677
+ ): { fn: ts.ArrowFunction | ts.FunctionExpression; compositeName: string } | undefined {
1678
+ for (const statement of scope.sourceFile.statements) {
1679
+ if (!ts.isVariableStatement(statement)) continue;
1680
+ if (!hasExportModifier(statement)) continue;
1681
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
1682
+
1683
+ for (const decl of statement.declarationList.declarations) {
1684
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName) continue;
1685
+ const init = decl.initializer;
1686
+ if (!init || !ts.isCallExpression(init) || !ts.isIdentifier(init.expression)) return undefined;
1687
+
1688
+ // `Composite` must be bound, in THIS module, to an import of chant's
1689
+ // own — the identical provenance question #1082 asks of an authoring
1690
+ // helper, answered by the identical predicate. A project-local
1691
+ // `function Composite(...)` shadowing the name is not chant's, and a
1692
+ // call to it is not a registered composite.
1693
+ const compositeBinding = scope.imports.get(init.expression.text);
1694
+ if (!compositeBinding || compositeBinding.imported !== "Composite") return undefined;
1695
+ if (!isChantOwnedHelperBinding(compositeBinding, { ...ctx, file: scope.file })) return undefined;
1696
+
1697
+ const [fnArg, nameArg] = init.arguments;
1698
+ if (!fnArg || (!ts.isArrowFunction(fnArg) && !ts.isFunctionExpression(fnArg))) return undefined;
1699
+ // `Composite()`'s own default when the name is omitted (../composite.ts).
1700
+ // COR017 requires the literal in practice; anything that is not a plain
1701
+ // string literal is not something to guess at.
1702
+ if (nameArg !== undefined && !ts.isStringLiteral(nameArg)) return undefined;
1703
+ return { fn: fnArg, compositeName: nameArg ? nameArg.text : "anonymous" };
1704
+ }
1705
+ }
1706
+ return undefined;
1707
+ }
1708
+
1709
+ /**
1710
+ * Decide whether `binding`'s callee is an interpretable composite — rules 1-5
1711
+ * of the contract above, in that order, with no evaluation and no import.
1712
+ * `undefined` means "not interpretable", never "broken".
1713
+ */
1714
+ async function resolveInterpretableFactory(
1715
+ binding: ImportBinding,
1716
+ ctx: ResolveCtx,
1717
+ ): Promise<InterpretableFactory | undefined> {
1718
+ // Rule 1 — project files only. A text check; no resolution performed for a
1719
+ // bare specifier, so this costs nothing for the (common) lexicon case.
1720
+ if (!isProjectFileSpecifier(binding.specifier)) return undefined;
1721
+ if (ctx.interpretDepth >= MAX_INTERPRETATION_DEPTH) return undefined;
1722
+
1723
+ let modulePath: string;
1724
+ try {
1725
+ modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1726
+ } catch {
1727
+ return undefined;
1728
+ }
1729
+
1730
+ const scope = await readFactoryModule(modulePath, ctx.session);
1731
+ if (!scope) return undefined;
1732
+
1733
+ // Rule 2.
1734
+ const definition = findCompositeDefinition(scope, binding.imported, ctx);
1735
+ if (!definition) return undefined;
1736
+
1737
+ // Rules 3-5.
1738
+ if (findFactorySubsetViolation(definition.fn) !== undefined) return undefined;
1739
+
1740
+ return { scope, fn: definition.fn, compositeName: definition.compositeName };
1741
+ }
1742
+
1743
+ /**
1744
+ * The SHAPE half of the contract (rules 3-5) — a lone `ts` node in, a reason
1745
+ * string out, or `undefined` when the factory is admissible. Deliberately
1746
+ * takes nothing but the node: it is the half a caller with no module graph
1747
+ * could evaluate, and keeping it separable is what lets the doc above claim
1748
+ * the shape rules are checkable without the provenance ones.
1749
+ */
1750
+ export function findFactorySubsetViolation(fn: ts.ArrowFunction | ts.FunctionExpression): string | undefined {
1751
+ // Rule 3 — at most one parameter, bound plainly.
1752
+ if (fn.parameters.length > 1) return "a composite factory takes a single props parameter";
1753
+ const param = fn.parameters[0];
1754
+ if (param) {
1755
+ if (param.dotDotDotToken) return "a rest parameter is not interpretable";
1756
+ if (param.initializer) return "a defaulted parameter is not interpretable";
1757
+ if (ts.isObjectBindingPattern(param.name)) {
1758
+ for (const el of param.name.elements) {
1759
+ if (bindingElementPropKey(el) === undefined) {
1760
+ return "a destructured props parameter with a rest, default, or nested element is not interpretable";
1761
+ }
1762
+ }
1763
+ } else if (!ts.isIdentifier(param.name)) {
1764
+ return "an array-destructured props parameter is not interpretable";
1765
+ }
1766
+ }
1767
+
1768
+ // Rule 4 — a concise expression body, or `const`s then one `return`.
1769
+ if (!ts.isBlock(fn.body)) return checkFactoryExpression(fn.body);
1770
+
1771
+ const statements = fn.body.statements;
1772
+ if (statements.length === 0) return "an empty composite factory body has no members to interpret";
1773
+ for (let i = 0; i < statements.length; i += 1) {
1774
+ const statement = statements[i];
1775
+ const last = i === statements.length - 1;
1776
+
1777
+ if (ts.isReturnStatement(statement)) {
1778
+ if (!last) return "an early `return` is not interpretable";
1779
+ if (!statement.expression) return "a composite factory must return its members";
1780
+ const violation = checkFactoryExpression(statement.expression);
1781
+ if (violation) return violation;
1782
+ continue;
1783
+ }
1784
+ if (last) return "a composite factory body must end in `return`";
1785
+
1786
+ if (!ts.isVariableStatement(statement)) {
1787
+ return `\`${ts.SyntaxKind[statement.kind]}\` in a composite factory body is not interpretable`;
1788
+ }
1789
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
1790
+ return "`let`/`var` in a composite factory body is not interpretable";
1791
+ }
1792
+ for (const decl of statement.declarationList.declarations) {
1793
+ if (!decl.initializer) return "an uninitialized `const` in a composite factory body is not interpretable";
1794
+ if (ts.isObjectBindingPattern(decl.name)) {
1795
+ for (const el of decl.name.elements) {
1796
+ if (bindingElementPropKey(el) === undefined) {
1797
+ return "a destructured `const` with a rest, default, or nested element is not interpretable";
1798
+ }
1799
+ }
1800
+ } else if (!ts.isIdentifier(decl.name)) {
1801
+ return "an array-destructured `const` is not interpretable";
1802
+ }
1803
+ const violation = checkFactoryExpression(decl.initializer);
1804
+ if (violation) return violation;
1805
+ }
1806
+ }
1807
+ return undefined;
1808
+ }
1809
+
1810
+ /**
1811
+ * Rule 5 — the fold subset, extended with `new Type(...)` in any value
1812
+ * position and a call through a bare identifier.
1813
+ *
1814
+ * Shape only, in ../fold/subset.ts's sense: whether a `new`'s constructor or a
1815
+ * call's callee actually resolves to something invocable is settled at
1816
+ * evaluation, where the module graph is available. Every operator/key/member
1817
+ * rule is read from ../fold/subset.ts's own exported sets rather than
1818
+ * re-listed, so widening `fold()` widens this in the same commit.
1819
+ */
1820
+ function checkFactoryExpression(node: ts.Expression): string | undefined {
1821
+ if (
1822
+ ts.isParenthesizedExpression(node) ||
1823
+ ts.isAsExpression(node) ||
1824
+ ts.isSatisfiesExpression(node) ||
1825
+ ts.isNonNullExpression(node) ||
1826
+ ts.isTypeAssertionExpression(node)
1827
+ ) {
1828
+ return checkFactoryExpression(node.expression);
1829
+ }
1830
+
1831
+ if (
1832
+ ts.isStringLiteral(node) ||
1833
+ ts.isNoSubstitutionTemplateLiteral(node) ||
1834
+ ts.isNumericLiteral(node) ||
1835
+ ts.isIdentifier(node) ||
1836
+ node.kind === ts.SyntaxKind.TrueKeyword ||
1837
+ node.kind === ts.SyntaxKind.FalseKeyword ||
1838
+ node.kind === ts.SyntaxKind.NullKeyword
1839
+ ) {
1840
+ return undefined;
1841
+ }
1842
+
1843
+ if (ts.isNewExpression(node) || ts.isCallExpression(node)) {
1844
+ // A bare-identifier callee only — `ns.Foo(...)`, `arr.map(...)` and
1845
+ // `props.factory(...)` are all property accesses and all stay out, which
1846
+ // is what keeps a data transform (chant EVL010) from sneaking in.
1847
+ if (!ts.isIdentifier(node.expression)) {
1848
+ return ts.isNewExpression(node)
1849
+ ? `\`new ${briefNodeText(node.expression)}(...)\` needs a plain imported constructor to interpret`
1850
+ : callExpressionMessage(node);
1851
+ }
1852
+ for (const arg of node.arguments ?? []) {
1853
+ const violation = checkFactoryExpression(arg);
1854
+ if (violation) return violation;
1855
+ }
1856
+ return undefined;
1857
+ }
1858
+
1859
+ if (ts.isTaggedTemplateExpression(node)) {
1860
+ if (!ts.isIdentifier(node.tag)) return unsupportedExpressionMessage(node);
1861
+ if (ts.isNoSubstitutionTemplateLiteral(node.template)) return undefined;
1862
+ for (const span of node.template.templateSpans) {
1863
+ const violation = checkFactoryExpression(span.expression);
1864
+ if (violation) return violation;
1865
+ }
1866
+ return undefined;
1867
+ }
1868
+
1869
+ if (ts.isTemplateExpression(node)) {
1870
+ for (const span of node.templateSpans) {
1871
+ const violation = checkFactoryExpression(span.expression);
1872
+ if (violation) return violation;
1873
+ }
1874
+ return undefined;
1875
+ }
1876
+
1877
+ if (ts.isObjectLiteralExpression(node)) {
1878
+ for (const prop of node.properties) {
1879
+ if (ts.isPropertyAssignment(prop)) {
1880
+ if (!isLiteralPropertyNameNode(prop.name)) return computedPropertyNameMessage(prop.name);
1881
+ const violation = checkFactoryExpression(prop.initializer);
1882
+ if (violation) return violation;
1883
+ } else if (ts.isShorthandPropertyAssignment(prop)) {
1884
+ if (prop.objectAssignmentInitializer) return UNSUPPORTED_OBJECT_MEMBER_MESSAGE;
1885
+ } else if (ts.isSpreadAssignment(prop)) {
1886
+ const violation = checkFactoryExpression(prop.expression);
1887
+ if (violation) return violation;
1888
+ } else {
1889
+ return UNSUPPORTED_OBJECT_MEMBER_MESSAGE;
1890
+ }
1891
+ }
1892
+ return undefined;
1893
+ }
1894
+
1895
+ if (ts.isArrayLiteralExpression(node)) {
1896
+ for (const el of node.elements) {
1897
+ const violation = checkFactoryExpression(ts.isSpreadElement(el) ? el.expression : el);
1898
+ if (violation) return violation;
1899
+ }
1900
+ return undefined;
1901
+ }
1902
+
1903
+ if (ts.isPropertyAccessExpression(node)) return checkFactoryExpression(node.expression);
1904
+
1905
+ if (ts.isElementAccessExpression(node)) {
1906
+ if (!isLiteralElementKey(node.argumentExpression)) {
1907
+ return `dynamic element access [${briefNodeText(node.argumentExpression)}] is not interpretable`;
1908
+ }
1909
+ return checkFactoryExpression(node.expression);
1910
+ }
1911
+
1912
+ if (ts.isPrefixUnaryExpression(node)) {
1913
+ if (!SUPPORTED_UNARY_OPERATORS.has(node.operator)) return UNSUPPORTED_UNARY_MESSAGE;
1914
+ return checkFactoryExpression(node.operand);
1915
+ }
1916
+
1917
+ if (ts.isBinaryExpression(node)) {
1918
+ if (!SUPPORTED_BINARY_OPERATORS.has(node.operatorToken.kind)) {
1919
+ return unsupportedBinaryMessage(node.operatorToken.kind);
1920
+ }
1921
+ return checkFactoryExpression(node.left) ?? checkFactoryExpression(node.right);
1922
+ }
1923
+
1924
+ if (ts.isConditionalExpression(node)) {
1925
+ return (
1926
+ checkFactoryExpression(node.condition) ??
1927
+ checkFactoryExpression(node.whenTrue) ??
1928
+ checkFactoryExpression(node.whenFalse)
1929
+ );
1930
+ }
1931
+
1932
+ return unsupportedExpressionMessage(node);
1933
+ }
1934
+
1935
+ /** `isLiteralPropertyName` narrowed to the node type {@link propName} accepts, without importing the type predicate's generic form twice. */
1936
+ function isLiteralPropertyNameNode(node: ts.PropertyName): boolean {
1937
+ return ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node);
1938
+ }
1939
+
1940
+ /**
1941
+ * Interpret an admissible factory's body against `args`, and assemble the
1942
+ * result through chant's own {@link Composite}.
1943
+ *
1944
+ * Returns `undefined` — a DECLINE, not a failure — when anything in the body
1945
+ * fails to resolve (an identifier the defining module's imports don't reach, a
1946
+ * constructor `--sandbox` refuses, a nested call that isn't a composite). The
1947
+ * caller then invokes for real, landing exactly where it landed before #1023.
1948
+ * That asymmetry is deliberate: interpretation may only ever REMOVE an
1949
+ * execution, never introduce a fold failure that wasn't there.
1950
+ */
1951
+ async function interpretCompositeFactory(
1952
+ factory: InterpretableFactory,
1953
+ args: readonly unknown[],
1954
+ ctx: ResolveCtx,
1955
+ ): Promise<{ value: unknown } | undefined> {
1956
+ // A `Composite()` factory takes exactly one props argument. Anything else at
1957
+ // the call site means the callee is being used as something this doesn't
1958
+ // model.
1959
+ if (args.length > 1) return undefined;
1960
+
1961
+ const { scope, fn } = factory;
1962
+ const resolved = await factoryModuleScopeResolved(scope, ctx.session);
1963
+ // The defining module's own scope, with the factory's locals layered on top.
1964
+ // `locals` is deliberately EMPTY: `resolveLiveValue`'s local-binding branch
1965
+ // exists to re-navigate a file's own top-level composite spine, which is not
1966
+ // what a name inside a factory body means. Body bindings live in `externals`
1967
+ // instead, where an identifier resolves to the value already computed for it
1968
+ // — which is what makes `role.Arn` a live `AttrRef` on the real instance.
1969
+ const consts = new Map(scope.consts);
1970
+ const externals = new Map(resolved.externals);
1971
+ const bodyCtx: ResolveCtx = {
1972
+ file: scope.file,
1973
+ consts,
1974
+ locals: new Map(),
1975
+ imports: scope.imports,
1976
+ namespaceImports: scope.namespaceImports,
1977
+ memo: new Map(),
1978
+ intrinsics: ctx.intrinsics,
1979
+ externals,
1980
+ crossFileFailures: resolved.failures,
1981
+ importCache: ctx.importCache,
1982
+ resolvePathCache: ctx.resolvePathCache,
1983
+ lexiconPackages: ctx.lexiconPackages,
1984
+ sandbox: ctx.sandbox,
1985
+ session: ctx.session,
1986
+ interpretDepth: ctx.interpretDepth + 1,
1987
+ };
1988
+
1989
+ const bind = (name: string, value: unknown): void => {
1990
+ // A body binding SHADOWS a module-level const of the same name, so the
1991
+ // const has to go — `fold()` consults `consts` before `externals`.
1992
+ consts.delete(name);
1993
+ externals.set(name, value);
1994
+ };
1995
+
1996
+ try {
1997
+ const param = fn.parameters[0];
1998
+ if (param) {
1999
+ const props = args[0];
2000
+ if (ts.isIdentifier(param.name)) {
2001
+ bind(param.name.text, props);
2002
+ } else if (ts.isObjectBindingPattern(param.name)) {
2003
+ if (!isIndexableObject(props)) return undefined;
2004
+ for (const el of param.name.elements) {
2005
+ const key = bindingElementPropKey(el);
2006
+ if (key === undefined) return undefined;
2007
+ bind(el.name.getText(), (props as Record<string, unknown>)[key]);
2008
+ }
2009
+ }
2010
+ }
2011
+
2012
+ const members = await interpretFactoryBody(fn, bodyCtx, bind);
2013
+ if (!isIndexableObject(members)) return undefined;
2014
+
2015
+ // Assembled by chant's own `Composite()` — see the contract's "what is
2016
+ // preserved" note. One definition object per interpreted call (rather than
2017
+ // one per module, as the run path has) is the single visible difference:
2018
+ // `_id` is a fresh symbol, which nothing outside `CompositeRegistry` — used
2019
+ // only by tests — reads.
2020
+ const definition = Composite<void, CompositeMembers>(() => members as CompositeMembers, factory.compositeName);
2021
+ const instance = definition();
2022
+ executionCounts.factoryInterpretations += 1;
2023
+ return { value: instance };
2024
+ } catch {
2025
+ return undefined;
2026
+ }
2027
+ }
2028
+
2029
+ /** Evaluate an admissible factory body's statements in order, binding each `const`, and return the `return` expression's value. */
2030
+ async function interpretFactoryBody(
2031
+ fn: ts.ArrowFunction | ts.FunctionExpression,
2032
+ ctx: ResolveCtx,
2033
+ bind: (name: string, value: unknown) => void,
2034
+ ): Promise<unknown> {
2035
+ if (!ts.isBlock(fn.body)) return interpretExpression(fn.body, ctx);
2036
+
2037
+ for (const statement of fn.body.statements) {
2038
+ if (ts.isReturnStatement(statement)) {
2039
+ // `findFactorySubsetViolation` has already established this is the last
2040
+ // statement and has an expression.
2041
+ return interpretExpression(statement.expression as ts.Expression, ctx);
2042
+ }
2043
+ const declarations = (statement as ts.VariableStatement).declarationList.declarations;
2044
+ for (const decl of declarations) {
2045
+ const value = await interpretExpression(decl.initializer as ts.Expression, ctx);
2046
+ if (ts.isIdentifier(decl.name)) {
2047
+ bind(decl.name.text, value);
2048
+ continue;
2049
+ }
2050
+ if (!isIndexableObject(value)) {
2051
+ throw cheapError(`destructured \`const\` source in "${briefNodeText(decl.name)}" is not an object`);
2052
+ }
2053
+ for (const el of (decl.name as ts.ObjectBindingPattern).elements) {
2054
+ bind(el.name.getText(), (value as Record<string, unknown>)[bindingElementPropKey(el) as string]);
2055
+ }
2056
+ }
2057
+ }
2058
+ // Unreachable: rule 4 requires a trailing `return`.
2059
+ throw cheapError("composite factory body did not return");
2060
+ }
2061
+
2062
+ /**
2063
+ * Evaluate one expression of a factory body.
2064
+ *
2065
+ * Only the cases that can CONTAIN a construction or a composite call are
2066
+ * handled here; everything else delegates to {@link resolveDeclaratorValue},
2067
+ * which is the same `resolveLiveValue` -> `fold()` -> revive pipeline every
2068
+ * other value in a fold takes. That split is the point: the interpreter owns
2069
+ * as little evaluation semantics as it possibly can, so `fold()` stays the one
2070
+ * definition of what an expression means, and the operators/short-circuiting
2071
+ * duplicated below are duplicated because they must not evaluate a branch
2072
+ * `fold()` would not have evaluated.
2073
+ */
2074
+ async function interpretExpression(node: ts.Expression, ctx: ResolveCtx): Promise<unknown> {
2075
+ if (
2076
+ ts.isParenthesizedExpression(node) ||
2077
+ ts.isAsExpression(node) ||
2078
+ ts.isSatisfiesExpression(node) ||
2079
+ ts.isNonNullExpression(node)
2080
+ ) {
2081
+ return interpretExpression(node.expression, ctx);
2082
+ }
2083
+
2084
+ if (ts.isNewExpression(node)) return interpretNewExpression(node, ctx);
2085
+
2086
+ if (ts.isObjectLiteralExpression(node)) {
2087
+ const obj: Record<string, unknown> = {};
2088
+ for (const prop of node.properties) {
2089
+ if (ts.isPropertyAssignment(prop)) {
2090
+ obj[propName(prop.name)] = await interpretExpression(prop.initializer, ctx);
2091
+ } else if (ts.isShorthandPropertyAssignment(prop)) {
2092
+ obj[prop.name.text] = await interpretExpression(prop.name, ctx);
2093
+ } else {
2094
+ const src = await interpretExpression((prop as ts.SpreadAssignment).expression, ctx);
2095
+ if (src === null || typeof src !== "object") throw cheapError("spread source not an object");
2096
+ Object.assign(obj, src);
2097
+ }
2098
+ }
2099
+ return obj;
2100
+ }
2101
+
2102
+ if (ts.isArrayLiteralExpression(node)) {
2103
+ const arr: unknown[] = [];
2104
+ for (const el of node.elements) {
2105
+ if (ts.isSpreadElement(el)) {
2106
+ const src = await interpretExpression(el.expression, ctx);
2107
+ if (!Array.isArray(src)) throw cheapError("spread source not an array");
2108
+ arr.push(...src);
2109
+ } else {
2110
+ arr.push(await interpretExpression(el, ctx));
2111
+ }
2112
+ }
2113
+ return arr;
2114
+ }
2115
+
2116
+ if (ts.isConditionalExpression(node)) {
2117
+ return (await interpretExpression(node.condition, ctx))
2118
+ ? interpretExpression(node.whenTrue, ctx)
2119
+ : interpretExpression(node.whenFalse, ctx);
2120
+ }
2121
+
2122
+ if (ts.isBinaryExpression(node)) {
2123
+ const S = ts.SyntaxKind;
2124
+ const opKind = node.operatorToken.kind;
2125
+ if (opKind === S.AmpersandAmpersandToken) {
2126
+ const left = await interpretExpression(node.left, ctx);
2127
+ return left ? interpretExpression(node.right, ctx) : left;
2128
+ }
2129
+ if (opKind === S.BarBarToken) {
2130
+ const left = await interpretExpression(node.left, ctx);
2131
+ return left ? left : interpretExpression(node.right, ctx);
2132
+ }
2133
+ if (opKind === S.QuestionQuestionToken) {
2134
+ const left = await interpretExpression(node.left, ctx);
2135
+ return left === null || left === undefined ? interpretExpression(node.right, ctx) : left;
2136
+ }
2137
+ }
2138
+
2139
+ return (await resolveDeclaratorValue(node, ctx)).value;
2140
+ }
2141
+
2142
+ /**
2143
+ * Construct a real resource from a `new Type(...)` anywhere inside a factory
2144
+ * body — a member, a nested property object, an array element.
2145
+ *
2146
+ * Deliberately NOT routed through {@link resolveResourceEntity}: a factory body
2147
+ * evaluates against the DEFINING module's scope with the caller's props already
2148
+ * bound to live values, so each argument is interpreted recursively (through
2149
+ * {@link interpretExpression}, which can produce a live composite instance a
2150
+ * plain `fold()` has no representation for) rather than folded and revived.
2151
+ *
2152
+ * chant #1169 removed the asymmetry that used to motivate this comment: a
2153
+ * nested `new` in a TOP-LEVEL value position now constructs too, via
2154
+ * {@link constructFoldedResource}. The two paths reach the same place — the
2155
+ * class named by an `import`, called with the arguments the source wrote — by
2156
+ * different routes, because a factory body and a file's own top level start
2157
+ * from different scopes.
2158
+ */
2159
+ async function interpretNewExpression(node: ts.NewExpression, ctx: ResolveCtx): Promise<unknown> {
2160
+ // Guaranteed an identifier by {@link checkFactoryExpression}; re-checked so a
2161
+ // future caller can't reach this with a dotted callee and get a silent miss.
2162
+ if (!ts.isIdentifier(node.expression)) {
2163
+ throw cheapError(`\`new ${briefNodeText(node.expression)}(...)\` needs a plain imported constructor`);
2164
+ }
2165
+ const typeName = node.expression.text;
2166
+ const binding = ctx.imports.get(typeName);
2167
+ if (!binding) throw cheapError(`constructor "${typeName}" is not a resolvable import`);
2168
+
2169
+ // chant #1093 — same gate, same reason, as every other site that imports a
2170
+ // module in order to execute something from it.
2171
+ const refusal = sandboxedExecutionRefusal(binding, ctx, typeName, "constructor");
2172
+ if (refusal) throw cheapError(refusal);
2173
+
2174
+ const modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
2175
+ const mod = await importModuleMemoized(modulePath, ctx.importCache);
2176
+ const Ctor = mod[binding.imported];
2177
+ if (typeof Ctor !== "function") {
2178
+ throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a constructor`);
2179
+ }
2180
+
2181
+ const ctorArgs: unknown[] = [];
2182
+ for (const arg of node.arguments ?? []) ctorArgs.push(await interpretExpression(arg, ctx));
2183
+ return new (Ctor as new (...ctorArguments: unknown[]) => unknown)(...ctorArgs);
1236
2184
  }
1237
2185
 
1238
2186
  /**
@@ -1293,10 +2241,16 @@ function applyResolvedValue(
1293
2241
  // envelope structurally (see ../serializer-walker.ts) with no revival
1294
2242
  // needed. But an intrinsic's OWN implementation (e.g. `SubIntrinsic`) needs
1295
2243
  // a genuine `AttrRef` instance internally (`instanceof` checks), which would
1296
- // require wiring a live `WeakRef` to the sibling entity — out of scope here,
1297
- // same call as the existing "nested `new Type(...)` as a value" rejection
1298
- // a few lines up: reject (fall back to run) rather than risk silently wrong
1299
- // output.
2244
+ // require wiring a live `WeakRef` to the sibling entity — out of scope here:
2245
+ // reject (fall back to run) rather than risk silently wrong output.
2246
+ //
2247
+ // chant #1169 adds the fourth envelope this walk revives, and the one that
2248
+ // closes the corpus's largest fold gate: `{__resource}`, a nested
2249
+ // `new Type(...)` used as a value. It is revived the same way and for the same
2250
+ // reason as the other three — the real class is resolved through the folding
2251
+ // file's own imports and called for real — so what the outer constructor
2252
+ // receives is the instance the run path would have handed it, not a look-alike.
2253
+ // See {@link constructFoldedResource}.
1300
2254
  // ─────────────────────────────────────────────────────────────────────────
1301
2255
 
1302
2256
  /** 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. */
@@ -1448,9 +2402,24 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiv
1448
2402
  }
1449
2403
 
1450
2404
  if ("__resource" in value) {
1451
- // fold() itself already rejects a nested `new Type(...)` as a value
1452
- // (see its own comment) this is defensive, not a reachable path today.
1453
- throw cheapError("nested resource as a value is not foldable");
2405
+ // chant #1169 a nested `new Type(...)` used as a value. This is the
2406
+ // branch that makes the envelope safe: it is replaced, here, by a REAL
2407
+ // instance of the class the source named, built by the same
2408
+ // resolve-through-this-file's-imports machinery `resolveResourceEntity`
2409
+ // uses for a top-level resource and `interpretNewExpression` (#1023) uses
2410
+ // for a construction inside a factory body. Nothing symbolic reaches the
2411
+ // serializer — see {@link fold}'s `new` branch for why that is the whole
2412
+ // safety argument.
2413
+ //
2414
+ // `requireLiveRefs` is propagated rather than reset: a construction in an
2415
+ // ordinary prop position keeps the top-level rule (a `{__attrRef}` inside
2416
+ // it stays an envelope, which the serializer's own walker resolves by name
2417
+ // through `propertyDeclarable` exactly as it does for a top-level
2418
+ // resource's props), while a construction inside an intrinsic's interior
2419
+ // keeps the stricter one and rejects — the receiving implementation
2420
+ // inspects what it is handed, and this is the direction that falls back to
2421
+ // run rather than emitting something wrong.
2422
+ return constructFoldedResource(value as FoldedResource, ctx, requireLiveRefs);
1454
2423
  }
1455
2424
 
1456
2425
  const revived: Record<string, unknown> = {};
@@ -1638,89 +2607,101 @@ function chantCoreRoot(): string {
1638
2607
  async function reviveFoldedProps(
1639
2608
  props: { [key: string]: FoldedValue },
1640
2609
  ctx: ResolveCtx,
2610
+ requireLiveRefs: boolean,
1641
2611
  ): Promise<Record<string, unknown>> {
1642
2612
  const revived: Record<string, unknown> = {};
1643
2613
  for (const [key, value] of Object.entries(props)) {
1644
- revived[key] = await reviveFoldedValue(value, ctx, false);
2614
+ revived[key] = await reviveFoldedValue(value, ctx, requireLiveRefs);
1645
2615
  }
1646
2616
  return revived;
1647
2617
  }
1648
2618
 
1649
2619
  // ─────────────────────────────────────────────────────────────────────────
1650
- // Resource construction — unchanged from #1022 (folds the ctor call's props
1651
- // via `fold()`, resolves the constructor through this file's imports,
1652
- // constructs the real Declarable).
2620
+ // Resource construction — #1022's mechanism (fold the ctor call's arguments
2621
+ // via `fold()`, resolve the constructor through this file's imports,
2622
+ // construct the real Declarable), split by chant #1169 into two reusable
2623
+ // halves so a NESTED `new Type(...)` used as a value is built by exactly the
2624
+ // same code as a file's own top-level resource declaration, not by a second
2625
+ // implementation that could quietly differ.
2626
+ //
2627
+ // `resolveResourceEntity` below is the top-level entry point and keeps its
2628
+ // per-declarator reason strings verbatim; `reviveResourceCtorArgs` and
2629
+ // `instantiateFoldedResource` are the shared halves, and
2630
+ // {@link constructFoldedResource} is the one-call composition of the two that
2631
+ // `reviveFoldedValue` uses for a nested construction.
1653
2632
  // ─────────────────────────────────────────────────────────────────────────
1654
2633
 
1655
- async function resolveResourceEntity(
1656
- name: string,
1657
- node: ts.NewExpression,
2634
+ /**
2635
+ * Revive a folded constructor call's arguments into the real values the class
2636
+ * will receive.
2637
+ *
2638
+ * chant #1039 — replays any folded intrinsic/symbol envelopes into their real
2639
+ * runtime values before the entity is constructed. A no-op walk when the file
2640
+ * used no registered intrinsics (the overwhelming majority of cases today).
2641
+ *
2642
+ * chant #1082 — when `spec.args` is present the constructor's argument list
2643
+ * isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter` is
2644
+ * `(type, props)`), so the whole list is revived and spread by the caller
2645
+ * instead of `spec.props`, which in that case is only a view onto one of its
2646
+ * entries and would be double-counted.
2647
+ */
2648
+ async function reviveResourceCtorArgs(
2649
+ spec: FoldedResource,
1658
2650
  ctx: ResolveCtx,
1659
- ): Promise<{ ok: true; entity: Declarable } | { ok: false; reason: string }> {
1660
- let spec: FoldedResource;
1661
- try {
1662
- spec = foldResource(node, ctx.consts, ctx.intrinsics, ctx.externals);
1663
- } catch (err) {
1664
- if (err instanceof FoldError) {
1665
- return { ok: false, reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}` };
1666
- }
1667
- throw err;
1668
- }
1669
-
1670
- // chant #1039 — replay any folded intrinsic/symbol envelopes into their
1671
- // real runtime values before constructing the entity. A no-op walk when
1672
- // this file used no registered intrinsics (the overwhelming majority of
1673
- // cases today).
1674
- //
1675
- // chant #1082 — when `spec.args` is present the constructor's argument list
1676
- // isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter`
1677
- // is `(type, props)`), so revive the whole list and spread it below instead
1678
- // of reviving `spec.props`, which in that case is only a view onto one of
1679
- // its entries and would be double-counted.
1680
- let ctorArgs: unknown[];
1681
- try {
1682
- if (spec.args) {
1683
- ctorArgs = [];
1684
- for (const arg of spec.args) ctorArgs.push(await reviveFoldedValue(arg, ctx, false));
1685
- } else {
1686
- const props = await reviveFoldedProps(spec.props, ctx);
1687
- // The runtime constructor's optional second argument (`attributes` —
1688
- // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
1689
- // ../runtime.ts) is only present in `spec` when the source actually
1690
- // passed one (see foldResource in ../fold/fold.ts). Passing `undefined`
1691
- // when it's absent matches the run path's own default
1692
- // (`attributes ?? {}` inside the constructor).
1693
- ctorArgs = [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined];
1694
- }
1695
- } catch (err) {
1696
- return {
1697
- ok: false,
1698
- reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}`,
1699
- };
2651
+ requireLiveRefs: boolean,
2652
+ ): Promise<unknown[]> {
2653
+ if (spec.args) {
2654
+ const revived: unknown[] = [];
2655
+ for (const arg of spec.args) revived.push(await reviveFoldedValue(arg, ctx, requireLiveRefs));
2656
+ return revived;
1700
2657
  }
2658
+ const props = await reviveFoldedProps(spec.props, ctx, requireLiveRefs);
2659
+ // The runtime constructor's optional second argument (`attributes` — CFN's
2660
+ // DependsOn/Condition/DeletionPolicy/…, see createResource in ../runtime.ts)
2661
+ // is only present in `spec` when the source actually passed one (see
2662
+ // foldResource in ../fold/fold.ts). Passing `undefined` when it's absent
2663
+ // matches the run path's own default (`attributes ?? {}` inside the
2664
+ // constructor).
2665
+ return [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx, requireLiveRefs) : undefined];
2666
+ }
1701
2667
 
1702
- const typeName = spec.__resource;
2668
+ /**
2669
+ * Resolve a folded constructor's NAME through `ctx`'s own `import`
2670
+ * declarations and call the real class with `ctorArgs`.
2671
+ *
2672
+ * Throws a {@link cheapError} for every failure; `resolveResourceEntity` turns
2673
+ * those into its per-declarator `reason` strings and the nested path lets them
2674
+ * fall the whole file back to run. `forClause` is the ` for "<export name>"`
2675
+ * fragment the top-level messages carry and a nested construction has no name
2676
+ * for — the only difference between the two callers' diagnostics.
2677
+ */
2678
+ async function instantiateFoldedResource(
2679
+ typeName: string,
2680
+ ctorArgs: readonly unknown[],
2681
+ ctx: ResolveCtx,
2682
+ forClause: string,
2683
+ ): Promise<unknown> {
1703
2684
  const binding = ctx.imports.get(typeName);
1704
2685
  if (!binding) {
1705
- return { ok: false, reason: `constructor "${typeName}" for "${name}" is not a resolvable import` };
2686
+ throw cheapError(`constructor "${typeName}"${forClause} is not a resolvable import`);
1706
2687
  }
1707
2688
 
1708
2689
  // chant #1093 — a resource class is a lexicon export in every corpus entry
1709
2690
  // today, but nothing forces that: `new Thing(...)` where `Thing` comes from
1710
2691
  // a project file (or an arbitrary dependency) would import and run that
1711
2692
  // module here, in the CLI's process. Same refusal as the composite-factory
1712
- // path above.
2693
+ // path above, and the reason a nested construction cannot widen the #1093
2694
+ // boundary: it reaches its class by exactly this gate.
1713
2695
  const refusal = sandboxedExecutionRefusal(binding, ctx, typeName, "constructor");
1714
- if (refusal) return { ok: false, reason: refusal };
2696
+ if (refusal) throw cheapError(refusal);
1715
2697
 
1716
2698
  let modulePath: string;
1717
2699
  try {
1718
2700
  modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1719
2701
  } catch (err) {
1720
- return {
1721
- ok: false,
1722
- reason: `could not resolve import "${binding.specifier}" for "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
1723
- };
2702
+ throw cheapError(
2703
+ `could not resolve import "${binding.specifier}" for "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2704
+ );
1724
2705
  }
1725
2706
 
1726
2707
  // Same import mechanism as `resolveCallExpression` above — see its comment.
@@ -1728,22 +2709,124 @@ async function resolveResourceEntity(
1728
2709
  try {
1729
2710
  mod = await importModuleMemoized(modulePath, ctx.importCache);
1730
2711
  } catch (err) {
1731
- return {
1732
- ok: false,
1733
- reason: `could not import "${binding.specifier}" to resolve "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
1734
- };
2712
+ throw cheapError(
2713
+ `could not import "${binding.specifier}" to resolve "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2714
+ );
1735
2715
  }
1736
2716
 
1737
2717
  const Ctor = mod[binding.imported];
1738
2718
  if (typeof Ctor !== "function") {
1739
- return { ok: false, reason: `"${binding.imported}" from "${binding.specifier}" is not a constructor` };
2719
+ throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a constructor`);
2720
+ }
2721
+
2722
+ // Constructed with exactly the arguments the source wrote.
2723
+ return new (Ctor as new (...ctorArguments: unknown[]) => unknown)(...ctorArgs);
2724
+ }
2725
+
2726
+ /**
2727
+ * chant #1169 — build a real instance from a nested `{__resource}` envelope:
2728
+ * revive its arguments, then resolve its class and call it. The composition
2729
+ * {@link reviveFoldedValue} reaches for; identical in every step to what
2730
+ * {@link resolveResourceEntity} does for a top-level declaration, which is the
2731
+ * point — a nested `new Image({...})` and a top-level `export const image = new
2732
+ * Image({...})` produce the same object, from the same class, from the same
2733
+ * resolved module path.
2734
+ */
2735
+ async function constructFoldedResource(
2736
+ spec: FoldedResource,
2737
+ ctx: ResolveCtx,
2738
+ requireLiveRefs: boolean,
2739
+ ): Promise<unknown> {
2740
+ const ctorArgs = await reviveResourceCtorArgs(spec, ctx, requireLiveRefs);
2741
+ return instantiateFoldedResource(spec.__resource, ctorArgs, ctx, "");
2742
+ }
2743
+
2744
+ /**
2745
+ * chant #1169 — construct every top-level `const x = new Type(...)` in the file
2746
+ * ONCE, in source order, before any exported declarator is resolved, and put
2747
+ * each instance in `ctx.externals` under its own name.
2748
+ *
2749
+ * This is what makes a same-file resource usable as a VALUE — `image:
2750
+ * nodeImage`, `DependsOn: [dbCluster]`, `export { app }` — and it is the half
2751
+ * of the #1169 gate the nested-`new` lift alone does not reach: the
2752
+ * `{__resource}` envelope covers a construction written INLINE at the value
2753
+ * position, while the far more common authoring shape names it once and refers
2754
+ * to it. `fold()` cannot answer that reference on its own (it is synchronous,
2755
+ * and constructing needs the module graph), so it defers to `externals` — see
2756
+ * its identifier branch for the full argument.
2757
+ *
2758
+ * ONE instance, and identity is the whole point. Every reference in the file
2759
+ * reads this map, and the exported-declarator loop reuses the same object
2760
+ * through `prebuilt` rather than constructing a second one, so a resource
2761
+ * referenced by name and the entity discovery registers are the same object —
2762
+ * which is what makes the serializer's `Ref`/`DependsOn` resolution land on a
2763
+ * logical name at all. Running the module top-to-bottom produces exactly this:
2764
+ * every top-level `const` evaluated once, in order, later ones seeing earlier
2765
+ * ones. Order matters and is preserved — `collectConsts` yields source order, so
2766
+ * `const b = new Thing({ x: a })` finds `a` already built.
2767
+ *
2768
+ * A construction that FAILS is skipped silently rather than failing the file:
2769
+ * the name stays absent from `externals`, so a reference to it rejects with the
2770
+ * identical message it produced before this existed, and an EXPORTED one falls
2771
+ * through to {@link resolveResourceEntity}, which reproduces the failure with
2772
+ * its own located reason. Strictly additive.
2773
+ *
2774
+ * Under `--sandbox` every construction here goes through the same
2775
+ * {@link sandboxedExecutionRefusal} as every other one, so a project-defined
2776
+ * class refuses, the name stays unresolved, and the file demotes to the
2777
+ * sandboxed child exactly as before. Under plain `--fold` this can import a
2778
+ * constructor's module for a const that is never exported — work the RUN path
2779
+ * performs unconditionally for the same file, and through the same
2780
+ * already-memoized `importModule`.
2781
+ */
2782
+ async function preresolveResourceConsts(ctx: ResolveCtx): Promise<Map<ts.Expression, unknown>> {
2783
+ const built = new Map<ts.Expression, unknown>();
2784
+ for (const [name, initializer] of ctx.consts) {
2785
+ if (!ts.isNewExpression(initializer) || !ts.isIdentifier(initializer.expression)) continue;
2786
+ try {
2787
+ const spec = foldResource(initializer, ctx.consts, ctx.intrinsics, ctx.externals);
2788
+ const instance = await constructFoldedResource(spec, ctx, false);
2789
+ built.set(initializer, instance);
2790
+ ctx.externals.set(name, instance);
2791
+ } catch {
2792
+ // Not constructible here (an unresolvable constructor import, a prop
2793
+ // outside the fold subset, a --sandbox refusal). Leave the name alone.
2794
+ }
2795
+ }
2796
+ return built;
2797
+ }
2798
+
2799
+ async function resolveResourceEntity(
2800
+ name: string,
2801
+ node: ts.NewExpression,
2802
+ ctx: ResolveCtx,
2803
+ ): Promise<{ ok: true; entity: Declarable } | { ok: false; reason: string }> {
2804
+ let spec: FoldedResource;
2805
+ try {
2806
+ spec = foldResource(node, ctx.consts, ctx.intrinsics, ctx.externals);
2807
+ } catch (err) {
2808
+ if (err instanceof FoldError) {
2809
+ return { ok: false, reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}` };
2810
+ }
2811
+ throw err;
2812
+ }
2813
+
2814
+ let ctorArgs: unknown[];
2815
+ try {
2816
+ ctorArgs = await reviveResourceCtorArgs(spec, ctx, false);
2817
+ } catch (err) {
2818
+ return {
2819
+ ok: false,
2820
+ reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}`,
2821
+ };
1740
2822
  }
1741
2823
 
1742
- // Constructed with exactly the arguments the source wrote (see the revival
1743
- // block above for how `ctorArgs` was built for each of the two shapes).
1744
- const ResourceCtor = Ctor as new (...ctorArguments: unknown[]) => Declarable;
1745
- const entity = new ResourceCtor(...ctorArgs);
1746
- return { ok: true, entity };
2824
+ try {
2825
+ const entity = (await instantiateFoldedResource(spec.__resource, ctorArgs, ctx, ` for "${name}"`)) as Declarable;
2826
+ return { ok: true, entity };
2827
+ } catch (err) {
2828
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
2829
+ }
1747
2830
  }
1748
2831
 
1749
2832
  // ─────────────────────────────────────────────────────────────────────────
@@ -2085,13 +3168,30 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
2085
3168
  resolvePathCache: session.resolvePathCache,
2086
3169
  lexiconPackages: session.lexiconPackages,
2087
3170
  sandbox: session.sandbox,
3171
+ session,
3172
+ interpretDepth: 0,
2088
3173
  };
2089
3174
 
3175
+ // chant #1169 — every same-file `const x = new Type(...)`, built once, in
3176
+ // source order, before anything references one. See
3177
+ // {@link preresolveResourceConsts}.
3178
+ const prebuiltResources = await preresolveResourceConsts(ctx);
3179
+
2090
3180
  const entities: FoldedEntity[] = [];
2091
3181
  const exportedValues = new Map<string, unknown>();
2092
3182
 
2093
3183
  for (const decl of scan.declarators) {
2094
3184
  if (decl.kind === "resource") {
3185
+ // chant #1169 — the pre-pass already built this exact node. Reuse that
3186
+ // instance rather than constructing a second one: a sibling prop that
3187
+ // referenced this resource by name holds the pre-pass object, and if
3188
+ // the entity discovery registers were a different one, the reference
3189
+ // would have no logical name to resolve against.
3190
+ const prebuilt = prebuiltResources.get(decl.node);
3191
+ if (prebuilt !== undefined) {
3192
+ applyResolvedValue(decl.name, prebuilt, entities, exportedValues);
3193
+ continue;
3194
+ }
2095
3195
  const result = await resolveResourceEntity(decl.name, decl.node, ctx);
2096
3196
  if (!result.ok) return result;
2097
3197
  applyResolvedValue(decl.name, result.entity, entities, exportedValues);