@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
@@ -0,0 +1,584 @@
1
+ /**
2
+ * chant#3598 — `foldProject` reports fold provenance, and agrees with `build()`.
3
+ *
4
+ * The specification's conformance adapter calls `foldProject`, not `build()`,
5
+ * so the four origin kinds (spec 2.2) have to be readable there. The tests
6
+ * compare against `build()` over the same files rather than restating the
7
+ * expected origins, because "the same shape build() uses" means the same
8
+ * answers too.
9
+ *
10
+ * The sandbox half pins the one rule that matters most: an entity whose
11
+ * provenance record did not survive is `unknown`, never `direct`.
12
+ */
13
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
14
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, dirname, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { build } from "../build";
20
+ import { foldProject, getProvenance, foldProvenanceOfEntities, type FoldProvenance } from "../index";
21
+ import { decodeEntitySet, encodeEntitySet } from "./entity-wire-codec";
22
+ import type { Serializer } from "../serializer";
23
+
24
+ const thisDir = dirname(fileURLToPath(import.meta.url));
25
+ const repoRoot = resolve(thisDir, "../../../..");
26
+ const compositePath = resolve(thisDir, "../composite");
27
+
28
+ const LEXICON = "@intentius/chant-lexicon-aws";
29
+ const LEXICON_NAME = "aws";
30
+
31
+ const namesSerializer: Serializer = {
32
+ name: LEXICON_NAME,
33
+ rulePrefix: "TEST",
34
+ serialize: (entities) => JSON.stringify({ resources: [...entities.keys()].sort() }, null, 2),
35
+ };
36
+
37
+ /**
38
+ * `WebService` is inside the admissible subset, so its body is interpreted:
39
+ * `BucketName` flows from a parameter and `VersioningConfiguration.Status` is
40
+ * a literal the body sets. `LegacyService` has an `if`, so it is invoked
41
+ * rather than interpreted and its fields are unknown.
42
+ */
43
+ const COMPOSITES = `
44
+ import { Bucket } from ${JSON.stringify(LEXICON)};
45
+ import { Composite } from ${JSON.stringify(compositePath)};
46
+
47
+ export const WebService = Composite((props) => {
48
+ const bucket = new Bucket({
49
+ BucketName: props.name,
50
+ VersioningConfiguration: { Status: "Enabled" },
51
+ });
52
+ return { bucket };
53
+ }, "WebService");
54
+
55
+ export const LegacyService = Composite((props) => {
56
+ if (!props.name) { throw new Error("name required"); }
57
+ const bucket = new Bucket({ BucketName: props.name, ObjectLockEnabled: true });
58
+ return { bucket };
59
+ }, "LegacyService");
60
+ `;
61
+
62
+ const MAIN = `
63
+ import { Bucket } from ${JSON.stringify(LEXICON)};
64
+ import { WebService } from "../composites";
65
+
66
+ export const logs = new Bucket({ BucketName: "logs" });
67
+ export const web = WebService({ name: "data" });
68
+ `;
69
+
70
+ const LEGACY_MAIN = `
71
+ import { LegacyService } from "../composites";
72
+
73
+ export const legacy = LegacyService({ name: "old" });
74
+ `;
75
+
76
+ /** Every field of every composite-expanded entity, with its origin kind. */
77
+ function compositeFieldKinds(provenance: FoldProvenance): string[] {
78
+ const out: string[] = [];
79
+ for (const [name, record] of Object.entries(provenance)) {
80
+ if (!record.composite) continue;
81
+ for (const [path, origin] of Object.entries(record.fields)) out.push(`${name}.${path}:${origin.kind}`);
82
+ }
83
+ return out.sort();
84
+ }
85
+
86
+ describe("foldProject reports fold provenance (chant#3598)", () => {
87
+ let testDir: string;
88
+ let srcDir: string;
89
+ let mainFile: string;
90
+ let legacyFile: string;
91
+
92
+ beforeAll(async () => {
93
+ // Inside the repo, so the fixture's import of the real lexicon resolves.
94
+ const dir = join(repoRoot, ".cache", `chant-3598-${process.pid}`);
95
+ await rm(dir, { recursive: true, force: true });
96
+ await mkdir(dir, { recursive: true });
97
+ testDir = await realpath(dir);
98
+ srcDir = join(testDir, "src");
99
+ await mkdir(srcDir, { recursive: true });
100
+ await writeFile(join(testDir, "composites.ts"), COMPOSITES);
101
+ mainFile = join(srcDir, "main.ts");
102
+ legacyFile = join(srcDir, "legacy.ts");
103
+ await writeFile(mainFile, MAIN);
104
+ await writeFile(legacyFile, LEGACY_MAIN);
105
+ });
106
+
107
+ afterAll(async () => {
108
+ await rm(testDir, { recursive: true, force: true });
109
+ });
110
+
111
+ test("parameter, literal and direct origins match what build() reports", async () => {
112
+ const verdicts = await foldProject([legacyFile, mainFile], [], { lexicons: [LEXICON_NAME] });
113
+ const main = verdicts.get(mainFile)!;
114
+ expect(main.verdict).toBe("fold");
115
+
116
+ const fromFold = main.foldProvenance!;
117
+ expect(Object.keys(fromFold).sort()).toEqual(["logs", "webBucket"]);
118
+ // chant#3597 — the per-file record carries where the call and its
119
+ // arguments were written, as build()'s does.
120
+ const lines = MAIN.split("\n");
121
+ const webLine = lines.findIndex((l) => l.includes("WebService({")) + 1;
122
+ const columnOf = (needle: string) => lines[webLine - 1].indexOf(needle) + 1;
123
+ const call = { file: mainFile, line: webLine, column: columnOf("WebService({") };
124
+ expect(fromFold.webBucket.compositeCall).toEqual(call);
125
+ expect(fromFold.webBucket.fields.BucketName).toEqual({
126
+ kind: "composite-parameter",
127
+ composite: "WebService",
128
+ instance: "web",
129
+ parameters: ["name"],
130
+ call,
131
+ arguments: [{ parameter: "name", file: mainFile, line: webLine, column: columnOf('name: "data"'), text: 'name: "data"' }],
132
+ });
133
+ expect(fromFold.webBucket.fields["VersioningConfiguration.Status"]).toEqual({
134
+ kind: "composite-literal",
135
+ composite: "WebService",
136
+ instance: "web",
137
+ call,
138
+ });
139
+ expect(fromFold.logs.compositeCall).toBeUndefined();
140
+ expect(fromFold.logs.fields.BucketName).toEqual({ kind: "direct" });
141
+ expect(fromFold.webBucket.sourceFile).toBe(mainFile);
142
+
143
+ const built = await build(srcDir, [namesSerializer], undefined, { fold: true, lexicons: [LEXICON_NAME] });
144
+ expect(built.errors).toEqual([]);
145
+ expect(fromFold.webBucket).toEqual(built.foldProvenance.webBucket);
146
+ expect(fromFold.logs).toEqual(built.foldProvenance.logs);
147
+
148
+ // The legacy file runs, so foldProject has no entities to attribute there.
149
+ const legacy = verdicts.get(legacyFile)!;
150
+ expect(legacy.verdict).toBe("run");
151
+ expect(legacy.foldProvenance).toBeUndefined();
152
+ });
153
+
154
+ test("a composite invoked rather than interpreted is unknown, as build() says", async () => {
155
+ const verdicts = await foldProject([legacyFile, mainFile], [], { lexicons: [LEXICON_NAME], executing: true });
156
+ const legacy = verdicts.get(legacyFile)!;
157
+ expect(legacy.verdict).toBe("fold");
158
+ const record = legacy.foldProvenance!.legacyBucket;
159
+ expect(record.composite).toBe("LegacyService");
160
+ expect(record.fields.BucketName).toEqual({ kind: "unknown", reason: "composite-not-interpreted" });
161
+ expect(record.fields.ObjectLockEnabled).toEqual({ kind: "unknown", reason: "composite-not-interpreted" });
162
+
163
+ const built = await build(srcDir, [namesSerializer], undefined, {
164
+ fold: true,
165
+ executing: true,
166
+ lexicons: [LEXICON_NAME],
167
+ });
168
+ expect(built.errors).toEqual([]);
169
+ expect(record).toEqual(built.foldProvenance.legacyBucket);
170
+ expect(verdicts.get(mainFile)!.foldProvenance).toEqual({
171
+ logs: built.foldProvenance.logs,
172
+ webBucket: built.foldProvenance.webBucket,
173
+ });
174
+ });
175
+
176
+ test("under sandbox, no composite-expanded field is ever reported direct", async () => {
177
+ const verdicts = await foldProject([legacyFile, mainFile], [], { lexicons: [LEXICON_NAME], sandbox: true });
178
+ const reported = [...verdicts.values()].flatMap((v) => (v.foldProvenance ? compositeFieldKinds(v.foldProvenance) : []));
179
+ expect(reported.length).toBeGreaterThan(0);
180
+ expect(reported.filter((entry) => entry.endsWith(":direct"))).toEqual([]);
181
+
182
+ // The sandboxed build: `legacy.ts` runs in the child, and its entities
183
+ // come back over the wire without a provenance record.
184
+ const built = await build(srcDir, [namesSerializer], undefined, {
185
+ fold: true,
186
+ sandbox: true,
187
+ lexicons: [LEXICON_NAME],
188
+ });
189
+ expect(built.errors).toEqual([]);
190
+ const legacyBucket = built.foldProvenance.legacyBucket;
191
+ expect(legacyBucket).toBeDefined();
192
+ expect(Object.values(legacyBucket.fields).length).toBeGreaterThan(0);
193
+ for (const origin of Object.values(legacyBucket.fields)) {
194
+ expect(origin).toEqual({ kind: "unknown", reason: "no-provenance" });
195
+ }
196
+ });
197
+
198
+ test("an entity set that crossed the wire reports unknown for every field, never direct", async () => {
199
+ const built = await build(srcDir, [namesSerializer], undefined, { fold: true, lexicons: [LEXICON_NAME] });
200
+ expect(built.foldProvenance.webBucket.fields.BucketName.kind).toBe("composite-parameter");
201
+
202
+ const decoded = decodeEntitySet(encodeEntitySet(built.entities));
203
+ const afterWire = foldProvenanceOfEntities(decoded, getProvenance);
204
+ expect(Object.keys(afterWire).sort()).toEqual(Object.keys(built.foldProvenance).sort());
205
+ for (const record of Object.values(afterWire)) {
206
+ expect(record.composite).toBeUndefined();
207
+ for (const origin of Object.values(record.fields)) {
208
+ expect(origin).toEqual({ kind: "unknown", reason: "no-provenance" });
209
+ }
210
+ }
211
+ });
212
+ });
213
+
214
+ /**
215
+ * A host's composite registration form (chant#2442) returns the factory's own
216
+ * members object rather than a chant `CompositeInstance`. Exported whole, its
217
+ * members still have to be attributed: before this, they had no record at all
218
+ * while a destructured member did.
219
+ */
220
+ describe("foldProject attributes a host composite instance exported whole (chant#3598)", () => {
221
+ let root: string;
222
+
223
+ beforeAll(() => {
224
+ root = mkdtempSync(join(tmpdir(), "chant-3598-host-"));
225
+ const pkg = join(root, "node_modules", "@tsad", "shapes");
226
+ mkdirSync(pkg, { recursive: true });
227
+ writeFileSync(
228
+ join(pkg, "package.json"),
229
+ JSON.stringify({ name: "@tsad/shapes", version: "0.0.0", type: "module", main: "index.js" }),
230
+ );
231
+ writeFileSync(
232
+ join(pkg, "index.js"),
233
+ [
234
+ "const MARK = Symbol.for('tsad.conformance.declarable');",
235
+ "export class Bucket {",
236
+ " constructor(props = {}) {",
237
+ " this.entityType = 'Bucket'; this.lexicon = 'shapes'; this.props = props;",
238
+ " Object.defineProperty(this, MARK, { value: true, enumerable: false });",
239
+ " }",
240
+ "}",
241
+ "export function Composite(factory, name) {",
242
+ " const d = (props) => factory(props); d.compositeName = name; return d;",
243
+ "}",
244
+ ].join("\n") + "\n",
245
+ );
246
+ writeFileSync(
247
+ join(root, "shapes.ts"),
248
+ 'import { Bucket, Composite } from "@tsad/shapes";\n' +
249
+ 'export const Store = Composite(({ name }) => ({ logs: new Bucket({ name, tier: "cold" }) }), "Store");\n',
250
+ );
251
+ });
252
+
253
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
254
+
255
+ test("a whole instance's members have records, as a destructured member does, and none is direct", async () => {
256
+ const file = join(root, "app.ts");
257
+ writeFileSync(
258
+ file,
259
+ 'import { Store } from "./shapes";\n' +
260
+ 'export const store = Store({ name: "a" });\n' +
261
+ 'export const { logs } = Store({ name: "b" });\n',
262
+ );
263
+
264
+ const verdict = (await foldProject([file], [], { lexiconPackages: ["@tsad/shapes"], sandbox: true })).get(file)!;
265
+ expect(verdict.reason).toBeUndefined();
266
+ expect(verdict.verdict).toBe("fold");
267
+ const provenance = verdict.foldProvenance!;
268
+ expect(Object.keys(provenance).sort()).toEqual(["logs", "storeLogs"]);
269
+
270
+ // chant#3597 — where each call and its argument were written.
271
+ const at = (line: number, source: string, needle: string) => ({ file, line, column: source.indexOf(needle) + 1 });
272
+ const storeLine = 'export const store = Store({ name: "a" });';
273
+ const logsLine = 'export const { logs } = Store({ name: "b" });';
274
+ const storeCall = at(2, storeLine, "Store({");
275
+ expect(provenance.storeLogs).toEqual({
276
+ sourceFile: file,
277
+ composite: "Store",
278
+ instance: "store",
279
+ compositeCall: storeCall,
280
+ fields: {
281
+ name: {
282
+ kind: "composite-parameter",
283
+ composite: "Store",
284
+ instance: "store",
285
+ parameters: ["name"],
286
+ call: storeCall,
287
+ arguments: [{ parameter: "name", ...at(2, storeLine, 'name: "a"'), text: 'name: "a"' }],
288
+ },
289
+ tier: { kind: "composite-literal", composite: "Store", instance: "store", call: storeCall },
290
+ },
291
+ });
292
+
293
+ // The destructured member keeps the answer it had, with its own call's locations.
294
+ const logsCall = at(3, logsLine, "Store({");
295
+ expect(provenance.logs.compositeCall).toEqual(logsCall);
296
+ expect(provenance.logs.fields.name).toEqual({
297
+ kind: "composite-parameter",
298
+ composite: "Store",
299
+ parameters: ["name"],
300
+ call: logsCall,
301
+ arguments: [{ parameter: "name", ...at(3, logsLine, 'name: "b"'), text: 'name: "b"' }],
302
+ });
303
+
304
+ for (const origin of Object.values(provenance.storeLogs.fields)) expect(origin.kind).not.toBe("direct");
305
+
306
+ // The exports handed back are untouched: the instance is still the members object.
307
+ expect(JSON.parse(JSON.stringify(verdict.exports!.get("store")))).toEqual({
308
+ logs: { entityType: "Bucket", lexicon: "shapes", props: { name: "a", tier: "cold" } },
309
+ });
310
+ });
311
+ });
312
+
313
+ /**
314
+ * chant#3608 — the two cases the specification's F-Obs-Provenance fixtures
315
+ * found, against a host shaped like the conformance suite's `shapes` host: its
316
+ * registration form returns a `CompositeInstance` carrying the members, and it
317
+ * publishes a composite of its own (`Archive`) that a fold invokes rather than
318
+ * interprets.
319
+ */
320
+ describe("foldProject provenance for host composites (chant#3608)", () => {
321
+ let root: string;
322
+
323
+ beforeAll(() => {
324
+ root = mkdtempSync(join(tmpdir(), "chant-3608-host-"));
325
+ const pkg = join(root, "node_modules", "@tsad", "shapes-3608");
326
+ mkdirSync(pkg, { recursive: true });
327
+ writeFileSync(
328
+ join(pkg, "package.json"),
329
+ JSON.stringify({ name: "@tsad/shapes-3608", version: "0.0.0", type: "module", main: "index.js" }),
330
+ );
331
+ writeFileSync(
332
+ join(pkg, "index.js"),
333
+ [
334
+ "const MARK = Symbol.for('tsad.conformance.declarable');",
335
+ "const COMPOSITE = Symbol.for('tsad.conformance.composite');",
336
+ "export class Bucket {",
337
+ " constructor(props = {}, attributes = {}) {",
338
+ " this.entityType = 'Bucket'; this.lexicon = 'shapes'; this.props = props; this.attributes = attributes;",
339
+ " Object.defineProperty(this, MARK, { value: true, enumerable: false });",
340
+ " }",
341
+ "}",
342
+ "class CompositeInstance { constructor() { this[COMPOSITE] = true; } }",
343
+ "export const Composite = (factory, name = 'anonymous') => {",
344
+ " const definition = (props) => Object.assign(new CompositeInstance(), factory(props));",
345
+ " return Object.assign(definition, { compositeName: name });",
346
+ "};",
347
+ "export const Archive = Composite((props) => ({ bucket: new Bucket({ name: props.name, versioned: true }) }), 'Archive');",
348
+ ].join("\n") + "\n",
349
+ );
350
+ writeFileSync(
351
+ join(root, "inner.ts"),
352
+ 'import { Bucket, Composite } from "@tsad/shapes-3608";\n' +
353
+ "export const Inner = Composite((props: { name: string }) => ({\n" +
354
+ ' bucket: new Bucket({ name: props.name, tier: "inner" }),\n' +
355
+ '}), "Inner");\n',
356
+ );
357
+ writeFileSync(
358
+ join(root, "outer.ts"),
359
+ 'import { Bucket, Composite } from "@tsad/shapes-3608";\n' +
360
+ 'import { Inner } from "./inner";\n' +
361
+ "export const Outer = Composite((props: { name: string }) => ({\n" +
362
+ " main: Inner({ name: props.name }),\n" +
363
+ ' fixed: Inner({ name: "fixed" }),\n' +
364
+ " side: new Bucket({ name: props.name }),\n" +
365
+ '}), "Outer");\n',
366
+ );
367
+ });
368
+
369
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
370
+
371
+ test("a host-published composite's members are unknown, whole or destructured, never direct", async () => {
372
+ mkdirSync(join(root, "archive"), { recursive: true });
373
+ const file = join(root, "archive", "app.ts");
374
+ writeFileSync(
375
+ file,
376
+ 'import { Archive } from "@tsad/shapes-3608";\n' +
377
+ 'export const archive = Archive({ name: "logs" });\n' +
378
+ 'export const { bucket } = Archive({ name: "old" });\n',
379
+ );
380
+
381
+ const verdict = (await foldProject([file], [], { lexiconPackages: ["@tsad/shapes-3608"], sandbox: true })).get(file)!;
382
+ expect(verdict.reason).toBeUndefined();
383
+ expect(verdict.verdict).toBe("fold");
384
+ const provenance = verdict.foldProvenance!;
385
+ expect(Object.keys(provenance).sort()).toEqual(["archiveBucket", "bucket"]);
386
+
387
+ const unknown = { kind: "unknown", reason: "composite-not-interpreted" };
388
+ expect(provenance.archiveBucket).toEqual({
389
+ sourceFile: file,
390
+ composite: "Archive",
391
+ instance: "archive",
392
+ fields: { name: unknown, versioned: unknown },
393
+ });
394
+ expect(provenance.bucket.composite).toBe("Archive");
395
+ expect(provenance.bucket.fields).toEqual({ name: unknown, versioned: unknown });
396
+
397
+ // build() does not expand a host instance exported whole, so the
398
+ // destructured member is the one it can be held to.
399
+ const built = await build(join(root, "archive"), [namesSerializer], undefined, { fold: true });
400
+ expect(built.errors).toEqual([]);
401
+ expect(built.foldProvenance.bucket).toEqual(provenance.bucket);
402
+ });
403
+
404
+ test("a whole instance's nested composite members have records, and the innermost composite wins", async () => {
405
+ mkdirSync(join(root, "site"), { recursive: true });
406
+ const file = join(root, "site", "app.ts");
407
+ writeFileSync(
408
+ file,
409
+ 'import { Outer } from "../outer";\n' +
410
+ 'export const site = Outer({ name: "web" });\n' +
411
+ 'export const { main, fixed, side } = Outer({ name: "api" });\n',
412
+ );
413
+
414
+ const verdict = (await foldProject([file], [], { lexiconPackages: ["@tsad/shapes-3608"], sandbox: true })).get(file)!;
415
+ expect(verdict.reason).toBeUndefined();
416
+ expect(verdict.verdict).toBe("fold");
417
+ const provenance = verdict.foldProvenance!;
418
+ expect(Object.keys(provenance).sort()).toEqual([
419
+ "fixedBucket",
420
+ "mainBucket",
421
+ "side",
422
+ "siteFixedBucket",
423
+ "siteMainBucket",
424
+ "siteSide",
425
+ ]);
426
+
427
+ const innerName = (instance?: string) => ({
428
+ kind: "composite-parameter",
429
+ composite: "Inner",
430
+ ...(instance ? { instance } : {}),
431
+ parameters: ["name"],
432
+ });
433
+ const innerTier = (instance?: string) => ({ kind: "composite-literal", composite: "Inner", ...(instance ? { instance } : {}) });
434
+
435
+ expect(provenance.siteMainBucket).toMatchObject({
436
+ sourceFile: file,
437
+ composite: "Inner",
438
+ instance: "site",
439
+ fields: { name: innerName("site"), tier: innerTier("site") },
440
+ });
441
+ expect(provenance.siteFixedBucket.fields).toMatchObject({ name: innerName("site"), tier: innerTier("site") });
442
+ expect(provenance.siteSide).toMatchObject({
443
+ sourceFile: file,
444
+ composite: "Outer",
445
+ instance: "site",
446
+ fields: { name: { kind: "composite-parameter", composite: "Outer", instance: "site", parameters: ["name"] } },
447
+ });
448
+
449
+ // chant#3597's locations follow the innermost writer too: a nested
450
+ // member's call is the `Inner(...)` call inside Outer's body, and a
451
+ // member Outer built itself points at the `Outer(...)` call in this file.
452
+ expect(provenance.siteMainBucket.fields.name).toMatchObject({ call: { file: join(root, "outer.ts"), line: 4 } });
453
+ expect(provenance.siteFixedBucket.fields.name).toMatchObject({ call: { file: join(root, "outer.ts"), line: 5 } });
454
+ expect(provenance.siteSide.fields.name).toMatchObject({ call: { file, line: 2 } });
455
+
456
+ // The destructured form agrees, field for field, apart from the instance:
457
+ // `main` and `fixed` are host instances exported whole in their own right.
458
+ expect(provenance.mainBucket.fields).toMatchObject({ name: innerName("main"), tier: innerTier("main") });
459
+ expect(provenance.fixedBucket.fields).toMatchObject({ name: innerName("fixed"), tier: innerTier("fixed") });
460
+ expect(provenance.side.fields.name).toMatchObject({ kind: "composite-parameter", composite: "Outer", parameters: ["name"] });
461
+ });
462
+ });
463
+
464
+ /**
465
+ * chant#3608 — a member destructured from a chant composite the build CALLS
466
+ * rather than interprets. A lexicon composite is the common case
467
+ * (`export const { namespace, resourceQuota } = NamespaceEnv({...})`, as the
468
+ * ray-kuberay-gke example writes it): nothing in the build says which
469
+ * argument produced which field, so every field is `unknown`, and before this
470
+ * every one of them read as `direct`.
471
+ */
472
+ describe("a member destructured from a called chant composite is unknown (chant#3608)", () => {
473
+ const K8S = "@intentius/chant-lexicon-k8s";
474
+ let srcDir: string;
475
+ let file: string;
476
+ let warn: typeof console.warn;
477
+
478
+ beforeAll(async () => {
479
+ // NamespaceEnv warns about a quota with no LimitRange defaults; not this test's concern.
480
+ warn = console.warn;
481
+ console.warn = () => {};
482
+ const dir = join(repoRoot, ".cache", `chant-3608-${process.pid}`);
483
+ await rm(dir, { recursive: true, force: true });
484
+ srcDir = join(await (async () => (await mkdir(dir, { recursive: true }), realpath(dir)))(), "src");
485
+ await mkdir(srcDir, { recursive: true });
486
+ file = join(srcDir, "namespace.ts");
487
+ await writeFile(
488
+ file,
489
+ `import { NamespaceEnv } from ${JSON.stringify(K8S)};\n` +
490
+ `import { propagate } from ${JSON.stringify(compositePath)};\n` +
491
+ 'export const { namespace, resourceQuota } = NamespaceEnv({ name: "ray-system", cpuQuota: "8" });\n' +
492
+ 'export const shared = propagate(NamespaceEnv({ name: "shared", cpuQuota: "4" }), { metadata: { labels: { team: "data" } } });\n',
493
+ );
494
+ });
495
+
496
+ afterAll(async () => {
497
+ console.warn = warn;
498
+ await rm(dirname(srcDir), { recursive: true, force: true });
499
+ });
500
+
501
+ const unknown = { kind: "unknown", reason: "composite-not-interpreted" };
502
+
503
+ /** Every field of every named record is unknown, and the record names the composite. */
504
+ function expectAllUnknown(provenance: FoldProvenance, names: string[]): void {
505
+ for (const name of names) {
506
+ const record = provenance[name];
507
+ expect(record, name).toBeDefined();
508
+ expect(record.composite, name).toBe("NamespaceEnv");
509
+ expect(Object.keys(record.fields).length, name).toBeGreaterThan(0);
510
+ for (const [path, origin] of Object.entries(record.fields)) expect(origin, `${name}.${path}`).toEqual(unknown);
511
+ }
512
+ }
513
+
514
+ test.each([{}, { executing: true }, { sandbox: true }])("foldProject and build() agree, %o", async (mode) => {
515
+ const verdict = (await foldProject([file], [], { lexicons: ["k8s"], ...mode })).get(file)!;
516
+ expect(verdict.verdict).toBe("fold");
517
+ const fromFold = verdict.foldProvenance!;
518
+ expectAllUnknown(fromFold, ["namespace", "resourceQuota", "sharedNamespace", "sharedResourceQuota"]);
519
+ // The propagated key, written by the composite and by the shared props, stays unknown.
520
+ expect(fromFold.sharedNamespace.fields["metadata.name"]).toEqual(unknown);
521
+
522
+ const built = await build(srcDir, [namesSerializer], undefined, { fold: true, lexicons: ["k8s"], ...mode });
523
+ expect(built.errors).toEqual([]);
524
+ for (const name of ["namespace", "resourceQuota"]) expect(built.foldProvenance[name], name).toEqual(fromFold[name]);
525
+ });
526
+
527
+ test("the run path reports them unknown too", async () => {
528
+ const built = await build(srcDir, [namesSerializer], undefined, {});
529
+ expect(built.errors).toEqual([]);
530
+ expectAllUnknown(built.foldProvenance, ["namespace", "resourceQuota", "sharedNamespace", "sharedResourceQuota"]);
531
+ });
532
+ });
533
+
534
+ describe("a value host code produced is unknown, never direct (typescript-as-data#248)", () => {
535
+ let testDir: string;
536
+ let srcDir: string;
537
+ let file: string;
538
+
539
+ beforeAll(async () => {
540
+ const dir = join(repoRoot, ".cache", `chant-tsad-248-${process.pid}`);
541
+ await rm(dir, { recursive: true, force: true });
542
+ await mkdir(join(dir, "src"), { recursive: true });
543
+ testDir = await realpath(dir);
544
+ srcDir = join(testDir, "src");
545
+ file = join(srcDir, "main.ts");
546
+ await writeFile(
547
+ file,
548
+ `
549
+ import { Bucket, AWS } from ${JSON.stringify(LEXICON)};
550
+ const upper = "logs".toUpperCase();
551
+ export const bucket = new Bucket({
552
+ BucketName: "x".toUpperCase(),
553
+ Tags: [{ Key: "n", Value: [1, 2].join("-") }],
554
+ ObjectLockEnabled: true,
555
+ });
556
+ export const logs = new Bucket({ BucketName: upper, AccelerateConfiguration: { AccelerationStatus: AWS.Region } });
557
+ `,
558
+ );
559
+ });
560
+
561
+ afterAll(async () => {
562
+ await rm(testDir, { recursive: true, force: true });
563
+ });
564
+
565
+ test("a host call and a host value report unknown with the reference's reasons, as build() does", async () => {
566
+ const verdict = (await foldProject([file], [], { lexicons: [LEXICON_NAME] })).get(file)!;
567
+ expect(verdict.verdict).toBe("fold");
568
+ const fromFold = verdict.foldProvenance!;
569
+ expect(fromFold.bucket.fields).toEqual({
570
+ BucketName: { kind: "unknown", reason: "host-call" },
571
+ ObjectLockEnabled: { kind: "direct" },
572
+ Tags: { kind: "unknown", reason: "host-call" },
573
+ });
574
+ expect(fromFold.logs.fields).toEqual({
575
+ "AccelerateConfiguration.AccelerationStatus": { kind: "unknown", reason: "host-value" },
576
+ BucketName: { kind: "unknown", reason: "host-call" },
577
+ });
578
+
579
+ const built = await build(srcDir, [namesSerializer], undefined, { fold: true, lexicons: [LEXICON_NAME] });
580
+ expect(built.errors).toEqual([]);
581
+ expect(built.foldProvenance.bucket).toEqual(fromFold.bucket);
582
+ expect(built.foldProvenance.logs).toEqual(fromFold.logs);
583
+ });
584
+ });