@intentius/chant 0.29.0 → 0.31.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 (60) hide show
  1. package/dist/cli/handlers/graph.d.ts.map +1 -1
  2. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +14 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/codegen/generate.d.ts +16 -0
  7. package/dist/codegen/generate.d.ts.map +1 -1
  8. package/dist/deep-observation.d.ts +257 -0
  9. package/dist/deep-observation.d.ts.map +1 -0
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/fold/fold.d.ts +23 -3
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +9 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/graph-ir.d.ts +44 -0
  16. package/dist/graph-ir.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/kubectl-context.d.ts +18 -1
  20. package/dist/kubectl-context.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +47 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/deep-diff.d.ts +103 -0
  24. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  25. package/dist/lifecycle/deep-observe.d.ts +62 -0
  26. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  27. package/dist/lifecycle/index.d.ts +3 -0
  28. package/dist/lifecycle/index.d.ts.map +1 -1
  29. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  30. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  31. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/handlers/graph.test.ts +86 -0
  34. package/src/cli/handlers/graph.ts +64 -3
  35. package/src/cli/handlers/lifecycle.test.ts +126 -1
  36. package/src/cli/handlers/lifecycle.ts +184 -3
  37. package/src/cli/main.test.ts +6 -0
  38. package/src/cli/main.ts +12 -0
  39. package/src/cli/registry.ts +14 -0
  40. package/src/codegen/generate.ts +25 -0
  41. package/src/deep-observation.test.ts +234 -0
  42. package/src/deep-observation.ts +489 -0
  43. package/src/discovery/fold-import.test.ts +372 -1
  44. package/src/discovery/fold-import.ts +235 -79
  45. package/src/fold/fold.test.ts +105 -0
  46. package/src/fold/fold.ts +88 -18
  47. package/src/fold/subset.test.ts +38 -7
  48. package/src/fold/subset.ts +9 -0
  49. package/src/graph-ir.ts +47 -0
  50. package/src/index.ts +1 -0
  51. package/src/kubectl-context.ts +22 -2
  52. package/src/lexicon.ts +59 -0
  53. package/src/lifecycle/deep-diff.test.ts +157 -0
  54. package/src/lifecycle/deep-diff.ts +213 -0
  55. package/src/lifecycle/deep-observe.test.ts +174 -0
  56. package/src/lifecycle/deep-observe.ts +173 -0
  57. package/src/lifecycle/index.ts +3 -0
  58. package/src/lifecycle/observation-baseline.test.ts +99 -0
  59. package/src/lifecycle/observation-baseline.ts +217 -0
  60. package/src/lifecycle/snapshot.ts +6 -11
@@ -2143,12 +2143,18 @@ async function interpretExpression(node: ts.Expression, ctx: ResolveCtx): Promis
2143
2143
  * Construct a real resource from a `new Type(...)` anywhere inside a factory
2144
2144
  * body — a member, a nested property object, an array element.
2145
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.
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.
2152
2158
  */
2153
2159
  async function interpretNewExpression(node: ts.NewExpression, ctx: ResolveCtx): Promise<unknown> {
2154
2160
  // Guaranteed an identifier by {@link checkFactoryExpression}; re-checked so a
@@ -2235,10 +2241,16 @@ function applyResolvedValue(
2235
2241
  // envelope structurally (see ../serializer-walker.ts) with no revival
2236
2242
  // needed. But an intrinsic's OWN implementation (e.g. `SubIntrinsic`) needs
2237
2243
  // a genuine `AttrRef` instance internally (`instanceof` checks), which would
2238
- // require wiring a live `WeakRef` to the sibling entity — out of scope here,
2239
- // same call as the existing "nested `new Type(...)` as a value" rejection
2240
- // a few lines up: reject (fall back to run) rather than risk silently wrong
2241
- // 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}.
2242
2254
  // ─────────────────────────────────────────────────────────────────────────
2243
2255
 
2244
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. */
@@ -2390,9 +2402,24 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiv
2390
2402
  }
2391
2403
 
2392
2404
  if ("__resource" in value) {
2393
- // fold() itself already rejects a nested `new Type(...)` as a value
2394
- // (see its own comment) this is defensive, not a reachable path today.
2395
- 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);
2396
2423
  }
