@intentius/chant 0.20.0 → 0.21.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 (45) hide show
  1. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
  2. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/codegen/docs-types.d.ts +2 -0
  5. package/dist/codegen/docs-types.d.ts.map +1 -1
  6. package/dist/declarable.d.ts +16 -0
  7. package/dist/declarable.d.ts.map +1 -1
  8. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +30 -1
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/index.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts +89 -16
  13. package/dist/fold/fold.d.ts.map +1 -1
  14. package/dist/fold/foldable-helpers.d.ts +121 -0
  15. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  16. package/dist/fold/subset.d.ts +35 -3
  17. package/dist/fold/subset.d.ts.map +1 -1
  18. package/dist/lexicon-schema.d.ts +2 -0
  19. package/dist/lexicon-schema.d.ts.map +1 -1
  20. package/dist/lexicon.d.ts +73 -23
  21. package/dist/lexicon.d.ts.map +1 -1
  22. package/dist/runtime.d.ts +10 -1
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
  26. package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
  27. package/src/cli/commands/check-lexicon.ts +18 -0
  28. package/src/codegen/docs-sections.test.ts +7 -1
  29. package/src/codegen/docs-sections.ts +1 -1
  30. package/src/codegen/docs-types.ts +2 -0
  31. package/src/declarable.ts +20 -0
  32. package/src/discovery/entity-wire-codec.ts +9 -7
  33. package/src/discovery/fold-import.test.ts +572 -0
  34. package/src/discovery/fold-import.ts +229 -36
  35. package/src/discovery/index.ts +9 -0
  36. package/src/fold/fold.test.ts +277 -0
  37. package/src/fold/fold.ts +213 -56
  38. package/src/fold/foldable-helpers.ts +171 -0
  39. package/src/fold/subset-doc-parity.test.ts +27 -0
  40. package/src/fold/subset.test.ts +111 -0
  41. package/src/fold/subset.ts +109 -28
  42. package/src/lexicon-schema.test.ts +43 -0
  43. package/src/lexicon-schema.ts +5 -0
  44. package/src/lexicon.ts +74 -24
  45. package/src/runtime.ts +11 -2
@@ -1,7 +1,7 @@
1
1
  import * as ts from "typescript";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { existsSync, statSync, readFileSync, realpathSync } from "node:fs";
4
- import { dirname, basename, join, isAbsolute, resolve as resolvePath } from "node:path";
4
+ import { dirname, basename, join, sep, isAbsolute, resolve as resolvePath } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createRequire } from "node:module";
7
7
  import { isDeclarable, type Declarable } from "../declarable";
@@ -16,8 +16,10 @@ import {
16
16
  type FoldedResource,
17
17
  type FoldedValue,
18
18
  type FoldedIntrinsic,
19
+ type FoldedHelperCall,
19
20
  type SymbolicValue,
20
21
  } from "../fold/fold";
22
+ import { isChantOwnedSpecifier } from "../fold/foldable-helpers";
21
23
  import { importModule } from "./import";
22
24
  import type { IntrinsicDef } from "../lexicon";
23
25
  import type { BuildParamValue } from "../build-params";
@@ -82,6 +84,20 @@ export type FoldFileResult =
82
84
  * exact same object every referencing file sees.
83
85
  */
84
86
  exportedValues: Map<string, unknown>;
87
+ /**
88
+ * chant #1044 — the OTHER project files whose exported OBJECTS this
89
+ * fold consumed (a cross-file `Declarable`, composite instance, or any
90
+ * other non-primitive reached through `buildExternals`/a re-export).
91
+ *
92
+ * Object identity is the thing that cannot survive one side of the
93
+ * build folding while the other runs, so `planFoldTaint` needs to know
94
+ * who consumed whose objects: if a file here is forced back to run, the
95
+ * instance THIS file already captured is not the instance discovery
96
+ * will collect, and serialization fails on an entity with no logical
97
+ * name. A primitive (string, number, boolean, null) is never recorded —
98
+ * it has no identity to disagree about.
99
+ */
100
+ liveSources: ReadonlySet<string>;
85
101
  }
86
102
  | { ok: false; reason: string };
87
103
 
