@intentius/chant 0.108.1 → 0.110.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 (46) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/composite.d.ts +5 -0
  3. package/dist/composite.d.ts.map +1 -1
  4. package/dist/discovery/collect.d.ts.map +1 -1
  5. package/dist/discovery/fold-import.d.ts +15 -0
  6. package/dist/discovery/fold-import.d.ts.map +1 -1
  7. package/dist/discovery/param-deps.d.ts +59 -4
  8. package/dist/discovery/param-deps.d.ts.map +1 -1
  9. package/dist/fold/subset.d.ts +17 -2
  10. package/dist/fold/subset.d.ts.map +1 -1
  11. package/dist/fold-provenance.d.ts +49 -4
  12. package/dist/fold-provenance.d.ts.map +1 -1
  13. package/dist/import/ir-utils.d.ts.map +1 -1
  14. package/dist/import/parser.d.ts +8 -0
  15. package/dist/import/parser.d.ts.map +1 -1
  16. package/dist/provenance.d.ts +49 -0
  17. package/dist/provenance.d.ts.map +1 -1
  18. package/dist/serializer-walker.d.ts.map +1 -1
  19. package/dist/stack-output.d.ts +5 -0
  20. package/dist/stack-output.d.ts.map +1 -1
  21. package/dist/yaml.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/import.test.ts +5 -4
  24. package/src/cli/handlers/lifecycle.test.ts +106 -1
  25. package/src/cli/handlers/lifecycle.ts +26 -5
  26. package/src/composite.ts +37 -0
  27. package/src/discovery/collect.ts +13 -3
  28. package/src/discovery/fold-composite.test.ts +22 -0
  29. package/src/discovery/fold-host-composite.test.ts +23 -0
  30. package/src/discovery/fold-import.ts +324 -30
  31. package/src/discovery/fold-package-composite.test.ts +14 -1
  32. package/src/discovery/fold-project-provenance.test.ts +584 -0
  33. package/src/discovery/param-deps.test.ts +163 -3
  34. package/src/discovery/param-deps.ts +173 -4
  35. package/src/fold/subset-public-export.test.ts +38 -0
  36. package/src/fold/subset.ts +17 -2
  37. package/src/fold-provenance.test.ts +190 -16
  38. package/src/fold-provenance.ts +158 -13
  39. package/src/import/ir-utils.ts +6 -1
  40. package/src/import/parser.ts +8 -0
  41. package/src/provenance.ts +59 -2
  42. package/src/serializer-walker.test.ts +10 -0
  43. package/src/serializer-walker.ts +17 -1
  44. package/src/stack-output.ts +6 -1
  45. package/src/yaml.test.ts +6 -0
  46. package/src/yaml.ts +4 -0
@@ -52,9 +52,14 @@ import {
52
52
  collectCompositeOrigins,
53
53
  collectParamDependencies,
54
54
  collectTagOrigins,
55
+ sourceLocationOf,
56
+ type CompositeCallSite,
55
57
  type CompositeParamScope,
58
+ type HostBindings,
56
59
  } from "./param-deps";
57
- import { getInterpolationFields, setPathProvenance } from "../provenance";
60
+ import { getInterpolationFields, getProvenance, setPathProvenance, setProvenance } from "../provenance";
61
+ import { foldProvenanceOfEntities, type FoldProvenance } from "../fold-provenance";
62
+ import { collectEntities } from "./collect";
58
63
  import { intrinsicCallFoldsEagerly, intrinsicTagFolds, type IntrinsicDef } from "../lexicon";
59
64
  import type { BuildParamValue } from "../build-params";
60
65
 
@@ -1376,6 +1381,13 @@ interface ResolveCtx {
1376
1381
  * failure as before #1020 — this is strictly additive.
1377
1382
  */
1378
1383
  externals: Map<string, unknown>;
1384
+ /**
1385
+ * INTENTIUS/typescript-as-data#248 — the names in {@link externals} a
1386
+ * lexicon package's export supplied. A props expression that reads one is a
1387
+ * `host-value` origin rather than the file's own `direct` one. Absent on a
1388
+ * context that never records direct-entity provenance.
1389
+ */
1390
+ hostValues?: ReadonlySet<string>;
1379
1391
  /**
1380
1392
  * chant #1020 — for a `file`-local import name whose cross-file
1381
1393
  * resolution attempt failed, WHY (a located, human-readable reason —
@@ -1479,6 +1491,13 @@ interface CompositeParamRecorder {
1479
1491
  * `fold()` sees them.
1480
1492
  */
1481
1493
  consts: Map<string, ts.Expression>;
1494
+ /**
1495
+ * chant #3597 — the composite call this body was reached from, when the
1496
+ * fold saw it as written. A call made from already-folded values (the
1497
+ * nested-value path, {@link resolveCompositeCall}) has no node, and its
1498
+ * members record origins without locations.
1499
+ */
1500
+ call?: CompositeCallSite;
1482
1501
  }
