@intentius/chant 0.20.0 → 0.22.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 (55) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  5. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
  6. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  8. package/dist/codegen/docs-types.d.ts +2 -0
  9. package/dist/codegen/docs-types.d.ts.map +1 -1
  10. package/dist/declarable.d.ts +16 -0
  11. package/dist/declarable.d.ts.map +1 -1
  12. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  13. package/dist/discovery/fold-import.d.ts +64 -3
  14. package/dist/discovery/fold-import.d.ts.map +1 -1
  15. package/dist/discovery/index.d.ts +12 -0
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/fold/fold.d.ts +89 -16
  18. package/dist/fold/fold.d.ts.map +1 -1
  19. package/dist/fold/foldable-helpers.d.ts +121 -0
  20. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  21. package/dist/fold/subset.d.ts +58 -3
  22. package/dist/fold/subset.d.ts.map +1 -1
  23. package/dist/lexicon-schema.d.ts +2 -0
  24. package/dist/lexicon-schema.d.ts.map +1 -1
  25. package/dist/lexicon.d.ts +73 -23
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/runtime.d.ts +10 -1
  28. package/dist/runtime.d.ts.map +1 -1
  29. package/dist/serializer-walker.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/build.ts +9 -0
  32. package/src/cli/commands/build.ts +9 -0
  33. package/src/cli/commands/check-lexicon-examples.ts +16 -1
  34. package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
  35. package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
  36. package/src/cli/commands/check-lexicon.ts +18 -0
  37. package/src/codegen/docs-sections.test.ts +7 -1
  38. package/src/codegen/docs-sections.ts +1 -1
  39. package/src/codegen/docs-types.ts +2 -0
  40. package/src/declarable.ts +20 -0
  41. package/src/discovery/entity-wire-codec.ts +9 -7
  42. package/src/discovery/fold-import.test.ts +900 -1
  43. package/src/discovery/fold-import.ts +441 -47
  44. package/src/discovery/index.ts +25 -1
  45. package/src/fold/fold.test.ts +277 -0
  46. package/src/fold/fold.ts +219 -58
  47. package/src/fold/foldable-helpers.ts +171 -0
  48. package/src/fold/subset-doc-parity.test.ts +27 -0
  49. package/src/fold/subset.test.ts +177 -0
  50. package/src/fold/subset.ts +139 -31
  51. package/src/lexicon-schema.test.ts +43 -0
  52. package/src/lexicon-schema.ts +5 -0
  53. package/src/lexicon.ts +74 -24
  54. package/src/runtime.ts +11 -2
  55. package/src/serializer-walker.ts +14 -0
@@ -1,12 +1,13 @@
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";
8
8
  import { isCompositeInstance, type CompositeInstance } from "../composite";
9
9
  import { isAttrRefLike } from "../utils";
10
+ import { isIntrinsic } from "../intrinsic";
10
11
  import {
11
12
  collectConsts,
12
13
  foldResource,
@@ -16,8 +17,11 @@ import {
16
17
  type FoldedResource,
17
18
  type FoldedValue,
18
19
  type FoldedIntrinsic,
20
+ type FoldedHelperCall,
19
21
  type SymbolicValue,
20
22
  } from "../fold/fold";
23
+ import { isChantOwnedSpecifier } from "../fold/foldable-helpers";
24
+ import { briefNodeText, callExpressionMessage } from "../fold/subset";
21
25
  import { importModule } from "./import";
22
26
  import type { IntrinsicDef } from "../lexicon";
23
27
  import type { BuildParamValue } from "../build-params";
@@ -82,6 +86,20 @@ export type FoldFileResult =
82
86
  * exact same object every referencing file sees.
83
87
  */
84
88
  exportedValues: Map<string, unknown>;
89
+ /**
90
+ * chant #1044 — the OTHER project files whose exported OBJECTS this
91
+ * fold consumed (a cross-file `Declarable`, composite instance, or any
92
+ * other non-primitive reached through `buildExternals`/a re-export).
93
+ *
94
+ * Object identity is the thing that cannot survive one side of the
95
+ * build folding while the other runs, so `planFoldTaint` needs to know
96
+ * who consumed whose objects: if a file here is forced back to run, the
97
+ * instance THIS file already captured is not the instance discovery
98
+ * will collect, and serialization fails on an entity with no logical
99
+ * name. A primitive (string, number, boolean, null) is never recorded —
100
+ * it has no identity to disagree about.
101
+ */
102
+ liveSources: ReadonlySet<string>;
85
103
  }
86
104
  | { ok: false; reason: string };
87
105
 