@@ -1114,13 +1130,23 @@ async function resolveSymbolicValue(text: string, ctx: ResolveCtx): Promise<unkn
1114
1130
  }
1115
1131
 
1116
1132
  /**
1117
- * Revive a folded value tree: replace any `{__intrinsic}`/`{__symbol}`
1118
- * envelope with the real value it represents. `insideIntrinsic` tracks
1119
- * whether the CURRENT node is (transitively) one of a `{__intrinsic}`'s own
1120
- * `values` — see the module-doc note above on why `{__attrRef}` is only
1121
- * rejected there, not everywhere.
1133
+ * Revive a folded value tree: replace any
1134
+ * `{__intrinsic}`/`{__helper}`/`{__symbol}` envelope with the real value it
1135
+ * represents.
1136
+ *
1137
+ * `requireLiveRefs` tracks whether the CURRENT node is (transitively) an
1138
+ * argument being handed to a real function that will inspect it — a
1139
+ * `{__intrinsic}`'s own interpolated `values`, or a `{__helper}` call's
1140
+ * arguments (chant #1082). In that position a symbolic `{__attrRef}` envelope
1141
+ * is rejected rather than passed along: the receiving implementation needs a
1142
+ * genuine `AttrRef` instance (`instanceof` checks, `WeakRef` derefs — see
1143
+ * `SubIntrinsic`, and `LexiconOutput`'s constructor in ../lexicon-output.ts),
1144
+ * and handing it a look-alike plain object produces output that is wrong
1145
+ * rather than absent. Everywhere else the envelope is left untouched, because
1146
+ * the serializer's generic walker already understands it — see the module-doc
1147
+ * note above.
1122
1148
  */
1123
- async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntrinsic: boolean): Promise<unknown> {
1149
+ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiveRefs: boolean): Promise<unknown> {
1124
1150
  if (value === null || typeof value !== "object") return value;
1125
1151
 
1126
1152
  // chant #1020 — a REAL, already-constructed live object reached via
@@ -1141,7 +1167,7 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1141
1167
 
1142
1168
  if (Array.isArray(value)) {
1143
1169
  const revived: unknown[] = [];
1144
- for (const el of value) revived.push(await reviveFoldedValue(el, ctx, insideIntrinsic));
1170
+ for (const el of value) revived.push(await reviveFoldedValue(el, ctx, requireLiveRefs));
1145
1171
  return revived;
1146
1172
  }
1147
1173
 
@@ -1154,17 +1180,33 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1154
1180
  const intrinsic = value as FoldedIntrinsic;
1155
1181
  const Fn = await resolveImportedExport(intrinsic.__intrinsic, ctx);
1156
1182
  if (typeof Fn !== "function") {
1157
- throw cheapError(`intrinsic tag "${intrinsic.__intrinsic}" did not resolve to a function`);
1183
+ throw cheapError(`intrinsic "${intrinsic.__intrinsic}" did not resolve to a function`);
1158
1184
  }
1159
- const revivedValues: unknown[] = [];
1160
- for (const v of intrinsic.values) revivedValues.push(await reviveFoldedValue(v, ctx, true));
1161
- return (Fn as (...fnArgs: unknown[]) => unknown)(intrinsic.strings, ...revivedValues);
1185
+ // Two authored forms, one envelope family (../fold/fold.ts's
1186
+ // `FoldedIntrinsic`): the tagged template replays as
1187
+ // `Name(strings, ...values)`, the plain call (chant #1044) as
1188
+ // `Name(...args)`. Both hand their interior to the REAL function the
1189
+ // file itself imported, with `requireLiveRefs` — an intrinsic inspects
1190
+ // what it is given (`SubIntrinsic`'s `instanceof` checks, `Ref`'s
1191
+ // `getLogicalName`), so a look-alike `{__attrRef}` envelope must be
1192
+ // rejected here rather than silently serialized as something else.
1193
+ const revived: unknown[] = [];
1194
+ if ("args" in intrinsic) {
1195
+ for (const a of intrinsic.args) revived.push(await reviveFoldedValue(a, ctx, true));
1196
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...revived);
1197
+ }
1198
+ for (const v of intrinsic.values) revived.push(await reviveFoldedValue(v, ctx, true));
1199
+ return (Fn as (...fnArgs: unknown[]) => unknown)(intrinsic.strings, ...revived);
1200
+ }
1201
+
1202
+ if ("__helper" in value) {
1203
+ return reviveHelperCall(value as FoldedHelperCall, ctx);
1162
1204
  }
