@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
@@ -18,6 +18,7 @@ import {
18
18
  classifyFieldOrigin,
19
19
  emittedFieldPaths,
20
20
  foldProvenanceOfEntity,
21
+ describeCompositeArguments,
21
22
  describeFoldFieldOrigin,
22
23
  resolveDeepDrift,
23
24
  resolveDriftedField,
@@ -76,7 +77,7 @@ describe("classifyFieldOrigin", () => {
76
77
  });
77
78
 
78
79
  test("a recorded composite literal says which composite fixes it", () => {
79
- expect(classifyFieldOrigin({ kind: "composite-literal", composite: "WebService" }, composite)).toEqual({
80
+ expect(classifyFieldOrigin({ kind: "composite-literal", composite: "WebService" }, composite)).toMatchObject({
80
81
  kind: "composite-literal",
81
82
  composite: "WebService",
82
83
  instance: "web",
@@ -102,6 +103,17 @@ describe("classifyFieldOrigin", () => {
102
103
  });
103
104
  });
104
105
 
106
+ test("a host origin is unknown with that reason, never a direct declaration (typescript-as-data#248)", () => {
107
+ expect(classifyFieldOrigin({ kind: "host", reason: "host-call" }, authored)).toEqual({
108
+ kind: "unknown",
109
+ reason: "host-call",
110
+ });
111
+ expect(classifyFieldOrigin({ kind: "host", reason: "host-value" }, authored)).toEqual({
112
+ kind: "unknown",
113
+ reason: "host-value",
114
+ });
115
+ });
116
+
105
117
  test("no origin on an entity no composite expanded is a direct declaration", () => {
106
118
  expect(classifyFieldOrigin(undefined, authored)).toEqual({ kind: "direct" });
107
119
  });
@@ -244,6 +256,126 @@ describe("resolveDriftedField", () => {
244
256
  });
245
257
  });
246
258
 
259
+ describe("the drift report's origin line (#3597)", () => {
260
+ const call = { file: "/p/src/app.ts", line: 10, column: 20 };
261
+ const compositeProv: EntityProvenance = {
262
+ sourceFile: "/p/src/app.ts",
263
+ composite: "WebApp",
264
+ compositeInstance: "web",
265
+ compositeCall: call,
266
+ };
267
+ const base = { entity: "webDeployment", entityType: "Deployment", root: "/p", declared: 3, live: 5 };
268
+
269
+ test("a composite parameter names the call, the argument as written, and its file and line", () => {
270
+ const resolved = resolveDriftedField({
271
+ ...base,
272
+ path: "spec.replicas",
273
+ origin: {
274
+ kind: "composite-parameter",
275
+ composite: "WebApp",
276
+ parameters: ["replicas"],
277
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
278
+ },
279
+ provenance: compositeProv,
280
+ });
281
+ expect(resolved.summary).toBe(
282
+ "spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12",
283
+ );
284
+ expect(resolved.resolution.description).toContain("the `web` call of composite WebApp at src/app.ts:12");
285
+ // The origin object carries the locations, for --json.
286
+ expect(resolved.origin).toEqual({
287
+ kind: "composite-parameter",
288
+ composite: "WebApp",
289
+ instance: "web",
290
+ parameters: ["replicas"],
291
+ call,
292
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
293
+ });
294
+ });
295
+
296
+ test("a nested parameter is shown nested, and a parameter the call does not write says so", () => {
297
+ expect(describeCompositeArguments("WebService", [
298
+ { parameter: "iam.path", file: "/p/a.ts", line: 4, column: 9, text: 'path: "/x/"' },
299
+ ])).toBe('WebService({ iam: { path: "/x/" } })');
300
+
301
+ const resolved = resolveDriftedField({
302
+ ...base,
303
+ path: "spec.template.spec.containers[#web].image",
304
+ origin: {
305
+ kind: "composite-parameter",
306
+ composite: "WebApp",
307
+ parameters: ["image"],
308
+ arguments: [{ parameter: "image", ...call }],
309
+ },
310
+ provenance: compositeProv,
311
+ });
312
+ expect(resolved.summary).toBe(
313
+ "spec.template.spec.containers[#web].image on Deployment webDeployment comes from WebApp(...) " +
314
+ "at src/app.ts:10 (parameter image, not written at the call)",
315
+ );
316
+ });
317
+
318
+ test("a composite literal says the composite fixes it, at the call", () => {
319
+ const resolved = resolveDriftedField({
320
+ ...base,
321
+ path: "spec.strategy.type",
322
+ origin: { kind: "composite-literal", composite: "WebApp" },
323
+ provenance: compositeProv,
324
+ });
325
+ expect(resolved.summary).toBe(
326
+ "spec.strategy.type on Deployment webDeployment is fixed inside composite WebApp; " +
327
+ "no argument to the call at src/app.ts:10 moves it",
328
+ );
329
+ expect(resolved.resolution.description).toContain("fixed by the `web` call of composite WebApp at src/app.ts:10");
330
+ });
331
+
332
+ test("a direct declaration says direct, and the resolution is today's", () => {
333
+ const resolved = resolveDriftedField({
334
+ ...base,
335
+ entity: "worker",
336
+ path: "spec.replicas",
337
+ provenance: { sourceFile: "/p/src/app.ts" },
338
+ });
339
+ expect(resolved.origin).toEqual({ kind: "direct" });
340
+ expect(resolved.summary).toBe("spec.replicas on Deployment worker is declared directly in src/app.ts");
341
+ expect(resolved.resolution).toEqual({
342
+ kind: "edit-declaration",
343
+ sourceFile: "/p/src/app.ts",
344
+ description: "change the declared value of `spec.replicas` in src/app.ts from 3 to 5.",
345
+ });
346
+ });
347
+
348
+ test("an unknown origin says unknown and why, and never reads as direct", () => {
349
+ for (const [provenance, why] of [
350
+ [{ sourceFile: "/p/src/app.ts", composite: "WebApp", compositeInstance: "web" }, "did not interpret"],
351
+ [undefined, "recorded no provenance"],
352
+ ] as const) {
353
+ const resolved = resolveDriftedField({ ...base, path: "spec.replicas", ...(provenance ? { provenance } : {}) });
354
+ expect(resolved.origin.kind).toBe("unknown");
355
+ expect(resolved.summary).toMatch(/^spec\.replicas on Deployment webDeployment has an unknown origin: /);
356
+ expect(resolved.summary).toContain(why);
357
+ expect(resolved.summary).not.toContain("direct");
358
+ expect(resolved.resolution.kind).toBe("fall-back");
359
+ }
360
+ });
361
+
362
+ test("a file outside the project root is printed as recorded", () => {
363
+ const resolved = resolveDriftedField({
364
+ ...base,
365
+ root: "/elsewhere",
366
+ path: "spec.replicas",
367
+ origin: {
368
+ kind: "composite-parameter",
369
+ composite: "WebApp",
370
+ parameters: ["replicas"],
371
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
372
+ },
373
+ provenance: compositeProv,
374
+ });
375
+ expect(resolved.summary).toContain("at /p/src/app.ts:12");
376
+ });
377
+ });
378
+
247
379
  describe("resolveDeepDrift", () => {
248
380
  test("reads the drifted list and nothing else", () => {
249
381
  const verdicts = resolveDeepDrift(
@@ -399,25 +531,25 @@ describe("fold provenance over a real composite build (#2161)", () => {
399
531
  // 1. A field the composite PARAMETERIZES, including a nested parameter
400
532
  // path, one read through a body `const`, and one read from inside an
401
533
  // array literal.
402
- expect(result.foldProvenance.webBucket.fields.BucketName).toEqual({
534
+ expect(result.foldProvenance.webBucket.fields.BucketName).toMatchObject({
403
535
  kind: "composite-parameter",
404
536
  composite: "WebService",
405
537
  instance: "web",
406
538
  parameters: ["name"],
407
539
  });
408
- expect(result.foldProvenance.webBucket.fields.Tags).toEqual({
540
+ expect(result.foldProvenance.webBucket.fields.Tags).toMatchObject({
409
541
  kind: "composite-parameter",
410
542
  composite: "WebService",
411
543
  instance: "web",
412
544
  parameters: ["tier"],
413
545
  });
414
- expect(result.foldProvenance.webRole.fields.RoleName).toEqual({
546
+ expect(result.foldProvenance.webRole.fields.RoleName).toMatchObject({
415
547
  kind: "composite-parameter",
416
548
  composite: "WebService",
417
549
  instance: "web",
418
550
  parameters: ["name"],
419
551
  });
420
- expect(result.foldProvenance.webRole.fields.Path).toEqual({
552
+ expect(result.foldProvenance.webRole.fields.Path).toMatchObject({
421
553
  kind: "composite-parameter",
422
554
  composite: "WebService",
423
555
  instance: "web",
@@ -425,12 +557,12 @@ describe("fold provenance over a real composite build (#2161)", () => {
425
557
  });
426
558
 
427
559
  // 2. A field the composite FIXES.
428
- expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toEqual({
560
+ expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toMatchObject({
429
561
  kind: "composite-literal",
430
562
  composite: "WebService",
431
563
  instance: "web",
432
564
  });
433
- expect(result.foldProvenance.webRole.fields.Description).toEqual({
565
+ expect(result.foldProvenance.webRole.fields.Description).toMatchObject({
434
566
  kind: "composite-literal",
435
567
  composite: "WebService",
436
568
  instance: "web",
@@ -507,6 +639,43 @@ describe("fold provenance over a real composite build (#2161)", () => {
507
639
  // A direct declaration: today's behaviour, unchanged and unremarked.
508
640
  expect(verdicts[3].resolution.kind).toBe("edit-declaration");
509
641
  });
642
+
643
+ test("the build records where the composite call and each argument were written (#3597)", async () => {
644
+ const result = await build(srcDir, [namesSerializer], undefined, {
645
+ fold: true,
646
+ lexicons: [LEXICON_NAME],
647
+ });
648
+ expect(result.errors).toEqual([]);
649
+ const main = join(srcDir, "main.ts");
650
+ const lines = MAIN.split("\n");
651
+ const lineOf = (needle: string) => lines.findIndex((l) => l.includes(needle)) + 1;
652
+ const columnOf = (needle: string) => (lines.find((l) => l.includes(needle)) as string).indexOf(needle) + 1;
653
+ const call = { file: main, line: lineOf("WebService({"), column: columnOf("WebService({") };
654
+
655
+ expect(result.foldProvenance.webBucket.compositeCall).toEqual(call);
656
+ expect(result.foldProvenance.webRole.compositeCall).toEqual(call);
657
+ expect(result.foldProvenance.webBucket.fields.BucketName).toEqual({
658
+ kind: "composite-parameter",
659
+ composite: "WebService",
660
+ instance: "web",
661
+ parameters: ["name"],
662
+ call,
663
+ arguments: [{ parameter: "name", file: main, line: call.line, column: columnOf('name: "data"'), text: 'name: "data"' }],
664
+ });
665
+ expect(result.foldProvenance.webRole.fields.Path).toMatchObject({
666
+ arguments: [{ parameter: "iam.path", line: call.line, column: columnOf('path: "/service/"'), text: 'path: "/service/"' }],
667
+ });
668
+ expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toEqual({
669
+ kind: "composite-literal",
670
+ composite: "WebService",
671
+ instance: "web",
672
+ call,
673
+ });
674
+
675
+ // A direct declaration and an uninterpreted composite record no call.
676
+ expect(result.foldProvenance.logs.compositeCall).toBeUndefined();
677
+ expect(result.foldProvenance.legacyBucket.compositeCall).toBeUndefined();
678
+ });
510
679
  });
511
680
 
512
681
  // ─────────────────────────────────────────────────────────────────────────
@@ -604,18 +773,23 @@ describe("fold provenance through a tagged template in a composite factory (#321
604
773
  const at = (parameters: string[]) => ({ kind: "composite-parameter", composite: "EventsTable", instance: "clicks", parameters });
605
774
  const fixed = { kind: "composite-literal", composite: "EventsTable", instance: "clicks" };
606
775
 
607
- expect(events.fields.name).toEqual(at(["name"]));
608
- expect(events.fields.ttl).toEqual(at(["ttlDays"]));
609
- expect(events.fields.ddl).toEqual(at(["name", "ttlDays"]));
610
- expect(events.fields.engine).toEqual(fixed);
611
- expect(events.fields.orderBy).toEqual(fixed);
776
+ expect(events.fields.name).toMatchObject(at(["name"]));
777
+ expect(events.fields.ttl).toMatchObject(at(["ttlDays"]));
778
+ expect(events.fields.ddl).toMatchObject(at(["name", "ttlDays"]));
779
+ expect(events.fields.engine).toMatchObject(fixed);
780
+ expect(events.fields.orderBy).toMatchObject(fixed);
781
+ // #3597 — the argument behind a tag-fed field, where the call wrote it.
782
+ expect(events.fields.ttl).toMatchObject({ arguments: [{ parameter: "ttlDays", line: 6, text: "ttlDays: 30" }] });
783
+ expect(events.fields.ddl).toMatchObject({
784
+ arguments: [{ parameter: "name", text: 'name: "clicks"' }, { parameter: "ttlDays", text: "ttlDays: 30" }],
785
+ });
612
786
 
613
787
  // Through a body `const`; an interpolated literal string is fixed; an
614
788
  // interpolated sibling entity is wiring, so it governs nothing.
615
- expect(rollup.fields.name).toEqual(at(["name"]));
616
- expect(rollup.fields.engine).toEqual(fixed);
617
- expect(rollup.fields.comment).toEqual(fixed);
618
- expect(rollup.fields.ddl).toEqual(at(["name"]));
789
+ expect(rollup.fields.name).toMatchObject(at(["name"]));
790
+ expect(rollup.fields.engine).toMatchObject(fixed);
791
+ expect(rollup.fields.comment).toMatchObject(fixed);
792
+ expect(rollup.fields.ddl).toMatchObject(at(["name"]));
619
793
 
620
794
  expect(result.foldProvenance.audit.fields.ttl).toBeUndefined();
621
795
  expect(result.foldProvenance.audit.fields.name).toEqual({ kind: "direct" });
@@ -70,10 +70,21 @@
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
- /** Why a field's origin could not be determined. Both are facts about the BUILD, not about the field. */
83
+ /**
84
+ * Why a field's origin could not be determined. The first two are facts about
85
+ * the BUILD; the host reasons are facts about the expression, and match the
86
+ * strings the specification's reference implementation reports.
87
+ */
77
88
  export type UnknownOriginReason =
78
89
  /**
79
90
  * A composite expanded the entity and this build did not interpret its body,
@@ -87,7 +98,15 @@ export type UnknownOriginReason =
87
98
  * composite make it" is answerable. A sandboxed child's entities arrive this
88
99
  * way: `discovery/entity-wire-codec.ts` drops build metadata over the wire.
89
100
  */
90
- | "no-provenance";
101
+ | "no-provenance"
102
+ /**
103
+ * INTENTIUS/typescript-as-data#248: host code computed the value, by a method
104
+ * called on a real receiver (`"x".toUpperCase()`, `[1, 2].join("-")`) or an
105
+ * eager lexicon helper. The file wrote the call, not the value.
106
+ */
107
+ | "host-call"
108
+ /** A value a lexicon package exports, bound by an import (`AWS.Region`, `S3Actions.GetObject`). */
109
+ | "host-value";
91
110
 
92
111
  /**
93
112
  * What produced one emitted property path.
@@ -97,8 +116,17 @@ export type UnknownOriginReason =
97
116
  * address and "the `api` call of `AutoscaledService`" is.
98
117
  */
99
118
  export type FoldFieldOrigin =
100
- | { kind: "composite-parameter"; composite: string; instance?: string; parameters: string[] }
101
- | { kind: "composite-literal"; composite: string; instance?: string }
119
+ | {
120
+ kind: "composite-parameter";
121
+ composite: string;
122
+ instance?: string;
123
+ parameters: string[];
124
+ /** Where the composite call was written (chant #3597), when the build saw it. */
125
+ call?: SourceLocation;
126
+ /** Where each parameter's argument was written at that call, in `parameters` order. */
127
+ arguments?: ArgumentLocation[];
128
+ }
129
+ | { kind: "composite-literal"; composite: string; instance?: string; call?: SourceLocation }
102
130
  | { kind: "direct" }
103
131
  | { kind: "unknown"; reason: UnknownOriginReason };
104
132
 
@@ -110,6 +138,11 @@ export interface EntityFoldProvenance {
110
138
  composite?: string;
111
139
  /** The composite call (export name) it belongs to, when one expanded it. */
112
140
  instance?: string;
141
+ /**
142
+ * Where that composite call was written (chant #3597), when the fold
143
+ * interpreted the factory at a call it saw as written.
144
+ */
145
+ compositeCall?: SourceLocation;
113
146
  /**
114
147
  * Emitted property path to origin, in sorted key order. Every path the entity
115
148
  * emits is present: a path with no attribution carries an `unknown` origin
@@ -178,6 +211,7 @@ export function classifyFieldOrigin(
178
211
  ): FoldFieldOrigin {
179
212
  if (!provenance) return { kind: "unknown", reason: "no-provenance" };
180
213
  const instance = provenance.compositeInstance;
214
+ const call = provenance.compositeCall;
181
215
 
182
216
  switch (origin?.kind) {
183
217
  case "composite-parameter":
@@ -186,15 +220,33 @@ export function classifyFieldOrigin(
186
220
  composite: origin.composite,
187
221
  ...(instance ? { instance } : {}),
188
222
  parameters: [...origin.parameters],
223
+ ...(call ? { call: { ...call } } : {}),
224
+ ...(origin.arguments ? { arguments: origin.arguments.map((a) => ({ ...a })) } : {}),
189
225
  };
190
226
  case "composite-literal":
191
- return { kind: "composite-literal", composite: origin.composite, ...(instance ? { instance } : {}) };
227
+ return {
228
+ kind: "composite-literal",
229
+ composite: origin.composite,
230
+ ...(instance ? { instance } : {}),
231
+ ...(call ? { call: { ...call } } : {}),
232
+ };
192
233
  case "composite":
193
234
  // The coarse kind: a composite expanded this, and nothing said which
194
235
  // parameter. Not a parameter, not a fixed literal, and above all not a
195
236
  // direct declaration.
196
237
  return { kind: "unknown", reason: "composite-not-interpreted" };
197
238
  case "authored":
239
+ // chant#3608 — `authored` is the file's own root record, and it reaches
240
+ // a path only when nothing finer did. On an entity a composite wrote (a
241
+ // member destructured from a composite the fold invoked) that means the
242
+ // fold could not attribute the path, which is `unknown`, never `direct`.
243
+ return provenance.composite
244
+ ? { kind: "unknown", reason: "composite-not-interpreted" }
245
+ : { kind: "direct" };
246
+ case "host":
247
+ // INTENTIUS/typescript-as-data#248 — host code produced the value, so
248
+ // editing the declaration edits a call, not the value it returned.
249
+ return { kind: "unknown", reason: origin.reason };
198
250
  case "build-param":
199
251
  // A build parameter governs the expression the AUTHOR wrote in their own
200
252
  // source, so the declaration is still theirs to edit.
@@ -224,6 +276,7 @@ export function foldProvenanceOfEntity(
224
276
  ...(provenance?.sourceFile ? { sourceFile: provenance.sourceFile } : {}),
225
277
  ...(provenance?.composite ? { composite: provenance.composite } : {}),
226
278
  ...(provenance?.compositeInstance ? { instance: provenance.compositeInstance } : {}),
279
+ ...(provenance?.compositeCall ? { compositeCall: { ...provenance.compositeCall } } : {}),
227
280
  fields,
228
281
  };
229
282
  }
@@ -270,6 +323,10 @@ export function unknownOriginText(reason: UnknownOriginReason): string {
270
323
  return "expanded by a composite whose factory this build did not interpret";
271
324
  case "no-provenance":
272
325
  return "this build recorded no provenance for the entity";
326
+ case "host-call":
327
+ return "the value is what a host call returned";
328
+ case "host-value":
329
+ return "the value is one a host package exports";
273
330
  }
274
331
  }
275
332
 
@@ -278,9 +335,57 @@ function compositeCall(composite: string, instance: string | undefined): string
278
335
  return instance ? `the \`${instance}\` call of composite ${composite}` : `composite ${composite}`;
279
336
  }
280
337
 
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}` : "";
338
+ /** A file as a report prints it: relative to `root` when it is inside it, otherwise as recorded. */
339
+ function displayFile(file: string, root: string | undefined): string {
340
+ if (!root || !isAbsolute(file)) return file;
341
+ const rel = relative(root, file);
342
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : file;
343
+ }
344
+
345
+ /** `src/app.ts:12`, relative to `root` when it is inside it. */
346
+ export function formatSourceLocation(location: SourceLocation, root?: string): string {
347
+ return `${displayFile(location.file, root)}:${location.line}`;
348
+ }
349
+
350
+ /** ` in src/app.infra.ts`, or nothing when the build recorded no file. */
351
+ function inFile(sourceFile: string | undefined, root: string | undefined): string {
352
+ return sourceFile ? ` in ${displayFile(sourceFile, root)}` : "";
353
+ }
354
+
355
+ /**
356
+ * The composite call as the author wrote the arguments that govern a field:
357
+ * `WebApp({ replicas: 3 })`, or `WebApp({ iam: { path: "/service/" } })` for a
358
+ * nested parameter. Only arguments actually written at the call appear; when
359
+ * none is, the call is shown as `WebApp(...)`.
360
+ */
361
+ export function describeCompositeArguments(composite: string, locations: readonly ArgumentLocation[]): string {
362
+ const written = locations.filter((a) => a.text !== undefined);
363
+ if (written.length === 0) return `${composite}(...)`;
364
+ const parts = written.map((a) => {
365
+ const outer = a.parameter.split(".").slice(0, -1);
366
+ return outer.reduceRight((inner, key) => `${key}: { ${inner} }`, a.text as string);
367
+ });
368
+ return `${composite}({ ${parts.join(", ")} })`;
369
+ }
370
+
371
+ /**
372
+ * Where a composite-parameter field was set, as `at src/app.ts:12`: the first
373
+ * argument written at the call, else the call, else the declaring file.
374
+ */
375
+ function parameterSite(
376
+ origin: Extract<FoldFieldOrigin, { kind: "composite-parameter" }>,
377
+ sourceFile: string | undefined,
378
+ root: string | undefined,
379
+ ): string {
380
+ const written = origin.arguments?.find((a) => a.text !== undefined);
381
+ const location = written ?? origin.call ?? origin.arguments?.[0];
382
+ if (location) return ` at ${formatSourceLocation(location, root)}`;
383
+ return inFile(sourceFile, root);
384
+ }
385
+
386
+ /** ` at src/app.ts:10` for the composite call, else the declaring file. */
387
+ function callSite(call: SourceLocation | undefined, sourceFile: string | undefined, root: string | undefined): string {
388
+ return call ? ` at ${formatSourceLocation(call, root)}` : inFile(sourceFile, root);
284
389
  }
285
390
 
286
391
  /** A value as a report prints it. */
@@ -327,6 +432,12 @@ export interface FieldReconcile {
327
432
  declared?: unknown;
328
433
  live?: unknown;
329
434
  origin: FoldFieldOrigin;
435
+ /**
436
+ * One line naming where the field came from (chant #3597):
437
+ * `spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12`.
438
+ * An `unknown` origin says so and why; it never reads as a direct declaration.
439
+ */
440
+ summary: string;
330
441
  resolution: DriftResolution;
331
442
  }
332
443
 
@@ -337,18 +448,48 @@ export interface FieldReconcile {
337
448
  * source declared, whose live value moved. Undeclared fields are held
338
449
  * elsewhere and never reach here, so the only question left is which of the
339
450
  * four origins produced the field.
451
+ *
452
+ * `entityType` names the resource in the summary line, and `root` (the
453
+ * project directory) makes the file names in it relative. Both are optional;
454
+ * without them the summary uses the entity name alone and absolute files.
340
455
  */
341
456
  export function resolveDriftedField(input: {
342
457
  entity: string;
458
+ entityType?: string;
343
459
  path: string;
344
460
  declared?: unknown;
345
461
  live?: unknown;
346
462
  origin?: PathOrigin;
347
463
  provenance?: EntityProvenance;
464
+ root?: string;
348
465
  }): FieldReconcile {
349
466
  const origin = classifyFieldOrigin(input.origin, input.provenance);
350
467
  const sourceFile = input.provenance?.sourceFile;
468
+ const root = input.root;
351
469
  const move = `${renderValue(input.declared)} to ${renderValue(input.live)}`;
470
+ const subject = `${input.path} on ${input.entityType ? `${input.entityType} ` : ""}${input.entity}`;
471
+
472
+ const summary = ((): string => {
473
+ switch (origin.kind) {
474
+ case "composite-parameter":
475
+ return (
476
+ `${subject} comes from ${describeCompositeArguments(origin.composite, origin.arguments ?? [])}` +
477
+ `${parameterSite(origin, sourceFile, root)}` +
478
+ (origin.arguments?.some((a) => a.text !== undefined)
479
+ ? ""
480
+ : ` (parameter ${origin.parameters.join(", ")}, not written at the call)`)
481
+ );
482
+ case "composite-literal":
483
+ return (
484
+ `${subject} is fixed inside composite ${origin.composite}; ` +
485
+ `no argument to the call${callSite(origin.call, sourceFile, root)} moves it`
486
+ );
487
+ case "direct":
488
+ return `${subject} is declared directly${inFile(sourceFile, root)}`;
489
+ case "unknown":
490
+ return `${subject} has an unknown origin: ${unknownOriginText(origin.reason)}`;
491
+ }
492
+ })();
352
493
 
353
494
  const resolution: DriftResolution = ((): DriftResolution => {
354
495
  switch (origin.kind) {
@@ -363,7 +504,7 @@ export function resolveDriftedField(input: {
363
504
  description:
364
505
  `change ${parameters.length === 1 ? "parameter" : "parameters"} ` +
365
506
  `${parameters.map((p) => `\`${p}\``).join(", ")} of ` +
366
- `${compositeCall(origin.composite, origin.instance)}${inFile(sourceFile)} ` +
507
+ `${compositeCall(origin.composite, origin.instance)}${parameterSite(origin, sourceFile, root)} ` +
367
508
  `so \`${input.path}\` moves from ${move}. The composite stays.`,
368
509
  };
369
510
  }
@@ -375,14 +516,14 @@ export function resolveDriftedField(input: {
375
516
  ...(sourceFile ? { sourceFile } : {}),
376
517
  description:
377
518
  `refused: \`${input.path}\` is fixed by ${compositeCall(origin.composite, origin.instance)}` +
378
- `${inFile(sourceFile)}, so no argument at the call site moves it. ` +
519
+ `${callSite(origin.call, sourceFile, root)}, so no argument at the call site moves it. ` +
379
520
  `Parameterize the field in composite ${origin.composite}, or stop using the composite here.`,
380
521
  };
381
522
  case "direct":
382
523
  return {
383
524
  kind: "edit-declaration",
384
525
  ...(sourceFile ? { sourceFile } : {}),
385
- description: `change the declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
526
+ description: `change the declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
386
527
  };
387
528
  case "unknown":
388
529
  return {
@@ -391,7 +532,7 @@ export function resolveDriftedField(input: {
391
532
  ...(sourceFile ? { sourceFile } : {}),
392
533
  description:
393
534
  `origin unknown (${unknownOriginText(origin.reason)}), so falling back to changing the ` +
394
- `declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
535
+ `declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
395
536
  };
396
537
  }
397
538
  })();
@@ -402,6 +543,7 @@ export function resolveDriftedField(input: {
402
543
  ...("declared" in input ? { declared: input.declared } : {}),
403
544
  ...("live" in input ? { live: input.live } : {}),
404
545
  origin,
546
+ summary,
405
547
  resolution,
406
548
  };
407
549
  }
@@ -420,6 +562,7 @@ export function resolveDriftedField(input: {
420
562
  export function resolveDeepDrift(
421
563
  drifted: readonly DeepEntityDrift[],
422
564
  provenanceOf: (entity: string) => EntityProvenance | undefined,
565
+ options: { root?: string } = {},
423
566
  ): FieldReconcile[] {
424
567
  const out: FieldReconcile[] = [];
425
568
  for (const entity of drifted) {
@@ -428,11 +571,13 @@ export function resolveDeepDrift(
428
571
  out.push(
429
572
  resolveDriftedField({
430
573
  entity: entity.name,
574
+ entityType: entity.type,
431
575
  path: change.path,
432
576
  ...("declared" in change ? { declared: change.declared } : {}),
433
577
  ...("live" in change ? { live: change.live } : {}),
434
578
  ...(change.origin ? { origin: change.origin } : {}),
435
579
  ...(provenance ? { provenance } : {}),
580
+ ...(options.root ? { root: options.root } : {}),
436
581
  }),
437
582
  );
438
583
  }
@@ -36,10 +36,15 @@ export function hasIntrinsicInValue(value: unknown, name: string): boolean {
36
36
  */
37
37
  export function irUsesIntrinsic(ir: TemplateIR, name: string): boolean {
38
38
  for (const resource of ir.resources) {
39
- if (hasIntrinsicInValue(resource.properties, name)) {
39
+ if (
40
+ hasIntrinsicInValue(resource.properties, name) ||
41
+ hasIntrinsicInValue(resource.metadata, name) ||
42
+ hasIntrinsicInValue(resource.attributes, name)
43
+ ) {
40
44
  return true;
41
45
  }
42
46
  }
47
+ if (hasIntrinsicInValue(ir.metadata, name)) return true;
43
48
  for (const condition of ir.conditions ?? []) {
44
49
  if (hasIntrinsicInValue(condition.expression, name)) {
45
50
  return true;
@@ -9,6 +9,9 @@ export interface ParameterIR {
9
9
  readonly description?: string;
10
10
  readonly defaultValue?: unknown;
11
11
  readonly required?: boolean;
12
+ /** What the value is checked against, under the source format's own keys
13
+ * and as written (CloudFormation `AllowedValues`, `MinLength`, `NoEcho`). */
14
+ readonly constraints?: Readonly<Record<string, unknown>>;
12
15
  }
13
16
 
14
17
  /**
@@ -22,6 +25,11 @@ export interface ResourceIR {
22
25
  /** Name of the condition gating this resource (CloudFormation `Condition`
23
26
  * key, or equivalent), when the source template declares one (#2069). */
24
27
  readonly condition?: string;
28
+ /** Resource-level attributes other than the condition, under the source
29
+ * format's own keys (CloudFormation `DependsOn`, `DeletionPolicy`,
30
+ * `UpdateReplacePolicy`, `UpdatePolicy`, `CreationPolicy`). Values are
31
+ * parsed value trees, like {@link properties}. */
32
+ readonly attributes?: Readonly<Record<string, unknown>>;
25
33
  }
26
34
 
27
35
  /**