@@ -155,14 +173,58 @@ export interface FoldSession {
155
173
  * doesn't use build-time parameters pays nothing extra here.
156
174
  */
157
175
  readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
176
+ /**
177
+ * chant #1063 — the exact package specifiers of the lexicons LOADED for
178
+ * this build (`@intentius/chant-lexicon-aws`, …), derived from the lexicon
179
+ * names the build already resolved (`resolveProjectLexicons` ->
180
+ * `loadPlugins`, see ../cli/plugins.ts). This is the entire allowlist
181
+ * {@link buildExternals} will follow a bare import specifier into — see
182
+ * {@link activeLexiconPackage} for why the set is matched by TEXT and
183
+ * built from names the build already knows, rather than by resolving
184
+ * specifiers to find out what they are.
185
+ *
186
+ * Empty when the caller supplied no lexicon list, which disables
187
+ * lexicon-package resolution entirely rather than falling back to
188
+ * something more permissive: "an active lexicon of this build" is the
189
+ * boundary, and a build that can't say what its lexicons are hasn't
190
+ * established one.
191
+ */
192
+ readonly lexiconPackages: ReadonlySet<string>;
193
+ }
194
+
195
+ /**
196
+ * chant #1063 — the package specifier a lexicon NAME (`"aws"`, `"gitlab"`)
197
+ * is installed under. The one naming convention the whole CLI already
198
+ * depends on: `loadPlugin(name)` imports exactly this
199
+ * (../cli/plugins.ts), `detectLexicons` scans source for exactly this
200
+ * (../detectLexicon.ts), and `chant init` writes exactly this into
201
+ * package.json.
202
+ */
203
+ export function lexiconPackageName(lexiconName: string): string {
204
+ return `@intentius/chant-lexicon-${lexiconName}`;
158
205
  }
159
206
 
160
- /** Create a fresh, empty {@link FoldSession}. */
207
+ /**
208
+ * Create a fresh, empty {@link FoldSession}.
209
+ *
210
+ * @param lexicons - chant #1063: the lexicon NAMES active for this build
211
+ * (`["aws", "k8s"]`). Converted to package specifiers via
212
+ * {@link lexiconPackageName}; see {@link FoldSession.lexiconPackages}.
213
+ */
161
214
  export function createFoldSession(
162
215
  intrinsics: readonly IntrinsicDef[] = [],
163
216
  buildParams?: Readonly<Record<string, BuildParamValue>>,
217
+ lexicons: readonly string[] = [],
164
218
  ): FoldSession {
165
- return { intrinsics, cache: new Map(), stack: [], importCache: new Map(), resolvePathCache: new Map(), buildParams };
219
+ return {
220
+ intrinsics,
221
+ cache: new Map(),
222
+ stack: [],
223
+ importCache: new Map(),
224
+ resolvePathCache: new Map(),
225
+ buildParams,
226
+ lexiconPackages: new Set(lexicons.map(lexiconPackageName)),
227
+ };
166
228
  }
167
229
 
