@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.
@@ -1,5 +1,5 @@
1
1
  import * as ts from "typescript";
2
- import type { PathOrigin } from "../provenance";
2
+ import type { ArgumentLocation, PathOrigin, SourceLocation } from "../provenance";
3
3
 
4
4
  /**
5
5
  * Which build parameters an authored props expression READS, per path (chant
@@ -288,6 +288,61 @@ function readCompositeParameters(
288
288
  visit(expr);
289
289
  }
290
290
 
291
+ /**
292
+ * The composite call an interpreted factory body was reached from (chant
293
+ * #3597): the `WebApp({ replicas: 3 })` expression in the author's source and
294
+ * the absolute path of the file it is written in. The node must come from a
295
+ * source file parsed with parent pointers, which is how `fold-import.ts`
296
+ * parses every file it interprets.
297
+ */
298
+ export interface CompositeCallSite {
299
+ file: string;
300
+ node: ts.CallExpression;
301
+ }
302
+
303
+ /** A node's start as a 1-based {@link SourceLocation}. */
304
+ export function sourceLocationOf(file: string, node: ts.Node): SourceLocation {
305
+ const sourceFile = node.getSourceFile();
306
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
307
+ return { file, line: line + 1, column: character + 1 };
308
+ }
309
+
310
+ /**
311
+ * Where `parameter` was written at `call`: the property at that path in the
312
+ * call's object-literal argument, descending nested object literals segment by
313
+ * segment, or the call itself when there is no such property.
314
+ *
315
+ * The fallback covers a parameter the call leaves out, one that arrives through
316
+ * a spread, and an argument that is not an object literal. In each of those the
317
+ * call is the nearest place that governs the value, and no `text` is given,
318
+ * because no property at the call says what it is.
319
+ */
320
+ export function argumentLocation(call: CompositeCallSite, parameter: string): ArgumentLocation {
321
+ const fallback: ArgumentLocation = { parameter, ...sourceLocationOf(call.file, call.node) };
322
+ const first = call.node.arguments[0];
323
+ if (!first) return fallback;
324
+ let object: ts.Expression = unwrap(first);
325
+ let found: ts.ObjectLiteralElementLike | undefined;
326
+ for (const segment of parameter.split(".")) {
327
+ if (!ts.isObjectLiteralExpression(object)) return fallback;
328
+ found = undefined;
329
+ // Last write wins, as it does when the object literal is evaluated.
330
+ for (const member of object.properties) {
331
+ if (ts.isShorthandPropertyAssignment(member) && member.name.text === segment) found = member;
332
+ else if (ts.isPropertyAssignment(member) && literalName(member.name) === segment) found = member;
333
+ }
334
+ if (!found) return fallback;
335
+ object = ts.isPropertyAssignment(found) ? unwrap(found.initializer) : found.name;
336
+ }
337
+ if (!found) return fallback;
338
+ return { parameter, ...sourceLocationOf(call.file, found), text: found.getText() };
339
+ }
340
+
341
+ /** {@link argumentLocation} for each of `parameters`, in order, or nothing when no call was seen. */
342
+ function argumentLocations(call: CompositeCallSite | undefined, parameters: readonly string[]): ArgumentLocation[] | undefined {
343
+ return call ? parameters.map((parameter) => argumentLocation(call, parameter)) : undefined;
344
+ }
345
+
291
346
  /**
292
347
  * Path → composite origin for one `new Type({...})` inside an interpreted
293
348
  * composite factory body (chant #2161).
@@ -304,12 +359,16 @@ function readCompositeParameters(
304
359
  * something under here", while `composite-literal` would be a claim that every
305
360
  * key it brings is fixed. That claim is left unmade, and those paths come back
306
361
  * `unknown` from ../fold-provenance.ts instead.
362
+ *
363
+ * `call` (chant #3597), when the fold saw the composite call, adds where each
364
+ * parameter's argument was written there, in the same pass.
307
365
  */