2397
2424
 
2398
2425
  const revived: Record<string, unknown> = {};
@@ -2580,89 +2607,101 @@ function chantCoreRoot(): string {
2580
2607
  async function reviveFoldedProps(
2581
2608
  props: { [key: string]: FoldedValue },
2582
2609
  ctx: ResolveCtx,
2610
+ requireLiveRefs: boolean,
2583
2611
  ): Promise<Record<string, unknown>> {
2584
2612
  const revived: Record<string, unknown> = {};
2585
2613
  for (const [key, value] of Object.entries(props)) {
2586
- revived[key] = await reviveFoldedValue(value, ctx, false);
2614
+ revived[key] = await reviveFoldedValue(value, ctx, requireLiveRefs);
2587
2615
  }
2588
2616
  return revived;
2589
2617
  }
2590
2618
 
2591
2619
  // ─────────────────────────────────────────────────────────────────────────
2592
- // Resource construction — unchanged from #1022 (folds the ctor call's props
2593
- // via `fold()`, resolves the constructor through this file's imports,
2594
- // 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.
2595
2632
  // ─────────────────────────────────────────────────────────────────────────
2596
2633
 
2597
- async function resolveResourceEntity(
2598
- name: string,
2599
- 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,
2600
2650
  ctx: ResolveCtx,
2601
- ): Promise<{ ok: true; entity: Declarable } | { ok: false; reason: string }> {
2602
- let spec: FoldedResource;
2603
- try {
2604
- spec = foldResource(node, ctx.consts, ctx.intrinsics, ctx.externals);
2605
- } catch (err) {
2606
- if (err instanceof FoldError) {
2607
- return { ok: false, reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}` };
2608
- }
2609
- throw err;
2610
- }
2611
-
2612
- // chant #1039 — replay any folded intrinsic/symbol envelopes into their
2613
- // real runtime values before constructing the entity. A no-op walk when
2614
- // this file used no registered intrinsics (the overwhelming majority of
2615
- // cases today).
2616
- //
2617
- // chant #1082 — when `spec.args` is present the constructor's argument list
2618
- // isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter`
2619
- // is `(type, props)`), so revive the whole list and spread it below instead
2620
- // of reviving `spec.props`, which in that case is only a view onto one of
2621
- // its entries and would be double-counted.
2622
- let ctorArgs: unknown[];
2623
- try {
2624
- if (spec.args) {
2625
- ctorArgs = [];
2626
- for (const arg of spec.args) ctorArgs.push(await reviveFoldedValue(arg, ctx, false));
2627
- } else {
2628
- const props = await reviveFoldedProps(spec.props, ctx);
2629
- // The runtime constructor's optional second argument (`attributes` —
2630
- // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
2631
- // ../runtime.ts) is only present in `spec` when the source actually
2632
- // passed one (see foldResource in ../fold/fold.ts). Passing `undefined`
2633
- // when it's absent matches the run path's own default
2634
- // (`attributes ?? {}` inside the constructor).
2635
- ctorArgs = [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined];
2636
- }
2637
- } catch (err) {
2638
- return {
2639
- ok: false,
2640
- reason: `"${name}" is not foldable: ${describeFoldFailure(err, ctx)}`,
2641
- };
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;
2642
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
+ }
2643
2667
 
2644
- 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> {
2645
2684
  const binding = ctx.imports.get(typeName);
2646
2685
  if (!binding) {
2647
- return { ok: false, reason: `constructor "${typeName}" for "${name}" is not a resolvable import` };
2686
+ throw cheapError(`constructor "${typeName}"${forClause} is not a resolvable import`);
2648
2687
  }
2649
2688
 
2650
2689
  // chant #1093 — a resource class is a lexicon export in every corpus entry
2651
2690
  // today, but nothing forces that: `new Thing(...)` where `Thing` comes from
2652
2691
  // a project file (or an arbitrary dependency) would import and run that
2653
2692
  // module here, in the CLI's process. Same refusal as the composite-factory
2654
- // 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.
2655
2695
  const refusal = sandboxedExecutionRefusal(binding, ctx, typeName, "constructor");
2656
- if (refusal) return { ok: false, reason: refusal };
2696
+ if (refusal) throw cheapError(refusal);
2657
2697
 
2658
2698
  let modulePath: string;
2659
2699
  try {
2660
2700
  modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
2661
2701
  } catch (err) {
2662
- return {
2663
- ok: false,
2664
- reason: `could not resolve import "${binding.specifier}" for "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2665
- };
2702
+ throw cheapError(
2703
+ `could not resolve import "${binding.specifier}" for "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2704
+ );
2666
2705
  }
2667
2706
 
2668
2707
  // Same import mechanism as `resolveCallExpression` above — see its comment.
@@ -2670,22 +2709,124 @@ async function resolveResourceEntity(
2670
2709
  try {
2671
2710
  mod = await importModuleMemoized(modulePath, ctx.importCache);
2672
2711
  } catch (err) {
2673
- return {
2674
- ok: false,
2675
- reason: `could not import "${binding.specifier}" to resolve "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2676
- };
2712
+ throw cheapError(
2713
+ `could not import "${binding.specifier}" to resolve "${typeName}": ${err instanceof Error ? err.message : String(err)}`,
2714
+ );
2677
2715
  }
2678
2716
 
2679
2717
  const Ctor = mod[binding.imported];
2680
2718
  if (typeof Ctor !== "function") {
2681
- 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`);
2682
2720
  }
2683
2721
 
2684
- // Constructed with exactly the arguments the source wrote (see the revival
2685
- // block above for how `ctorArgs` was built for each of the two shapes).
2686
- const ResourceCtor = Ctor as new (...ctorArguments: unknown[]) => Declarable;
2687
- const entity = new ResourceCtor(...ctorArgs);
2688
- return { ok: true, entity };
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
+ };
2822
+ }
2823
+
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
+ }
2689
2830
  }
2690
2831
 
2691
2832
  // ─────────────────────────────────────────────────────────────────────────
@@ -3031,11 +3172,26 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3031
3172
  interpretDepth: 0,
3032
3173
  };
3033
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
+
3034
3180
  const entities: FoldedEntity[] = [];
3035
3181
  const exportedValues = new Map<string, unknown>();
3036
3182
 
3037
3183
  for (const decl of scan.declarators) {
3038
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
+ }
3039
3195
  const result = await resolveResourceEntity(decl.name, decl.node, ctx);
3040
3196
  if (!result.ok) return result;
3041
3197
  applyResolvedValue(decl.name, result.entity, entities, exportedValues);
@@ -810,3 +810,108 @@ describe("foldModule", () => {
810
810
  expect(result.bad?.ok).toBe(false);
811
811
  });
812
812
  });