168
230
  /**
@@ -789,6 +851,100 @@ function paramsModulePath(): string | null {
789
851
  return paramsModulePathMemo;
790
852
  }
791
853
 
854
+ /**
855
+ * chant #1063 — is `specifier` a bare import of a package that is an ACTIVE
856
+ * LEXICON of this build? Returns the specifier itself when so, `undefined`
857
+ * otherwise.
858
+ *
859
+ * Three deliberate restrictions, each of which is the point rather than an
860
+ * omission:
861
+ *
862
+ * - **Text only, no resolution.** The answer is a `Set.has` on a set built
863
+ * from the lexicon names the build ALREADY resolved before discovery ran
864
+ * (`resolveProjectLexicons` -> `loadPlugins`). Nothing is probed, read, or
865
+ * resolved to decide whether a specifier is in scope — so an import of
866
+ * some unrelated package costs a string comparison and is then left alone,
867
+ * never resolved "just to find out what it is". That matters here more
868
+ * than anywhere: `resolveModulePath`'s bare branch can fall through to
869
+ * `createRequire(fromFile).resolve(specifier)`, measured at up to ~361s
870
+ * for the first resolution of a genuinely new bare specifier in a process
871
+ * (see {@link bareSpecifierPathCache}/{@link fastResolveBareSpecifier} —
872
+ * this exact class of cost regressed twice during chant#1020).
873
+ *
874
+ * - **Exact package specifier, no subpaths.** `@intentius/chant-lexicon-aws`
875
+ * matches; `@intentius/chant-lexicon-aws/actions` does not. Every lexicon
876
+ * re-exports its whole public surface from its barrel and every example
877
+ * imports it that way, so subpaths buy nothing — and they would cost
878
+ * something real: {@link fastResolveBareSpecifier} only recognizes a
879
+ * package ROOT (it looks for `<node_modules>/<specifier>/package.json`),
880
+ * so a subpath specifier falls through to exactly the slow
881
+ * `createRequire().resolve()` path this restriction exists to avoid.
882
+ *
883
+ * - **Lexicons of THIS build only.** Not "any `@intentius/chant-lexicon-*`
884
+ * package on disk", and emphatically not "any bare specifier". A lexicon
885
+ * the build did not load is as out of scope as `node:fs`.
886
+ */
887
+ function activeLexiconPackage(specifier: string, lexiconPackages: ReadonlySet<string>): string | undefined {
888
+ return lexiconPackages.has(specifier) ? specifier : undefined;
889
+ }
890
+
891
+ /**
892
+ * chant #1063 — resolve one named import binding against an active lexicon
893
+ * package's REAL exports, for {@link buildExternals}.
894
+ *
895
+ * The lexicon module is imported (through the session-wide
896
+ * {@link FoldSession.importCache}, so at most one real `import()` per package
897
+ * per build) and the requested export read straight off it. That is the same
898
+ * module object the run path gets — `importModule` is a plain dynamic
899
+ * `import()` with no cache-busting — so what fold captures here is not a
900
+ * reconstruction of the lexicon's data but the identical value, down to
901
+ * object identity for `Azure.ResourceGroupLocation`-style singletons. It is
902
+ * also the same two-step (resolve path, then import) that
903
+ * `resolveImportedExport` has always used to reach a lexicon's constructors
904
+ * and intrinsic functions; the only thing new is that a plain DATA export is
905
+ * now reachable too.
906
+ *
907
+ * Callable exports are deliberately excluded. A lexicon's functions — its
908
+ * resource classes, composite factories, intrinsic implementations — already
909
+ * have dedicated resolution paths that know how to INVOKE them
910
+ * (`resolveResourceEntity`, `resolveCallExpression`, `reviveFoldedValue`), and
911
+ * binding them as plain identifier values here would widen what folds in ways
912
+ * this issue neither needs nor measured: none of the references #1063 exists
913
+ * to unblock (`Azure`, `GCP`, `S3Actions`, gitlab's `CI`) is a function.
914
+ *
915
+ * Returns `undefined` — never throws — for a package that isn't an active
916
+ * lexicon, an export the package doesn't have, a callable export, or an
917
+ * import that fails. The binding is then simply absent from `externals`,
918
+ * exactly as before, and `fold()`'s ordinary "unresolved identifier" failure
919
+ * still fires if the name is actually referenced.
920
+ */
921
+ async function resolveActiveLexiconExport(
922
+ binding: ImportBinding,
923
+ fromFile: string,
924
+ session: FoldSession,
925
+ ): Promise<{ value: unknown } | undefined> {
926
+ if (!activeLexiconPackage(binding.specifier, session.lexiconPackages)) return undefined;
927
+
928
+ let modulePath: string;
929
+ try {
930
+ modulePath = resolveModulePathMemoized(binding.specifier, fromFile, session.resolvePathCache);
931
+ } catch {
932
+ return undefined;
933
+ }
934
+
935
+ let mod: Record<string, unknown>;
936
+ try {
937
+ mod = await importModuleMemoized(modulePath, session.importCache);
938
+ } catch {
939
+ return undefined;
940
+ }
941
+
942
+ if (!(binding.imported in mod)) return undefined;
943
+ const value = mod[binding.imported];
944
+ if (typeof value === "function") return undefined;
945
+ return { value };
946
+ }
947
+
792
948
  // ─────────────────────────────────────────────────────────────────────────
793
949
  // Resolution: given the scan + import map, compute the REAL runtime value
794
950
  // (Declarable | CompositeInstance) each foldable export would have had if
@@ -946,13 +1102,18 @@ async function resolveLiveValue(node: ts.Expression, ctx: ResolveCtx): Promise<L
946
1102
  * `isDeclarable`/`isCompositeInstance` value for a top-level export).
947
1103
  */
948
1104
  async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx): Promise<unknown> {
1105
+ // chant #1054 — reuses ../fold/subset's `callExpressionMessage` (the SAME
1106
+ // builder `fold()` throws with for a call used as a prop value) rather
1107
+ // than a hand-written "call expression as a value" copy: the two used to
1108
+ // say different things for the identical rejection, which silently broke
1109
+ // any tooling grouping fallback reasons by text.
949
1110
  if (!ts.isIdentifier(node.expression)) {
950
- throw cheapError(`call expression as a value is not foldable: ${node.expression.getText()}(...)`);
1111
+ throw cheapError(callExpressionMessage(node));
951
1112
  }
952
1113
  const calleeName = node.expression.text;
953
1114
  const binding = ctx.imports.get(calleeName);
954
1115
  if (!binding) {
955
- throw cheapError(`call expression as a value is not foldable: ${calleeName}(...)`);
1116
+ throw cheapError(callExpressionMessage(node));
956
1117
  }
957
1118
 
958
1119
  let modulePath: string;
@@ -1114,13 +1275,23 @@ async function resolveSymbolicValue(text: string, ctx: ResolveCtx): Promise<unkn
1114
1275
  }
1115
1276
 
1116
1277
  /**
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.
1278
+ * Revive a folded value tree: replace any
1279
+ * `{__intrinsic}`/`{__helper}`/`{__symbol}` envelope with the real value it
1280
+ * represents.
1281
+ *
1282
+ * `requireLiveRefs` tracks whether the CURRENT node is (transitively) an
1283
+ * argument being handed to a real function that will inspect it — a
1284
+ * `{__intrinsic}`'s own interpolated `values`, or a `{__helper}` call's
1285
+ * arguments (chant #1082). In that position a symbolic `{__attrRef}` envelope
1286
+ * is rejected rather than passed along: the receiving implementation needs a
1287
+ * genuine `AttrRef` instance (`instanceof` checks, `WeakRef` derefs — see
1288
+ * `SubIntrinsic`, and `LexiconOutput`'s constructor in ../lexicon-output.ts),
1289
+ * and handing it a look-alike plain object produces output that is wrong
1290
+ * rather than absent. Everywhere else the envelope is left untouched, because
1291
+ * the serializer's generic walker already understands it — see the module-doc
1292
+ * note above.
1122
1293
  */
