@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.
@@ -70,7 +70,14 @@
70
70
  */
71
71
 
72
72
  import { isResourceDeclarable, type Declarable } from "./declarable";
73
- import { originOfPath, type EntityProvenance, type PathOrigin } from "./provenance";
73
+ import { relative, isAbsolute } from "node:path";
74
+ import {
75
+ originOfPath,
76
+ type ArgumentLocation,
77
+ type EntityProvenance,
78
+ type PathOrigin,
79
+ type SourceLocation,
80
+ } from "./provenance";
74
81
  import type { DeepEntityDrift, PropertyDrift } from "./lifecycle/deep-diff";
75
82
 
76
83
  /** Why a field's origin could not be determined. Both are facts about the BUILD, not about the field. */
@@ -97,8 +104,17 @@ export type UnknownOriginReason =
97
104
  * address and "the `api` call of `AutoscaledService`" is.
98
105
  */
99
106
  export type FoldFieldOrigin =
100
- | { kind: "composite-parameter"; composite: string; instance?: string; parameters: string[] }
101
- | { kind: "composite-literal"; composite: string; instance?: string }
107
+ | {
108
+ kind: "composite-parameter";
109
+ composite: string;
110
+ instance?: string;
111
+ parameters: string[];
112
+ /** Where the composite call was written (chant #3597), when the build saw it. */
113
+ call?: SourceLocation;
114
+ /** Where each parameter's argument was written at that call, in `parameters` order. */
115
+ arguments?: ArgumentLocation[];
116
+ }
117
+ | { kind: "composite-literal"; composite: string; instance?: string; call?: SourceLocation }
102
118
  | { kind: "direct" }
103
119
  | { kind: "unknown"; reason: UnknownOriginReason };
104
120
 
