@intentius/chant 0.27.0 → 0.29.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/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/graph.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/config.d.ts +46 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +153 -17
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  13. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +25 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  18. package/dist/env.d.ts +5 -2
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts +12 -0
  21. package/dist/fold/fold.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +29 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +100 -0
  27. package/dist/kubectl-context.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +31 -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/live-diff.d.ts +25 -1
  33. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  34. package/dist/lifecycle/observe.d.ts +4 -2
  35. package/dist/lifecycle/observe.d.ts.map +1 -1
  36. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  37. package/dist/lifecycle/status.d.ts +26 -1
  38. package/dist/lifecycle/status.d.ts.map +1 -1
  39. package/dist/lifecycle/types.d.ts +8 -0
  40. package/dist/lifecycle/types.d.ts.map +1 -1
  41. package/dist/lint/config.d.ts +80 -0
  42. package/dist/lint/config.d.ts.map +1 -1
  43. package/dist/lint/policy.d.ts +8 -2
  44. package/dist/lint/policy.d.ts.map +1 -1
  45. package/dist/lint/post-synth.d.ts +18 -1
  46. package/dist/lint/post-synth.d.ts.map +1 -1
  47. package/dist/live-endpoint.d.ts +92 -0
  48. package/dist/live-endpoint.d.ts.map +1 -0
  49. package/dist/observation.d.ts +123 -0
  50. package/dist/observation.d.ts.map +1 -0
  51. package/dist/stack-output.d.ts +9 -4
  52. package/dist/stack-output.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/commands/build.test.ts +190 -0
  55. package/src/cli/commands/build.ts +34 -2
  56. package/src/cli/commands/lint.ts +17 -25
  57. package/src/cli/handlers/components.test.ts +63 -4
  58. package/src/cli/handlers/components.ts +78 -35
  59. package/src/cli/handlers/graph.test.ts +69 -6
  60. package/src/cli/handlers/graph.ts +61 -27
  61. package/src/cli/handlers/lifecycle.test.ts +285 -6
  62. package/src/cli/handlers/lifecycle.ts +297 -185
  63. package/src/config.test.ts +75 -0
  64. package/src/config.ts +61 -3
  65. package/src/discovery/fold-composite.test.ts +594 -0
  66. package/src/discovery/fold-import.ts +987 -43
  67. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  68. package/src/discovery/sandbox/config-run.ts +3 -0
  69. package/src/discovery/sandbox/config-wire.ts +3 -2
  70. package/src/discovery/sandbox/fork.ts +75 -1
  71. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  72. package/src/discovery/sandbox/policy-run.ts +15 -1
  73. package/src/discovery/sandbox/run.test.ts +85 -1
  74. package/src/discovery/sandbox/run.ts +3 -0
  75. package/src/env.test.ts +12 -0
  76. package/src/env.ts +12 -4
  77. package/src/fold/fold.ts +12 -2
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +68 -12
  80. package/src/index.ts +1 -0
  81. package/src/kubectl-context.test.ts +94 -0
  82. package/src/kubectl-context.ts +207 -0
  83. package/src/lexicon.ts +41 -6
  84. package/src/lifecycle/change-set.test.ts +93 -1
  85. package/src/lifecycle/change-set.ts +65 -13
  86. package/src/lifecycle/live-diff.test.ts +39 -0
  87. package/src/lifecycle/live-diff.ts +51 -5
  88. package/src/lifecycle/observe.test.ts +74 -3
  89. package/src/lifecycle/observe.ts +82 -22
  90. package/src/lifecycle/snapshot.test.ts +39 -1
  91. package/src/lifecycle/snapshot.ts +34 -9
  92. package/src/lifecycle/status.test.ts +89 -8
  93. package/src/lifecycle/status.ts +53 -3
  94. package/src/lifecycle/types.ts +8 -0
  95. package/src/lint/config.test.ts +93 -1
  96. package/src/lint/config.ts +108 -0
  97. package/src/lint/policy.test.ts +90 -0
  98. package/src/lint/policy.ts +17 -5
  99. package/src/lint/post-synth.test.ts +4 -0
  100. package/src/lint/post-synth.ts +30 -1
  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 +76 -3
  106. package/src/stack-output.ts +59 -26
@@ -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,767 @@ 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}: that one
2147
+ * folds its arguments with {@link foldResource}, which rejects a nested `new`
2148
+ * as a value because a top-level fold has no way to construct one. Here there
2149
+ * IS a way — the defining module's imports are in hand — so each argument is
2150
+ * interpreted recursively and the constructor is called with exactly the
2151
+ * arguments the source wrote, which is what the run path does.
2152
+ */
2153
+ async function interpretNewExpression(node: ts.NewExpression, ctx: ResolveCtx): Promise<unknown> {
2154
+ // Guaranteed an identifier by {@link checkFactoryExpression}; re-checked so a
2155
+ // future caller can't reach this with a dotted callee and get a silent miss.
2156
+ if (!ts.isIdentifier(node.expression)) {
2157
+ throw cheapError(`\`new ${briefNodeText(node.expression)}(...)\` needs a plain imported constructor`);
2158
+ }
2159
+ const typeName = node.expression.text;
2160
+ const binding = ctx.imports.get(typeName);
2161
+ if (!binding) throw cheapError(`constructor "${typeName}" is not a resolvable import`);
2162
+
2163
+ // chant #1093 — same gate, same reason, as every other site that imports a
2164
+ // module in order to execute something from it.
2165
+ const refusal = sandboxedExecutionRefusal(binding, ctx, typeName, "constructor");
2166
+ if (refusal) throw cheapError(refusal);
2167
+
2168
+ const modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
2169
+ const mod = await importModuleMemoized(modulePath, ctx.importCache);
2170
+ const Ctor = mod[binding.imported];
2171
+ if (typeof Ctor !== "function") {
2172
+ throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a constructor`);
2173
+ }
2174
+
2175
+ const ctorArgs: unknown[] = [];
2176
+ for (const arg of node.arguments ?? []) ctorArgs.push(await interpretExpression(arg, ctx));
2177
+ return new (Ctor as new (...ctorArguments: unknown[]) => unknown)(...ctorArgs);
1236
2178
  }
1237
2179
 
1238
2180
  /**
@@ -2085,6 +3027,8 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
2085
3027
  resolvePathCache: session.resolvePathCache,
2086
3028
  lexiconPackages: session.lexiconPackages,
2087
3029
  sandbox: session.sandbox,
3030
+ session,
3031
+ interpretDepth: 0,
2088
3032
  };
2089
3033
 
2090
3034
  const entities: FoldedEntity[] = [];