1123
- async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntrinsic: boolean): Promise<unknown> {
1294
+ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiveRefs: boolean): Promise<unknown> {
1124
1295
  if (value === null || typeof value !== "object") return value;
1125
1296
 
1126
1297
  // chant #1020 — a REAL, already-constructed live object reached via
@@ -1135,13 +1306,23 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1135
1306
  // destroying the very identity #1020 exists to preserve. Passed through
1136
1307
  // completely unchanged, exactly like `resolveCallExpression`'s own
1137
1308
  // `live.value` passthrough for a composite-call argument.
1138
- if (isAttrRefLike(value) || isDeclarable(value) || isCompositeInstance(value)) {
1309
+ // chant #1063 adds the third kind of real object cross-file resolution can
1310
+ // now put here: a live `Intrinsic` instance read off an active lexicon
1311
+ // package (`Azure.ResourceGroupLocation`, `GCP.ProjectId` — see
1312
+ // `resolveActiveLexiconExport`). Same hazard, same fix: the generic walk
1313
+ // below would rebuild it as a plain `{}` copy, dropping the prototype that
1314
+ // carries `toJSON()` and so serializing `{}` where the run path emits
1315
+ // `[resourceGroup().location]`. `isIntrinsic` keys off
1316
+ // `Symbol.for("chant.intrinsic")` (../intrinsic.ts), a GLOBAL symbol, so it
1317
+ // holds across separately-loaded copies of chant-core the way a bare
1318
+ // `instanceof` would not.
1319
+ if (isAttrRefLike(value) || isDeclarable(value) || isCompositeInstance(value) || isIntrinsic(value)) {
1139
1320
  return value;
1140
1321
  }
1141
1322
 
1142
1323
  if (Array.isArray(value)) {
1143
1324
  const revived: unknown[] = [];
1144
- for (const el of value) revived.push(await reviveFoldedValue(el, ctx, insideIntrinsic));
1325
+ for (const el of value) revived.push(await reviveFoldedValue(el, ctx, requireLiveRefs));
1145
1326
  return revived;
1146
1327
  }
1147
1328
 
@@ -1154,17 +1335,33 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1154
1335
  const intrinsic = value as FoldedIntrinsic;
1155
1336
  const Fn = await resolveImportedExport(intrinsic.__intrinsic, ctx);
1156
1337
  if (typeof Fn !== "function") {
1157
- throw cheapError(`intrinsic tag "${intrinsic.__intrinsic}" did not resolve to a function`);
1338
+ throw cheapError(`intrinsic "${intrinsic.__intrinsic}" did not resolve to a function`);
1158
1339
  }
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);
1340
+ // Two authored forms, one envelope family (../fold/fold.ts's
1341
+ // `FoldedIntrinsic`): the tagged template replays as
1342
+ // `Name(strings, ...values)`, the plain call (chant #1044) as
1343
+ // `Name(...args)`. Both hand their interior to the REAL function the
1344
+ // file itself imported, with `requireLiveRefs` — an intrinsic inspects
1345
+ // what it is given (`SubIntrinsic`'s `instanceof` checks, `Ref`'s
1346
+ // `getLogicalName`), so a look-alike `{__attrRef}` envelope must be
1347
+ // rejected here rather than silently serialized as something else.
1348
+ const revived: unknown[] = [];
1349
+ if ("args" in intrinsic) {
1350
+ for (const a of intrinsic.args) revived.push(await reviveFoldedValue(a, ctx, true));
1351
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...revived);
1352
+ }
1353
+ for (const v of intrinsic.values) revived.push(await reviveFoldedValue(v, ctx, true));
1354
+ return (Fn as (...fnArgs: unknown[]) => unknown)(intrinsic.strings, ...revived);
1355
+ }
1356
+
1357
+ if ("__helper" in value) {
1358
+ return reviveHelperCall(value as FoldedHelperCall, ctx);
1162
1359
  }
1163
1360
 
