@intentius/chant 0.108.1 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/composite.d.ts +5 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +30 -3
- package/dist/discovery/param-deps.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold-provenance.d.ts +35 -2
- package/dist/fold-provenance.d.ts.map +1 -1
- package/dist/provenance.d.ts +38 -0
- package/dist/provenance.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +106 -1
- package/src/cli/handlers/lifecycle.ts +26 -5
- package/src/composite.ts +37 -0
- package/src/discovery/collect.ts +13 -3
- package/src/discovery/fold-import.ts +251 -9
- package/src/discovery/fold-package-composite.test.ts +14 -1
- package/src/discovery/fold-project-provenance.test.ts +532 -0
- package/src/discovery/param-deps.test.ts +120 -1
- package/src/discovery/param-deps.ts +73 -3
- package/src/fold/subset-public-export.test.ts +38 -0
- package/src/fold/subset.ts +10 -2
- package/src/fold-provenance.test.ts +179 -16
- package/src/fold-provenance.ts +136 -11
- package/src/provenance.ts +46 -1
|
@@ -0,0 +1,532 @@
|
|
|
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
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { describe, expect, test } from "vitest";
|
|
3
3
|
import { collectConsts } from "../fold/fold";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
argumentLocation,
|
|
6
|
+
collectCompositeOrigins,
|
|
7
|
+
collectParamDependencies,
|
|
8
|
+
collectTagOrigins,
|
|
9
|
+
type CompositeCallSite,
|
|
10
|
+
} from "./param-deps";
|
|
5
11
|
import type { PathOrigin } from "../provenance";
|
|
6
12
|
|
|
7
13
|
/**
|
|
@@ -292,3 +298,116 @@ describe("collectTagOrigins", () => {
|
|
|
292
298
|
).toEqual({ "engine.args": fromParam("version"), engine: fixed });
|
|
293
299
|
});
|
|
294
300
|
});
|
|
301
|
+
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// chant #3597 — where the argument behind a parameter was written.
|
|
304
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** The first call expression in `source`, as a call site in `/p/src/app.ts`. */
|
|
307
|
+
function callSiteOf(source: string): CompositeCallSite {
|
|
308
|
+
const file = ts.createSourceFile("/p/src/app.ts", source, ts.ScriptTarget.Latest, true);
|
|
309
|
+
let node: ts.CallExpression | undefined;
|
|
310
|
+
const visit = (n: ts.Node): void => {
|
|
311
|
+
if (!node && ts.isCallExpression(n)) node = n;
|
|
312
|
+
ts.forEachChild(n, visit);
|
|
313
|
+
};
|
|
314
|
+
visit(file);
|
|
315
|
+
if (!node) throw new Error("fixture has no call");
|
|
316
|
+
return { file: "/p/src/app.ts", node };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
describe("argumentLocation (#3597)", () => {
|
|
320
|
+
const source = [
|
|
321
|
+
'import { WebApp } from "./web-app";',
|
|
322
|
+
"",
|
|
323
|
+
"export const web = WebApp({",
|
|
324
|
+
' name: "web-app",',
|
|
325
|
+
" replicas: 3,",
|
|
326
|
+
' iam: { path: "/service/" },',
|
|
327
|
+
" port,",
|
|
328
|
+
"});",
|
|
329
|
+
].join("\n");
|
|
330
|
+
|
|
331
|
+
test("a top-level argument is the property as written, on its own line", () => {
|
|
332
|
+
expect(argumentLocation(callSiteOf(source), "replicas")).toEqual({
|
|
333
|
+
parameter: "replicas",
|
|
334
|
+
file: "/p/src/app.ts",
|
|
335
|
+
line: 5,
|
|
336
|
+
column: 3,
|
|
337
|
+
text: "replicas: 3",
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("a nested parameter path descends the argument's object literals", () => {
|
|
342
|
+
expect(argumentLocation(callSiteOf(source), "iam.path")).toEqual({
|
|
343
|
+
parameter: "iam.path",
|
|
344
|
+
file: "/p/src/app.ts",
|
|
345
|
+
line: 6,
|
|
346
|
+
column: 10,
|
|
347
|
+
text: 'path: "/service/"',
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("a shorthand property is written as its name", () => {
|
|
352
|
+
expect(argumentLocation(callSiteOf(source), "port")).toMatchObject({ line: 7, column: 3, text: "port" });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a parameter the call leaves out falls back to the call, and claims no text", () => {
|
|
356
|
+
expect(argumentLocation(callSiteOf(source), "image")).toEqual({
|
|
357
|
+
parameter: "image",
|
|
358
|
+
file: "/p/src/app.ts",
|
|
359
|
+
line: 3,
|
|
360
|
+
column: 20,
|
|
361
|
+
});
|
|
362
|
+
expect(argumentLocation(callSiteOf(source), "iam.role")).not.toHaveProperty("text");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("an argument that is not an object literal falls back to the call", () => {
|
|
366
|
+
const location = argumentLocation(callSiteOf("export const web = WebApp(shared);"), "replicas");
|
|
367
|
+
expect(location).toEqual({ parameter: "replicas", file: "/p/src/app.ts", line: 1, column: 20 });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("collectCompositeOrigins records the locations in the same pass, in parameter order", () => {
|
|
371
|
+
const call = callSiteOf(source);
|
|
372
|
+
const body = ts.createSourceFile(
|
|
373
|
+
"factory.ts",
|
|
374
|
+
"const f = (props) => new Thing({ spec: { replicas: props.replicas }, label: `${props.name}-${props.iam.path}`, kind: \"web\" });",
|
|
375
|
+
ts.ScriptTarget.Latest,
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
let props: ts.ObjectLiteralExpression | undefined;
|
|
379
|
+
const visit = (n: ts.Node): void => {
|
|
380
|
+
if (!props && ts.isNewExpression(n)) props = n.arguments?.find(ts.isObjectLiteralExpression);
|
|
381
|
+
ts.forEachChild(n, visit);
|
|
382
|
+
};
|
|
383
|
+
visit(body);
|
|
384
|
+
const origins = collectCompositeOrigins(
|
|
385
|
+
props as ts.ObjectLiteralExpression,
|
|
386
|
+
new Map(),
|
|
387
|
+
{ whole: new Set(["props"]), destructured: new Map() },
|
|
388
|
+
"WebApp",
|
|
389
|
+
call,
|
|
390
|
+
);
|
|
391
|
+
expect(origins["spec.replicas"]).toEqual({
|
|
392
|
+
kind: "composite-parameter",
|
|
393
|
+
composite: "WebApp",
|
|
394
|
+
parameters: ["replicas"],
|
|
395
|
+
arguments: [{ parameter: "replicas", file: "/p/src/app.ts", line: 5, column: 3, text: "replicas: 3" }],
|
|
396
|
+
});
|
|
397
|
+
expect(origins.label).toMatchObject({
|
|
398
|
+
parameters: ["iam.path", "name"],
|
|
399
|
+
arguments: [
|
|
400
|
+
{ parameter: "iam.path", line: 6, text: 'path: "/service/"' },
|
|
401
|
+
{ parameter: "name", line: 4, text: 'name: "web-app"' },
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
// A literal carries no location: nothing at the call moves it.
|
|
405
|
+
expect(origins.kind).toEqual({ kind: "composite-literal", composite: "WebApp" });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("without a call site the origins are what they were", () => {
|
|
409
|
+
expect(compositeOriginsOf("return new Thing({ replicas: props.replicas });")).toEqual({
|
|
410
|
+
replicas: { kind: "composite-parameter", composite: "WebService", parameters: ["replicas"] },
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|