1483
1502
 
1484
1503
  /** `{ value }` when `node`'s shape was recognized and resolved (value may itself be `undefined`/`null` — e.g. an optional composite member that wasn't created); `undefined` when the shape isn't one the live resolver understands (a plain literal, etc.) — callers fall back to the original, unchanged handling for that shape. */
@@ -1698,7 +1717,7 @@ async function resolveImportedCall(
1698
1717
 
1699
1718
  if (factory) {
1700
1719
  const args = await resolveCallArguments(node, calleeName, ctx);
1701
- const interpreted = await interpretCompositeFactory(factory, args, ctx);
1720
+ const interpreted = await interpretCompositeFactory(factory, args, ctx, { file: ctx.file, node });
1702
1721
  if (interpreted) return interpreted.value;
1703
1722
  // Declined — the body's shape passed but something in it did not resolve
1704
1723
  // (an identifier the module's own imports don't reach, a constructor
@@ -1808,7 +1827,17 @@ async function invokeResolvedCallee(
1808
1827
  const packageInternal = ctx.packageRoot !== undefined && isUnderDirectory(modulePath, ctx.packageRoot);
1809
1828
  if (isProjectImport(binding.specifier, ctx.file) && !packageInternal) executionCounts.projectFactoryInvocations += 1;
1810
1829
 
1811
- return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1830
+ const result = (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1831
+ // chant#3608 — a host composite invoked rather than interpreted (one the
1832
+ // host publishes, or one whose body interpretation declined) returns the
1833
+ // host's own instance. Mark it as the interpreted form is marked, so an
1834
+ // instance exported whole is expanded and its members, whole or
1835
+ // destructured, are known to come from a composite.
1836
+ const compositeName = (Fn as { compositeName?: unknown }).compositeName;
1837
+ if (typeof compositeName === "string" && isIndexableObject(result) && !isCompositeInstance(result)) {
1838
+ markHostCompositeInstance(result, compositeName);
1839
+ }
1840
+ return result;
1812
1841
  }
1813
1842
 
1814
1843
  /**
@@ -1902,16 +1931,22 @@ async function resolveCallArguments(
1902
1931
  const args: unknown[] = [];
1903
1932
  for (const argNode of node.arguments) {
1904
1933
  const live = await resolveLiveValue(argNode, ctx);
1905
- if (live === undefined) await resolveSameFileCallReferences(argNode, ctx);
1906
- // chant #1039 — a folded (non-live) argument may itself contain a
1907
- // registered intrinsic tagged template; revive it into the real value
1908
- // before the composite factory actually runs on it (see the "Intrinsic
1909
- // revival" section below `resolveResourceEntity` uses the same way).
1910
- args.push(
1911
- live !== undefined
1912
- ? live.value
1913
- : await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx, helperArgs),
1914
- );
1934
+ if (live !== undefined) {
1935
+ args.push(live.value);
1936
+ continue;
1937
+ }
1938
+ // chant#3610 — the bindings hold for this argument only, so a read of the
1939
+ // same name outside any argument still refuses, whatever the declarator order.
1940
+ const bound = await resolveSameFileCallReferences(argNode, ctx);
1941
+ try {
1942
+ // chant #1039 — a folded (non-live) argument may itself contain a
1943
+ // registered intrinsic tagged template; revive it into the real value
1944
+ // before the composite factory actually runs on it (see the "Intrinsic
1945
+ // revival" section below `resolveResourceEntity` uses the same way).
1946
+ args.push(await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx, helperArgs));
1947
+ } finally {
1948
+ for (const name of bound) ctx.externals.delete(name);
1949
+ }
1915
1950
  }
1916
1951
  return args;
1917
1952
  }
@@ -1933,8 +1968,12 @@ async function resolveCallArguments(
1933
1968
  * resolution that fails, or gives anything but a Declarable or a composite
1934
1969
  * instance, is left alone too, so the reference rejects with the message it
1935
1970
  * always had. Inside a factory body `locals` is empty, so nothing here applies.
1971
+ *
1972
+ * chant#3610 — returns the names it bound, which the caller removes from
1973
+ * `externals` once the argument has folded. A host composite's instance
1974
+ * counts as a composite instance here, as it does everywhere else.
1936
1975
  */
1937
- async function resolveSameFileCallReferences(node: ts.Node, ctx: ResolveCtx): Promise<void> {
1976
+ async function resolveSameFileCallReferences(node: ts.Node, ctx: ResolveCtx): Promise<string[]> {
1938
1977
  const names = new Set<string>();
1939
1978
  const visit = (n: ts.Node): void => {
1940
1979
  if (ts.isIdentifier(n)) {
@@ -1950,6 +1989,7 @@ async function resolveSameFileCallReferences(node: ts.Node, ctx: ResolveCtx): Pr
1950
1989
  ts.forEachChild(n, visit);
1951
1990
  };
1952
1991
  visit(node);
1992
+ const bound: string[] = [];
1953
1993
  for (const name of names) {
1954
1994
  let resolved: LiveResolution;
1955
1995
  try {
@@ -1957,10 +1997,17 @@ async function resolveSameFileCallReferences(node: ts.Node, ctx: ResolveCtx): Pr
1957
1997
  } catch {
1958
1998
  continue;
1959
1999
  }
1960
- if (resolved && (isDeclarable(resolved.value) || isCompositeInstance(resolved.value))) {
2000
+ if (
2001
+ resolved &&
2002
+ (isDeclarable(resolved.value) ||
2003
+ isCompositeInstance(resolved.value) ||
2004
+ hostCompositeName(resolved.value) !== undefined)
2005
+ ) {
1961
2006
  ctx.externals.set(name, resolved.value);
2007
+ bound.push(name);
1962
2008
  }
1963
2009
  }
2010
+ return bound;
1964
2011
  }
1965
2012
 
1966
2013
  /** A same-file `const name = Callee(...)` that `fold()` would refuse as a function call used as a value. */
@@ -2798,6 +2845,7 @@ function isLiteralPropertyNameNode(node: ts.PropertyName): boolean {
2798
2845
  function compositeParamRecorder(
2799
2846
  factory: InterpretableFactory,
2800
2847
  moduleConsts: ReadonlyMap<string, ts.Expression>,
2848
+ call?: CompositeCallSite,
2801
2849
  ): CompositeParamRecorder {
2802
2850
  const whole = new Set<string>();
2803
2851
  const destructured = new Map<string, string>();
@@ -2828,7 +2876,7 @@ function compositeParamRecorder(
2828
2876
  }
2829
2877
  }
2830
2878
 
2831
- return { composite: factory.compositeName, scope: { whole, destructured }, consts };
2879
+ return { composite: factory.compositeName, scope: { whole, destructured }, consts, ...(call ? { call } : {}) };
2832
2880
  }
2833
2881
 
2834
2882
  /**
@@ -2852,10 +2900,21 @@ function stampCompositeOrigins(entity: unknown, node: ts.NewExpression, ctx: Res
2852
2900
  }
2853
2901
  }
2854
2902
  if (!propsArg) return;
2855
- const origins = collectCompositeOrigins(propsArg, recorder.consts, recorder.scope, recorder.composite);
2903
+ stampCompositeCall(entity, recorder);
2904
+ const origins = collectCompositeOrigins(propsArg, recorder.consts, recorder.scope, recorder.composite, recorder.call);
2856
2905
  for (const [path, origin] of Object.entries(origins)) setPathProvenance(entity, path, origin);
2857
2906
  }
2858
2907
 
2908
+ /**
2909
+ * chant #3597 — record where the composite call that expanded `entity` was
2910
+ * written, beside the per-path origins and in the same pass. First writer
2911
+ * wins, as for every provenance field, so the innermost call is kept.
2912
+ */
2913
+ function stampCompositeCall(entity: object, recorder: CompositeParamRecorder): void {
2914
+ if (!recorder.call) return;
2915
+ setProvenance(entity, { compositeCall: sourceLocationOf(recorder.call.file, recorder.call.node) });
2916
+ }
2917
+
2859
2918
  /**
2860
2919
  * chant #3212 — the same record for an entity a registered tag built inside an
2861
2920
  * interpreted factory body (`` table`CREATE TABLE ${props.name} ...` ``).
@@ -2873,7 +2932,16 @@ function stampTagOrigins(entity: unknown, node: ts.TaggedTemplateExpression, ctx
2873
2932
  const interpolations = ts.isTemplateExpression(node.template)
2874
2933
  ? node.template.templateSpans.map((span) => span.expression)
2875
2934
  : [];
2876
- const origins = collectTagOrigins(interpolations, fields, entity.props, recorder.consts, recorder.scope, recorder.composite);
2935
+ stampCompositeCall(entity, recorder);
2936
+ const origins = collectTagOrigins(
2937
+ interpolations,
2938
+ fields,
2939
+ entity.props,
2940
+ recorder.consts,
2941
+ recorder.scope,
2942
+ recorder.composite,
2943
+ recorder.call,
2944
+ );
2877
2945
  for (const [path, origin] of Object.entries(origins)) setPathProvenance(entity, path, origin);
2878
2946
  }
2879
2947
 
@@ -2892,6 +2960,7 @@ async function interpretCompositeFactory(
2892
2960
  factory: InterpretableFactory,
2893
2961
  args: readonly unknown[],
2894
2962
  ctx: ResolveCtx,
2963
+ call?: CompositeCallSite,
2895
2964
  ): Promise<{ value: unknown } | undefined> {
2896
2965
  // A `Composite()` factory takes exactly one props argument. Anything else at
2897
2966
  // the call site means the callee is being used as something this doesn't
@@ -2932,7 +3001,7 @@ async function interpretCompositeFactory(
2932
3001
  interpretDepth: ctx.interpretDepth + 1,
2933
3002
  // chant #2161 — set unconditionally, and NOT inherited from `ctx`: a nested
2934
3003
  // composite records its own parameters, never the enclosing one's.
2935
- compositeParams: compositeParamRecorder(factory, scope.consts),
3004
+ compositeParams: compositeParamRecorder(factory, scope.consts, call),
2936
3005
  };
2937
3006
 
2938
3007
  const bind = (name: string, value: unknown): void => {
@@ -2973,7 +3042,7 @@ async function interpretCompositeFactory(
2973
3042
  // rejecting it for not being chant's rather than for being malformed.
2974
3043
  // Chant's own form still goes through `Composite`, unchanged.
2975
3044
  const value = factory.hostForm
2976
- ? members
3045
+ ? markHostCompositeInstance(members, factory.compositeName)
2977
3046
  : (Composite<void, CompositeMembers>(() => members as CompositeMembers, factory.compositeName))();
2978
3047
  executionCounts.factoryInterpretations += 1;
2979
3048
  return { value };
@@ -2987,6 +3056,53 @@ async function interpretCompositeFactory(
2987
3056
  }
2988
3057
  }
2989
3058
 
3059
+ /**
3060
+ * chant#3598 — a host composite instance's own marker. Non-enumerable and
3061
+ * symbol-keyed, like provenance, so nothing that serializes or compares the
3062
+ * exports sees it. It carries the composite's name, which the members object
3063
+ * otherwise has no record of once it is returned.
3064
+ */
3065
+ const HOST_COMPOSITE_INSTANCE = Symbol.for("chant.fold.hostCompositeInstance");
3066
+
3067
+ /**
3068
+ * Mark `members` as the instance a host's composite produced, and return it
3069
+ * unchanged.
3070
+ *
3071
+ * chant#3608 — every declarable member, and every member of a nested host
3072
+ * instance, is also stamped with the composite, the way `expandComposite`
3073
+ * stamps a chant instance's members. A destructured member is never expanded,
3074
+ * so this is the only record it has that a composite wrote it, and without
3075
+ * it a path the fold did not attribute reads as authored and so `direct`.
3076
+ * `setProvenance` keeps the first writer, and a nested instance was marked
3077
+ * when its own call returned, so a member keeps its innermost composite.
3078
+ */
3079
+ function markHostCompositeInstance(members: Record<string, unknown>, compositeName: string): Record<string, unknown> {
3080
+ if (Object.isExtensible(members) && !Object.prototype.hasOwnProperty.call(members, HOST_COMPOSITE_INSTANCE)) {
3081
+ Object.defineProperty(members, HOST_COMPOSITE_INSTANCE, {
3082
+ value: compositeName,
3083
+ enumerable: false,
3084
+ configurable: true,
3085
+ });
3086
+ }
3087
+ stampHostCompositeMembers(members, compositeName);
3088
+ return members;
3089
+ }
3090
+
3091
+ /** Stamp `composite` on each declarable member of a host instance, descending into nested host instances. */
3092
+ function stampHostCompositeMembers(members: Record<string, unknown>, compositeName: string): void {
3093
+ for (const member of Object.values(members)) {
3094
+ if (isDeclarable(member)) setProvenance(member, { composite: compositeName });
3095
+ else if (hostCompositeName(member) !== undefined) stampHostCompositeMembers(member as Record<string, unknown>, compositeName);
3096
+ }
3097
+ }
3098
+
3099
+ /** The composite name a host composite instance was marked with, or `undefined` for any other value. */
3100
+ function hostCompositeName(value: unknown): string | undefined {
3101
+ if (!isIndexableObject(value)) return undefined;
3102
+ const name = (value as Record<symbol, unknown>)[HOST_COMPOSITE_INSTANCE];
3103
+ return typeof name === "string" ? name : undefined;
3104
+ }
3105
+
2990
3106
  /** Evaluate an admissible factory body's statements in order, binding each `const`, and return the `return` expression's value. */
2991
3107
  async function interpretFactoryBody(
2992
3108
  fn: ts.ArrowFunction | ts.FunctionExpression,
@@ -3829,13 +3945,19 @@ function paramLocalNames(ctx: ResolveCtx): Set<string> {
3829
3945
  /**
3830
3946
  * chant #1443 — record which build parameters each of a resource's authored
3831
3947
  * property expressions reads, before fold substitutes them away. Best-effort
3832
- * and additive: a file with no `params` import, or a constructor called with no
3833
- * object literal, records nothing.
3948
+ * and additive: a constructor called with no object literal records nothing.
3949
+ *
3950
+ * INTENTIUS/typescript-as-data#248 — and which of them read host code, a
3951
+ * method call, an eager lexicon helper or a lexicon export, so fold provenance
3952
+ * reports those `unknown` rather than `direct`.
3834
3953
  */
3835
3954
  function stampParamDependencies(entity: unknown, node: ts.NewExpression, ctx: ResolveCtx): void {
3836
3955
  if (typeof entity !== "object" || entity === null) return;
3837
3956
  const paramLocals = paramLocalNames(ctx);
3838
- if (paramLocals.size === 0) return;
3957
+ const host: HostBindings = {
3958
+ values: ctx.hostValues ?? new Set(),
3959
+ eagerCalls: new Set(ctx.intrinsics.filter((i) => intrinsicCallFoldsEagerly(i)).map((i) => i.name)),
3960
+ };
3839
3961
  // The same argument `foldResource` treats as props: the first object literal.
3840
3962
  let propsArg: ts.ObjectLiteralExpression | undefined;
3841
3963
  for (const argument of node.arguments ?? []) {
@@ -3845,7 +3967,7 @@ function stampParamDependencies(entity: unknown, node: ts.NewExpression, ctx: Re
3845
3967
  }
3846
3968
  }
3847
3969
  if (!propsArg) return;
3848
- for (const [path, origin] of Object.entries(collectParamDependencies(propsArg, ctx.consts, paramLocals))) {
3970
+ for (const [path, origin] of Object.entries(collectParamDependencies(propsArg, ctx.consts, paramLocals, host))) {
3849
3971
  setPathProvenance(entity, path, origin);
3850
3972
  }
3851
3973
  }
@@ -4085,13 +4207,20 @@ async function buildExternals(
4085
4207
  * a path lexicon's are, never folded as project files.
4086
4208
  */
4087
4209
  extraLexiconRoots: readonly string[] = [],
4088
- ): Promise<{ externals: Map<string, unknown>; failures: Map<string, string>; liveSources: Set<string> }> {
4210
+ ): Promise<{
4211
+ externals: Map<string, unknown>;
4212
+ failures: Map<string, string>;
4213
+ liveSources: Set<string>;
4214
+ /** Local names bound to a lexicon package's export (INTENTIUS/typescript-as-data#248). */
4215
+ hostValues: Set<string>;
4216
+ }> {
4089
4217
  const lexiconRoots =
4090
4218
  extraLexiconRoots.length === 0 ? session.lexiconRoots : [...session.lexiconRoots, ...extraLexiconRoots];
4091
4219
  const externals = new Map<string, unknown>();
4092
4220
  const failures = new Map<string, string>();
4093
4221
  // chant #1044 — see `FoldFileResult.liveSources`.
4094
4222
  const liveSources = new Set<string>();
4223
+ const hostValues = new Set<string>();
4095
4224
 
4096
4225
  for (const [localName, binding] of imports) {
4097
4226
  // chant #1064 — a named `params` import that resolves to chant-core's own
@@ -4168,6 +4297,7 @@ async function buildExternals(
4168
4297
  const lexiconExport = await resolveActiveLexiconExport(binding, file, session);
4169
4298
  if (lexiconExport) {
4170
4299
  externals.set(localName, lexiconExport.value);
4300
+ hostValues.add(localName);
4171
4301
  continue;
4172
4302
  }
4173
4303
  // Every other bare specifier (a non-lexicon vendor package, a lexicon
@@ -4202,7 +4332,10 @@ async function buildExternals(
4202
4332
  // with no `liveSources` edge for the same reason.
4203
4333
  if (session.lexiconModules.has(targetPath)) {
4204
4334
  const lexiconExport = await resolveActiveLexiconExport(binding, file, session);
4205
- if (lexiconExport) externals.set(localName, lexiconExport.value);
4335
+ if (lexiconExport) {
4336
+ externals.set(localName, lexiconExport.value);
4337
+ hostValues.add(localName);
4338
+ }
4206
4339
  continue;
4207
4340
  }
4208
4341
  // chant#2590 — another file of a multi-file path lexicon is a lexicon
@@ -4220,7 +4353,10 @@ async function buildExternals(
4220
4353
  // constructor or composite resolves through the import path.
4221
4354
  if (inPathLexiconRoot(targetPath, extraLexiconRoots)) {
4222
4355
  const value = await packageDataExport(targetPath, binding.imported, session);
4223
- if (value) externals.set(localName, value.value);
4356
+ if (value) {
4357
+ externals.set(localName, value.value);
4358
+ hostValues.add(localName);
4359
+ }
4224
4360
  continue;
4225
4361
  }
4226
4362
  if (inPathLexiconRoot(targetPath, lexiconRoots)) continue;
@@ -4269,7 +4405,7 @@ async function buildExternals(
4269
4405
  }
4270
4406
  }
4271
4407
 
4272
- return { externals, failures, liveSources };
4408
+ return { externals, failures, liveSources, hostValues };
4273
4409
  }
4274
4410
 
4275
4411
  /**
@@ -4345,7 +4481,12 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
4345
4481
  if (scan.declarators.length === 0) return { ok: false, reason: "no foldable resource exports" };
4346
4482
 
4347
4483
  const collected = collectImports(sourceFile);
4348
- const { externals, failures, liveSources } = await buildExternals(file, collected.named, collected.namespaces, session);
4484
+ const { externals, failures, liveSources, hostValues } = await buildExternals(
4485
+ file,
4486
+ collected.named,
4487
+ collected.namespaces,
4488
+ session,
4489
+ );
4349
4490
 
4350
4491
  const ctx: ResolveCtx = {
4351
4492
  file,
@@ -4356,6 +4497,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
4356
4497
  memo: new Map(),
4357
4498
  intrinsics: session.intrinsics,
4358
4499
  externals,
4500
+ hostValues,
4359
4501
  crossFileFailures: failures,
4360
4502
  importCache: session.importCache,
4361
4503
  resolvePathCache: session.resolvePathCache,
@@ -4850,6 +4992,20 @@ export interface FoldProjectVerdict {
4850
4992
  readonly taintedBy?: { from: string; kind: TaintEdgeKind };
4851
4993
  /** The file's complete export namespace, present only when `verdict` is "fold". */
4852
4994
  readonly exports?: ReadonlyMap<string, unknown>;
4995
+ /**
4996
+ * chant#3598 — fold provenance for the entities this file contributes,
4997
+ * keyed by entity name, in the shape `build()` reports as
4998
+ * `BuildResult.foldProvenance`. Present only when `verdict` is "fold" and
4999
+ * the folded files' entities could be collected (a duplicate entity name
5000
+ * fails collection for the whole set, as it fails a build, and then no
5001
+ * verdict carries it). A file that folds and declares nothing with
5002
+ * properties carries `{}`.
5003
+ *
5004
+ * A "run" file has no entry: foldProject never executes one, so it has no
5005
+ * entities to attribute. That holds under `sandbox` too, where nothing
5006
+ * crosses the child-process wire because no child is started.
5007
+ */
5008
+ readonly foldProvenance?: FoldProvenance;
4853
5009
  }
4854
5010
 
4855
5011
  /**
@@ -4910,12 +5066,29 @@ export async function foldProject(
4910
5066
  })),
4911
5067
  );
4912
5068
 
5069
+ const folded = files.filter((file) => attempts.get(file)?.ok === true && !plan.tainted.has(file));
5070
+ const provenanceByFile = foldProvenanceByFile(
5071
+ folded.map((file) => {
5072
+ const attempt = attempts.get(file);
5073
+ return {
5074
+ file,
5075
+ exports: attempt?.ok === true ? expandHostCompositeExports(Object.fromEntries(attempt.exportedValues)) : {},
5076
+ };
5077
+ }),
5078
+ );
5079
+
4913
5080
  const out = new Map<string, FoldProjectVerdict>();
4914
5081
  for (const file of files) {
4915
5082
  const attempt = attempts.get(file)!;
4916
5083
  const tentative = attempt.ok ? "fold" : "run";
4917
5084
  if (attempt.ok && !plan.tainted.has(file)) {
4918
- out.set(file, { verdict: "fold", tentative, exports: attempt.exportedValues });
5085
+ const foldProvenance = provenanceByFile?.get(file);
5086
+ out.set(file, {
5087
+ verdict: "fold",
5088
+ tentative,
5089
+ exports: attempt.exportedValues,
5090
+ ...(foldProvenance ? { foldProvenance } : {}),
5091
+ });
4919
5092
  continue;
4920
5093
  }
4921
5094
  out.set(file, {
@@ -4927,3 +5100,124 @@ export async function foldProject(
4927
5100
  }
4928
5101
  return out;
4929
5102
  }
5103
+
5104
+ /**
5105
+ * chant#3598 — the folded files' fold provenance, split by declaring file.
5106
+ *
5107
+ * This is `build()`'s computation, not a second one: the same
5108
+ * `collectEntities` over the same folded export namespaces `discover()` hands
5109
+ * it, then `foldProvenanceOfEntities` with `getProvenance`. One call over the
5110
+ * whole folded set rather than one per file, because composite expansion and
5111
+ * cross-directory name disambiguation (#932) are decided over the set and an
5112
+ * entity's name is part of what is being reported. The build root is the
5113
+ * files' common directory, which is what `chant build <dir>` passes when every
5114
+ * file sits under `<dir>`.
5115
+ *
5116
+ * Returns `undefined` when collection throws (a duplicate name), the case
5117
+ * where a build has no entities either.
5118
+ */
5119
+ function foldProvenanceByFile(
5120
+ modules: Array<{ file: string; exports: Record<string, unknown> }>,
5121
+ ): Map<string, FoldProvenance> | undefined {
5122
+ if (modules.length === 0) return new Map();
5123
+ let entities: Map<string, Declarable>;
5124
+ try {
5125
+ entities = collectEntities(modules, commonDirectory(modules.map((m) => m.file)));
5126
+ } catch {
5127
+ return undefined;
5128
+ }
5129
+
5130
+ const byResolved = new Map<string, FoldProvenance>();
5131
+ const out = new Map<string, FoldProvenance>();
5132
+ for (const { file } of modules) {
5133
+ const record: FoldProvenance = {};
5134
+ byResolved.set(resolvePath(file), record);
5135
+ out.set(file, record);
5136
+ }
5137
+
5138
+ const all = foldProvenanceOfEntities(entities, getProvenance);
5139
+ for (const [name, record] of Object.entries(all)) {
5140
+ const sourceFile = getProvenance(entities.get(name) as Declarable)?.sourceFile;
5141
+ const target = sourceFile === undefined ? undefined : byResolved.get(resolvePath(sourceFile));
5142
+ if (target) target[name] = record;
5143
+ }
5144
+ return out;
5145
+ }
5146
+
5147
+ /**
5148
+ * chant#3598 — a file's exports with every host composite instance replaced by
5149
+ * its members, for {@link foldProvenanceByFile} only.
5150
+ *
5151
+ * A host's registration form (chant#2442) returns the members object the
5152
+ * factory built, not a chant `CompositeInstance`, because wrapping it would run
5153
+ * chant's member validation against the host's own entities. `collectEntities`
5154
+ * expands only chant's instances, so an instance exported whole
5155
+ * (`export const store = Store({...})`) contributed no entity and therefore no
5156
+ * record, while a destructured member, a plain declarable export, did.
5157
+ *
5158
+ * The members are named and stamped the way `expandComposite` names and stamps
5159
+ * a chant instance: `${exportName}${Member}`, with a whole-entity `composite`
5160
+ * origin under the paths the fold already attributed. That origin is written
5161
+ * before `collectEntities` adds its `authored` one, and the first writer wins,
5162
+ * so a path the fold did not attribute is `unknown` rather than `direct`.
5163
+ *
5164
+ * Only a top-level export is expanded. A host instance nested in another
5165
+ * composite's body or placed in an array does not fold today, so its file is
5166
+ * `run` and has no record to give.
5167
+ *
5168
+ * The exports a caller receives in the verdict are the originals.
5169
+ */
5170
+ function expandHostCompositeExports(exports: Record<string, unknown>): Record<string, unknown> {
5171
+ const out: Record<string, unknown> = {};
5172
+ for (const [name, value] of Object.entries(exports)) {
5173
+ if (hostCompositeName(value) === undefined) {
5174
+ out[name] = value;
5175
+ continue;
5176
+ }
5177
+ expandHostCompositeInstance(name, value as Record<string, unknown>, name, out);
5178
+ }
5179
+ return out;
5180
+ }
5181
+
5182
+ /**
5183
+ * chant#3608 — one host instance's members into `out`, a nested host instance
5184
+ * expanded in place under its member's name (`siteMainBucket`), as
5185
+ * `expandComposite` expands a nested chant instance. Every member belongs to
5186
+ * the top-level `instance`; its composite is the innermost one, which
5187
+ * {@link markHostCompositeInstance} already stamped, so the whole-entity
5188
+ * origin names that composite too.
5189
+ */
5190
+ function expandHostCompositeInstance(
5191
+ prefix: string,
5192
+ members: Record<string, unknown>,
5193
+ instance: string,
5194
+ out: Record<string, unknown>,
5195
+ ): void {
5196
+ const compositeName = hostCompositeName(members) as string;
5197
+ for (const [memberName, member] of Object.entries(members)) {
5198
+ if (memberName.length === 0) continue;
5199
+ const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
5200
+ if (hostCompositeName(member) !== undefined) {
5201
+ expandHostCompositeInstance(fullName, member as Record<string, unknown>, instance, out);
5202
+ continue;
5203
+ }
5204
+ if (!isDeclarable(member)) continue;
5205
+ setProvenance(member, { composite: compositeName, compositeInstance: instance });
5206
+ const composite = getProvenance(member)?.composite ?? compositeName;
5207
+ setPathProvenance(member, "", { kind: "composite", composite, instance });
5208
+ out[fullName] = member;
5209
+ }
5210
+ }
5211
+
5212
+ /** The deepest directory every one of `files` sits under. */
5213
+ function commonDirectory(files: readonly string[]): string {
5214
+ const split = files.map((file) => dirname(resolvePath(file)).split(sep));
5215
+ const first = split[0];
5216
+ let depth = first.length;
5217
+ for (const parts of split.slice(1)) {
5218
+ let i = 0;
5219
+ while (i < depth && i < parts.length && parts[i] === first[i]) i++;
5220
+ depth = i;
5221
+ }
5222
+ return first.slice(0, depth).join(sep) || sep;
5223
+ }
@@ -96,15 +96,28 @@ describe("lexicon-package and re-exported composites are interpreted (chant#3247
96
96
  expect(folded.errors).toEqual([]);
97
97
  expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 1, factoryInvocations: 0 });
98
98
  const fields = folded.foldProvenance.sinkAssignmentActivityLogSink!.fields;
99
+ // chant#3597 — where the call and each argument were written in main.ts.
100
+ const file = join(srcDir, "main.ts");
101
+ const lines = CALL_SINK.split("\n");
102
+ const line = lines.findIndex((l) => l.includes("ActivityLogSink({")) + 1;
103
+ const at = (needle: string) => ({ file, line, column: lines[line - 1].indexOf(needle) + 1 });
104
+ const call = at("ActivityLogSink({");
105
+ const written: Record<string, string> = {
106
+ location: 'location: "eastus"',
107
+ workspaceId: 'workspaceId: "/subscriptions/s/workspaces/logs"',
108
+ };
99
109
  const param = (...parameters: string[]) => ({
100
110
  kind: "composite-parameter",
101
111
  composite: "ActivityLogSink",
102
112
  instance: "sink",
103
113
  parameters,
114
+ call,
115
+ arguments: parameters.map((parameter) => ({ parameter, ...at(written[parameter]), text: written[parameter] })),
104
116
  });
105
117
  expect(fields.location).toEqual(param("location"));
106
118
  expect(fields["parameters.logAnalytics.value"]).toEqual(param("workspaceId"));
107
- expect(fields.name).toEqual({ kind: "composite-literal", composite: "ActivityLogSink", instance: "sink" });
119
+ expect(fields.name).toEqual({ kind: "composite-literal", composite: "ActivityLogSink", instance: "sink", call });
120
+ expect(folded.foldProvenance.sinkAssignmentActivityLogSink!.compositeCall).toEqual(call);
108
121
 
109
122
  // Interpreting is not a second evaluation: the run path writes the same bytes.
110
123
  const run = await build(srcDir, [propsSerializer("azure")], undefined, { fold: false, lexicons: ["azure"] });