1164
1361
  if ("__attrRef" in value) {
1165
- if (insideIntrinsic) {
1362
+ if (requireLiveRefs) {
1166
1363
  throw cheapError(
1167
- "a same-file resource reference inside a folded intrinsic's interpolation is not foldable yet",
1364
+ "a same-file resource reference passed to a folded intrinsic or authoring helper is not foldable yet",
1168
1365
  );
1169
1366
  }
1170
1367
  return value;
@@ -1178,11 +1375,93 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, insideIntr
1178
1375
 
1179
1376
  const revived: Record<string, unknown> = {};
1180
1377
  for (const [key, v] of Object.entries(value)) {
1181
- revived[key] = await reviveFoldedValue(v as FoldedValue, ctx, insideIntrinsic);
1378
+ revived[key] = await reviveFoldedValue(v as FoldedValue, ctx, requireLiveRefs);
1182
1379
  }
1183
1380
  return revived;
1184
1381
  }
1185
1382
 
1383
+ /**
1384
+ * chant #1082 — the provenance half of the registered-authoring-helper check
1385
+ * (the shape/name half is `fold()`'s, see ../fold/foldable-helpers.ts's module
1386
+ * doc). Being in the allowlist is not permission to invoke whatever the name
1387
+ * happens to be bound to: the name must be bound by THIS FILE'S OWN `import`,
1388
+ * and that import must actually come from chant. Only then is the real
1389
+ * function called, with the folded arguments — the same function the run path
1390
+ * would have called, from the same module the source itself named, so fold
1391
+ * cannot diverge from run by construction.
1392
+ *
1393
+ * Anything short of that throws, which falls the whole file back to run: a
1394
+ * local `function phase(...)`, a `phase` imported from the project's own
1395
+ * helpers, a chant-owned import that turns out not to be a function.
1396
+ */
1397
+ async function reviveHelperCall(call: FoldedHelperCall, ctx: ResolveCtx): Promise<unknown> {
1398
+ const name = call.__helper;
1399
+ const binding = ctx.imports.get(name);
1400
+ if (!binding) {
1401
+ throw cheapError(`authoring helper "${name}(...)" is not a resolvable import`);
1402
+ }
1403
+ if (!isChantOwnedHelperBinding(binding, ctx)) {
1404
+ throw cheapError(
1405
+ `"${name}" is imported from "${binding.specifier}", which is not chant's own — ` +
1406
+ `only chant's registered authoring helpers fold as calls`,
1407
+ );
1408
+ }
1409
+
1410
+ const Fn = await resolveImportedExport(name, ctx);
1411
+ if (typeof Fn !== "function") {
1412
+ throw cheapError(`authoring helper "${name}" did not resolve to a function`);
1413
+ }
1414
+
1415
+ // `requireLiveRefs` — a helper receives its arguments as real values and may
1416
+ // inspect them (`output()` derefs the ref's `WeakRef` parent), so a symbolic
1417
+ // `{__attrRef}` envelope is rejected here rather than silently wrapped into
1418
+ // a wrong result. A ref that resolved cross-file to a genuine live `AttrRef`
1419
+ // passes straight through (see `reviveFoldedValue`'s early return).
1420
+ const args: unknown[] = [];
1421
+ for (const arg of call.args) args.push(await reviveFoldedValue(arg, ctx, true));
1422
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1423
+ }
1424
+
1425
+ /**
1426
+ * True when `binding` names a helper import chant itself owns: a published
1427
+ * chant package specifier ({@link isChantOwnedSpecifier}), or — for in-repo
1428
+ * and test callers, which import chant-core by relative/absolute path the way
1429
+ * this module's own fixtures do — a specifier that resolves to a file inside
1430
+ * chant-core's own tree.
1431
+ *
1432
+ * The path arm resolves only for a relative/absolute specifier, never a bare
1433
+ * one: resolving an arbitrary bare specifier here would reintroduce the
1434
+ * pathological cold-resolution cost chant#1020 measured (see
1435
+ * {@link fastResolveBareSpecifier}), and a bare specifier chant publishes is
1436
+ * already covered by the text arm.
1437
+ */
1438
+ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
1439
+ if (isChantOwnedSpecifier(binding.specifier)) return true;
1440
+ if (!isProjectFileSpecifier(binding.specifier)) return false;
1441
+ let targetPath: string;
1442
+ try {
1443
+ targetPath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
1444
+ } catch {
1445
+ return false;
1446
+ }
1447
+ const root = chantCoreRoot();
1448
+ return targetPath === root || targetPath.startsWith(root + sep);
1449
+ }
1450
+
1451
+ /**
1452
+ * chant-core's own module root — `packages/core/src` in this repo,
1453
+ * `<pkg>/dist` in a published install — derived from THIS module's location.
1454
+ * Pure string arithmetic on `import.meta.url`, no filesystem access at all, so
1455
+ * it is safe in the #1045 sandbox child (which locks reads to an allowlist);
1456
+ * lazy and memoized purely to avoid doing it per call.
1457
+ */
1458
+ let chantCoreRootMemo: string | undefined;
1459
+ function chantCoreRoot(): string {
1460
+ // .../<root>/discovery/fold-import.ts -> .../<root>
1461
+ chantCoreRootMemo ??= dirname(dirname(fileURLToPath(import.meta.url)));
1462
+ return chantCoreRootMemo;
1463
+ }
1464
+
1186
1465
  /** Revive every value in a folded props/attributes object (see {@link reviveFoldedValue}). */
