@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.
Files changed (55) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  5. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
  6. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  8. package/dist/codegen/docs-types.d.ts +2 -0
  9. package/dist/codegen/docs-types.d.ts.map +1 -1
  10. package/dist/declarable.d.ts +16 -0
  11. package/dist/declarable.d.ts.map +1 -1
  12. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  13. package/dist/discovery/fold-import.d.ts +64 -3
  14. package/dist/discovery/fold-import.d.ts.map +1 -1
  15. package/dist/discovery/index.d.ts +12 -0
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/fold/fold.d.ts +89 -16
  18. package/dist/fold/fold.d.ts.map +1 -1
  19. package/dist/fold/foldable-helpers.d.ts +121 -0
  20. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  21. package/dist/fold/subset.d.ts +58 -3
  22. package/dist/fold/subset.d.ts.map +1 -1
  23. package/dist/lexicon-schema.d.ts +2 -0
  24. package/dist/lexicon-schema.d.ts.map +1 -1
  25. package/dist/lexicon.d.ts +73 -23
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/runtime.d.ts +10 -1
  28. package/dist/runtime.d.ts.map +1 -1
  29. package/dist/serializer-walker.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/build.ts +9 -0
  32. package/src/cli/commands/build.ts +9 -0
  33. package/src/cli/commands/check-lexicon-examples.ts +16 -1
  34. package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
  35. package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
  36. package/src/cli/commands/check-lexicon.ts +18 -0
  37. package/src/codegen/docs-sections.test.ts +7 -1
  38. package/src/codegen/docs-sections.ts +1 -1
  39. package/src/codegen/docs-types.ts +2 -0
  40. package/src/declarable.ts +20 -0
  41. package/src/discovery/entity-wire-codec.ts +9 -7
  42. package/src/discovery/fold-import.test.ts +900 -1
  43. package/src/discovery/fold-import.ts +441 -47
  44. package/src/discovery/index.ts +25 -1
  45. package/src/fold/fold.test.ts +277 -0
  46. package/src/fold/fold.ts +219 -58
  47. package/src/fold/foldable-helpers.ts +171 -0
  48. package/src/fold/subset-doc-parity.test.ts +27 -0
  49. package/src/fold/subset.test.ts +177 -0
  50. package/src/fold/subset.ts +139 -31
  51. package/src/lexicon-schema.test.ts +43 -0
  52. package/src/lexicon-schema.ts +5 -0
  53. package/src/lexicon.ts +74 -24
  54. package/src/runtime.ts +11 -2
  55. 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
- expect(result.reason).toContain("LocalStack");
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
+ });