@@ -110,6 +126,11 @@ export interface EntityFoldProvenance {
110
126
  composite?: string;
111
127
  /** The composite call (export name) it belongs to, when one expanded it. */
112
128
  instance?: string;
129
+ /**
130
+ * Where that composite call was written (chant #3597), when the fold
131
+ * interpreted the factory at a call it saw as written.
132
+ */
133
+ compositeCall?: SourceLocation;
113
134
  /**
114
135
  * Emitted property path to origin, in sorted key order. Every path the entity
115
136
  * emits is present: a path with no attribution carries an `unknown` origin
@@ -178,6 +199,7 @@ export function classifyFieldOrigin(
178
199
  ): FoldFieldOrigin {
179
200
  if (!provenance) return { kind: "unknown", reason: "no-provenance" };
180
201
  const instance = provenance.compositeInstance;
202
+ const call = provenance.compositeCall;
181
203
 
182
204
  switch (origin?.kind) {
183
205
  case "composite-parameter":
@@ -186,15 +208,29 @@ export function classifyFieldOrigin(
186
208
  composite: origin.composite,
187
209
  ...(instance ? { instance } : {}),
188
210
  parameters: [...origin.parameters],
211
+ ...(call ? { call: { ...call } } : {}),
212
+ ...(origin.arguments ? { arguments: origin.arguments.map((a) => ({ ...a })) } : {}),
189
213
  };
190
214
  case "composite-literal":
191
- return { kind: "composite-literal", composite: origin.composite, ...(instance ? { instance } : {}) };
215
+ return {
216
+ kind: "composite-literal",
217
+ composite: origin.composite,
218
+ ...(instance ? { instance } : {}),
219
+ ...(call ? { call: { ...call } } : {}),
220
+ };
192
221
  case "composite":
193
222
  // The coarse kind: a composite expanded this, and nothing said which
194
223
  // parameter. Not a parameter, not a fixed literal, and above all not a
195
224
  // direct declaration.
196
225
  return { kind: "unknown", reason: "composite-not-interpreted" };
197
226
  case "authored":
227
+ // chant#3608 — `authored` is the file's own root record, and it reaches
228
+ // a path only when nothing finer did. On an entity a composite wrote (a
229
+ // member destructured from a composite the fold invoked) that means the
230
+ // fold could not attribute the path, which is `unknown`, never `direct`.
231
+ return provenance.composite
232
+ ? { kind: "unknown", reason: "composite-not-interpreted" }
233
+ : { kind: "direct" };
198
234
  case "build-param":
199
235
  // A build parameter governs the expression the AUTHOR wrote in their own
200
236
  // source, so the declaration is still theirs to edit.
@@ -224,6 +260,7 @@ export function foldProvenanceOfEntity(
224
260
  ...(provenance?.sourceFile ? { sourceFile: provenance.sourceFile } : {}),
225
261
  ...(provenance?.composite ? { composite: provenance.composite } : {}),
226
262
  ...(provenance?.compositeInstance ? { instance: provenance.compositeInstance } : {}),
263
+ ...(provenance?.compositeCall ? { compositeCall: { ...provenance.compositeCall } } : {}),
227
264
  fields,
228
265
  };
229
266
  }
@@ -278,9 +315,57 @@ function compositeCall(composite: string, instance: string | undefined): string
278
315
  return instance ? `the \`${instance}\` call of composite ${composite}` : `composite ${composite}`;
279
316
  }
280
317
 
281
- /** `in src/app.infra.ts`, or nothing when the build recorded no file. */
282
- function inFile(sourceFile: string | undefined): string {
283
- return sourceFile ? ` in ${sourceFile}` : "";
318
+ /** A file as a report prints it: relative to `root` when it is inside it, otherwise as recorded. */
319
+ function displayFile(file: string, root: string | undefined): string {
320
+ if (!root || !isAbsolute(file)) return file;
321
+ const rel = relative(root, file);
322
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : file;
323
+ }
324
+
325
+ /** `src/app.ts:12`, relative to `root` when it is inside it. */
326
+ export function formatSourceLocation(location: SourceLocation, root?: string): string {
327
+ return `${displayFile(location.file, root)}:${location.line}`;
328
+ }
329
+
330
+ /** ` in src/app.infra.ts`, or nothing when the build recorded no file. */
331
+ function inFile(sourceFile: string | undefined, root: string | undefined): string {
332
+ return sourceFile ? ` in ${displayFile(sourceFile, root)}` : "";
333
+ }
334
+
335
+ /**
336
+ * The composite call as the author wrote the arguments that govern a field:
337
+ * `WebApp({ replicas: 3 })`, or `WebApp({ iam: { path: "/service/" } })` for a
338
+ * nested parameter. Only arguments actually written at the call appear; when
339
+ * none is, the call is shown as `WebApp(...)`.
340
+ */
341
+ export function describeCompositeArguments(composite: string, locations: readonly ArgumentLocation[]): string {
342
+ const written = locations.filter((a) => a.text !== undefined);
343
+ if (written.length === 0) return `${composite}(...)`;
344
+ const parts = written.map((a) => {
345
+ const outer = a.parameter.split(".").slice(0, -1);
346
+ return outer.reduceRight((inner, key) => `${key}: { ${inner} }`, a.text as string);
347
+ });
348
+ return `${composite}({ ${parts.join(", ")} })`;
349
+ }
350
+
351
+ /**
352
+ * Where a composite-parameter field was set, as `at src/app.ts:12`: the first
353
+ * argument written at the call, else the call, else the declaring file.
354
+ */
355
+ function parameterSite(
356
+ origin: Extract<FoldFieldOrigin, { kind: "composite-parameter" }>,
357
+ sourceFile: string | undefined,
358
+ root: string | undefined,
359
+ ): string {
360
+ const written = origin.arguments?.find((a) => a.text !== undefined);
361
+ const location = written ?? origin.call ?? origin.arguments?.[0];
362
+ if (location) return ` at ${formatSourceLocation(location, root)}`;
363
+ return inFile(sourceFile, root);
364
+ }
365
+
366
+ /** ` at src/app.ts:10` for the composite call, else the declaring file. */
367
+ function callSite(call: SourceLocation | undefined, sourceFile: string | undefined, root: string | undefined): string {
368
+ return call ? ` at ${formatSourceLocation(call, root)}` : inFile(sourceFile, root);
284
369
  }
285
370
 
286
371
  /** A value as a report prints it. */
@@ -327,6 +412,12 @@ export interface FieldReconcile {
327
412
  declared?: unknown;
328
413
  live?: unknown;
329
414
  origin: FoldFieldOrigin;
415
+ /**
416
+ * One line naming where the field came from (chant #3597):
417
+ * `spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12`.
418
+ * An `unknown` origin says so and why; it never reads as a direct declaration.
419
+ */
420
+ summary: string;
330
421
  resolution: DriftResolution;
331
422
  }
332
423
 
@@ -337,18 +428,48 @@ export interface FieldReconcile {
337
428
  * source declared, whose live value moved. Undeclared fields are held
338
429
  * elsewhere and never reach here, so the only question left is which of the
339
430
  * four origins produced the field.
431
+ *
432
+ * `entityType` names the resource in the summary line, and `root` (the
433
+ * project directory) makes the file names in it relative. Both are optional;
434
+ * without them the summary uses the entity name alone and absolute files.
340
435
  */
341
436
  export function resolveDriftedField(input: {
342
437
  entity: string;
438
+ entityType?: string;
343
439
  path: string;
344
440
  declared?: unknown;
345
441
  live?: unknown;
346
442
  origin?: PathOrigin;
347
443
  provenance?: EntityProvenance;
444
+ root?: string;
348
445
  }): FieldReconcile {
349
446
  const origin = classifyFieldOrigin(input.origin, input.provenance);
350
447
  const sourceFile = input.provenance?.sourceFile;
448
+ const root = input.root;
351
449
  const move = `${renderValue(input.declared)} to ${renderValue(input.live)}`;
450
+ const subject = `${input.path} on ${input.entityType ? `${input.entityType} ` : ""}${input.entity}`;
451
+
452
+ const summary = ((): string => {
453
+ switch (origin.kind) {
454
+ case "composite-parameter":
455
+ return (
456
+ `${subject} comes from ${describeCompositeArguments(origin.composite, origin.arguments ?? [])}` +
457
+ `${parameterSite(origin, sourceFile, root)}` +
458
+ (origin.arguments?.some((a) => a.text !== undefined)
459
+ ? ""
460
+ : ` (parameter ${origin.parameters.join(", ")}, not written at the call)`)
461
+ );
462
+ case "composite-literal":
463
+ return (
464
+ `${subject} is fixed inside composite ${origin.composite}; ` +
465
+ `no argument to the call${callSite(origin.call, sourceFile, root)} moves it`
466
+ );
467
+ case "direct":
468
+ return `${subject} is declared directly${inFile(sourceFile, root)}`;
469
+ case "unknown":
470
+ return `${subject} has an unknown origin: ${unknownOriginText(origin.reason)}`;
471
+ }
472
+ })();
352
473
 
353
474
  const resolution: DriftResolution = ((): DriftResolution => {
354
475
  switch (origin.kind) {
@@ -363,7 +484,7 @@ export function resolveDriftedField(input: {
363
484
  description:
364
485
  `change ${parameters.length === 1 ? "parameter" : "parameters"} ` +
365
486
  `${parameters.map((p) => `\`${p}\``).join(", ")} of ` +
366
- `${compositeCall(origin.composite, origin.instance)}${inFile(sourceFile)} ` +
487
+ `${compositeCall(origin.composite, origin.instance)}${parameterSite(origin, sourceFile, root)} ` +
367
488
  `so \`${input.path}\` moves from ${move}. The composite stays.`,
368
489
  };
369
490
  }
@@ -375,14 +496,14 @@ export function resolveDriftedField(input: {
375
496
  ...(sourceFile ? { sourceFile } : {}),
376
497
  description:
377
498
  `refused: \`${input.path}\` is fixed by ${compositeCall(origin.composite, origin.instance)}` +
378
- `${inFile(sourceFile)}, so no argument at the call site moves it. ` +
499
+ `${callSite(origin.call, sourceFile, root)}, so no argument at the call site moves it. ` +
379
500
  `Parameterize the field in composite ${origin.composite}, or stop using the composite here.`,
380
501
  };
381
502
  case "direct":
382
503
  return {
383
504
  kind: "edit-declaration",
384
505
  ...(sourceFile ? { sourceFile } : {}),
385
- description: `change the declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
506
+ description: `change the declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
386
507
  };
387
508
  case "unknown":
388
509
  return {
@@ -391,7 +512,7 @@ export function resolveDriftedField(input: {
391
512
  ...(sourceFile ? { sourceFile } : {}),
392
513
  description:
393
514
  `origin unknown (${unknownOriginText(origin.reason)}), so falling back to changing the ` +
394
- `declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
515
+ `declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
395
516
  };
396
517
  }
397
518
  })();
@@ -402,6 +523,7 @@ export function resolveDriftedField(input: {
402
523
  ...("declared" in input ? { declared: input.declared } : {}),
403
524
  ...("live" in input ? { live: input.live } : {}),
404
525
  origin,
526
+ summary,
405
527
  resolution,
406
528
  };
407
529
  }
@@ -420,6 +542,7 @@ export function resolveDriftedField(input: {
420
542
  export function resolveDeepDrift(
421
543
  drifted: readonly DeepEntityDrift[],
422
544
  provenanceOf: (entity: string) => EntityProvenance | undefined,
545
+ options: { root?: string } = {},
423
546
  ): FieldReconcile[] {
424
547
  const out: FieldReconcile[] = [];
425
548
  for (const entity of drifted) {
@@ -428,11 +551,13 @@ export function resolveDeepDrift(
428
551
  out.push(
429
552
  resolveDriftedField({
430
553
  entity: entity.name,
554
+ entityType: entity.type,
431
555
  path: change.path,
432
556
  ...("declared" in change ? { declared: change.declared } : {}),
433
557
  ...("live" in change ? { live: change.live } : {}),
434
558
  ...(change.origin ? { origin: change.origin } : {}),
435
559
  ...(provenance ? { provenance } : {}),
560
+ ...(options.root ? { root: options.root } : {}),
436
561
  }),
437
562
  );
438
563
  }
package/src/provenance.ts CHANGED
@@ -32,6 +32,13 @@ export interface EntityProvenance {
32
32
  * collapse a composite to a single node at coarse diagram detail levels (#494).
33
33
  */
34
34
  compositeInstance?: string;
35
+ /**
36
+ * Where the composite call that expanded this entity was written (chant
37
+ * #3597): the `WebApp({...})` expression, not the factory body. Recorded
38
+ * only when the fold interpreted the factory at a call it could see; the
39
+ * run path and a nested call made from already-folded values leave it unset.
40
+ */
41
+ compositeCall?: SourceLocation;
35
42
  /**
36
43
  * Per-path origins within the entity's `props` (chant #1443). Read with
37
44
  * {@link originOfPath}, which does the prefix resolution these keys are
@@ -90,9 +97,46 @@ export type PathOrigin =
90
97
  | { kind: "authored" }
91
98
  | { kind: "composite"; composite: string; instance: string }
92
99
  | { kind: "build-param"; params: string[] }
93
- | { kind: "composite-parameter"; composite: string; parameters: string[] }
100
+ | {
101
+ kind: "composite-parameter";
102
+ composite: string;
103
+ parameters: string[];
104
+ /**
105
+ * Where each parameter's argument was written at the composite call
106
+ * (chant #3597), in `parameters` order. Absent when the build did not
107
+ * see the call; see {@link ArgumentLocation} for the fallback.
108
+ */
109
+ arguments?: ArgumentLocation[];
110
+ }
94
111
  | { kind: "composite-literal"; composite: string };
95
112
 
113
+ /**
114
+ * A place in a source file. `file` is absolute, as
115
+ * {@link EntityProvenance.sourceFile} is; `line` and `column` are 1-based, the
116
+ * way an editor and `file:line:column` print them.
117
+ */
118
+ export interface SourceLocation {
119
+ file: string;
120
+ line: number;
121
+ column: number;
122
+ }
123
+
124
+ /**
125
+ * Where one composite parameter's argument was written at the call (chant
126
+ * #3597). When the call's argument object has a property at the parameter's
127
+ * path, the location is that property and `text` is the property as written
128
+ * (`replicas: 3`). When it does not (the parameter was left out, or arrived
129
+ * through a spread or a non-literal argument), the location is the call's own
130
+ * and `text` is absent: the call is the nearest place that governs it, and
131
+ * nothing claims a property that is not there.
132
+ */
133
+ export interface ArgumentLocation extends SourceLocation {
134
+ /** The factory parameter path, as in `parameters` (`replicas`, `iam.path`). */
135
+ parameter: string;
136
+ /** The innermost property as written at the call, when one is. */
137
+ text?: string;
138
+ }
139
+
96
140
  /** True when `prefix` addresses `path` or an ancestor of it, on a segment boundary. */
97
141
  function isPathPrefix(prefix: string, path: string): boolean {
98
142
  if (prefix === "") return true;
@@ -114,6 +158,7 @@ export function setProvenance(entity: object, prov: EntityProvenance): void {
114
158
  existing.sourceFile ??= prov.sourceFile;
115
159
  existing.composite ??= prov.composite;
116
160
  existing.compositeInstance ??= prov.compositeInstance;
161
+ existing.compositeCall ??= prov.compositeCall;
117
162
  if (prov.paths) {
118
163
  existing.paths ??= {};
119
164
  for (const [path, origin] of Object.entries(prov.paths)) {