1187
1466
  async function reviveFoldedProps(
1188
1467
  props: { [key: string]: FoldedValue },
@@ -1220,11 +1499,27 @@ async function resolveResourceEntity(
1220
1499
  // real runtime values before constructing the entity. A no-op walk when
1221
1500
  // this file used no registered intrinsics (the overwhelming majority of
1222
1501
  // cases today).
1223
- let props: Record<string, unknown>;
1224
- let attributes: Record<string, unknown> | undefined;
1502
+ //
1503
+ // chant #1082 when `spec.args` is present the constructor's argument list
1504
+ // isn't the classic `(props)`/`(props, attributes)` shape (AWS's `Parameter`
1505
+ // is `(type, props)`), so revive the whole list and spread it below instead
1506
+ // of reviving `spec.props`, which in that case is only a view onto one of
1507
+ // its entries and would be double-counted.
1508
+ let ctorArgs: unknown[];
1225
1509
  try {
1226
- props = await reviveFoldedProps(spec.props, ctx);
1227
- attributes = spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined;
1510
+ if (spec.args) {
1511
+ ctorArgs = [];
1512
+ for (const arg of spec.args) ctorArgs.push(await reviveFoldedValue(arg, ctx, false));
1513
+ } else {
1514
+ const props = await reviveFoldedProps(spec.props, ctx);
1515
+ // The runtime constructor's optional second argument (`attributes` —
1516
+ // CFN's DependsOn/Condition/DeletionPolicy/…, see createResource in
1517
+ // ../runtime.ts) is only present in `spec` when the source actually
1518
+ // passed one (see foldResource in ../fold/fold.ts). Passing `undefined`
1519
+ // when it's absent matches the run path's own default
1520
+ // (`attributes ?? {}` inside the constructor).
1521
+ ctorArgs = [props, spec.attributes ? await reviveFoldedProps(spec.attributes, ctx) : undefined];
1522
+ }
1228
1523
  } catch (err) {
1229
1524
  return {
1230
1525
  ok: false,
@@ -1264,17 +1559,10 @@ async function resolveResourceEntity(
1264
1559
  return { ok: false, reason: `"${binding.imported}" from "${binding.specifier}" is not a constructor` };
1265
1560
  }
1266
1561
 
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);
1562
+ // Constructed with exactly the arguments the source wrote (see the revival
1563
+ // block above for how `ctorArgs` was built for each of the two shapes).
1564
+ const ResourceCtor = Ctor as new (...ctorArguments: unknown[]) => Declarable;
1565
+ const entity = new ResourceCtor(...ctorArgs);
1278
1566
  return { ok: true, entity };
1279
1567
  }
1280
1568
 
@@ -1310,6 +1598,18 @@ function describeFoldFailure(err: unknown, ctx: ResolveCtx): string {
1310
1598
  return err.message;
1311
1599
  }
1312
1600
 
1601
+ /**
1602
+ * chant #1054 — a short, single-line label for a destructured export's
1603
+ * source expression, for a fold fallback reason: the callee plus `(...)`
1604
+ * for the common composite-call source (`GkeCluster(...)`), or a brief,
1605
+ * bounded rendering of whatever else it is otherwise. Never the source's own
1606
+ * `getText()` — for a real composite call that's the entire multi-line
1607
+ * argument list.
1608
+ */
1609
+ function describeDestructureSource(node: ts.Expression): string {
1610
+ return ts.isCallExpression(node) ? `${briefNodeText(node.expression)}(...)` : briefNodeText(node);
1611
+ }
1612
+
1313
1613
  /** Build a located `FoldError`'s formatted "line:col - message" string anchored at `node` — for a cross-file failure detected here in fold-import.ts (an import cycle, a name genuinely absent from the target module's exports) rather than inside `fold()` itself. */
1314
1614
  function locatedMessage(node: ts.Node, message: string): string {
1315
1615
  const { line, column } = locate(node);
@@ -1394,9 +1694,11 @@ async function buildExternals(
1394
1694
  imports: Map<string, ImportBinding>,
1395
1695
  namespaceImports: Map<string, NamespaceImportBinding>,
1396
1696
  session: FoldSession,
1397
- ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string> }> {
1697
+ ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string>; liveSources: Set<string> }> {
1398
1698
  const externals = new Map<string, unknown>();
1399
1699
  const failures = new Map<string, string>();
1700
+ // chant #1044 — see `FoldFileResult.liveSources`.
1701
+ const liveSources = new Set<string>();
1400
1702
 
1401
1703
  for (const [localName, binding] of imports) {
1402
1704
  // chant #1064 — a named `params` import that resolves to chant-core's own
@@ -1450,10 +1752,36 @@ async function buildExternals(
1450
1752
  }
1451
1753
 
1452
1754
  if (!isProjectFileSpecifier(binding.specifier)) {
1453
- // Every other bare specifier (a lexicon/vendor package) is left alone
1454
- // here, exactly as before #1064: it resolves lazily, through the
1455
- // pre-existing `importModule` mechanism, only once a constructor/
1456
- // composite-factory/intrinsic tag actually consumes it.
1755
+ // chant #1063 — a bare specifier naming one of THIS BUILD's active
1756
+ // lexicon packages resolves to that package's real export, so a plain
1757
+ // data export a lexicon publishes (`Azure`/`GCP`'s pseudo-parameter
1758
+ // namespaces, AWS's `S3Actions`, gitlab's `CI`) is an ordinary
1759
+ // identifier value here rather than fold's most common remaining
1760
+ // "unresolved identifier" failure. See
1761
+ // {@link resolveActiveLexiconExport} for the allowlist, the no-cold-
1762
+ // resolution rule, and why callable exports stay out.
1763
+ //
1764
+ // No `liveSources` edge is recorded for what comes back, unlike the
1765
+ // project-file case just below. `liveSources` (chant #1044) exists so
1766
+ // that a folded file which captured ANOTHER FILE's objects is
1767
+ // invalidated when that file is forced back to run — a fold/run
1768
+ // disagreement about identity. A lexicon package has no such duality:
1769
+ // it is not a discovered source file, `planFoldTaint` never considers
1770
+ // it (it filters to the discovered `files` set), it never falls back to
1771
+ // run, and both paths reach it through the identical un-cache-busted
1772
+ // `import()` of the identical resolved path — so the object fold
1773
+ // captures IS the object the run path holds. There is nothing for the
1774
+ // two sides to disagree about, hence nothing to taint.
1775
+ const lexiconExport = await resolveActiveLexiconExport(binding, file, session);
1776
+ if (lexiconExport) {
1777
+ externals.set(localName, lexiconExport.value);
1778
+ continue;
1779
+ }
1780
+ // Every other bare specifier (a non-lexicon vendor package, a lexicon
1781
+ // this build didn't load) is left alone here, exactly as before #1064:
1782
+ // it resolves lazily, through the pre-existing `importModule`
1783
+ // mechanism, only once a constructor/composite-factory/intrinsic tag
1784
+ // actually consumes it.
1457
1785
  continue;
1458
1786
  }
1459
1787
  let targetPath: string;
@@ -1468,7 +1796,9 @@ async function buildExternals(
1468
1796
  continue;
1469
1797
  }
1470
1798
  if (result.exportedValues.has(binding.imported)) {
1471
- externals.set(localName, result.exportedValues.get(binding.imported));
1799
+ const value = result.exportedValues.get(binding.imported);
1800
+ externals.set(localName, value);
1801
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
1472
1802
  } else {
1473
1803
  failures.set(
1474
1804
  localName,
@@ -1494,9 +1824,28 @@ async function buildExternals(
1494
1824
  // access on it (`ns.someExport`) is then just an ordinary bracket index,
1495
1825
  // exactly like on a real composite instance (see `isIndexableObject`).
1496
1826
  externals.set(localName, Object.fromEntries(result.exportedValues));
1827
+ for (const value of result.exportedValues.values()) {
1828
+ if (hasObjectIdentity(value)) {
1829
+ liveSources.add(targetPath);
1830
+ break;
1831
+ }
1832
+ }
1497
1833
  }
1498
1834
 
1499
- return { externals, failures };
1835
+ return { externals, failures, liveSources };
1836
+ }
1837
+
1838
+ /**
1839
+ * True when `value` is something whose IDENTITY matters across the
1840
+ * fold/run boundary — any object or function, as opposed to a primitive
1841
+ * (chant #1044). Deliberately coarse: an object that merely *contains* a
1842
+ * `Declarable` is as identity-bearing as the Declarable itself, and cheaply
1843
+ * treating every object as such avoids a deep walk whose only payoff would
1844
+ * be keeping a handful of extra files folded inside an entry that already
1845
+ * falls back. See {@link FoldFileResult.liveSources}.
1846
+ */
1847
+ function hasObjectIdentity(value: unknown): boolean {
1848
+ return value !== null && (typeof value === "object" || typeof value === "function");
1500
1849
  }
1501
1850
 
1502
1851
  /**
@@ -1540,7 +1889,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
1540
1889
  if (scan.declarators.length === 0) return { ok: false, reason: "no foldable resource exports" };
1541
1890
 
1542
1891
  const collected = collectImports(sourceFile);
1543
- const { externals, failures } = await buildExternals(file, collected.named, collected.namespaces, session);
1892
+ const { externals, failures, liveSources } = await buildExternals(file, collected.named, collected.namespaces, session);
1544
1893
 
1545
1894
  const ctx: ResolveCtx = {
1546
1895
  file,
@@ -1580,18 +1929,26 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
1580
1929
 
1581
1930
  if (decl.kind === "destructure") {
1582
1931
  let value: unknown;
1932
+ // chant #1054 — identify the destructured export by its BINDING
1933
+ // NAMES and the source's callee (`"cluster, nodePool" (destructured
1934
+ // from GkeCluster(...))`), never `decl.node.getText()`: for a real
1935
+ // composite call that's the entire multi-line source, which buries
1936
+ // the actual error after it and breaks any line-oriented consumer of
1937
+ // `[fold:run]` output.
1938
+ const boundNames = decl.elements.map((el) => el.bindingName).join(", ");
1939
+ const source = describeDestructureSource(decl.node);
1583
1940
  try {
1584
1941
  value = (await resolveDeclaratorValue(decl.node, ctx)).value;
1585
1942
  } catch (err) {
1586
1943
  return {
1587
1944
  ok: false,
1588
- reason: `destructured export from "${decl.node.getText()}" is not foldable: ${describeFoldFailure(err, ctx)}`,
1945
+ reason: `"${boundNames}" (destructured from ${source}) is not foldable: ${describeFoldFailure(err, ctx)}`,
1589
1946
  };
1590
1947
  }
1591
1948
  if (!isIndexableObject(value)) {
1592
1949
  return {
1593
1950
  ok: false,
1594
- reason: `destructured export from "${decl.node.getText()}" is not foldable (not a composite call or object)`,
1951
+ reason: `"${boundNames}" (destructured from ${source}) is not foldable: not a composite call or object`,
1595
1952
  };
1596
1953
  }
1597
1954
  for (const { propKey, bindingName } of decl.elements) {
@@ -1644,11 +2001,16 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
1644
2001
  reason: locatedMessage(decl.specifierNode, `"${imported}" is not exported by "${decl.specifier}"`),
1645
2002
  };
1646
2003
  }
1647
- applyResolvedValue(exportedName, result.exportedValues.get(imported), entities, exportedValues);
2004
+ const value = result.exportedValues.get(imported);
2005
+ // chant #1044 — a re-export hands another file's OBJECT straight
2006
+ // through under this file's name, so it is a live-identity edge
2007
+ // exactly like an imported binding is (see `liveSources`).
2008
+ if (hasObjectIdentity(value)) liveSources.add(targetPath);
2009
+ applyResolvedValue(exportedName, value, entities, exportedValues);
1648
2010
  }
1649
2011
  }
1650
2012
 
1651
- return { ok: true, entities, exportedValues };
2013
+ return { ok: true, entities, exportedValues, liveSources };
1652
2014
  } catch (err) {
1653
2015
  // Any unexpected failure degrades to "fall back to run" rather than
1654
2016
  // taking discovery down with it — fold is opt-in, not a new failure mode.
@@ -1725,6 +2087,21 @@ export async function tryFoldFile(
1725
2087
  * the discovered files' relative-import graph, seeded from every file that
1726
2088
  * doesn't fold on its own.
1727
2089
  *
2090
+ * chant #1044 adds the OTHER half of the same hazard, in the opposite
2091
+ * direction along the same edges. Forward taint covers "a run file imports a
2092
+ * folded file"; it does not cover "a FOLDED file consumed the objects of a
2093
+ * file that later got forced to run". Once a plain-call intrinsic can fold,
2094
+ * that second case is easy to reach: in `lexicons/aws/examples/lambda-api`,
2095
+ * `health-api.ts` folds and captures `params.ts`'s real `Parameter` instance
2096
+ * through `Ref(environment)`, while `params.ts` itself is forced to run
2097
+ * because a DIFFERENT sibling (`data-bucket.ts`) imports it and falls back.
2098
+ * Discovery then collects the run instance and serializes the folded one —
2099
+ * the same "Logical name not set" crash described above, arriving from the
2100
+ * other side. So `liveSources` (see {@link FoldFileResult}) contributes
2101
+ * reverse edges here: a tainted file taints every folded file that captured
2102
+ * one of its objects. Only object identity propagates — a file that imported
2103
+ * a plain string from a tainted file has nothing to disagree about.
2104
+ *
1728
2105
  * chant #1020 changes the calculus but not this function: `alb.ts` can now
1729
2106
  * often fold `network.vpc.VpcId` too (see `buildExternals`/`foldFileMemoized`
1730
2107
  * above), by reusing THE EXACT SAME `tryFoldFile("network.ts")` call (memoized
@@ -1740,6 +2117,7 @@ export async function tryFoldFile(
1740
2117
  export async function planFoldTaint(
1741
2118
  files: readonly string[],
1742
2119
  wouldFold: ReadonlyMap<string, boolean>,
2120
+ liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
1743
2121
  ): Promise<Set<string>> {
1744
2122
  const fileSet = new Set(files);
1745
2123
 
@@ -1789,6 +2167,22 @@ export async function planFoldTaint(
1789
2167
  edges.set(file, targets);
1790
2168
  }
1791
2169
 
2170
+ // chant #1044 — reverse edges: consumed-file -> the folded files that
2171
+ // captured its objects. Same taint set, same fixpoint walk; see this
2172
+ // function's doc for the crash this closes.
2173
+ for (const [consumer, sources] of liveSources ?? []) {
2174
+ if (!fileSet.has(consumer)) continue;
2175
+ for (const source of sources) {
2176
+ if (!fileSet.has(source)) continue;
2177
+ let back = edges.get(source);
2178
+ if (!back) {
2179
+ back = new Set<string>();
2180
+ edges.set(source, back);
2181
+ }
2182
+ back.add(consumer);
2183
+ }
2184
+ }
2185
+
1792
2186
  const tainted = new Set<string>(files.filter((f) => wouldFold.get(f) !== true));
1793
2187
  const queue = [...tainted];
1794
2188
  while (queue.length > 0) {