1163
1205
 
1164
1206
  if ("__attrRef" in value) {
1165
- if (insideIntrinsic) {
1207
+ if (requireLiveRefs) {
1166
1208
  throw cheapError(
1167
- "a same-file resource reference inside a folded intrinsic's interpolation is not foldable yet",
1209
+ "a same-file resource reference passed to a folded intrinsic or authoring helper is not foldable yet",
1168
1210
  );
1169
1211
  }
1170
1212
  return value;
@@ -1178,11 +1220,93 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1178
1220
 
1179
1221
  const revived: Record<string, unknown> = {};
1180
1222
  for (const [key, v] of Object.entries(value)) {
1181
- revived[key] = await reviveFoldedValue(v as FoldedValue, ctx, insideIntrinsic);
1223
+ revived[key] = await reviveFoldedValue(v as FoldedValue, ctx, requireLiveRefs);
1182
1224
  }
1183
1225
  return revived;
1184
1226
  }
1185
1227
 
1228
+ /**
1229
+ * chant #1082 — the provenance half of the registered-authoring-helper check
1230
+ * (the shape/name half is `fold()`'s, see ../fold/foldable-helpers.ts's module
1231
+ * doc). Being in the allowlist is not permission to invoke whatever the name
1232
+ * happens to be bound to: the name must be bound by THIS FILE'S OWN `import`,
1233
+ * and that import must actually come from chant. Only then is the real
1234
+ * function called, with the folded arguments — the same function the run path
1235
+ * would have called, from the same module the source itself named, so fold
1236
+ * cannot diverge from run by construction.
1237
+ *
1238
+ * Anything short of that throws, which falls the whole file back to run: a
1239
+ * local `function phase(...)`, a `phase` imported from the project's own
1240
+ * helpers, a chant-owned import that turns out not to be a function.
1241
+ */
1242
+ async function reviveHelperCall(call: FoldedHelperCall, ctx: ResolveCtx): Promise<unknown> {
1243
+ const name = call.__helper;
1244
+ const binding = ctx.imports.get(name);
1245
+ if (!binding) {
1246
+ throw cheapError(`authoring helper "${name}(...)" is not a resolvable import`);
1247
+ }
1248
+ if (!isChantOwnedHelperBinding(binding, ctx)) {
1249
+ throw cheapError(
1250
+ `"${name}" is imported from "${binding.specifier}", which is not chant's own — ` +
1251
+ `only chant's registered authoring helpers fold as calls`,
1252
+ );
1253
+ }
1254
+
1255
+ const Fn = await resolveImportedExport(name, ctx);
1256
+ if (typeof Fn !== "function") {
1257
+ throw cheapError(`authoring helper "${name}" did not resolve to a function`);
1258
+ }
1259
+
1260
+ // `requireLiveRefs` — a helper receives its arguments as real values and may
1261
+ // inspect them (`output()` derefs the ref's `WeakRef` parent), so a symbolic
1262
+ // `{__attrRef}` envelope is rejected here rather than silently wrapped into
1263
+ // a wrong result. A ref that resolved cross-file to a genuine live `AttrRef`
1264
+ // passes straight through (see `reviveFoldedValue`'s early return).
1265
+ const args: unknown[] = [];
1266
+ for (const arg of call.args) args.push(await reviveFoldedValue(arg, ctx, true));
1267
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1268
+ }
1269
+
1270
+ /**
1271
+ * True when `binding` names a helper import chant itself owns: a published
1272
+ * chant package specifier ({@link isChantOwnedSpecifier}), or — for in-repo
1273
+ * and test callers, which import chant-core by relative/absolute path the way
1274
+ * this module's own fixtures do — a specifier that resolves to a file inside
1275
+ * chant-core's own tree.
1276
+ *
1277
+ * The path arm resolves only for a relative/absolute specifier, never a bare
1278
+ * one: resolving an arbitrary bare specifier here would reintroduce the
1279
+ * pathological cold-resolution cost chant#1020 measured (see
1280
+ * {@link fastResolveBareSpecifier}), and a bare specifier chant publishes is
1281
+ * already covered by the text arm.
1282
+ */
1283
+ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
1284
+ if (isChantOwnedSpecifier(binding.specifier)) return true;
1285
+ if (!isProjectFileSpecifier(binding.specifier)) return false;
1286
+ let targetPath: string;
1287
+ try {
1288
+ targetPath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1289
+ } catch {
1290
+ return false;
1291
+ }
1292
+ const root = chantCoreRoot();
1293
+ return targetPath === root || targetPath.startsWith(root + sep);
1294
+ }
1295
+
1296
+ /**
1297
+ * chant-core's own module root — `packages/core/src` in this repo,
1298
+ * `<pkg>/dist` in a published install — derived from THIS module's location.
1299
+ * Pure string arithmetic on `import.meta.url`, no filesystem access at all, so
1300
+ * it is safe in the #1045 sandbox child (which locks reads to an allowlist);
1301
+ * lazy and memoized purely to avoid doing it per call.
1302
+ */
1303
+ let chantCoreRootMemo: string | undefined;
1304
+ function chantCoreRoot(): string {
1305
+ // .../<root>/discovery/fold-import.ts -> .../<root>
1306
+ chantCoreRootMemo ??= dirname(dirname(fileURLToPath(import.meta.url)));
1307
+ return chantCoreRootMemo;
1308
+ }
1309
+
1186
1310
  /** Revive every value in a folded props/attributes object (see {@link reviveFoldedValue}). */