813
+
814
+ /**
815
+ * chant #1169 — a `new Type(...)` used as a VALUE.
816
+ *
817
+ * `fold()` produces the same {@link FoldedResource} envelope for a nested
818
+ * construction as for a top-level one, and it is symbolic in exactly the sense
819
+ * `{__intrinsic}`/`{__helper}` are: nothing is executed here, the constructor's
820
+ * NAME and folded arguments are recorded, and ../discovery/fold-import.ts
821
+ * resolves the name through the file's own imports and builds the real
822
+ * instance. These tests own the envelope; the construction half is
823
+ * ../discovery/fold-import.test.ts's.
824
+ */
825
+ describe("constructions as values (chant #1169)", () => {
826
+ test("a nested construction folds to a `{__resource}` envelope, recursively", () => {
827
+ const src = `
828
+ export const job = new Job({
829
+ image: new Image({ name: "node:22" }),
830
+ rules: [new Rule({ if: "$CI" })],
831
+ deep: { inner: new Guest({ cpus: new Count({ n: 1 }) }) },
832
+ });
833
+ `;
834
+ expect(foldModule(src).job).toEqual({
835
+ ok: true,
836
+ spec: {
837
+ __resource: "Job",
838
+ props: {
839
+ image: { __resource: "Image", props: { name: "node:22" } },
840
+ rules: [{ __resource: "Rule", props: { if: "$CI" } }],
841
+ deep: {
842
+ inner: { __resource: "Guest", props: { cpus: { __resource: "Count", props: { n: 1 } } } },
843
+ },
844
+ },
845
+ },
846
+ });
847
+ });
848
+
849
+ test("a nested construction with a non-props-first signature keeps its positional `args`", () => {
850
+ // chant #1082's shape, nested. `props` stays the first object-literal
851
+ // argument (a view, for readers); `args` is what the entity is built from.
852
+ const src = `export const p = new Outer({ x: new Parameter("String", { Default: "dev" }) });`;
853
+ const result = foldModule(src);
854
+ expect(result.p?.ok).toBe(true);
855
+ if (!result.p?.ok) return;
856
+ expect(result.p.spec.props.x).toEqual({
857
+ __resource: "Parameter",
858
+ props: { Default: "dev" },
859
+ args: ["String", { Default: "dev" }],
860
+ });
861
+ });
862
+
863
+ test("a nested `new ns.Type(...)` is rejected — the class must be reachable through a named import", () => {
864
+ const src = `export const job = new Job({ image: new ns.Image({ name: "x" }) });`;
865
+ const result = foldModule(src);
866
+ expect(result.job?.ok).toBe(false);
867
+ if (result.job?.ok) return;
868
+ expect(result.job?.error).toContain("needs a plain imported constructor");
869
+ });
870
+
871
+ test("a BARE reference to a same-file resource const is rejected without a caller that can construct", () => {
872
+ // `fold()` on its own has no module graph, so it cannot hand back the
873
+ // instance the reference means — and re-folding the initializer would build
874
+ // a duplicate. Rejected; `externals` (the bridge's pre-resolved same-file
875
+ // instances) is the only thing that answers this.
876
+ const src = `
877
+ const db = new DbCluster({ engine: "aurora" });
878
+ export const worker = new Instance({ needs: [db] });
879
+ `;
880
+ const result = foldModule(src);
881
+ expect(result.worker?.ok).toBe(false);
882
+ if (result.worker?.ok) return;
883
+ expect(result.worker?.error).toContain("same-file resource `db` used as a value is not foldable");
884
+ });
885
+
886
+ test("`externals` answers a same-file resource reference when a caller pre-resolved it", () => {
887
+ const src = `
888
+ const db = new DbCluster({ engine: "aurora" });
889
+ export const worker = new Instance({ needs: [db] });
890
+ `;
891
+ const sourceFile = ts.createSourceFile("t.ts", src, ts.ScriptTarget.Latest, true);
892
+ const consts = collectConsts(sourceFile);
893
+ const liveDb = { pretendInstance: true };
894
+
895
+ const folded = fold(consts.get("worker") as ts.Expression, consts, [], new Map([["db", liveDb]]));
896
+ // The exact object, not a copy of it: identity is what makes the reference
897
+ // and the registered entity the same thing.
898
+ expect((folded as { props: { needs: unknown[] } }).props.needs[0]).toBe(liveDb);
899
+ });
900
+
901
+ test("an ATTRIBUTE reference to a same-file resource is unaffected — still the `{__attrRef}` envelope", () => {
902
+ const src = `
903
+ const db = new DbCluster({ engine: "aurora" });
904
+ export const worker = new Instance({ host: db.Endpoint });
905
+ `;
906
+ const sourceFile = ts.createSourceFile("t.ts", src, ts.ScriptTarget.Latest, true);
907
+ const consts = collectConsts(sourceFile);
908
+
909
+ const folded = fold(consts.get("worker") as ts.Expression, consts, [], new Map([["db", { live: true }]]));
910
+ // `consts` is consulted before `externals` for a property access, so a
911
+ // pre-resolved instance does not change what a sibling attribute reference
912
+ // folds to.
913
+ expect((folded as { props: Record<string, unknown> }).props.host).toEqual({
914
+ __attrRef: { entity: "db", attribute: "Endpoint" },
915
+ });
916
+ });
917
+ });
package/src/fold/fold.ts CHANGED
@@ -27,12 +27,23 @@ import { isFoldableHelperName } from "./foldable-helpers";
27
27
  * cross-resource `{ __attrRef }` case, literal-key-only), unary `!`/`-`,