308
366
  export function collectCompositeOrigins(
309
367
  props: ts.ObjectLiteralExpression,
310
368
  consts: ReadonlyMap<string, ts.Expression>,
311
369
  scope: CompositeParamScope,
312
370
  composite: string,
371
+ call?: CompositeCallSite,
313
372
  ): Record<string, PathOrigin> {
314
373
  const out: Record<string, PathOrigin> = {};
315
374
 
@@ -327,7 +386,7 @@ export function collectCompositeOrigins(
327
386
  existing && existing.kind === "composite-parameter"
328
387
  ? [...new Set([...existing.parameters, ...parameters])].sort()
329
388
  : parameters;
330
- out[path] = { kind: "composite-parameter", composite, parameters: merged };
389
+ out[path] = compositeParameterOrigin(composite, merged, call);
331
390
  return;
332
391
  }
333
392
  if (spread) return;
@@ -384,6 +443,7 @@ export function collectTagOrigins(
384
443
  consts: ReadonlyMap<string, ts.Expression>,
385
444
  scope: CompositeParamScope,
386
445
  composite: string,
446
+ call?: CompositeCallSite,
387
447
  ): Record<string, PathOrigin> {
388
448
  const out: Record<string, PathOrigin> = {};
389
449
 
@@ -395,7 +455,7 @@ export function collectTagOrigins(
395
455
  const existing = out[path];
396
456
  const merged = new Set(existing?.kind === "composite-parameter" ? existing.parameters : []);
397
457
  for (const parameter of found) merged.add(parameter);
398
- out[path] = { kind: "composite-parameter", composite, parameters: [...merged].sort() };
458
+ out[path] = compositeParameterOrigin(composite, [...merged].sort(), call);
399
459
  }
400
460
  });
401
461
 
@@ -406,3 +466,13 @@ export function collectTagOrigins(
406
466
  }
407
467
  return out;
408
468
  }
469
+
470
+ /** A `composite-parameter` origin, with its argument locations when the call was seen. */
471
+ function compositeParameterOrigin(
472
+ composite: string,
473
+ parameters: string[],
474
+ call: CompositeCallSite | undefined,
475
+ ): PathOrigin {
476
+ const locations = argumentLocations(call, parameters);
477
+ return { kind: "composite-parameter", composite, parameters, ...(locations ? { arguments: locations } : {}) };
478
+ }
@@ -56,3 +56,41 @@ describe("SPEC_VERSION is declared on the package entry (chant#2424)", () => {
56
56
  expect(chant.SPEC_VERSION).toBe(SPEC_VERSION);
57
57
  });
58
58
  });
59
+
60
+ /**
61
+ * chant#3598 — the conformance adapter reads fold provenance (spec 2.2) off
62
+ * `foldProject`'s verdicts, and maps or recomputes it with the functions
63
+ * below. All of them, and the types the verdict field is written in, have to
64
+ * be reachable from this namespace.
65
+ */
66
+ describe("fold provenance is on the package entry (chant#3598)", () => {
67
+ test("the functions are exported", () => {
68
+ for (const name of [
69
+ "foldProject",
70
+ "foldProvenanceOfEntities",
71
+ "foldProvenanceOfEntity",
72
+ "classifyFieldOrigin",
73
+ "emittedFieldPaths",
74
+ "describeFoldFieldOrigin",
75
+ "getProvenance",
76
+ ] as const) {
77
+ expect(typeof chant[name]).toBe("function");
78
+ }
79
+ });
80
+
81
+ test("a verdict's foldProvenance is typed in the exported shapes", () => {
82
+ const origins: chant.FoldFieldOrigin[] = [
83
+ { kind: "composite-parameter", composite: "C", instance: "i", parameters: ["p"] },
84
+ { kind: "composite-literal", composite: "C" },
85
+ { kind: "direct" },
86
+ { kind: "unknown", reason: "no-provenance" satisfies chant.UnknownOriginReason },
87
+ ];
88
+ const record: chant.EntityFoldProvenance = { fields: Object.fromEntries(origins.map((o, i) => [`f${i}`, o])) };
89
+ const verdict: chant.FoldProjectVerdict = {
90
+ verdict: "fold",
91
+ tentative: "fold",
92
+ foldProvenance: { e: record } satisfies chant.FoldProvenance,
93
+ };
94
+ expect(Object.keys(verdict.foldProvenance!.e.fields)).toEqual(["f0", "f1", "f2", "f3"]);
95
+ });
96
+ });
@@ -179,7 +179,7 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
179
179
  * `spec/VERSION` in the specification repository carries the same string, and
180
180
  * the conformance adapter reads this one to fill its `specVersion` field.
181
181
  *
182
- * ## Why 1.6, and what each version since 1.0 asked for (chant#2445)
182
+ * ## Why 2.2, and what each version since 1.0 asked for (chant#2445)
183
183
  *
184
184
  * Raising this is the last step of adopting a rule set, so each version was
185
185
  * checked against what chant does rather than against whether the suite is
@@ -220,6 +220,14 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
220
220
  * - **1.8** added `ι = executing`, and chant carries it as of `0.72.3`
