@intentius/chant 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +23 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +44 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +86 -0
- package/src/cli/handlers/graph.ts +64 -3
- package/src/cli/handlers/lifecycle.test.ts +126 -1
- package/src/cli/handlers/lifecycle.ts +184 -3
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +235 -79
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +88 -18
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir.ts +47 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +59 -0
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- 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}:
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2151
|
-
*
|
|
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
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
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
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
|
|
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,
|
|
2614
|
+
revived[key] = await reviveFoldedValue(value, ctx, requireLiveRefs);
|
|
2587
2615
|
}
|
|
2588
2616
|
return revived;
|
|
2589
2617
|
}
|
|
2590
2618
|
|
|
2591
2619
|
// ─────────────────────────────────────────────────────────────────────────
|
|
2592
|
-
// Resource construction —
|
|
2593
|
-
// via `fold()`,
|
|
2594
|
-
//
|
|
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
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
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
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
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
|
-
|
|
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
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
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);
|
package/src/fold/fold.test.ts
CHANGED
|
@@ -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
|
|
31
|
-
* lexicon intrinsic tagged templates — is defined ONCE, in
|
|
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
|
-
|
|
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
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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)) {
|