@intentius/chant 0.108.1 → 0.109.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/composite.ts CHANGED
@@ -134,6 +134,16 @@ export function Composite<P, M extends CompositeFactoryMembers = CompositeMember
134
134
  }
135
135
  }
136
136
 
137
+ // chant#3608 — every member records the composite that built it, here,
138
+ // when the instance is made. A member destructured from the call
139
+ // (`export const { vpc } = VpcDefault({})`) is never expanded, so this is
140
+ // the only trace that a composite wrote it; `collectEntities` reads it
141
+ // through {@link compositeOfMember}. A nested instance recorded its own
142
+ // members first and the first record is kept, so a member keeps its
143
+ // innermost composite. Kept off the provenance record so the composite
144
+ // `expandComposite` records for an instance exported whole is unchanged.
145
+ markCompositeMembers(members, compositeName);
146
+
137
147
  // Define `members` and `_definition` as non-enumerable so spreading a
138
148
  // composite instance (`...someComposite`) only exposes the actual member
139
149
  // resources, not the framework's bookkeeping properties. Without this, a
@@ -156,6 +166,33 @@ export function Composite<P, M extends CompositeFactoryMembers = CompositeMember
156
166
  return definition;
157
167
  }
158
168
 
169
+ /** Non-enumerable, symbol-keyed: invisible to serializers, spreads and comparisons, like provenance. */
170
+ const COMPOSITE_MEMBER_OF = Symbol.for("chant.composite.memberOf");
171
+
172
+ /** Record `compositeName` on each declarable member, descending into nested instances. First record wins. */
173
+ function markCompositeMembers(members: object, compositeName: string): void {
174
+ for (const member of Object.values(members)) {
175
+ if (isCompositeInstance(member)) {
176
+ markCompositeMembers(member.members, compositeName);
177
+ } else if (
178
+ isDeclarable(member) &&
179
+ Object.isExtensible(member) &&
180
+ !Object.prototype.hasOwnProperty.call(member, COMPOSITE_MEMBER_OF)
181
+ ) {
182
+ Object.defineProperty(member, COMPOSITE_MEMBER_OF, { value: compositeName, enumerable: false, configurable: true });
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * chant#3608 — the composite whose call built `entity`, or `undefined` for an
189
+ * entity no chant composite built.
190
+ */
191
+ export function compositeOfMember(entity: object): string | undefined {
192
+ const name = (entity as Record<symbol, unknown>)[COMPOSITE_MEMBER_OF];
193
+ return typeof name === "string" ? name : undefined;
194
+ }
195
+
159
196
  /**
160
197
  * Expands a CompositeInstance into a flat Map of prefixed entity names to Declarables.
161
198
  * Handles nested composites recursively.
@@ -1,6 +1,6 @@
1
1
  import { basename, dirname, relative, resolve } from "node:path";
2
2
  import { isDeclarable, type Declarable } from "../declarable";
3
- import { isCompositeInstance, expandComposite } from "../composite";
3
+ import { isCompositeInstance, expandComposite, compositeOfMember } from "../composite";
4
4
  import { isLexiconOutput } from "../lexicon-output";
5
5
  import { DiscoveryError } from "../errors";
6
6
  import { setProvenance, type EntityProvenance } from "../provenance";
@@ -43,6 +43,16 @@ function stackPrefix(file: string, buildRoot: string | undefined): string {
43
43
  */
44
44
  const AUTHORED_ROOT: EntityProvenance["paths"] = { "": { kind: "authored" } };
45
45
 
46
+ /**
47
+ * The record for an entity exported on its own. chant#3608: one destructured
48
+ * from a composite call also names that composite, so fold provenance reads
49
+ * its unattributed paths as `unknown` rather than as the file's own `direct`.
50
+ */
51
+ function authoredProvenance(entity: Declarable, file: string): EntityProvenance {
52
+ const composite = compositeOfMember(entity);
53
+ return { sourceFile: file, ...(composite ? { composite } : {}), paths: AUTHORED_ROOT };
54
+ }
55
+
46
56
  /** One entity to be placed into the map, produced by {@link enumerateEntries}. */
47
57
  interface PendingEntry {
48
58
  /** The un-disambiguated map key this entity would take (export name, indexed
@@ -82,12 +92,12 @@ function enumerateEntries(
82
92
  for (const [rawName, value] of sortedExports) {
83
93
  const name = exportKey(rawName, file);
84
94
  if (isDeclarable(value)) {
85
- entries.push({ bareKey: name, value, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
95
+ entries.push({ bareKey: name, value, file, provenance: authoredProvenance(value, file) });
86
96
  } else if (Array.isArray(value)) {
87
97
  for (let i = 0; i < value.length; i++) {
88
98
  const item = value[i];
89
99
  if (isDeclarable(item)) {
90
- entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
100
+ entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: authoredProvenance(item, file) });
91
101
  } else if (isCompositeInstance(item)) {
92
102
  const indexedName = `${name}_${i}`;
93
103
  for (const [expandedName, entity] of expandComposite(indexedName, item)) {
@@ -52,9 +52,13 @@ import {
52
52
  collectCompositeOrigins,
53
53
  collectParamDependencies,
54
54
  collectTagOrigins,
55
+ sourceLocationOf,
56
+ type CompositeCallSite,
55
57
  type CompositeParamScope,
56
58
  } from "./param-deps";
57
- import { getInterpolationFields, setPathProvenance } from "../provenance";
59
+ import { getInterpolationFields, getProvenance, setPathProvenance, setProvenance } from "../provenance";
60
+ import { foldProvenanceOfEntities, type FoldProvenance } from "../fold-provenance";
61
+ import { collectEntities } from "./collect";
58
62
  import { intrinsicCallFoldsEagerly, intrinsicTagFolds, type IntrinsicDef } from "../lexicon";
59
63
  import type { BuildParamValue } from "../build-params";
60
64
 
@@ -1479,6 +1483,13 @@ interface CompositeParamRecorder {
1479
1483
  * `fold()` sees them.
1480
1484
  */
1481
1485
  consts: Map<string, ts.Expression>;
1486
+ /**
1487
+ * chant #3597 — the composite call this body was reached from, when the
1488
+ * fold saw it as written. A call made from already-folded values (the
1489
+ * nested-value path, {@link resolveCompositeCall}) has no node, and its
1490
+ * members record origins without locations.
1491
+ */
1492
+ call?: CompositeCallSite;
1482
1493
  }
1483
1494
 
1484
1495
  /** `{ 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 +1709,7 @@ async function resolveImportedCall(
1698
1709
 
1699
1710
  if (factory) {
1700
1711
  const args = await resolveCallArguments(node, calleeName, ctx);
1701
- const interpreted = await interpretCompositeFactory(factory, args, ctx);
1712
+ const interpreted = await interpretCompositeFactory(factory, args, ctx, { file: ctx.file, node });
1702
1713
  if (interpreted) return interpreted.value;
1703
1714
  // Declined — the body's shape passed but something in it did not resolve
1704
1715
  // (an identifier the module's own imports don't reach, a constructor
@@ -1808,7 +1819,17 @@ async function invokeResolvedCallee(
1808
1819
  const packageInternal = ctx.packageRoot !== undefined && isUnderDirectory(modulePath, ctx.packageRoot);
1809
1820
  if (isProjectImport(binding.specifier, ctx.file) && !packageInternal) executionCounts.projectFactoryInvocations += 1;
1810
1821
 
1811
- return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1822
+ const result = (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1823
+ // chant#3608 — a host composite invoked rather than interpreted (one the
1824
+ // host publishes, or one whose body interpretation declined) returns the
1825
+ // host's own instance. Mark it as the interpreted form is marked, so an
1826
+ // instance exported whole is expanded and its members, whole or
1827
+ // destructured, are known to come from a composite.
1828
+ const compositeName = (Fn as { compositeName?: unknown }).compositeName;
1829
+ if (typeof compositeName === "string" && isIndexableObject(result) && !isCompositeInstance(result)) {
1830
+ markHostCompositeInstance(result, compositeName);
1831
+ }
1832
+ return result;
1812
1833
  }
1813
1834
 
1814
1835
  /**
@@ -2798,6 +2819,7 @@ function isLiteralPropertyNameNode(node: ts.PropertyName): boolean {
2798
2819
  function compositeParamRecorder(
2799
2820
  factory: InterpretableFactory,
2800
2821
  moduleConsts: ReadonlyMap<string, ts.Expression>,
2822
+ call?: CompositeCallSite,
2801
2823
  ): CompositeParamRecorder {
2802
2824
  const whole = new Set<string>();
2803
2825
  const destructured = new Map<string, string>();
@@ -2828,7 +2850,7 @@ function compositeParamRecorder(
2828
2850
  }
2829
2851
  }
2830
2852
 
2831
- return { composite: factory.compositeName, scope: { whole, destructured }, consts };
2853
+ return { composite: factory.compositeName, scope: { whole, destructured }, consts, ...(call ? { call } : {}) };
2832
2854
  }
2833
2855
 
2834
2856
  /**
@@ -2852,10 +2874,21 @@ function stampCompositeOrigins(entity: unknown, node: ts.NewExpression, ctx: Res
2852
2874
  }
2853
2875
  }
2854
2876
  if (!propsArg) return;
2855
- const origins = collectCompositeOrigins(propsArg, recorder.consts, recorder.scope, recorder.composite);
2877
+ stampCompositeCall(entity, recorder);
2878
+ const origins = collectCompositeOrigins(propsArg, recorder.consts, recorder.scope, recorder.composite, recorder.call);
2856
2879
  for (const [path, origin] of Object.entries(origins)) setPathProvenance(entity, path, origin);
2857
2880
  }
2858
2881
 
2882
+ /**
2883
+ * chant #3597 — record where the composite call that expanded `entity` was
2884
+ * written, beside the per-path origins and in the same pass. First writer
2885
+ * wins, as for every provenance field, so the innermost call is kept.
2886
+ */
2887
+ function stampCompositeCall(entity: object, recorder: CompositeParamRecorder): void {
2888
+ if (!recorder.call) return;
2889
+ setProvenance(entity, { compositeCall: sourceLocationOf(recorder.call.file, recorder.call.node) });
2890
+ }
2891
+
2859
2892
  /**
2860
2893
  * chant #3212 — the same record for an entity a registered tag built inside an
2861
2894
  * interpreted factory body (`` table`CREATE TABLE ${props.name} ...` ``).
@@ -2873,7 +2906,16 @@ function stampTagOrigins(entity: unknown, node: ts.TaggedTemplateExpression, ctx
2873
2906
  const interpolations = ts.isTemplateExpression(node.template)
2874
2907
  ? node.template.templateSpans.map((span) => span.expression)
2875
2908
  : [];
2876
- const origins = collectTagOrigins(interpolations, fields, entity.props, recorder.consts, recorder.scope, recorder.composite);
2909
+ stampCompositeCall(entity, recorder);
2910
+ const origins = collectTagOrigins(
2911
+ interpolations,
2912
+ fields,
2913
+ entity.props,
2914
+ recorder.consts,
2915
+ recorder.scope,
2916
+ recorder.composite,
2917
+ recorder.call,
2918
+ );
2877
2919
  for (const [path, origin] of Object.entries(origins)) setPathProvenance(entity, path, origin);
2878
2920
  }
2879
2921
 
@@ -2892,6 +2934,7 @@ async function interpretCompositeFactory(
2892
2934
  factory: InterpretableFactory,
2893
2935
  args: readonly unknown[],
2894
2936
  ctx: ResolveCtx,
2937
+ call?: CompositeCallSite,
2895
2938
  ): Promise<{ value: unknown } | undefined> {
2896
2939
  // A `Composite()` factory takes exactly one props argument. Anything else at
2897
2940
  // the call site means the callee is being used as something this doesn't
@@ -2932,7 +2975,7 @@ async function interpretCompositeFactory(
2932
2975
  interpretDepth: ctx.interpretDepth + 1,
2933
2976
  // chant #2161 — set unconditionally, and NOT inherited from `ctx`: a nested
2934
2977
  // composite records its own parameters, never the enclosing one's.
2935
- compositeParams: compositeParamRecorder(factory, scope.consts),
2978
+ compositeParams: compositeParamRecorder(factory, scope.consts, call),
2936
2979
  };
2937
2980
 
2938
2981
  const bind = (name: string, value: unknown): void => {
@@ -2973,7 +3016,7 @@ async function interpretCompositeFactory(
2973
3016
  // rejecting it for not being chant's rather than for being malformed.
2974
3017
  // Chant's own form still goes through `Composite`, unchanged.
2975
3018
  const value = factory.hostForm
2976
- ? members
3019
+ ? markHostCompositeInstance(members, factory.compositeName)
2977
3020
  : (Composite<void, CompositeMembers>(() => members as CompositeMembers, factory.compositeName))();
2978
3021
  executionCounts.factoryInterpretations += 1;
2979
3022
  return { value };
@@ -2987,6 +3030,53 @@ async function interpretCompositeFactory(
2987
3030
  }
2988
3031
  }
2989
3032
 
3033
+ /**
3034
+ * chant#3598 — a host composite instance's own marker. Non-enumerable and
3035
+ * symbol-keyed, like provenance, so nothing that serializes or compares the
3036
+ * exports sees it. It carries the composite's name, which the members object
3037
+ * otherwise has no record of once it is returned.
3038
+ */
3039
+ const HOST_COMPOSITE_INSTANCE = Symbol.for("chant.fold.hostCompositeInstance");
3040
+
3041
+ /**
3042
+ * Mark `members` as the instance a host's composite produced, and return it
3043
+ * unchanged.
3044
+ *
3045
+ * chant#3608 — every declarable member, and every member of a nested host
3046
+ * instance, is also stamped with the composite, the way `expandComposite`
3047
+ * stamps a chant instance's members. A destructured member is never expanded,
3048
+ * so this is the only record it has that a composite wrote it, and without
3049
+ * it a path the fold did not attribute reads as authored and so `direct`.
3050
+ * `setProvenance` keeps the first writer, and a nested instance was marked
3051
+ * when its own call returned, so a member keeps its innermost composite.
3052
+ */
3053
+ function markHostCompositeInstance(members: Record<string, unknown>, compositeName: string): Record<string, unknown> {
3054
+ if (Object.isExtensible(members) && !Object.prototype.hasOwnProperty.call(members, HOST_COMPOSITE_INSTANCE)) {
3055
+ Object.defineProperty(members, HOST_COMPOSITE_INSTANCE, {
3056
+ value: compositeName,
3057
+ enumerable: false,
3058
+ configurable: true,
3059
+ });
3060
+ }
3061
+ stampHostCompositeMembers(members, compositeName);
3062
+ return members;
3063
+ }
3064
+
3065
+ /** Stamp `composite` on each declarable member of a host instance, descending into nested host instances. */
3066
+ function stampHostCompositeMembers(members: Record<string, unknown>, compositeName: string): void {
3067
+ for (const member of Object.values(members)) {
3068
+ if (isDeclarable(member)) setProvenance(member, { composite: compositeName });
3069
+ else if (hostCompositeName(member) !== undefined) stampHostCompositeMembers(member as Record<string, unknown>, compositeName);
3070
+ }
3071
+ }
3072
+
3073
+ /** The composite name a host composite instance was marked with, or `undefined` for any other value. */
3074
+ function hostCompositeName(value: unknown): string | undefined {
3075
+ if (!isIndexableObject(value)) return undefined;
3076
+ const name = (value as Record<symbol, unknown>)[HOST_COMPOSITE_INSTANCE];
3077
+ return typeof name === "string" ? name : undefined;
3078
+ }
3079
+
2990
3080
  /** Evaluate an admissible factory body's statements in order, binding each `const`, and return the `return` expression's value. */
2991
3081
  async function interpretFactoryBody(
2992
3082
  fn: ts.ArrowFunction | ts.FunctionExpression,
@@ -4850,6 +4940,20 @@ export interface FoldProjectVerdict {
4850
4940
  readonly taintedBy?: { from: string; kind: TaintEdgeKind };
4851
4941
  /** The file's complete export namespace, present only when `verdict` is "fold". */
4852
4942
  readonly exports?: ReadonlyMap<string, unknown>;
4943
+ /**
4944
+ * chant#3598 — fold provenance for the entities this file contributes,
4945
+ * keyed by entity name, in the shape `build()` reports as
4946
+ * `BuildResult.foldProvenance`. Present only when `verdict` is "fold" and
4947
+ * the folded files' entities could be collected (a duplicate entity name
4948
+ * fails collection for the whole set, as it fails a build, and then no
4949
+ * verdict carries it). A file that folds and declares nothing with
4950
+ * properties carries `{}`.
4951
+ *
4952
+ * A "run" file has no entry: foldProject never executes one, so it has no
4953
+ * entities to attribute. That holds under `sandbox` too, where nothing
4954
+ * crosses the child-process wire because no child is started.
4955
+ */
4956
+ readonly foldProvenance?: FoldProvenance;
4853
4957
  }
4854
4958
 
4855
4959
  /**
@@ -4910,12 +5014,29 @@ export async function foldProject(
4910
5014
  })),
4911
5015
  );
4912
5016
 
5017
+ const folded = files.filter((file) => attempts.get(file)?.ok === true && !plan.tainted.has(file));
5018
+ const provenanceByFile = foldProvenanceByFile(
5019
+ folded.map((file) => {
5020
+ const attempt = attempts.get(file);
5021
+ return {
5022
+ file,
5023
+ exports: attempt?.ok === true ? expandHostCompositeExports(Object.fromEntries(attempt.exportedValues)) : {},
5024
+ };
5025
+ }),
5026
+ );
5027
+
4913
5028
  const out = new Map<string, FoldProjectVerdict>();
4914
5029
  for (const file of files) {
4915
5030
  const attempt = attempts.get(file)!;
4916
5031
  const tentative = attempt.ok ? "fold" : "run";
4917
5032
  if (attempt.ok && !plan.tainted.has(file)) {
4918
- out.set(file, { verdict: "fold", tentative, exports: attempt.exportedValues });
5033
+ const foldProvenance = provenanceByFile?.get(file);
5034
+ out.set(file, {
5035
+ verdict: "fold",
5036
+ tentative,
5037
+ exports: attempt.exportedValues,
5038
+ ...(foldProvenance ? { foldProvenance } : {}),
5039
+ });
4919
5040
  continue;
4920
5041
  }
4921
5042
  out.set(file, {
@@ -4927,3 +5048,124 @@ export async function foldProject(
4927
5048
  }
4928
5049
  return out;
4929
5050
  }
5051
+
5052
+ /**
5053
+ * chant#3598 — the folded files' fold provenance, split by declaring file.
5054
+ *
5055
+ * This is `build()`'s computation, not a second one: the same
5056
+ * `collectEntities` over the same folded export namespaces `discover()` hands
5057
+ * it, then `foldProvenanceOfEntities` with `getProvenance`. One call over the
5058
+ * whole folded set rather than one per file, because composite expansion and
5059
+ * cross-directory name disambiguation (#932) are decided over the set and an
5060
+ * entity's name is part of what is being reported. The build root is the
5061
+ * files' common directory, which is what `chant build <dir>` passes when every
5062
+ * file sits under `<dir>`.
5063
+ *
5064
+ * Returns `undefined` when collection throws (a duplicate name), the case
5065
+ * where a build has no entities either.
5066
+ */
5067
+ function foldProvenanceByFile(
5068
+ modules: Array<{ file: string; exports: Record<string, unknown> }>,
5069
+ ): Map<string, FoldProvenance> | undefined {
5070
+ if (modules.length === 0) return new Map();
5071
+ let entities: Map<string, Declarable>;
5072
+ try {
5073
+ entities = collectEntities(modules, commonDirectory(modules.map((m) => m.file)));
5074
+ } catch {
5075
+ return undefined;
5076
+ }
5077
+
5078
+ const byResolved = new Map<string, FoldProvenance>();
5079
+ const out = new Map<string, FoldProvenance>();
5080
+ for (const { file } of modules) {
5081
+ const record: FoldProvenance = {};
5082
+ byResolved.set(resolvePath(file), record);
5083
+ out.set(file, record);
5084
+ }
5085
+
5086
+ const all = foldProvenanceOfEntities(entities, getProvenance);
5087
+ for (const [name, record] of Object.entries(all)) {
5088
+ const sourceFile = getProvenance(entities.get(name) as Declarable)?.sourceFile;
5089
+ const target = sourceFile === undefined ? undefined : byResolved.get(resolvePath(sourceFile));
5090
+ if (target) target[name] = record;
5091
+ }
5092
+ return out;
5093
+ }
5094
+
5095
+ /**
5096
+ * chant#3598 — a file's exports with every host composite instance replaced by
5097
+ * its members, for {@link foldProvenanceByFile} only.
5098
+ *
5099
+ * A host's registration form (chant#2442) returns the members object the
5100
+ * factory built, not a chant `CompositeInstance`, because wrapping it would run
5101
+ * chant's member validation against the host's own entities. `collectEntities`
5102
+ * expands only chant's instances, so an instance exported whole
5103
+ * (`export const store = Store({...})`) contributed no entity and therefore no
5104
+ * record, while a destructured member, a plain declarable export, did.
5105
+ *
5106
+ * The members are named and stamped the way `expandComposite` names and stamps
5107
+ * a chant instance: `${exportName}${Member}`, with a whole-entity `composite`
5108
+ * origin under the paths the fold already attributed. That origin is written
5109
+ * before `collectEntities` adds its `authored` one, and the first writer wins,
5110
+ * so a path the fold did not attribute is `unknown` rather than `direct`.
5111
+ *
5112
+ * Only a top-level export is expanded. A host instance nested in another
5113
+ * composite's body or placed in an array does not fold today, so its file is
5114
+ * `run` and has no record to give.
5115
+ *
5116
+ * The exports a caller receives in the verdict are the originals.
5117
+ */
5118
+ function expandHostCompositeExports(exports: Record<string, unknown>): Record<string, unknown> {
5119
+ const out: Record<string, unknown> = {};
5120
+ for (const [name, value] of Object.entries(exports)) {
5121
+ if (hostCompositeName(value) === undefined) {
5122
+ out[name] = value;
5123
+ continue;
5124
+ }
5125
+ expandHostCompositeInstance(name, value as Record<string, unknown>, name, out);
5126
+ }
5127
+ return out;
5128
+ }
5129
+
5130
+ /**
5131
+ * chant#3608 — one host instance's members into `out`, a nested host instance
5132
+ * expanded in place under its member's name (`siteMainBucket`), as
5133
+ * `expandComposite` expands a nested chant instance. Every member belongs to
5134
+ * the top-level `instance`; its composite is the innermost one, which
5135
+ * {@link markHostCompositeInstance} already stamped, so the whole-entity
5136
+ * origin names that composite too.
5137
+ */
5138
+ function expandHostCompositeInstance(
5139
+ prefix: string,
5140
+ members: Record<string, unknown>,
5141
+ instance: string,
5142
+ out: Record<string, unknown>,
5143
+ ): void {
5144
+ const compositeName = hostCompositeName(members) as string;
5145
+ for (const [memberName, member] of Object.entries(members)) {
5146
+ if (memberName.length === 0) continue;
5147
+ const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
5148
+ if (hostCompositeName(member) !== undefined) {
5149
+ expandHostCompositeInstance(fullName, member as Record<string, unknown>, instance, out);
5150
+ continue;
5151
+ }
5152
+ if (!isDeclarable(member)) continue;
5153
+ setProvenance(member, { composite: compositeName, compositeInstance: instance });
5154
+ const composite = getProvenance(member)?.composite ?? compositeName;
5155
+ setPathProvenance(member, "", { kind: "composite", composite, instance });
5156
+ out[fullName] = member;
5157
+ }
5158
+ }
5159
+
5160
+ /** The deepest directory every one of `files` sits under. */
5161
+ function commonDirectory(files: readonly string[]): string {
5162
+ const split = files.map((file) => dirname(resolvePath(file)).split(sep));
5163
+ const first = split[0];
5164
+ let depth = first.length;
5165
+ for (const parts of split.slice(1)) {
5166
+ let i = 0;
5167
+ while (i < depth && i < parts.length && parts[i] === first[i]) i++;
5168
+ depth = i;
5169
+ }
5170
+ return first.slice(0, depth).join(sep) || sep;
5171
+ }
@@ -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"] });