221
221
  * (#2455) — `FoldProjectOptions.executing`, mutually exclusive with
222
222
  * `sandbox`. `open` stays the default and stays strict (#2453).
223
+ * - **2.2** added obligations to `F-Obs-Provenance` for an implementation that
224
+ * claims provenance: one origin per emitted path of four kinds, the
225
+ * innermost writer wins, and an origin that cannot be determined is reported
226
+ * as unknown, never as direct. chant claims provenance, so this is the
227
+ * version whose obligations it takes on. 2.2 also widens two rules to what
228
+ * chant already did: a call-bound local exported by name (`export { x }`)
229
+ * resolves by F-Call as the single declarator does, and a same-file call's
230
+ * result read inside an F-Call argument resolves once per file (#3329).
223
231
  *
224
232
  * `scripts/check-docs-citations.ts` refuses to run when this constant and the
225
233
  * rules it loads disagree, so this and the `@intentius/tsad-conformance`
@@ -231,7 +239,7 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
231
239
  * Until chant#2470 the rules came from a commit `docs-check.yml` checked out;
232
240
  * now they come from the published package, so bumping it is what moves this.
233
241
  */
234
- export const SPEC_VERSION = "2.1";
242
+ export const SPEC_VERSION = "2.2";
235
243
 
236
244
  /** The two EVL rule ids a shape violation can be attributed to. */
237
245
  export type SubsetRuleId = "EVL001" | "EVL003";
@@ -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",
@@ -244,6 +245,126 @@ describe("resolveDriftedField", () => {
244
245
  });
245
246
  });
246
247
 
248
+ describe("the drift report's origin line (#3597)", () => {
249
+ const call = { file: "/p/src/app.ts", line: 10, column: 20 };
250
+ const compositeProv: EntityProvenance = {
251
+ sourceFile: "/p/src/app.ts",
252
+ composite: "WebApp",
253
+ compositeInstance: "web",
254
+ compositeCall: call,
255
+ };
256
+ const base = { entity: "webDeployment", entityType: "Deployment", root: "/p", declared: 3, live: 5 };
257
+
258
+ test("a composite parameter names the call, the argument as written, and its file and line", () => {
259
+ const resolved = resolveDriftedField({
260
+ ...base,
261
+ path: "spec.replicas",
262
+ origin: {
263
+ kind: "composite-parameter",
264
+ composite: "WebApp",
265
+ parameters: ["replicas"],
266
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
267
+ },
268
+ provenance: compositeProv,
269
+ });
270
+ expect(resolved.summary).toBe(
271
+ "spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12",
272
+ );
273
+ expect(resolved.resolution.description).toContain("the `web` call of composite WebApp at src/app.ts:12");
274
+ // The origin object carries the locations, for --json.
275
+ expect(resolved.origin).toEqual({
276
+ kind: "composite-parameter",
277
+ composite: "WebApp",
278
+ instance: "web",
279
+ parameters: ["replicas"],
280
+ call,
281
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
282
+ });
283
+ });
284
+
285
+ test("a nested parameter is shown nested, and a parameter the call does not write says so", () => {
286
+ expect(describeCompositeArguments("WebService", [
287
+ { parameter: "iam.path", file: "/p/a.ts", line: 4, column: 9, text: 'path: "/x/"' },
288
+ ])).toBe('WebService({ iam: { path: "/x/" } })');
289
+
290
+ const resolved = resolveDriftedField({
291
+ ...base,
292
+ path: "spec.template.spec.containers[#web].image",
293
+ origin: {
294
+ kind: "composite-parameter",
295
+ composite: "WebApp",
296
+ parameters: ["image"],
297
+ arguments: [{ parameter: "image", ...call }],
298
+ },
299
+ provenance: compositeProv,
300
+ });
301
+ expect(resolved.summary).toBe(
302
+ "spec.template.spec.containers[#web].image on Deployment webDeployment comes from WebApp(...) " +
303
+ "at src/app.ts:10 (parameter image, not written at the call)",
304
+ );
305
+ });
306
+
307
+ test("a composite literal says the composite fixes it, at the call", () => {
308
+ const resolved = resolveDriftedField({
309
+ ...base,
310
+ path: "spec.strategy.type",
311
+ origin: { kind: "composite-literal", composite: "WebApp" },
312
+ provenance: compositeProv,
313
+ });
314
+ expect(resolved.summary).toBe(
315
+ "spec.strategy.type on Deployment webDeployment is fixed inside composite WebApp; " +
316
+ "no argument to the call at src/app.ts:10 moves it",
317
+ );
318
+ expect(resolved.resolution.description).toContain("fixed by the `web` call of composite WebApp at src/app.ts:10");
319
+ });
320
+
321
+ test("a direct declaration says direct, and the resolution is today's", () => {
322
+ const resolved = resolveDriftedField({
323
+ ...base,
324
+ entity: "worker",
325
+ path: "spec.replicas",
326
+ provenance: { sourceFile: "/p/src/app.ts" },
327
+ });
328
+ expect(resolved.origin).toEqual({ kind: "direct" });
329
+ expect(resolved.summary).toBe("spec.replicas on Deployment worker is declared directly in src/app.ts");
330
+ expect(resolved.resolution).toEqual({
331
+ kind: "edit-declaration",
332
+ sourceFile: "/p/src/app.ts",
333
+ description: "change the declared value of `spec.replicas` in src/app.ts from 3 to 5.",
334
+ });
335
+ });
336
+
337
+ test("an unknown origin says unknown and why, and never reads as direct", () => {
338
+ for (const [provenance, why] of [
339
+ [{ sourceFile: "/p/src/app.ts", composite: "WebApp", compositeInstance: "web" }, "did not interpret"],
340
+ [undefined, "recorded no provenance"],
341
+ ] as const) {
342
+ const resolved = resolveDriftedField({ ...base, path: "spec.replicas", ...(provenance ? { provenance } : {}) });
343
+ expect(resolved.origin.kind).toBe("unknown");
344
+ expect(resolved.summary).toMatch(/^spec\.replicas on Deployment webDeployment has an unknown origin: /);
345
+ expect(resolved.summary).toContain(why);
346
+ expect(resolved.summary).not.toContain("direct");
347
+ expect(resolved.resolution.kind).toBe("fall-back");
348
+ }
349
+ });
350
+
351
+ test("a file outside the project root is printed as recorded", () => {
352
+ const resolved = resolveDriftedField({
353
+ ...base,
354
+ root: "/elsewhere",
355
+ path: "spec.replicas",
356
+ origin: {
357
+ kind: "composite-parameter",
358
+ composite: "WebApp",
359
+ parameters: ["replicas"],
360
+ arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 12, column: 3, text: "replicas: 3" }],
361
+ },
362
+ provenance: compositeProv,
363
+ });
364
+ expect(resolved.summary).toContain("at /p/src/app.ts:12");
365
+ });
366
+ });
367
+
247
368
  describe("resolveDeepDrift", () => {
248
369
  test("reads the drifted list and nothing else", () => {
249
370
  const verdicts = resolveDeepDrift(
@@ -399,25 +520,25 @@ describe("fold provenance over a real composite build (#2161)", () => {
399
520
  // 1. A field the composite PARAMETERIZES, including a nested parameter
400
521
  // path, one read through a body `const`, and one read from inside an
401
522
  // array literal.
402
- expect(result.foldProvenance.webBucket.fields.BucketName).toEqual({
523
+ expect(result.foldProvenance.webBucket.fields.BucketName).toMatchObject({
403
524
  kind: "composite-parameter",
404
525
  composite: "WebService",
405
526
  instance: "web",
406
527
  parameters: ["name"],
407
528
  });
408
- expect(result.foldProvenance.webBucket.fields.Tags).toEqual({
529
+ expect(result.foldProvenance.webBucket.fields.Tags).toMatchObject({
409
530
  kind: "composite-parameter",
410
531
  composite: "WebService",
411
532
  instance: "web",
412
533
  parameters: ["tier"],
413
534
  });
414
- expect(result.foldProvenance.webRole.fields.RoleName).toEqual({
535
+ expect(result.foldProvenance.webRole.fields.RoleName).toMatchObject({
415
536
  kind: "composite-parameter",
416
537
  composite: "WebService",
417
538
  instance: "web",
418
539
  parameters: ["name"],
419
540
  });
420
- expect(result.foldProvenance.webRole.fields.Path).toEqual({
541
+ expect(result.foldProvenance.webRole.fields.Path).toMatchObject({
421
542
  kind: "composite-parameter",
422
543
  composite: "WebService",
423
544
  instance: "web",
@@ -425,12 +546,12 @@ describe("fold provenance over a real composite build (#2161)", () => {
425
546
  });
426
547
 
427
548
  // 2. A field the composite FIXES.
428
- expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toEqual({
549
+ expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toMatchObject({
429
550
  kind: "composite-literal",
430
551
  composite: "WebService",
431
552
  instance: "web",
432
553
  });
433
- expect(result.foldProvenance.webRole.fields.Description).toEqual({
554
+ expect(result.foldProvenance.webRole.fields.Description).toMatchObject({
434
555
  kind: "composite-literal",
435
556
  composite: "WebService",
436
557
  instance: "web",
@@ -507,6 +628,43 @@ describe("fold provenance over a real composite build (#2161)", () => {
507
628
  // A direct declaration: today's behaviour, unchanged and unremarked.
508
629
  expect(verdicts[3].resolution.kind).toBe("edit-declaration");
509
630
  });
631
+
632
+ test("the build records where the composite call and each argument were written (#3597)", async () => {
633
+ const result = await build(srcDir, [namesSerializer], undefined, {
634
+ fold: true,
635
+ lexicons: [LEXICON_NAME],
636
+ });
637
+ expect(result.errors).toEqual([]);
638
+ const main = join(srcDir, "main.ts");
639
+ const lines = MAIN.split("\n");
640
+ const lineOf = (needle: string) => lines.findIndex((l) => l.includes(needle)) + 1;
641
+ const columnOf = (needle: string) => (lines.find((l) => l.includes(needle)) as string).indexOf(needle) + 1;
642
+ const call = { file: main, line: lineOf("WebService({"), column: columnOf("WebService({") };
643
+
644
+ expect(result.foldProvenance.webBucket.compositeCall).toEqual(call);
645
+ expect(result.foldProvenance.webRole.compositeCall).toEqual(call);
646
+ expect(result.foldProvenance.webBucket.fields.BucketName).toEqual({
647
+ kind: "composite-parameter",
648
+ composite: "WebService",
649
+ instance: "web",
650
+ parameters: ["name"],
651
+ call,
652
+ arguments: [{ parameter: "name", file: main, line: call.line, column: columnOf('name: "data"'), text: 'name: "data"' }],
653
+ });
654
+ expect(result.foldProvenance.webRole.fields.Path).toMatchObject({
655
+ arguments: [{ parameter: "iam.path", line: call.line, column: columnOf('path: "/service/"'), text: 'path: "/service/"' }],
656
+ });
657
+ expect(result.foldProvenance.webBucket.fields["VersioningConfiguration.Status"]).toEqual({
658
+ kind: "composite-literal",
659
+ composite: "WebService",
660
+ instance: "web",
661
+ call,
662
+ });
663
+
664
+ // A direct declaration and an uninterpreted composite record no call.
665
+ expect(result.foldProvenance.logs.compositeCall).toBeUndefined();
666
+ expect(result.foldProvenance.legacyBucket.compositeCall).toBeUndefined();
667
+ });
510
668
  });
511
669
 
512
670
  // ─────────────────────────────────────────────────────────────────────────
@@ -604,18 +762,23 @@ describe("fold provenance through a tagged template in a composite factory (#321
604
762
  const at = (parameters: string[]) => ({ kind: "composite-parameter", composite: "EventsTable", instance: "clicks", parameters });
605
763
  const fixed = { kind: "composite-literal", composite: "EventsTable", instance: "clicks" };
606
764
 
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);
765
+ expect(events.fields.name).toMatchObject(at(["name"]));
766
+ expect(events.fields.ttl).toMatchObject(at(["ttlDays"]));
767
+ expect(events.fields.ddl).toMatchObject(at(["name", "ttlDays"]));
768
+ expect(events.fields.engine).toMatchObject(fixed);
769
+ expect(events.fields.orderBy).toMatchObject(fixed);
770
+ // #3597 — the argument behind a tag-fed field, where the call wrote it.
771
+ expect(events.fields.ttl).toMatchObject({ arguments: [{ parameter: "ttlDays", line: 6, text: "ttlDays: 30" }] });
772
+ expect(events.fields.ddl).toMatchObject({
773
+ arguments: [{ parameter: "name", text: 'name: "clicks"' }, { parameter: "ttlDays", text: "ttlDays: 30" }],
774
+ });
612
775
 
613
776
  // Through a body `const`; an interpolated literal string is fixed; an
614
777
  // 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"]));
778
+ expect(rollup.fields.name).toMatchObject(at(["name"]));
779
+ expect(rollup.fields.engine).toMatchObject(fixed);
780
+ expect(rollup.fields.comment).toMatchObject(fixed);
781
+ expect(rollup.fields.ddl).toMatchObject(at(["name"]));
619
782
 
620
783
  expect(result.foldProvenance.audit.fields.ttl).toBeUndefined();
621
784
  expect(result.foldProvenance.audit.fields.name).toEqual({ kind: "direct" });