1187
1311
  async function reviveFoldedProps(
1188
1312
  props: { [key: string]: FoldedValue },
@@ -1220,11 +1344,27 @@ async function resolveResourceEntity(
1220
1344
  // real runtime values before constructing the entity. A no-op walk when
1221
1345
  // this file used no registered intrinsics (the overwhelming majority of
1222
1346
  // cases today).
1223
- let props: Record<string, unknown>;
1224
- let attributes: Record<string, unknown> | undefined;
1347
+ //
1348
+ // chant #1082 when `spec.args` is present the constructor's argument list
1349
+ // isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter`
1350
+ // is `(type, props)`), so revive the whole list and spread it below instead
1351
+ // of reviving `spec.props`, which in that case is only a view onto one of
1352
+ // its entries and would be double-counted.
1353
+ let ctorArgs: unknown[];
1225
1354
  try {
1226
- props = await reviveFoldedProps(spec.props, ctx);
1227
- attributes = spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined;
1355
+ if (spec.args) {
1356
+ ctorArgs = [];
1357
+ for (const arg of spec.args) ctorArgs.push(await reviveFoldedValue(arg, ctx, false));
1358
+ } else {
1359
+ const props = await reviveFoldedProps(spec.props, ctx);
1360
+ // The runtime constructor's optional second argument (`attributes` —
1361
+ // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
1362
+ // ../runtime.ts) is only present in `spec` when the source actually
1363
+ // passed one (see foldResource in ../fold/fold.ts). Passing `undefined`
1364
+ // when it's absent matches the run path's own default
1365
+ // (`attributes ?? {}` inside the constructor).
1366
+ ctorArgs = [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined];
1367
+ }
1228
1368
  } catch (err) {
1229
1369
  return {
1230
1370
  ok: false,
@@ -1264,17 +1404,10 @@ async function resolveResourceEntity(
1264
1404
  return { ok: false, reason: `"${binding.imported}" from "${binding.specifier}" is not a constructor` };
1265
1405
  }
1266
1406
 
1267
- // The runtime constructor's optional second argument (`attributes`
1268
- // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
1269
- // ../runtime.ts) is only present in `spec` when the source actually passed
1270
- // one (see foldResource in ../fold/fold.ts). Passing `undefined` when it's
1271
- // absent matches the run path's own default (`attributes ?? {}` inside the
1272
- // constructor).
1273
- const ResourceCtor = Ctor as new (
1274
- props: Record<string, unknown>,
1275
- attributes?: Record<string, unknown>,
1276
- ) => Declarable;
1277
- const entity = new ResourceCtor(props, attributes);
1407
+ // Constructed with exactly the arguments the source wrote (see the revival
1408
+ // block above for how `ctorArgs` was built for each of the two shapes).
1409
+ const ResourceCtor = Ctor as new (...ctorArguments: unknown[]) => Declarable;
1410
+ const entity = new ResourceCtor(...ctorArgs);
1278
1411
  return { ok: true, entity };
1279
1412
  }
1280
1413
 
@@ -1394,9 +1527,11 @@ async function buildExternals(
1394
1527
  imports: Map<string, ImportBinding>,
1395
1528
  namespaceImports: Map<string, NamespaceImportBinding>,
1396
1529
  session: FoldSession,
1397
- ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string> }> {
1530
+ ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string>; liveSources: Set<string> }> {
1398
1531
  const externals = new Map<string, unknown>();
1399
1532
  const failures = new Map<string, string>();
1533
+ // chant #1044 — see `FoldFileResult.liveSources`.
1534
+ const liveSources = new Set<string>();
1400
1535
 
1401
1536
  for (const [localName, binding] of imports) {
1402
1537
  // chant #1064 — a named `params` import that resolves to chant-core's own
@@ -1468,7 +1603,9 @@ async function buildExternals(
1468
1603
  continue;
1469
1604
  }
1470
1605
  if (result.exportedValues.has(binding.imported)) {
1471
- externals.set(localName, result.exportedValues.get(binding.imported));
1606
+ const value = result.exportedValues.get(binding.imported);
1607
+ externals.set(localName, value);
1608
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
1472
1609
  } else {
1473
1610
  failures.set(
1474
1611
  localName,
@@ -1494,9 +1631,28 @@ async function buildExternals(
1494
1631
  // access on it (`ns.someExport`) is then just an ordinary bracket index,
1495
1632
  // exactly like on a real composite instance (see `isIndexableObject`).
1496
1633
  externals.set(localName, Object.fromEntries(result.exportedValues));
1634
+ for (const value of result.exportedValues.values()) {
1635
+ if (hasObjectIdentity(value)) {
1636
+ liveSources.add(targetPath);
1637
+ break;
1638
+ }
1639
+ }
1497
1640
  }
1498
1641
 
1499
- return { externals, failures };
1642
+ return { externals, failures, liveSources };
1643
+ }
1644
+
1645
+ /**
1646
+ * True when `value` is something whose IDENTITY matters across the
1647
+ * fold/run boundary — any object or function, as opposed to a primitive
1648
+ * (chant #1044). Deliberately coarse: an object that merely *contains* a
1649
+ * `Declarable` is as identity-bearing as the Declarable itself, and cheaply
1650
+ * treating every object as such avoids a deep walk whose only payoff would
1651
+ * be keeping a handful of extra files folded inside an entry that already
1652
+ * falls back. See {@link FoldFileResult.liveSources}.
1653
+ */
1654
+ function hasObjectIdentity(value: unknown): boolean {
1655
+ return value !== null && (typeof value === "object" || typeof value === "function");
1500
1656
  }
1501
1657
 
1502
1658
  /**
@@ -1540,7 +1696,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
1540
1696
  if (scan.declarators.length === 0) return { ok: false, reason: "no foldable resource exports" };
1541
1697
 
1542
1698
  const collected = collectImports(sourceFile);
1543
- const { externals, failures } = await buildExternals(file, collected.named, collected.namespaces, session);
1699
+ const { externals, failures, liveSources } = await buildExternals(file, collected.named, collected.namespaces, session);
1544
1700
 
1545
1701
  const ctx: ResolveCtx = {
1546
1702
  file,
@@ -1644,11 +1800,16 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
1644
1800
  reason: locatedMessage(decl.specifierNode, `"${imported}" is not exported by "${decl.specifier}"`),
1645
1801
  };
1646
1802
  }
1647
- applyResolvedValue(exportedName, result.exportedValues.get(imported), entities, exportedValues);
1803
+ const value = result.exportedValues.get(imported);
1804
+ // chant #1044 — a re-export hands another file's OBJECT straight
1805
+ // through under this file's name, so it is a live-identity edge
1806
+ // exactly like an imported binding is (see `liveSources`).
1807
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
1808
+ applyResolvedValue(exportedName, value, entities, exportedValues);
1648
1809
  }
1649
1810
  }
1650
1811
 
1651
- return { ok: true, entities, exportedValues };
1812
+ return { ok: true, entities, exportedValues, liveSources };
1652
1813
  } catch (err) {
1653
1814
  // Any unexpected failure degrades to "fall back to run" rather than
1654
1815
  // taking discovery down with it — fold is opt-in, not a new failure mode.
@@ -1725,6 +1886,21 @@ export async function tryFoldFile(
1725
1886
  * the discovered files' relative-import graph, seeded from every file that
1726
1887
  * doesn't fold on its own.
1727
1888
  *
1889
+ * chant #1044 adds the OTHER half of the same hazard, in the opposite
1890
+ * direction along the same edges. Forward taint covers "a run file imports a
1891
+ * folded file"; it does not cover "a FOLDED file consumed the objects of a
1892
+ * file that later got forced to run". Once a plain-call intrinsic can fold,
1893
+ * that second case is easy to reach: in `lexicons/aws/examples/lambda-api`,
1894
+ * `health-api.ts` folds and captures `params.ts`'s real `Parameter` instance
1895
+ * through `Ref(environment)`, while `params.ts` itself is forced to run
1896
+ * because a DIFFERENT sibling (`data-bucket.ts`) imports it and falls back.
1897
+ * Discovery then collects the run instance and serializes the folded one —
1898
+ * the same "Logical name not set" crash described above, arriving from the
1899
+ * other side. So `liveSources` (see {@link FoldFileResult}) contributes
1900
+ * reverse edges here: a tainted file taints every folded file that captured
1901
+ * one of its objects. Only object identity propagates — a file that imported
1902
+ * a plain string from a tainted file has nothing to disagree about.
1903
+ *
1728
1904
  * chant #1020 changes the calculus but not this function: `alb.ts` can now
1729
1905
  * often fold `network.vpc.VpcId` too (see `buildExternals`/`foldFileMemoized`
1730
1906
  * above), by reusing THE EXACT SAME `tryFoldFile("network.ts")` call (memoized
@@ -1740,6 +1916,7 @@ export async function tryFoldFile(
1740
1916
  export async function planFoldTaint(
1741
1917
  files: readonly string[],
1742
1918
  wouldFold: ReadonlyMap<string, boolean>,
1919
+ liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
1743
1920
  ): Promise<Set<string>> {
1744
1921
  const fileSet = new Set(files);
1745
1922
 
@@ -1789,6 +1966,22 @@ export async function planFoldTaint(
1789
1966
  edges.set(file, targets);
1790
1967
  }
1791
1968
 
1969
+ // chant #1044 — reverse edges: consumed-file -> the folded files that
1970
+ // captured its objects. Same taint set, same fixpoint walk; see this
1971
+ // function's doc for the crash this closes.
1972
+ for (const [consumer, sources] of liveSources ?? []) {
1973
+ if (!fileSet.has(consumer)) continue;
1974
+ for (const source of sources) {
1975
+ if (!fileSet.has(source)) continue;
1976
+ let back = edges.get(source);
1977
+ if (!back) {
1978
+ back = new Set<string>();
1979
+ edges.set(source, back);
1980
+ }
1981
+ back.add(consumer);
1982
+ }
1983
+ }
1984
+
1792
1985
  const tainted = new Set<string>(files.filter((f) => wouldFold.get(f) !== true));
1793
1986
  const queue = [...tainted];
1794
1987
  while (queue.length > 0) {
@@ -166,6 +166,15 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
166
166
  ? await planFoldTaint(
167
167
  files,
168
168
  new Map(files.map((file) => [file, foldAttempts.get(file)?.ok === true])),
169
+ // chant #1044 — which files' OBJECTS each successful fold captured,
170
+ // so a file forced back to run also invalidates the folds that
171
+ // already hold its instances (see planFoldTaint's doc).
172
+ new Map(
173
+ files.flatMap((file) => {
174
+ const attempt = foldAttempts.get(file);
175
+ return attempt?.ok === true ? [[file, attempt.liveSources] as const] : [];
176
+ }),
177
+ ),
169
178
  )
170
179
  : new Set<string>();
171
180