28
28
  * the binary operators `+ - * / === !== > < >= <=`, short-circuit
29
29
  * `&& || ??`, conditional expressions, `as`/`satisfies`/`!`/parenthesized
30
- * unwrapping, a nested `new Type({...})` resource-as-value, and registered
31
- * lexicon intrinsic tagged templates — is defined ONCE, in {@link "./subset"}
32
- * ({@link findSubsetViolation}), and shared with EVL001/EVL003
30
+ * unwrapping, a nested `new Type({...})` resource-as-value (chant #1169), and
31
+ * registered lexicon intrinsic tagged templates — is defined ONCE, in
32
+ * {@link "./subset"} ({@link findSubsetViolation}), and shared with EVL001/EVL003
33
33
  * ({@link "../lint/rules/evl001-non-literal-expression"}), so the linted
34
34
  * subset and the folded subset can never drift apart (#1024).
35
35
  *
36
+ * chant #1169 closed the largest of the documented fold/EVL divergences in the
37
+ * process: a nested `new Type(...)` used as a value was shape-valid for
38
+ * `./subset` and rejected by `fold()`, because `fold()` could only produce the
39
+ * `{__resource, props}` envelope and nothing constructed it. Now it folds to
40
+ * that envelope and ../discovery/fold-import.ts constructs the REAL instance
41
+ * from it. The remaining divergences are the resolution-dependent ones
42
+ * ./subset's own module doc enumerates, plus one this change adds in the same
43
+ * safe direction: a BARE identifier bound to a same-file `new` is shape-valid
44
+ * there and rejected here, because folding it would build a duplicate of a
45
+ * resource discovery already registered — see {@link fold}'s identifier branch.
46
+ *
36
47
  * A `CallExpression` has almost no case — a function call as a value is
37
48
  * structurally unrepresentable, not merely linted against. Composite
38
49
  * factory calls are out of scope here (epic Phase 5, #1023). There are
@@ -170,6 +181,15 @@ export interface SymbolicValue {
170
181
 
171
182
  /**
172
183
  * The result of folding a resource constructor: `new Type({ ...props })`.
184
+ *
185
+ * chant #1169 — produced for a NESTED `new Type(...)` used as a value too, not
186
+ * just for a file's own top-level resource declaration. It is symbolic in
187
+ * exactly the sense {@link FoldedIntrinsic} and {@link FoldedHelperCall} are:
188
+ * `fold()` executes nothing, it records which constructor the source named and
189
+ * with what arguments. ../discovery/fold-import.ts resolves that name through
190
+ * the folding file's own imports and calls the real class, so a folded
191
+ * construction and a run construction are the same construction. An envelope
192
+ * must never reach a serializer — see the `new` branch of {@link fold}.
173
193
  */
174
194
  export interface FoldedResource {
175
195
  __resource: string;
@@ -546,7 +566,37 @@ export function fold(
546
566
  }
547
567
  throw foldError(node, `unresolved identifier: ${node.text}`);
548
568
  }
549
- return fold(consts.get(node.text) as ts.Expression, consts, intrinsics, externals);
569
+ const initializer = consts.get(node.text) as ts.Expression;
570
+ // chant #1169 — a BARE reference to a same-file `const x = new T(...)` is a
571
+ // reference to THAT resource instance, and the ONE thing it must never
572
+ // become is a second one. Re-folding the initializer here would do exactly
573
+ // that now that the `new` branch below constructs: the consumer would get a
574
+ // duplicate object discovery never registered, whose `AttrRef`s could never
575
+ // be assigned a logical name ("Cannot serialize AttrRef …: logical name not
576
+ // set") and whose `Ref` would silently inline instead of referencing. A
577
+ // crash or wrong output, not drift.
578
+ //
579
+ // So `externals` — and ONLY `externals` — answers this one. A caller with a
580
+ // module graph (../discovery/fold-import.ts) pre-resolves each of this
581
+ // file's `new`-valued consts to ONE instance, in source order, before any
582
+ // declarator is folded, and puts it here; every reference in the file then
583
+ // reads that same object, exactly as running the module top-to-bottom
584
+ // would. A caller without one (`foldModule`, a unit test) has no way to
585
+ // construct anything, so the reference stays a rejection and the file falls
586
+ // back to run.
587
+ //
588
+ // `bucket.name` is a different question and keeps its answer regardless: the
589
+ // property-access branch below consults `consts` first, so a sibling
590
+ // attribute reference is still the symbolic `{__attrRef}` the serializer
591
+ // resolves by NAME. Nothing about the existing envelope changes.
592
+ if (ts.isNewExpression(initializer)) {
593
+ if (externals?.has(node.text)) return externals.get(node.text) as FoldedValue;
594
+ throw foldError(
595
+ node,
596
+ `same-file resource \`${node.text}\` used as a value is not foldable — falls back to run`,
597
+ );
598
+ }
599
+ return fold(initializer, consts, intrinsics, externals);
550
600
  }
551
601
 
552
602
  if (ts.isPropertyAccessExpression(node)) {
@@ -636,20 +686,40 @@ export function fold(
636
686
  }
637
687
 
638
688
  if (ts.isNewExpression(node)) {
639
- // A nested `new Type({...})` used as a property VALUE is not leaf-foldable.
640
- // fold can only produce the {__resource, props} envelope, and unlike a
641
- // TOP-LEVEL resource, which fold-import constructs into a real Declarable
642
- // a nested one is never constructed, so the envelope leaks into serialization
643
- // as the wrong value (real fold-vs-run drift; the #1025 differential caught
644
- // this on gitlab/multi-stage-deploy, where `new Image({...})` as a job's
645
- // `image:` must serialize as `{ name }`, not `{ __resource, props }`).
646
- // Reject so the file falls back to run, which constructs and serializes it
647
- // correctly. EVL permits this statically — it's a documented fold/EVL
648
- // divergence, like identifier resolution and spread runtime type.
649
- throw foldError(
650
- node,
651
- `nested \`new ${briefNodeText(node.expression)}(...)\` as a value is not foldable falls back to run`,
652
- );
689
+ // chant #1169 — a nested `new Type({...})` used as a property VALUE folds
690
+ // to the SAME {@link FoldedResource} envelope a top-level resource
691
+ // declaration does, and is constructed for real by the same bridge.
692
+ //
693
+ // This used to be an unconditional rejection, and the reason it was is
694
+ // worth keeping in view: `fold()` alone can only produce the envelope, and
695
+ // an envelope that reaches serialization is the wrong value the #1025
696
+ // differential caught exactly that on gitlab/multi-stage-deploy, where
697
+ // `new Image({...})` as a job's `image:` must serialize as the constructed
698
+ // Image's own shape, not as `{__resource, props}`. What changed is not the
699
+ // envelope's safety but who consumes it: ../discovery/fold-import.ts's
700
+ // `reviveFoldedValue` now REVIVES a `{__resource}` node into a real
701
+ // instance, built by the class the file's own `import` names, resolved
702
+ // through the same provenance-checked machinery that already constructs a
703
+ // top-level resource (and that #1023's factory interpreter already uses to
704
+ // construct a nested `new` inside a factory body — the asymmetry that
705
+ // motivated this change). Nothing symbolic survives into the serializer:
706
+ // the value the outer constructor receives is the same object the run path
707
+ // would have handed it, with the same prototype, the same `props`, and the
708
+ // same `toJSON`/`kind` the serializer's walker dispatches on.
709
+ //
710
+ // Only a PLAIN IDENTIFIER constructor is admissible. `new ns.Type(...)`
711
+ // cannot be resolved to a live class through the file's named imports, so
712
+ // it stays a rejection rather than folding to an envelope nothing can
713
+ // revive — the same bare-identifier rule #1023's `checkFactoryExpression`
714
+ // applies to a body-level construction, and the same one `fold()` applies
715
+ // to an intrinsic call and an authoring helper.
716
+ if (!ts.isIdentifier(node.expression)) {
717
+ throw foldError(
718
+ node,
719
+ `nested \`new ${briefNodeText(node.expression)}(...)\` as a value needs a plain imported constructor — falls back to run`,
720
+ );
721
+ }
722
+ return foldResource(node, consts, intrinsics, externals);
653
723
  }
654
724
 
655
725
  if (ts.isCallExpression(node)) {