@intentius/chant 0.20.0 → 0.22.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/build.d.ts +7 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +2 -0
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/declarable.d.ts +16 -0
- package/dist/declarable.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +64 -3
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts +12 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +89 -16
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/foldable-helpers.d.ts +121 -0
- package/dist/fold/foldable-helpers.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +58 -3
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +73 -23
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/runtime.d.ts +10 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/serializer-walker.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +9 -0
- package/src/cli/commands/build.ts +9 -0
- package/src/cli/commands/check-lexicon-examples.ts +16 -1
- package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
- package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
- package/src/cli/commands/check-lexicon.ts +18 -0
- package/src/codegen/docs-sections.test.ts +7 -1
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs-types.ts +2 -0
- package/src/declarable.ts +20 -0
- package/src/discovery/entity-wire-codec.ts +9 -7
- package/src/discovery/fold-import.test.ts +900 -1
- package/src/discovery/fold-import.ts +441 -47
- package/src/discovery/index.ts +25 -1
- package/src/fold/fold.test.ts +277 -0
- package/src/fold/fold.ts +219 -58
- package/src/fold/foldable-helpers.ts +171 -0
- package/src/fold/subset-doc-parity.test.ts +27 -0
- package/src/fold/subset.test.ts +177 -0
- package/src/fold/subset.ts +139 -31
- package/src/lexicon-schema.test.ts +43 -0
- package/src/lexicon-schema.ts +5 -0
- package/src/lexicon.ts +74 -24
- package/src/runtime.ts +11 -2
- package/src/serializer-walker.ts +14 -0
|
@@ -7,6 +7,7 @@ import { tryFoldFile, createFoldSession } from "./fold-import";
|
|
|
7
7
|
import { isDeclarable } from "../declarable";
|
|
8
8
|
import { isCompositeInstance } from "../composite";
|
|
9
9
|
import { isAttrRefLike } from "../utils";
|
|
10
|
+
import { isIntrinsic } from "../intrinsic";
|
|
10
11
|
import { params as sharedParams } from "../params";
|
|
11
12
|
import type { AttrRef } from "../attrref";
|
|
12
13
|
import type { IntrinsicDef } from "../lexicon";
|
|
@@ -165,7 +166,28 @@ describe("tryFoldFile", () => {
|
|
|
165
166
|
|
|
166
167
|
expect(result.ok).toBe(false);
|
|
167
168
|
if (result.ok) return;
|
|
168
|
-
|
|
169
|
+
// chant #1054 — locks in the ONE wording for "a call used as a value,
|
|
170
|
+
// not foldable" (../fold/subset.ts's `callExpressionMessage`), which
|
|
171
|
+
// `resolveCallExpression` now reuses instead of its own hand-written
|
|
172
|
+
// "call expression as a value" copy.
|
|
173
|
+
expect(result.reason).toBe('"stack" is not foldable: function call as a value is not foldable: LocalStack(...)');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("falls back when a top-level export's callee isn't a plain identifier (chant #1054: same wording as any other call-as-a-value rejection)", async () => {
|
|
177
|
+
const file = join(testDir, "main.ts");
|
|
178
|
+
await writeFile(
|
|
179
|
+
file,
|
|
180
|
+
`
|
|
181
|
+
const builder = { build: (props: { name: string }) => ({ name: props.name }) };
|
|
182
|
+
export const stack = builder.build({ name: "x" });
|
|
183
|
+
`,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const result = await tryFoldFile(file);
|
|
187
|
+
|
|
188
|
+
expect(result.ok).toBe(false);
|
|
189
|
+
if (result.ok) return;
|
|
190
|
+
expect(result.reason).toBe('"stack" is not foldable: function call as a value is not foldable: builder.build(...)');
|
|
169
191
|
});
|
|
170
192
|
|
|
171
193
|
test("falls back when a prop value is not foldable", async () => {
|
|
@@ -286,6 +308,77 @@ describe("tryFoldFile", () => {
|
|
|
286
308
|
DependsOn: "otherResource",
|
|
287
309
|
});
|
|
288
310
|
});
|
|
311
|
+
|
|
312
|
+
// chant #1054 — a destructured composite export's fallback reason used to
|
|
313
|
+
// embed `decl.node.getText()`: the ENTIRE call expression, which for a
|
|
314
|
+
// real multi-prop composite call is many lines, burying the real error
|
|
315
|
+
// after all of it. These lock in the fix: the reason names the binding(s)
|
|
316
|
+
// and the callee briefly instead, and stays on one line.
|
|
317
|
+
test("a destructured composite export whose source fails to resolve reports a brief, single-line reason naming the binding names and the callee", async () => {
|
|
318
|
+
await writeFile(
|
|
319
|
+
join(testDir, "composites.ts"),
|
|
320
|
+
`
|
|
321
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
322
|
+
import { Composite } from ${JSON.stringify(compositePath)};
|
|
323
|
+
const Cluster = createResource("Test::Cluster", "gcp", {});
|
|
324
|
+
const NodePool = createResource("Test::NodePool", "gcp", {});
|
|
325
|
+
export const GkeCluster = Composite<{ name: string; location: string }>((props) => {
|
|
326
|
+
const cluster = new Cluster({ name: props.name });
|
|
327
|
+
const nodePool = new NodePool({ location: props.location });
|
|
328
|
+
return { cluster, nodePool };
|
|
329
|
+
}, "GkeCluster");
|
|
330
|
+
`,
|
|
331
|
+
);
|
|
332
|
+
const file = join(testDir, "cluster.ts");
|
|
333
|
+
await writeFile(
|
|
334
|
+
file,
|
|
335
|
+
`
|
|
336
|
+
import { GkeCluster } from "./composites";
|
|
337
|
+
export const { cluster, nodePool } = GkeCluster({
|
|
338
|
+
name: config.clusterName,
|
|
339
|
+
location: config.region,
|
|
340
|
+
});
|
|
341
|
+
`,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const result = await tryFoldFile(file);
|
|
345
|
+
|
|
346
|
+
expect(result.ok).toBe(false);
|
|
347
|
+
if (result.ok) return;
|
|
348
|
+
expect(result.reason).not.toContain("\n");
|
|
349
|
+
expect(result.reason).toContain('"cluster, nodePool" (destructured from GkeCluster(...))');
|
|
350
|
+
expect(result.reason).toContain("unresolved identifier: config");
|
|
351
|
+
// The whole multi-line source is gone — the reason never reproduces the
|
|
352
|
+
// call's own argument list verbatim.
|
|
353
|
+
expect(result.reason).not.toContain("clusterName");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("a destructured export whose source resolves but isn't an object still reports a brief, single-line reason", async () => {
|
|
357
|
+
await writeFile(
|
|
358
|
+
join(testDir, "factory.ts"),
|
|
359
|
+
`
|
|
360
|
+
export function StringFactory(props: { text: string }): string {
|
|
361
|
+
return props.text;
|
|
362
|
+
}
|
|
363
|
+
`,
|
|
364
|
+
);
|
|
365
|
+
const file = join(testDir, "main.ts");
|
|
366
|
+
await writeFile(
|
|
367
|
+
file,
|
|
368
|
+
`
|
|
369
|
+
import { StringFactory } from "./factory";
|
|
370
|
+
export const { length } = StringFactory({ text: "hello" });
|
|
371
|
+
`,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const result = await tryFoldFile(file);
|
|
375
|
+
|
|
376
|
+
expect(result.ok).toBe(false);
|
|
377
|
+
if (result.ok) return;
|
|
378
|
+
expect(result.reason).toBe(
|
|
379
|
+
'"length" (destructured from StringFactory(...)) is not foldable: not a composite call or object',
|
|
380
|
+
);
|
|
381
|
+
});
|
|
289
382
|
});
|
|
290
383
|
|
|
291
384
|
// chant #1020 (epic #1019) — cross-file resolution: an imported binding now
|
|
@@ -1024,3 +1117,809 @@ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
|
|
|
1024
1117
|
setBuildParams({});
|
|
1025
1118
|
});
|
|
1026
1119
|
});
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* chant #1082 — registered authoring helpers, and constructor argument
|
|
1123
|
+
* positions.
|
|
1124
|
+
*
|
|
1125
|
+
* Fixtures import chant-core's REAL helpers by absolute path (the convention
|
|
1126
|
+
* every other suite in this file uses), which is also the second arm of the
|
|
1127
|
+
* provenance check: a specifier that resolves inside chant-core's own tree
|
|
1128
|
+
* counts as chant's own, exactly like the `@intentius/chant*` package
|
|
1129
|
+
* specifier a real project writes.
|
|
1130
|
+
*/
|
|
1131
|
+
describe("tryFoldFile — registered authoring helpers (#1082)", () => {
|
|
1132
|
+
let testDir: string;
|
|
1133
|
+
|
|
1134
|
+
beforeEach(async () => {
|
|
1135
|
+
testDir = join(tmpdir(), `chant-fold-import-helpers-test-${Date.now()}-${Math.random()}`);
|
|
1136
|
+
await mkdir(testDir, { recursive: true });
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
afterEach(async () => {
|
|
1140
|
+
await rm(testDir, { recursive: true, force: true });
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
/** Absolute path to the real component-authoring helpers (`phase`, `gate`, `stackOutput`). */
|
|
1144
|
+
const componentPath = resolve(thisDir, "../components/component");
|
|
1145
|
+
/** Absolute path to the real `output()` / `LexiconOutput`. */
|
|
1146
|
+
const lexiconOutputPath = resolve(thisDir, "../lexicon-output");
|
|
1147
|
+
|
|
1148
|
+
test("a component authored with phase()/gate()/stackOutput() folds to the real plain data — zero module execution", async () => {
|
|
1149
|
+
const file = join(testDir, "web.component.ts");
|
|
1150
|
+
await writeFile(
|
|
1151
|
+
file,
|
|
1152
|
+
`
|
|
1153
|
+
import { phase, gate, stackOutput } from ${JSON.stringify(componentPath)};
|
|
1154
|
+
throw new Error("must never execute — sentinel for #1082 fold verification");
|
|
1155
|
+
export const web = {
|
|
1156
|
+
name: "web",
|
|
1157
|
+
dependsOn: ["shared-foundation"],
|
|
1158
|
+
deploy: [
|
|
1159
|
+
phase("Apply", [
|
|
1160
|
+
{ kind: "cfn-deploy", stack: "web", inputs: { pVpcId: stackOutput("shared-foundation", "oVpcId") } },
|
|
1161
|
+
gate("approve", { timeout: "24h" }),
|
|
1162
|
+
], { parallel: true }),
|
|
1163
|
+
],
|
|
1164
|
+
};
|
|
1165
|
+
`,
|
|
1166
|
+
);
|
|
1167
|
+
|
|
1168
|
+
const result = await tryFoldFile(file);
|
|
1169
|
+
|
|
1170
|
+
expect(result.ok).toBe(true);
|
|
1171
|
+
if (!result.ok) return;
|
|
1172
|
+
// The revived value is what the real helpers return, not an envelope:
|
|
1173
|
+
// `phase()`'s `{ phase, steps, parallel }`, `gate()`'s `{ kind: "gate", … }`,
|
|
1174
|
+
// `stackOutput()`'s `{ stackOutput: { stack, name } }`.
|
|
1175
|
+
expect(result.exportedValues.get("web")).toEqual({
|
|
1176
|
+
name: "web",
|
|
1177
|
+
dependsOn: ["shared-foundation"],
|
|
1178
|
+
deploy: [
|
|
1179
|
+
{
|
|
1180
|
+
phase: "Apply",
|
|
1181
|
+
parallel: true,
|
|
1182
|
+
steps: [
|
|
1183
|
+
{
|
|
1184
|
+
kind: "cfn-deploy",
|
|
1185
|
+
stack: "web",
|
|
1186
|
+
inputs: { pVpcId: { stackOutput: { stack: "shared-foundation", name: "oVpcId" } } },
|
|
1187
|
+
},
|
|
1188
|
+
{ kind: "gate", signalName: "approve", timeout: "24h" },
|
|
1189
|
+
],
|
|
1190
|
+
},
|
|
1191
|
+
],
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
test("a nested (fan-out) phase folds — helper envelopes revive bottom-up", async () => {
|
|
1196
|
+
const file = join(testDir, "fanout.component.ts");
|
|
1197
|
+
await writeFile(
|
|
1198
|
+
file,
|
|
1199
|
+
`
|
|
1200
|
+
import { phase } from ${JSON.stringify(componentPath)};
|
|
1201
|
+
export const fanout = { deploy: [phase("Outer", [phase("Inner", [{ kind: "noop" }])])] };
|
|
1202
|
+
`,
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
const result = await tryFoldFile(file);
|
|
1206
|
+
|
|
1207
|
+
expect(result.ok).toBe(true);
|
|
1208
|
+
if (!result.ok) return;
|
|
1209
|
+
expect(result.exportedValues.get("fanout")).toEqual({
|
|
1210
|
+
deploy: [{ phase: "Outer", steps: [{ phase: "Inner", steps: [{ kind: "noop" }] }] }],
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test("a registered NAME imported from the project's own code does NOT fold — the allowlist is chant's, not the name's", async () => {
|
|
1215
|
+
await writeFile(
|
|
1216
|
+
join(testDir, "helpers.ts"),
|
|
1217
|
+
`export function phase(name, steps) { return { phase: name, steps, mine: true }; }`,
|
|
1218
|
+
);
|
|
1219
|
+
const file = join(testDir, "web.component.ts");
|
|
1220
|
+
await writeFile(
|
|
1221
|
+
file,
|
|
1222
|
+
`
|
|
1223
|
+
import { phase } from "./helpers";
|
|
1224
|
+
export const web = { deploy: [phase("Apply", [])] };
|
|
1225
|
+
`,
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
const result = await tryFoldFile(file);
|
|
1229
|
+
|
|
1230
|
+
expect(result.ok).toBe(false);
|
|
1231
|
+
if (result.ok) return;
|
|
1232
|
+
expect(result.reason).toContain("./helpers");
|
|
1233
|
+
expect(result.reason).toContain("not chant's own");
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("a registered name declared in the file itself does NOT fold — it is not an import at all", async () => {
|
|
1237
|
+
const file = join(testDir, "local.component.ts");
|
|
1238
|
+
await writeFile(
|
|
1239
|
+
file,
|
|
1240
|
+
`
|
|
1241
|
+
function phase(name, steps) { return { phase: name, steps }; }
|
|
1242
|
+
export const web = { deploy: [phase("Apply", [])] };
|
|
1243
|
+
`,
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
const result = await tryFoldFile(file);
|
|
1247
|
+
|
|
1248
|
+
expect(result.ok).toBe(false);
|
|
1249
|
+
if (result.ok) return;
|
|
1250
|
+
expect(result.reason).toContain("is not a resolvable import");
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test("an UNREGISTERED chant import called as a value still falls back — registration is per-helper, not per-module", async () => {
|
|
1254
|
+
const file = join(testDir, "unregistered.component.ts");
|
|
1255
|
+
await writeFile(
|
|
1256
|
+
file,
|
|
1257
|
+
`
|
|
1258
|
+
import { inferArchetype } from ${JSON.stringify(componentPath)};
|
|
1259
|
+
export const web = { archetype: inferArchetype({ deploy: [] }) };
|
|
1260
|
+
`,
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
const result = await tryFoldFile(file);
|
|
1264
|
+
|
|
1265
|
+
expect(result.ok).toBe(false);
|
|
1266
|
+
if (result.ok) return;
|
|
1267
|
+
expect(result.reason).toContain("inferArchetype(...)");
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
test("output() folds against a REAL cross-file AttrRef, producing a genuine LexiconOutput", async () => {
|
|
1271
|
+
await writeFile(
|
|
1272
|
+
join(testDir, "defs.ts"),
|
|
1273
|
+
`
|
|
1274
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1275
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1276
|
+
`,
|
|
1277
|
+
);
|
|
1278
|
+
await writeFile(
|
|
1279
|
+
join(testDir, "resources.ts"),
|
|
1280
|
+
`
|
|
1281
|
+
import { Bucket } from "./defs";
|
|
1282
|
+
export const bucket = new Bucket({ name: "my-bucket" });
|
|
1283
|
+
`,
|
|
1284
|
+
);
|
|
1285
|
+
const file = join(testDir, "outputs.ts");
|
|
1286
|
+
await writeFile(
|
|
1287
|
+
file,
|
|
1288
|
+
`
|
|
1289
|
+
import { output } from ${JSON.stringify(lexiconOutputPath)};
|
|
1290
|
+
import { bucket } from "./resources";
|
|
1291
|
+
export const oArn = bucket ? output(bucket.arn, "oArn") : undefined;
|
|
1292
|
+
`,
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
const result = await tryFoldFile(file);
|
|
1296
|
+
|
|
1297
|
+
expect(result.ok).toBe(true);
|
|
1298
|
+
if (!result.ok) return;
|
|
1299
|
+
const oArn = result.exportedValues.get("oArn") as { outputName: string; sourceLexicon: string };
|
|
1300
|
+
// A genuine LexiconOutput built from the real AttrRef — it read the ref's
|
|
1301
|
+
// parent through its WeakRef to learn the lexicon, which is only possible
|
|
1302
|
+
// with a live reference, never with a `{ __attrRef }` envelope.
|
|
1303
|
+
expect(oArn.outputName).toBe("oArn");
|
|
1304
|
+
expect(oArn.sourceLexicon).toBe("aws");
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
test("output() over a SAME-FILE resource reference falls back rather than wrapping a symbolic envelope", async () => {
|
|
1308
|
+
await writeFile(
|
|
1309
|
+
join(testDir, "defs.ts"),
|
|
1310
|
+
`
|
|
1311
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1312
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1313
|
+
`,
|
|
1314
|
+
);
|
|
1315
|
+
const file = join(testDir, "same-file-outputs.ts");
|
|
1316
|
+
await writeFile(
|
|
1317
|
+
file,
|
|
1318
|
+
`
|
|
1319
|
+
import { Bucket } from "./defs";
|
|
1320
|
+
import { output } from ${JSON.stringify(lexiconOutputPath)};
|
|
1321
|
+
const enabled = true;
|
|
1322
|
+
export const bucket = new Bucket({ name: "my-bucket" });
|
|
1323
|
+
export const oArn = enabled ? output(bucket.arn, "oArn") : undefined;
|
|
1324
|
+
`,
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
const result = await tryFoldFile(file);
|
|
1328
|
+
|
|
1329
|
+
// `bucket.arn` folds to a `{ __attrRef }` envelope, and `LexiconOutput`'s
|
|
1330
|
+
// constructor needs a real `AttrRef` (it derefs the parent to learn the
|
|
1331
|
+
// lexicon). Constructing it from the envelope would silently produce a
|
|
1332
|
+
// wrong output, so the whole file falls back to run instead.
|
|
1333
|
+
expect(result.ok).toBe(false);
|
|
1334
|
+
if (result.ok) return;
|
|
1335
|
+
expect(result.reason).toContain("same-file resource reference");
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
describe("tryFoldFile — constructor argument positions (#1082)", () => {
|
|
1340
|
+
let testDir: string;
|
|
1341
|
+
|
|
1342
|
+
beforeEach(async () => {
|
|
1343
|
+
testDir = join(tmpdir(), `chant-fold-import-ctorargs-test-${Date.now()}-${Math.random()}`);
|
|
1344
|
+
await mkdir(testDir, { recursive: true });
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
afterEach(async () => {
|
|
1348
|
+
await rm(testDir, { recursive: true, force: true });
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
/** A `(type, props)` constructor, structurally identical to the real AWS deploy-time `Parameter` (lexicons/aws/src/parameter.ts). */
|
|
1352
|
+
async function writeParameterDef(): Promise<void> {
|
|
1353
|
+
await writeFile(
|
|
1354
|
+
join(testDir, "parameter.ts"),
|
|
1355
|
+
`
|
|
1356
|
+
import { DECLARABLE_MARKER } from ${JSON.stringify(resolve(thisDir, "../declarable"))};
|
|
1357
|
+
|
|
1358
|
+
export class Parameter {
|
|
1359
|
+
constructor(type, options) {
|
|
1360
|
+
this[DECLARABLE_MARKER] = true;
|
|
1361
|
+
this.lexicon = "aws";
|
|
1362
|
+
this.entityType = "AWS::CloudFormation::Parameter";
|
|
1363
|
+
this.parameterType = type;
|
|
1364
|
+
this.description = options?.description;
|
|
1365
|
+
this.defaultValue = options?.defaultValue;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
`,
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
test("`new Parameter(\"String\", {...})` folds — the props object need not be the first argument", async () => {
|
|
1373
|
+
await writeParameterDef();
|
|
1374
|
+
const file = join(testDir, "params.ts");
|
|
1375
|
+
await writeFile(
|
|
1376
|
+
file,
|
|
1377
|
+
`
|
|
1378
|
+
import { Parameter } from "./parameter";
|
|
1379
|
+
throw new Error("must never execute — sentinel for #1082 fold verification");
|
|
1380
|
+
export const pVpcId = new Parameter("AWS::EC2::VPC::Id", { description: "vpc id" });
|
|
1381
|
+
`,
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
const result = await tryFoldFile(file);
|
|
1385
|
+
|
|
1386
|
+
expect(result.ok).toBe(true);
|
|
1387
|
+
if (!result.ok) return;
|
|
1388
|
+
expect(result.entities).toHaveLength(1);
|
|
1389
|
+
const [name, entity] = result.entities[0];
|
|
1390
|
+
expect(name).toBe("pVpcId");
|
|
1391
|
+
if (!isDeclarable(entity)) throw new Error("expected a Declarable");
|
|
1392
|
+
// Both arguments reached the real constructor, in the right positions.
|
|
1393
|
+
expect((entity as unknown as { parameterType: string }).parameterType).toBe("AWS::EC2::VPC::Id");
|
|
1394
|
+
expect((entity as unknown as { description: string }).description).toBe("vpc id");
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("a constructor called with only a non-object argument folds too — no props object is invented", async () => {
|
|
1398
|
+
await writeParameterDef();
|
|
1399
|
+
const file = join(testDir, "params.ts");
|
|
1400
|
+
await writeFile(
|
|
1401
|
+
file,
|
|
1402
|
+
`
|
|
1403
|
+
import { Parameter } from "./parameter";
|
|
1404
|
+
export const pName = new Parameter("String");
|
|
1405
|
+
`,
|
|
1406
|
+
);
|
|
1407
|
+
|
|
1408
|
+
const result = await tryFoldFile(file);
|
|
1409
|
+
|
|
1410
|
+
expect(result.ok).toBe(true);
|
|
1411
|
+
if (!result.ok) return;
|
|
1412
|
+
const [, entity] = result.entities[0];
|
|
1413
|
+
expect((entity as unknown as { parameterType: string }).parameterType).toBe("String");
|
|
1414
|
+
expect((entity as unknown as { description?: string }).description).toBeUndefined();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
test("a non-foldable argument in any position still falls the file back to run", async () => {
|
|
1418
|
+
await writeParameterDef();
|
|
1419
|
+
const file = join(testDir, "params.ts");
|
|
1420
|
+
await writeFile(
|
|
1421
|
+
file,
|
|
1422
|
+
`
|
|
1423
|
+
import { Parameter } from "./parameter";
|
|
1424
|
+
export const pName = new Parameter(computeType(), { description: "x" });
|
|
1425
|
+
`,
|
|
1426
|
+
);
|
|
1427
|
+
|
|
1428
|
+
const result = await tryFoldFile(file);
|
|
1429
|
+
|
|
1430
|
+
expect(result.ok).toBe(false);
|
|
1431
|
+
if (result.ok) return;
|
|
1432
|
+
expect(result.reason).toContain("computeType(...)");
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* chant #1044 — registered lexicon intrinsics in PLAIN-CALL form, end to end.
|
|
1438
|
+
*
|
|
1439
|
+
* The unit tests in ../fold/fold.test.ts cover the reducer's half (a call
|
|
1440
|
+
* reduces to a `{__intrinsic, args}` envelope, executing nothing). These
|
|
1441
|
+
* cover the other half: the envelope is revived by resolving the name
|
|
1442
|
+
* through THIS FILE'S OWN imports and invoking the real function, so what
|
|
1443
|
+
* lands in a resource's props is a genuine live intrinsic instance — the
|
|
1444
|
+
* same object the run path would have built.
|
|
1445
|
+
*/
|
|
1446
|
+
describe("tryFoldFile — registered call-form intrinsics (#1044)", () => {
|
|
1447
|
+
let testDir: string;
|
|
1448
|
+
|
|
1449
|
+
beforeEach(async () => {
|
|
1450
|
+
testDir = join(tmpdir(), `chant-fold-import-callintrinsic-test-${Date.now()}-${Math.random()}`);
|
|
1451
|
+
await mkdir(testDir, { recursive: true });
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
afterEach(async () => {
|
|
1455
|
+
await rm(testDir, { recursive: true, force: true });
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
const REF: IntrinsicDef = { name: "Ref", isTag: false, foldsAsCall: true, outputKey: "Test::Ref" };
|
|
1459
|
+
const NOT_OPTED_IN: IntrinsicDef = { name: "Ref", isTag: false };
|
|
1460
|
+
|
|
1461
|
+
/** A plain-call intrinsic shaped exactly like aws's real `Ref`: a factory returning a class that implements the `Intrinsic` contract and resolves its target at `toJSON()` time. */
|
|
1462
|
+
async function writeCallIntrinsicDefs(): Promise<void> {
|
|
1463
|
+
await writeFile(
|
|
1464
|
+
join(testDir, "intrinsics.ts"),
|
|
1465
|
+
`
|
|
1466
|
+
import { INTRINSIC_MARKER } from ${JSON.stringify(intrinsicPath)};
|
|
1467
|
+
|
|
1468
|
+
export class RefIntrinsic {
|
|
1469
|
+
constructor(target) {
|
|
1470
|
+
this[INTRINSIC_MARKER] = true;
|
|
1471
|
+
this.target = target;
|
|
1472
|
+
}
|
|
1473
|
+
toJSON() {
|
|
1474
|
+
return { "Test::Ref": typeof this.target === "string" ? this.target : this.target.logicalHint };
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
export function Ref(target) {
|
|
1479
|
+
return new RefIntrinsic(target);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export const NS = { Region: "NS::Region" };
|
|
1483
|
+
`,
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
test("an opted-in call folds end-to-end to the real intrinsic instance — zero module execution", async () => {
|
|
1488
|
+
await writeCallIntrinsicDefs();
|
|
1489
|
+
await writeFile(
|
|
1490
|
+
join(testDir, "resources.ts"),
|
|
1491
|
+
`
|
|
1492
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1493
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1494
|
+
`,
|
|
1495
|
+
);
|
|
1496
|
+
const file = join(testDir, "main.ts");
|
|
1497
|
+
await writeFile(
|
|
1498
|
+
file,
|
|
1499
|
+
`
|
|
1500
|
+
import { Bucket } from "./resources";
|
|
1501
|
+
import { Ref } from "./intrinsics";
|
|
1502
|
+
throw new Error("must never execute — sentinel for #1044 fold verification");
|
|
1503
|
+
export const bucket = new Bucket({ name: Ref("environment") });
|
|
1504
|
+
`,
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
const result = await tryFoldFile(file, [REF]);
|
|
1508
|
+
|
|
1509
|
+
expect(result.ok).toBe(true);
|
|
1510
|
+
if (!result.ok) return;
|
|
1511
|
+
const [, entity] = result.entities[0];
|
|
1512
|
+
if (!isDeclarable(entity)) throw new Error("expected a Declarable");
|
|
1513
|
+
const name = (entity as unknown as { props: { name: unknown } }).props.name;
|
|
1514
|
+
// A genuine live instance built by the file's own `Ref`, not the
|
|
1515
|
+
// `{ __intrinsic, args }` envelope the reducer produced internally.
|
|
1516
|
+
expect((name as { toJSON(): unknown }).toJSON()).toEqual({ "Test::Ref": "environment" });
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
test("a call-form intrinsic folds inside a registered tag's interpolation, and a symbolic argument resolves through the file's own import", async () => {
|
|
1520
|
+
await writeFile(
|
|
1521
|
+
join(testDir, "intrinsics.ts"),
|
|
1522
|
+
`
|
|
1523
|
+
import { INTRINSIC_MARKER } from ${JSON.stringify(intrinsicPath)};
|
|
1524
|
+
export class RefIntrinsic {
|
|
1525
|
+
constructor(target) { this[INTRINSIC_MARKER] = true; this.target = target; }
|
|
1526
|
+
toJSON() { return { "Test::Ref": this.target }; }
|
|
1527
|
+
}
|
|
1528
|
+
export function Ref(target) { return new RefIntrinsic(target); }
|
|
1529
|
+
export class SubIntrinsic {
|
|
1530
|
+
constructor(strings, values) { this[INTRINSIC_MARKER] = true; this.strings = strings; this.values = values; }
|
|
1531
|
+
toJSON() {
|
|
1532
|
+
let out = "";
|
|
1533
|
+
for (let i = 0; i < this.strings.length; i++) {
|
|
1534
|
+
out += this.strings[i];
|
|
1535
|
+
if (i < this.values.length) out += JSON.stringify(this.values[i].toJSON ? this.values[i].toJSON() : this.values[i]);
|
|
1536
|
+
}
|
|
1537
|
+
return { "Test::Sub": out };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
export function Sub(strings, ...values) { return new SubIntrinsic(strings, values); }
|
|
1541
|
+
export const NS = { Region: "NS::Region" };
|
|
1542
|
+
`,
|
|
1543
|
+
);
|
|
1544
|
+
await writeFile(
|
|
1545
|
+
join(testDir, "resources.ts"),
|
|
1546
|
+
`
|
|
1547
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1548
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1549
|
+
`,
|
|
1550
|
+
);
|
|
1551
|
+
const file = join(testDir, "main.ts");
|
|
1552
|
+
await writeFile(
|
|
1553
|
+
file,
|
|
1554
|
+
`
|
|
1555
|
+
import { Bucket } from "./resources";
|
|
1556
|
+
import { Sub, Ref, NS } from "./intrinsics";
|
|
1557
|
+
export const bucket = new Bucket({ name: Sub\`\${Ref(NS.Region)}-fn\` });
|
|
1558
|
+
`,
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
const result = await tryFoldFile(file, [REF, { name: "Sub", isTag: true }]);
|
|
1562
|
+
|
|
1563
|
+
expect(result.ok).toBe(true);
|
|
1564
|
+
if (!result.ok) return;
|
|
1565
|
+
const [, entity] = result.entities[0];
|
|
1566
|
+
if (!isDeclarable(entity)) throw new Error("expected a Declarable");
|
|
1567
|
+
const name = (entity as unknown as { props: { name: unknown } }).props.name;
|
|
1568
|
+
expect((name as { toJSON(): unknown }).toJSON()).toEqual({
|
|
1569
|
+
"Test::Sub": `{"Test::Ref":"NS::Region"}-fn`,
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
test("a cross-file resource passed to an intrinsic call arrives as the SHARED live instance, not a re-imported copy", async () => {
|
|
1574
|
+
await writeCallIntrinsicDefs();
|
|
1575
|
+
await writeFile(
|
|
1576
|
+
join(testDir, "resources.ts"),
|
|
1577
|
+
`
|
|
1578
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1579
|
+
export const Param = createResource("Test::Param", "aws", {});
|
|
1580
|
+
`,
|
|
1581
|
+
);
|
|
1582
|
+
await writeFile(
|
|
1583
|
+
join(testDir, "params.ts"),
|
|
1584
|
+
`
|
|
1585
|
+
import { Param } from "./resources";
|
|
1586
|
+
export const environment = new Param({ logicalHint: "EnvParam" });
|
|
1587
|
+
`,
|
|
1588
|
+
);
|
|
1589
|
+
const file = join(testDir, "main.ts");
|
|
1590
|
+
await writeFile(
|
|
1591
|
+
file,
|
|
1592
|
+
`
|
|
1593
|
+
import { Param } from "./resources";
|
|
1594
|
+
import { environment } from "./params";
|
|
1595
|
+
import { Ref } from "./intrinsics";
|
|
1596
|
+
export const other = new Param({ name: Ref(environment) });
|
|
1597
|
+
`,
|
|
1598
|
+
);
|
|
1599
|
+
|
|
1600
|
+
const session = createFoldSession([REF]);
|
|
1601
|
+
const paramsResult = await tryFoldFile(join(testDir, "params.ts"), [REF], session);
|
|
1602
|
+
const result = await tryFoldFile(file, [REF], session);
|
|
1603
|
+
|
|
1604
|
+
expect(result.ok).toBe(true);
|
|
1605
|
+
if (!result.ok || !paramsResult.ok) return;
|
|
1606
|
+
const [, entity] = result.entities[0];
|
|
1607
|
+
if (!isDeclarable(entity)) throw new Error("expected a Declarable");
|
|
1608
|
+
const ref = (entity as unknown as { props: { name: { target: unknown } } }).props.name;
|
|
1609
|
+
// Identity, not equality: the SAME object params.ts's own fold produced
|
|
1610
|
+
// and discovery will collect. A second instance would serialize with no
|
|
1611
|
+
// logical name at all (see planFoldTaint's doc).
|
|
1612
|
+
expect(ref.target).toBe(paramsResult.exportedValues.get("environment"));
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("a REGISTERED intrinsic with no opt-in still falls the file back to run", async () => {
|
|
1616
|
+
await writeCallIntrinsicDefs();
|
|
1617
|
+
await writeFile(
|
|
1618
|
+
join(testDir, "resources.ts"),
|
|
1619
|
+
`
|
|
1620
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1621
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1622
|
+
`,
|
|
1623
|
+
);
|
|
1624
|
+
const file = join(testDir, "main.ts");
|
|
1625
|
+
await writeFile(
|
|
1626
|
+
file,
|
|
1627
|
+
`
|
|
1628
|
+
import { Bucket } from "./resources";
|
|
1629
|
+
import { Ref } from "./intrinsics";
|
|
1630
|
+
export const bucket = new Bucket({ name: Ref("environment") });
|
|
1631
|
+
`,
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
const result = await tryFoldFile(file, [NOT_OPTED_IN]);
|
|
1635
|
+
|
|
1636
|
+
expect(result.ok).toBe(false);
|
|
1637
|
+
if (result.ok) return;
|
|
1638
|
+
expect(result.reason).toContain("function call as a value is not foldable: Ref(...)");
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
test("an opted-in NAME that this file never imported falls back — the registry is not permission to invoke a name", async () => {
|
|
1642
|
+
await writeFile(
|
|
1643
|
+
join(testDir, "resources.ts"),
|
|
1644
|
+
`
|
|
1645
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1646
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1647
|
+
`,
|
|
1648
|
+
);
|
|
1649
|
+
const file = join(testDir, "main.ts");
|
|
1650
|
+
await writeFile(
|
|
1651
|
+
file,
|
|
1652
|
+
`
|
|
1653
|
+
import { Bucket } from "./resources";
|
|
1654
|
+
export const bucket = new Bucket({ name: Ref("environment") });
|
|
1655
|
+
`,
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
const result = await tryFoldFile(file, [REF]);
|
|
1659
|
+
|
|
1660
|
+
expect(result.ok).toBe(false);
|
|
1661
|
+
if (result.ok) return;
|
|
1662
|
+
expect(result.reason).toContain(`"Ref" is not a resolvable import`);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
test("a same-file resource reference passed to an intrinsic call falls back rather than folding to the wrong value", async () => {
|
|
1666
|
+
await writeCallIntrinsicDefs();
|
|
1667
|
+
await writeFile(
|
|
1668
|
+
join(testDir, "resources.ts"),
|
|
1669
|
+
`
|
|
1670
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1671
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
1672
|
+
`,
|
|
1673
|
+
);
|
|
1674
|
+
const file = join(testDir, "main.ts");
|
|
1675
|
+
await writeFile(
|
|
1676
|
+
file,
|
|
1677
|
+
`
|
|
1678
|
+
import { Bucket } from "./resources";
|
|
1679
|
+
import { Ref } from "./intrinsics";
|
|
1680
|
+
export const source = new Bucket({ name: "src" });
|
|
1681
|
+
export const bucket = new Bucket({ name: Ref(source.arn) });
|
|
1682
|
+
`,
|
|
1683
|
+
);
|
|
1684
|
+
|
|
1685
|
+
const result = await tryFoldFile(file, [REF]);
|
|
1686
|
+
|
|
1687
|
+
expect(result.ok).toBe(false);
|
|
1688
|
+
if (result.ok) return;
|
|
1689
|
+
expect(result.reason).toContain("same-file resource reference");
|
|
1690
|
+
});
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* chant #1063 — cross-file references into an ACTIVE LEXICON PACKAGE's
|
|
1695
|
+
* exports.
|
|
1696
|
+
*
|
|
1697
|
+
* #1020 taught fold to follow an identifier into another module, but only
|
|
1698
|
+
* through a relative/absolute specifier — a bare package specifier was
|
|
1699
|
+
* skipped outright, so `Azure`, `GCP`, `S3Actions` and gitlab's `CI` all
|
|
1700
|
+
* failed as "unresolved identifier" even though every one of them is a plain
|
|
1701
|
+
* `as const` object the folder already handles. These tests install a real
|
|
1702
|
+
* package into the fixture tree's own `node_modules` and drive the identical
|
|
1703
|
+
* resolution a real `@intentius/chant-lexicon-azure` import takes: a genuine
|
|
1704
|
+
* bare specifier, resolved by {@link fastResolveBareSpecifier} walking up to
|
|
1705
|
+
* `<testDir>/node_modules`, then really imported.
|
|
1706
|
+
*
|
|
1707
|
+
* Every fixture package gets a UNIQUE name. `bareSpecifierPathCache` is
|
|
1708
|
+
* process-wide and keyed by specifier alone (deliberately — see its doc), so
|
|
1709
|
+
* two tests reusing one name would have the second silently resolve to the
|
|
1710
|
+
* first's already-deleted directory.
|
|
1711
|
+
*/
|
|
1712
|
+
describe("tryFoldFile — active lexicon package exports (#1063)", () => {
|
|
1713
|
+
let testDir: string;
|
|
1714
|
+
let seq = 0;
|
|
1715
|
+
|
|
1716
|
+
beforeEach(async () => {
|
|
1717
|
+
testDir = join(tmpdir(), `chant-fold-import-lexpkg-test-${Date.now()}-${Math.random()}`);
|
|
1718
|
+
await mkdir(testDir, { recursive: true });
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
afterEach(async () => {
|
|
1722
|
+
await rm(testDir, { recursive: true, force: true });
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Install a package into `<testDir>/node_modules/<name>` and return its
|
|
1727
|
+
* bare specifier. `lexiconName` is what a build would list in
|
|
1728
|
+
* `chant.config.ts`'s `lexicons`; the package name follows chant's one
|
|
1729
|
+
* naming convention (`@intentius/chant-lexicon-<name>`), same as
|
|
1730
|
+
* `loadPlugin()` uses.
|
|
1731
|
+
*/
|
|
1732
|
+
async function installLexiconPackage(source: string): Promise<{ lexicon: string; specifier: string }> {
|
|
1733
|
+
const lexicon = `fold1063x${seq++}${Date.now().toString(36)}`;
|
|
1734
|
+
const specifier = `@intentius/chant-lexicon-${lexicon}`;
|
|
1735
|
+
const dir = join(testDir, "node_modules", specifier);
|
|
1736
|
+
await mkdir(dir, { recursive: true });
|
|
1737
|
+
await writeFile(
|
|
1738
|
+
join(dir, "package.json"),
|
|
1739
|
+
JSON.stringify({ name: specifier, version: "0.0.0", type: "module", exports: { ".": "./index.js" } }),
|
|
1740
|
+
);
|
|
1741
|
+
await writeFile(join(dir, "index.js"), source);
|
|
1742
|
+
return { lexicon, specifier };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** A pseudo-parameter namespace shaped exactly like `lexicons/azure/src/pseudo.ts`'s `Azure`, plus a plain-data constants object and a callable export. */
|
|
1746
|
+
const LEXICON_SOURCE = `
|
|
1747
|
+
const INTRINSIC_MARKER = Symbol.for("chant.intrinsic");
|
|
1748
|
+
class ArmPseudoParameter {
|
|
1749
|
+
constructor(expression) {
|
|
1750
|
+
this[INTRINSIC_MARKER] = true;
|
|
1751
|
+
this.expression = expression;
|
|
1752
|
+
}
|
|
1753
|
+
toJSON() { return this.expression; }
|
|
1754
|
+
}
|
|
1755
|
+
export const ResourceGroupLocation = new ArmPseudoParameter("[resourceGroup().location]");
|
|
1756
|
+
export const Azure = { ResourceGroupLocation };
|
|
1757
|
+
export const CI = { CommitBranch: "$CI_COMMIT_BRANCH", DefaultBranch: "$CI_DEFAULT_BRANCH" };
|
|
1758
|
+
export const S3Actions = { ReadOnly: ["s3:GetObject", "s3:ListBucket"] };
|
|
1759
|
+
export function defaultTags(tags) { return tags; }
|
|
1760
|
+
`;
|
|
1761
|
+
|
|
1762
|
+
test("a lexicon package's plain-data export resolves and folds when referenced as a value", async () => {
|
|
1763
|
+
const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1764
|
+
const file = join(testDir, "main.ts");
|
|
1765
|
+
await writeFile(
|
|
1766
|
+
file,
|
|
1767
|
+
`
|
|
1768
|
+
import { CI, S3Actions } from ${JSON.stringify(specifier)};
|
|
1769
|
+
throw new Error("must never execute — sentinel for #1063 fold verification");
|
|
1770
|
+
export const branch = CI.CommitBranch;
|
|
1771
|
+
export const rule = \`\${CI.CommitBranch} == \${CI.DefaultBranch}\`;
|
|
1772
|
+
export const actions = S3Actions.ReadOnly;
|
|
1773
|
+
`,
|
|
1774
|
+
);
|
|
1775
|
+
|
|
1776
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
|
|
1777
|
+
|
|
1778
|
+
expect(result.ok).toBe(true);
|
|
1779
|
+
if (!result.ok) return;
|
|
1780
|
+
expect(result.exportedValues.get("branch")).toBe("$CI_COMMIT_BRANCH");
|
|
1781
|
+
// A template literal is the real test that the VALUE arrived, not a
|
|
1782
|
+
// symbolic envelope: an envelope would stringify to "[object Object]".
|
|
1783
|
+
expect(result.exportedValues.get("rule")).toBe("$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH");
|
|
1784
|
+
expect(result.exportedValues.get("actions")).toEqual(["s3:GetObject", "s3:ListBucket"]);
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
test("a pseudo-parameter reached through the namespace stays the REAL live Intrinsic instance, prototype intact", async () => {
|
|
1788
|
+
const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1789
|
+
await writeFile(
|
|
1790
|
+
join(testDir, "resources.ts"),
|
|
1791
|
+
`
|
|
1792
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
1793
|
+
export const Group = createResource("Test::Group", "azure", {});
|
|
1794
|
+
`,
|
|
1795
|
+
);
|
|
1796
|
+
const file = join(testDir, "main.ts");
|
|
1797
|
+
await writeFile(
|
|
1798
|
+
file,
|
|
1799
|
+
`
|
|
1800
|
+
import { Azure } from ${JSON.stringify(specifier)};
|
|
1801
|
+
import { Group } from "./resources";
|
|
1802
|
+
export const group = new Group({ location: Azure.ResourceGroupLocation });
|
|
1803
|
+
`,
|
|
1804
|
+
);
|
|
1805
|
+
|
|
1806
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
|
|
1807
|
+
|
|
1808
|
+
expect(result.ok).toBe(true);
|
|
1809
|
+
if (!result.ok) return;
|
|
1810
|
+
const [, entity] = result.entities[0];
|
|
1811
|
+
const location = (entity as unknown as { props: { location: unknown } }).props.location;
|
|
1812
|
+
// Not a plain-object copy: the generic revive walk would have rebuilt it
|
|
1813
|
+
// as `{ expression: … }` and dropped `toJSON`, which is what actually
|
|
1814
|
+
// reaches the serializer.
|
|
1815
|
+
expect(isIntrinsic(location)).toBe(true);
|
|
1816
|
+
expect((location as { toJSON(): unknown }).toJSON()).toBe("[resourceGroup().location]");
|
|
1817
|
+
// Identity: the very object the module exports, not a reconstruction —
|
|
1818
|
+
// the same one a run-path import of this package would hand the file.
|
|
1819
|
+
const mod = (await import(join(testDir, "node_modules", specifier, "index.js"))) as {
|
|
1820
|
+
Azure: { ResourceGroupLocation: unknown };
|
|
1821
|
+
};
|
|
1822
|
+
expect(location).toBe(mod.Azure.ResourceGroupLocation);
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
test("a lexicon package NOT active for this build is not resolved", async () => {
|
|
1826
|
+
const { specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1827
|
+
const file = join(testDir, "main.ts");
|
|
1828
|
+
await writeFile(
|
|
1829
|
+
file,
|
|
1830
|
+
`
|
|
1831
|
+
import { CI } from ${JSON.stringify(specifier)};
|
|
1832
|
+
export const branch = CI.CommitBranch;
|
|
1833
|
+
`,
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
// The package is installed and importable — the ONLY thing missing is
|
|
1837
|
+
// this build having loaded it as a lexicon.
|
|
1838
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, ["aws"]));
|
|
1839
|
+
|
|
1840
|
+
expect(result.ok).toBe(false);
|
|
1841
|
+
if (result.ok) return;
|
|
1842
|
+
expect(result.reason).toContain("unresolved identifier: CI");
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test("with no active lexicons at all, a bare specifier is skipped exactly as before", async () => {
|
|
1846
|
+
const { specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1847
|
+
const file = join(testDir, "main.ts");
|
|
1848
|
+
await writeFile(
|
|
1849
|
+
file,
|
|
1850
|
+
`
|
|
1851
|
+
import { CI } from ${JSON.stringify(specifier)};
|
|
1852
|
+
export const branch = CI.CommitBranch;
|
|
1853
|
+
`,
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
const result = await tryFoldFile(file);
|
|
1857
|
+
|
|
1858
|
+
expect(result.ok).toBe(false);
|
|
1859
|
+
if (result.ok) return;
|
|
1860
|
+
expect(result.reason).toContain("unresolved identifier: CI");
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
test("a non-lexicon package is never followed, even when it is installed right next to one that is", async () => {
|
|
1864
|
+
const { lexicon } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1865
|
+
const vendorDir = join(testDir, "node_modules", "some-vendor-pkg");
|
|
1866
|
+
await mkdir(vendorDir, { recursive: true });
|
|
1867
|
+
await writeFile(
|
|
1868
|
+
join(vendorDir, "package.json"),
|
|
1869
|
+
JSON.stringify({ name: "some-vendor-pkg", version: "0.0.0", type: "module", exports: { ".": "./index.js" } }),
|
|
1870
|
+
);
|
|
1871
|
+
await writeFile(join(vendorDir, "index.js"), `export const SETTINGS = { region: "us-east-1" };`);
|
|
1872
|
+
|
|
1873
|
+
const file = join(testDir, "main.ts");
|
|
1874
|
+
await writeFile(
|
|
1875
|
+
file,
|
|
1876
|
+
`
|
|
1877
|
+
import { SETTINGS } from "some-vendor-pkg";
|
|
1878
|
+
export const region = SETTINGS.region;
|
|
1879
|
+
`,
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
|
|
1883
|
+
|
|
1884
|
+
expect(result.ok).toBe(false);
|
|
1885
|
+
if (result.ok) return;
|
|
1886
|
+
expect(result.reason).toContain("unresolved identifier: SETTINGS");
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
test("a lexicon package's CALLABLE export is not bound as an identifier value", async () => {
|
|
1890
|
+
const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1891
|
+
const file = join(testDir, "main.ts");
|
|
1892
|
+
await writeFile(
|
|
1893
|
+
file,
|
|
1894
|
+
`
|
|
1895
|
+
import { defaultTags } from ${JSON.stringify(specifier)};
|
|
1896
|
+
export const tags = defaultTags;
|
|
1897
|
+
`,
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
|
|
1901
|
+
|
|
1902
|
+
expect(result.ok).toBe(false);
|
|
1903
|
+
if (result.ok) return;
|
|
1904
|
+
expect(result.reason).toContain("unresolved identifier: defaultTags");
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
test("process.env still rejects, with the build-parameter guidance, even with an active lexicon package in scope", async () => {
|
|
1908
|
+
const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
|
|
1909
|
+
const file = join(testDir, "main.ts");
|
|
1910
|
+
await writeFile(
|
|
1911
|
+
file,
|
|
1912
|
+
`
|
|
1913
|
+
import { CI } from ${JSON.stringify(specifier)};
|
|
1914
|
+
export const branch = CI.CommitBranch;
|
|
1915
|
+
export const region = process.env.AWS_REGION;
|
|
1916
|
+
`,
|
|
1917
|
+
);
|
|
1918
|
+
|
|
1919
|
+
const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
|
|
1920
|
+
|
|
1921
|
+
expect(result.ok).toBe(false);
|
|
1922
|
+
if (result.ok) return;
|
|
1923
|
+
expect(result.reason).toContain(`ambient "process" read is not foldable`);
|
|
1924
|
+
});
|
|
1925
|
+
});
|