@intentius/chant 0.62.0 → 0.63.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 (107) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  16. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  17. package/dist/lifecycle/index.d.ts +1 -0
  18. package/dist/lifecycle/index.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-digest.d.ts +33 -0
  20. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  21. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  22. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  23. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  24. package/dist/op/activities/lifecycle.d.ts +27 -0
  25. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  26. package/dist/op/activities/reconcile.d.ts +196 -27
  27. package/dist/op/activities/reconcile.d.ts.map +1 -1
  28. package/dist/op/builders.d.ts +6 -0
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/composites/apply-op.d.ts +6 -0
  31. package/dist/op/composites/apply-op.d.ts.map +1 -1
  32. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  33. package/dist/op/gate-summary.d.ts +16 -0
  34. package/dist/op/gate-summary.d.ts.map +1 -1
  35. package/dist/op/gate.d.ts +104 -13
  36. package/dist/op/gate.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +3 -2
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +17 -0
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-output.d.ts.map +1 -1
  42. package/dist/op/op-ir.d.ts +8 -1
  43. package/dist/op/op-ir.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +2 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/types.d.ts +19 -0
  47. package/dist/op/types.d.ts.map +1 -1
  48. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  49. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  50. package/dist/terraform/graph.d.ts +18 -2
  51. package/dist/terraform/graph.d.ts.map +1 -1
  52. package/dist/terraform/parse.d.ts.map +1 -1
  53. package/dist/terraform/types.d.ts +7 -0
  54. package/dist/terraform/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli/handlers/operator.test.ts +130 -0
  57. package/src/cli/handlers/operator.ts +56 -2
  58. package/src/cli/handlers/run.ts +19 -0
  59. package/src/cli/main.ts +2 -0
  60. package/src/cli/registry.ts +2 -0
  61. package/src/components/cli-support.ts +29 -4
  62. package/src/components/driver-output.ts +10 -0
  63. package/src/components/driver.test.ts +31 -0
  64. package/src/components/driver.ts +54 -8
  65. package/src/discovery/fold-import.test.ts +55 -0
  66. package/src/fold/fold.test.ts +152 -0
  67. package/src/fold/fold.ts +102 -2
  68. package/src/fold/subset-doc-parity.test.ts +35 -1
  69. package/src/fold/subset.ts +10 -0
  70. package/src/lifecycle/gate-ledger.test.ts +133 -1
  71. package/src/lifecycle/gate-ledger.ts +108 -0
  72. package/src/lifecycle/index.ts +1 -0
  73. package/src/lifecycle/plan-digest.test.ts +49 -0
  74. package/src/lifecycle/plan-digest.ts +86 -0
  75. package/src/lifecycle/run-ledger.ts +1 -0
  76. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  77. package/src/op/activities/lexicon-upgrade.ts +19 -3
  78. package/src/op/activities/lifecycle.ts +51 -2
  79. package/src/op/activities/reconcile.test.ts +512 -26
  80. package/src/op/activities/reconcile.ts +307 -34
  81. package/src/op/builders.ts +7 -1
  82. package/src/op/composites/apply-op.ts +16 -0
  83. package/src/op/composites/composites.test.ts +15 -2
  84. package/src/op/composites/reconcile-op.test.ts +18 -0
  85. package/src/op/composites/reconcile-op.ts +7 -1
  86. package/src/op/gate-summary.test.ts +33 -0
  87. package/src/op/gate-summary.ts +31 -0
  88. package/src/op/gate.test.ts +111 -1
  89. package/src/op/gate.ts +181 -23
  90. package/src/op/index.ts +5 -2
  91. package/src/op/local-executor.test.ts +226 -3
  92. package/src/op/local-executor.ts +61 -12
  93. package/src/op/local-output.test.ts +38 -0
  94. package/src/op/local-output.ts +24 -1
  95. package/src/op/op-ir.test.ts +22 -0
  96. package/src/op/op-ir.ts +9 -0
  97. package/src/op/runtime.ts +2 -0
  98. package/src/op/types.ts +19 -0
  99. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  100. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  101. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  102. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  103. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  104. package/src/terraform/graph.test.ts +148 -1
  105. package/src/terraform/graph.ts +144 -6
  106. package/src/terraform/parse.ts +4 -1
  107. package/src/terraform/types.ts +7 -0
@@ -385,6 +385,7 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
385
385
  actor: ctx.args.approver ?? ctx.args.actor,
386
386
  note: ctx.args.note,
387
387
  url: ctx.args.url,
388
+ plan: ctx.args.plan,
388
389
  });
389
390
  if (!outcome.ok) return 1;
390
391
 
@@ -744,6 +745,16 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
744
745
  console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate)}`));
745
746
  if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
746
747
  console.error(formatInfo(`expires : ${gate.expiresAt}`));
748
+ // #2310: this run's own append reached only the local chant/lifecycle
749
+ // branch. The gate is still right to stand, but an operator elsewhere
750
+ // cannot see the pending fact to approve it, and nothing else here says
751
+ // why not.
752
+ if (result.gated.pushed === false) {
753
+ console.error(formatWarning({
754
+ message: `the pending fact was not pushed to the remote: ${result.gated.pushWarning ?? "recorded locally only"}`,
755
+ hint: "an operator working from a clone of the remote cannot approve it until it does",
756
+ }));
757
+ }
747
758
  reportGatedRun(
748
759
  {
749
760
  op: gate.op,
@@ -751,6 +762,8 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
751
762
  ...(gate.description ? { description: gate.description } : {}),
752
763
  expiresAt: gate.expiresAt,
753
764
  ...(gate.url ? { url: gate.url } : {}),
765
+ ...(result.gated.pushed === false ? { pushed: false, pushWarning: result.gated.pushWarning } : {}),
766
+ ...(gate.planDigest ? { planDigest: gate.planDigest } : {}),
754
767
  },
755
768
  gatedExit,
756
769
  );
@@ -866,6 +879,12 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
866
879
  ...(pending?.description ? { description: pending.description } : {}),
867
880
  ...(pending?.expiresAt ? { expiresAt: pending.expiresAt } : {}),
868
881
  ...(pending?.url ? { url: pending.url } : {}),
882
+ // #2310: the local runtime knows whether this run's own append
883
+ // reached the remote; a runtime that reports only a state does not.
884
+ ...(status.result?.gatePushed === false
885
+ ? { pushed: false, pushWarning: status.result.gatePushWarning }
886
+ : {}),
887
+ ...(pending?.planDigest ? { planDigest: pending.planDigest } : {}),
869
888
  },
870
889
  gatedExit,
871
890
  );
package/src/cli/main.ts CHANGED
@@ -404,6 +404,8 @@ export function parseArgs(args: string[]): ParsedArgs {
404
404
  result.note = args[++i];
405
405
  } else if (arg === "--expire") {
406
406
  result.expire = true;
407
+ } else if (arg === "--plan") {
408
+ result.plan = args[++i];
407
409
  } else if (arg === "--url") {
408
410
  result.url = args[++i];
409
411
  } else if (arg === "--op") {
@@ -318,6 +318,8 @@ export interface ParsedArgs {
318
318
  note?: string;
319
319
  /** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
320
320
  expire?: boolean;
321
+ /** `chant approve <op> <gate> --plan <digest>` (#2300) — the plan this approval is for, as `sha256:<64 hex>`. Omitted, the digest is taken from the gate's standing pending fact, which is the plan the run that stopped at the gate actually produced; pass it to approve a plan explicitly, or to approve one before any run has recorded a pending fact. */
322
+ plan?: string;
321
323
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
322
324
  op?: string;
323
325
  /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
@@ -425,7 +425,13 @@ export interface RunComponentsResult {
425
425
  selected: string[];
426
426
  error?: string;
427
427
  /** Set when the run stopped at a `gate` nobody has approved (#2119): the component it stopped at and the pending fact recorded for it. */
428
- gated?: { component: string; gate: PendingGateRecord };
428
+ gated?: {
429
+ component: string;
430
+ gate: PendingGateRecord;
431
+ /** Whether this run's own append reached the remote (#2310); absent when the pending fact was already standing. */
432
+ pushed?: boolean;
433
+ pushWarning?: string;
434
+ };
429
435
  /** This run's resolved build-time parameters (chant #1108) — the component-driver counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Present only once the run actually reached dispatch (mirrors `BuildResult.buildParams`, which is likewise absent on an early-error return). */
430
436
  buildParams?: BuildParamProvenance[];
431
437
  }
@@ -571,7 +577,14 @@ export async function runComponents(
571
577
  selected,
572
578
  buildParams: options.buildParams,
573
579
  ...(run.status === "gated" && run.gate
574
- ? { gated: { component: run.gatedComponent ?? selected[0], gate: run.gate } }
580
+ ? {
581
+ gated: {
582
+ component: run.gatedComponent ?? selected[0],
583
+ gate: run.gate,
584
+ ...(run.gatePushed !== undefined ? { pushed: run.gatePushed } : {}),
585
+ ...(run.gatePushWarning ? { pushWarning: run.gatePushWarning } : {}),
586
+ },
587
+ }
575
588
  : {}),
576
589
  };
577
590
  }
@@ -613,7 +626,12 @@ export async function runComponents(
613
626
  status: componentResult.status,
614
627
  failedComponent: componentResult.status === "fail" ? componentResult.component : undefined,
615
628
  ...(componentResult.status === "gated"
616
- ? { gatedComponent: componentResult.component, gate: componentResult.gate }
629
+ ? {
630
+ gatedComponent: componentResult.component,
631
+ gate: componentResult.gate,
632
+ ...(componentResult.gatePushed !== undefined ? { gatePushed: componentResult.gatePushed } : {}),
633
+ ...(componentResult.gatePushWarning ? { gatePushWarning: componentResult.gatePushWarning } : {}),
634
+ }
617
635
  : {}),
618
636
  componentOutputs,
619
637
  };
@@ -623,7 +641,14 @@ export async function runComponents(
623
641
  selected,
624
642
  buildParams: options.buildParams,
625
643
  ...(componentResult.status === "gated" && componentResult.gate
626
- ? { gated: { component: componentResult.component, gate: componentResult.gate } }
644
+ ? {
645
+ gated: {
646
+ component: componentResult.component,
647
+ gate: componentResult.gate,
648
+ ...(componentResult.gatePushed !== undefined ? { pushed: componentResult.gatePushed } : {}),
649
+ ...(componentResult.gatePushWarning ? { pushWarning: componentResult.gatePushWarning } : {}),
650
+ },
651
+ }
627
652
  : {}),
628
653
  };
629
654
  } catch (err) {
@@ -72,6 +72,16 @@ export function renderDriverHuman(result: DriverRunResult, write: Writer = stder
72
72
  write(`interpret run completed (${result.order.length} component(s))`);
73
73
  } else if (result.status === "gated") {
74
74
  write(`interpret run is gated at component "${result.gatedComponent}"`);
75
+ // #2310: this run's own append reached only the local chant/lifecycle
76
+ // branch. The gate still stands correctly — but an operator elsewhere
77
+ // cannot see the pending fact to approve it.
78
+ if (result.gatePushed === false) {
79
+ write(
80
+ ` warning : the pending fact was not pushed to the remote — ` +
81
+ (result.gatePushWarning ?? "it exists only in this checkout") +
82
+ `. An operator elsewhere cannot approve it until it does.`,
83
+ );
84
+ }
75
85
  } else {
76
86
  write(`interpret run failed at component "${result.failedComponent}"`);
77
87
  }
@@ -14,6 +14,7 @@ import { describe, expect, it } from "vitest";
14
14
  import { CapabilityRegistry, type DeployContext } from "./capability";
15
15
  import { stubCapability } from "./verbs/stub";
16
16
  import { memoryGateLedgerPort } from "../op/gate";
17
+ import type { PendingGateInput } from "../lifecycle/gate-ledger";
17
18
  import {
18
19
  DependencyCycleError,
19
20
  DriverRunFailure,
@@ -259,6 +260,36 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
259
260
  ]);
260
261
  });
261
262
 
263
+ // #2310: the driver's own `pushLifecycle` swallow — the same shape
264
+ // `../op/gate.ts`'s `appendPending` had — a component still ends `gated`
265
+ // when its own append fails to reach the remote (the local fact is still
266
+ // correct), but the result now says so instead of staying silent.
267
+ it("still ends gated when the push is rejected, and reports it on the result", async () => {
268
+ const { registry, calls } = registryWithCalls();
269
+ const rejectingPort = {
270
+ async read() {
271
+ return { resolutions: [], pending: [] };
272
+ },
273
+ async appendPending(input: PendingGateInput) {
274
+ return {
275
+ record: { version: 1 as const, kind: "pending" as const, ...input },
276
+ pushed: false,
277
+ pushWarning: "chant/lifecycle remote branch has moved since this run started",
278
+ };
279
+ },
280
+ };
281
+ const result = await runComponentDeploy(
282
+ gatedComponent(), { env: "dev", component: "neo4j-cluster" }, registry, {}, undefined,
283
+ { port: rejectingPort, now: NOW },
284
+ );
285
+
286
+ expect(result.status).toBe("gated");
287
+ expect(result.gate).toMatchObject({ op: "neo4j-cluster", gate: "approve-node-1" });
288
+ expect(result.gatePushed).toBe(false);
289
+ expect(result.gatePushWarning).toBe("chant/lifecycle remote branch has moved since this run started");
290
+ expect(calls).toEqual(["cfn-deploy"]);
291
+ });
292
+
262
293
  // #2202: `signalName` was the key that named a component gate through 0.58.0
263
294
  // and is still read, so a component on the old key gates identically.
264
295
  it("still reads a gate step's deprecated `signalName` key", async () => {
@@ -159,6 +159,14 @@ export interface DriverComponentResult {
159
159
  records: DriverStepRecord[];
160
160
  /** Present when `status === "gated"`: the pending fact this component stopped on. */
161
161
  gate?: PendingGateRecord;
162
+ /**
163
+ * Present when `status === "gated"` and this run's own append tried to
164
+ * push: whether it reached the remote (#2310). Absent when the component
165
+ * stopped on a pending fact an earlier run had already recorded.
166
+ */
167
+ gatePushed?: boolean;
168
+ /** Set when `gatePushed` is false: why, in one line. */
169
+ gatePushWarning?: string;
162
170
  }
163
171
 
164
172
  export interface DriverRunResult {
@@ -176,6 +184,10 @@ export interface DriverRunResult {
176
184
  gatedComponent?: string;
177
185
  /** The pending fact the run stopped on, when `status === "gated"`. */
178
186
  gate?: PendingGateRecord;
187
+ /** Present when `status === "gated"`: whether the gated component's own append reached the remote (#2310). */
188
+ gatePushed?: boolean;
189
+ /** Set when `gatePushed` is false: why, in one line. */
190
+ gatePushWarning?: string;
179
191
  /**
180
192
  * The accumulated cross-component/cross-stack outputs after the run — each
181
193
  * component's `publish` output and, for an applied stack, its `cfn-deploy`
@@ -355,6 +367,9 @@ class GateStop extends Error {
355
367
  public readonly records: DriverStepRecord[],
356
368
  public readonly executed: ExecutedStep[],
357
369
  public readonly pending: PendingGateRecord,
370
+ /** Whether this run's own append reached the remote — see {@link DriverComponentResult.gatePushed} (#2310). */
371
+ public readonly pushed?: boolean,
372
+ public readonly pushWarning?: string,
358
373
  ) {
359
374
  super(`gate "${pending.gate}" is pending approval`);
360
375
  this.name = "GateStop";
@@ -462,7 +477,7 @@ async function runPhase(
462
477
  for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
463
478
  gateRecords.push(skippedRecord(skipped));
464
479
  }
465
- throw new GateStop(gateRecords, [], check.pending);
480
+ throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning);
466
481
  }
467
482
  gateRecords.push({
468
483
  ...base,
@@ -483,7 +498,14 @@ async function runPhase(
483
498
 
484
499
  const runEntry = async (
485
500
  entry: DriverStep | DriverPhase,
486
- ): Promise<{ records: DriverStepRecord[]; executed: ExecutedStep[]; failed: boolean; pending?: PendingGateRecord }> => {
501
+ ): Promise<{
502
+ records: DriverStepRecord[];
503
+ executed: ExecutedStep[];
504
+ failed: boolean;
505
+ pending?: PendingGateRecord;
506
+ pushed?: boolean;
507
+ pushWarning?: string;
508
+ }> => {
487
509
  if (isPhaseStep(entry)) {
488
510
  try {
489
511
  const nested = await runPhase(entry, ctx, registry, phaseOutputs, componentOutputs, gates, onProgress);
@@ -493,7 +515,14 @@ async function runPhase(
493
515
  // A nested fan-out phase's gate stops the whole component, but the
494
516
  // records it produced before the gate still belong in the run.
495
517
  if (err instanceof GateStop) {
496
- return { records: err.records, executed: err.executed, failed: false, pending: err.pending };
518
+ return {
519
+ records: err.records,
520
+ executed: err.executed,
521
+ failed: false,
522
+ pending: err.pending,
523
+ pushed: err.pushed,
524
+ pushWarning: err.pushWarning,
525
+ };
497
526
  }
498
527
  throw err;
499
528
  }
@@ -539,8 +568,10 @@ async function runPhase(
539
568
  const results = await Promise.all(entries.map(runEntry));
540
569
  const records = gateRecords.concat(results.flatMap((r) => r.records));
541
570
  const executed = results.flatMap((r) => r.executed);
542
- const pending = results.find((r) => r.pending)?.pending;
543
- if (pending) throw new GateStop(records, executed, pending);
571
+ const withPending = results.find((r) => r.pending);
572
+ if (withPending?.pending) {
573
+ throw new GateStop(records, executed, withPending.pending, withPending.pushed, withPending.pushWarning);
574
+ }
544
575
  if (results.some((r) => r.failed)) throw new StepFailure(records, executed);
545
576
  return { records, executed };
546
577
  }
@@ -553,7 +584,7 @@ async function runPhase(
553
584
  executed.push(...result.executed);
554
585
  if (result.pending) {
555
586
  for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
556
- throw new GateStop(records, executed, result.pending);
587
+ throw new GateStop(records, executed, result.pending, result.pushed, result.pushWarning);
557
588
  }
558
589
  if (result.failed) {
559
590
  for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
@@ -681,7 +712,15 @@ export async function runComponentDeploy(
681
712
  // them would make every gated run a no-op with a rollback attached.
682
713
  if (err instanceof GateStop) {
683
714
  records.push(...err.records);
684
- return { component: component.name, ok: false, status: "gated", records, gate: err.pending };
715
+ return {
716
+ component: component.name,
717
+ ok: false,
718
+ status: "gated",
719
+ records,
720
+ gate: err.pending,
721
+ ...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
722
+ ...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
723
+ };
685
724
  }
686
725
 
687
726
  if (err instanceof StepFailure) {
@@ -912,7 +951,14 @@ export async function runInterpretDriver(
912
951
  onProgress?.({ type: "run-done", status: progressStatus(status) });
913
952
  const result: DriverRunResult = {
914
953
  order, waves, results, ok, status, failedComponent, componentOutputs,
915
- ...(gated ? { gatedComponent: gated.component, gate: gated.gate } : {}),
954
+ ...(gated
955
+ ? {
956
+ gatedComponent: gated.component,
957
+ gate: gated.gate,
958
+ ...(gated.gatePushed !== undefined ? { gatePushed: gated.gatePushed } : {}),
959
+ ...(gated.gatePushWarning ? { gatePushWarning: gated.gatePushWarning } : {}),
960
+ }
961
+ : {}),
916
962
  };
917
963
  if (status === "fail") throw new DriverRunFailure(result);
918
964
  return result;
@@ -262,6 +262,61 @@ describe("tryFoldFile", () => {
262
262
  expect(result.reason).toBe("no foldable resource exports");
263
263
  });
264
264
 
265
+ // chant #2328 — the cross-file shape of the nullish property read. A
266
+ // sibling file's `export const region = undefined;` resolves through the
267
+ // module graph like any other imported const, so `externals.has("region")`
268
+ // is true and `get("region")` is `undefined`; the importer's `region.name`
269
+ // then folded to `undefined` and the prop vanished from the built resource,
270
+ // while running the same two files throws `TypeError: Cannot read
271
+ // properties of undefined (reading 'name')`. It must fall back instead.
272
+ test("falls back when a property is read off an imported binding whose value is undefined (#2328)", async () => {
273
+ await writeResourceDefs();
274
+ await writeFile(join(testDir, "config.ts"), `export const region = undefined;`);
275
+ const file = join(testDir, "main.ts");
276
+ await writeFile(
277
+ file,
278
+ `
279
+ import { Bucket } from "./resources";
280
+ import { region } from "./config";
281
+ export const bucket = new Bucket({ name: region.name });
282
+ `,
283
+ );
284
+
285
+ const result = await tryFoldFile(file);
286
+
287
+ expect(result.ok).toBe(false);
288
+ if (result.ok) return;
289
+ expect(result.reason).toContain('property "name" read on undefined is not foldable');
290
+ });
291
+
292
+ // The same file with the read written optionally is genuinely `undefined`
293
+ // in JavaScript, so it keeps folding — the refusal above is about the
294
+ // non-optional read, not about nullish imports.
295
+ test("an optional read off the same undefined import still folds (#2328)", async () => {
296
+ await writeResourceDefs();
297
+ await writeFile(join(testDir, "config.ts"), `export const region = undefined;`);
298
+ const file = join(testDir, "main.ts");
299
+ await writeFile(
300
+ file,
301
+ `
302
+ import { Bucket } from "./resources";
303
+ import { region } from "./config";
304
+ throw new Error("must never execute — sentinel for #2328");
305
+ export const bucket = new Bucket({ name: "b", region: region?.name });
306
+ `,
307
+ );
308
+
309
+ const result = await tryFoldFile(file);
310
+
311
+ expect(result.ok).toBe(true);
312
+ if (!result.ok) return;
313
+ const [, entity] = result.entities[0];
314
+ expect((entity as unknown as { props: Record<string, unknown> }).props).toEqual({
315
+ name: "b",
316
+ region: undefined,
317
+ });
318
+ });
319
+
265
320
  // chant #1020: a plain-value-only export now folds too (contributing
266
321
  // nothing to `entities` — only Declarable/CompositeInstance land there —
267
322
  // but recorded in `exportedValues`). This is intentional, not a relaxed
@@ -253,6 +253,158 @@ describe("fold — element access", () => {
253
253
  });
254
254
  });
255
255
 
256
+ // chant #2328 — a property or element read whose object folded to `null` or
257
+ // `undefined` returned `undefined`, so a mistyped nested path folded away and
258
+ // the build carried on emitting a resource with the property missing, while
259
+ // RUNNING the same file throws a TypeError at that expression. Both branches
260
+ // now refuse, and the file falls back to run — with `?.`, which JavaScript
261
+ // DEFINES as `undefined` on a nullish object, still folding.
262
+ describe("fold — property/element access on a nullish object (#2328)", () => {
263
+ test("a mistyped nested path throws a located FoldError rather than folding the property away", () => {
264
+ const src = `
265
+ const cfg = { net: { vpcId: "vpc-1" } };
266
+ const vpcId = cfg.nett.vpcId;
267
+ `;
268
+ let error: unknown;
269
+ try {
270
+ foldConst(src, "vpcId");
271
+ } catch (e) {
272
+ error = e;
273
+ }
274
+ expect(error).toBeInstanceOf(FoldError);
275
+ expect((error as FoldError).message).toContain('property "vpcId" read on undefined is not foldable');
276
+ expect((error as FoldError).message).toContain("throws a TypeError");
277
+ // Located at the failing access itself — line 3 of the snippet above.
278
+ expect((error as FoldError).line).toBe(3);
279
+ });
280
+
281
+ test("the message points at `?.` as the way to say the value is genuinely optional", () => {
282
+ const src = `
283
+ const cfg = {};
284
+ const x = cfg.net.vpcId;
285
+ `;
286
+ expect(() => foldConst(src, "x")).toThrow(/write `\?\.` if the value is genuinely optional/);
287
+ });
288
+
289
+ test("a read on null refuses the same way a read on undefined does", () => {
290
+ const src = `
291
+ const cfg = null;
292
+ const x = cfg.vpcId;
293
+ `;
294
+ expect(() => foldConst(src, "x")).toThrow(/property "vpcId" read on null is not foldable/);
295
+ });
296
+
297
+ test("element access on a nullish object refuses too, naming the bracketed key", () => {
298
+ const src = `
299
+ const cfg = { net: { vpcId: "vpc-1" } };
300
+ const x = cfg["nett"]["vpcId"];
301
+ `;
302
+ expect(() => foldConst(src, "x")).toThrow(FoldError);
303
+ expect(() => foldConst(src, "x")).toThrow(/property "vpcId" read on undefined is not foldable/);
304
+ });
305
+
306
+ // The cross-file shape #2328 names: a sibling file's `export const a =
307
+ // undefined;` puts `a -> undefined` in the importer's externals, so
308
+ // `externals.has("a")` is true and `get("a")` is `undefined`. Held here at
309
+ // the `fold()` seam; ../discovery/fold-import.test.ts runs the real
310
+ // two-file version through `tryFoldFile`.
311
+ test("an imported binding whose value is undefined refuses on a property read, not folds", () => {
312
+ const src = `const x = a.vpcId;`;
313
+ const consts = parseConsts(src);
314
+ const externals = new Map<string, unknown>([["a", undefined]]);
315
+ expect(() => fold(consts.get("x") as ts.Expression, consts, [], externals)).toThrow(
316
+ /property "vpcId" read on undefined is not foldable/,
317
+ );
318
+ });
319
+
320
+ test("a method call on a nullish receiver keeps refusing, as it has since #1966", () => {
321
+ const src = `
322
+ const cfg = undefined;
323
+ const x = cfg.toString();
324
+ `;
325
+ expect(() => foldConst(src, "x")).toThrow(/cannot call "\.toString\(\.\.\.\)" on undefined/);
326
+ });
327
+ });
328
+
329
+ // chant #2328 — `a?.b` on a nullish `a` is DEFINED to be `undefined` in
330
+ // JavaScript, and so is every link that follows it in the same chain. Fold
331
+ // has to agree with that as exactly as it now disagrees with a plain `.`.
332
+ describe("fold — optional chaining short-circuits rather than refusing (#2328)", () => {
333
+ test("`a?.b` on a nullish object folds to undefined", () => {
334
+ const src = `
335
+ const a = undefined;
336
+ const x = a?.b;
337
+ `;
338
+ expect(foldConst(src, "x")).toBeUndefined();
339
+ });
340
+
341
+ test("a `?.` earlier in the chain carries the whole chain to undefined", () => {
342
+ const src = `
343
+ const a = undefined;
344
+ const x = a?.b.c.d;
345
+ `;
346
+ expect(foldConst(src, "x")).toBeUndefined();
347
+ });
348
+
349
+ test("the bracketed spelling short-circuits identically", () => {
350
+ const src = `
351
+ const a = null;
352
+ const x = a?.["b"]["c"];
353
+ `;
354
+ expect(foldConst(src, "x")).toBeUndefined();
355
+ });
356
+
357
+ test("a non-null assertion inside the chain is transparent to the short-circuit", () => {
358
+ const src = `
359
+ const a = undefined;
360
+ const x = a?.b!.c;
361
+ `;
362
+ expect(foldConst(src, "x")).toBeUndefined();
363
+ });
364
+
365
+ test("an optional call link short-circuits with the rest of the chain", () => {
366
+ const src = `
367
+ const a = undefined;
368
+ const x = a?.b();
369
+ const y = a?.b.c();
370
+ `;
371
+ expect(foldConst(src, "x")).toBeUndefined();
372
+ expect(foldConst(src, "y")).toBeUndefined();
373
+ });
374
+
375
+ test("`?.` on a present object still indexes it — the short-circuit is not a blanket undefined", () => {
376
+ const src = `
377
+ const a = { b: { c: "deep" } };
378
+ const x = a?.b.c;
379
+ `;
380
+ expect(foldConst(src, "x")).toBe("deep");
381
+ });
382
+
383
+ test("parentheses end the chain, so the access after them refuses exactly as running it throws", () => {
384
+ const src = `
385
+ const a = undefined;
386
+ const x = (a?.b).c;
387
+ `;
388
+ expect(() => foldConst(src, "x")).toThrow(/property "c" read on undefined is not foldable/);
389
+ });
390
+
391
+ test("a genuine undefined mid-chain is not a short-circuit — the next plain link refuses", () => {
392
+ const src = `
393
+ const a = { b: undefined };
394
+ const x = a?.b.c;
395
+ `;
396
+ expect(() => foldConst(src, "x")).toThrow(/property "c" read on undefined is not foldable/);
397
+ });
398
+
399
+ test("...and the same read written optionally folds to undefined", () => {
400
+ const src = `
401
+ const a = { b: undefined };
402
+ const x = a?.b?.c;
403
+ `;
404
+ expect(foldConst(src, "x")).toBeUndefined();
405
+ });
406
+ });
407
+
256
408
  describe("fold — intrinsic tagged templates", () => {
257
409
  const SUB: IntrinsicDef = { name: "Sub", isTag: true, outputKey: "Fn::Sub" };
258
410
 
package/src/fold/fold.ts CHANGED
@@ -784,6 +784,87 @@ function attrRefOnFoldedResource(
784
784
  );
785
785
  }
786
786
 
787
+ /**
788
+ * chant #2328 — the marker an optional-chain link leaves behind when it
789
+ * short-circuits, carried up the rest of the chain and unwrapped to
790
+ * `undefined` at its end.
791
+ *
792
+ * `a?.b` on a nullish `a` is DEFINED to be `undefined` in JavaScript, and so
793
+ * is every link after it: `a?.b.c` is `undefined` too, not a `TypeError` on
794
+ * `undefined.c`. A non-optional `a.b` on a nullish `a` throws. Both shapes
795
+ * reach the property-access branch below with the same nullish object, so
796
+ * telling them apart needs the answer to "did an EARLIER link short-circuit?"
797
+ * — which a plain `undefined` return cannot carry, because a genuine
798
+ * `undefined` looks identical: `({}).b.c` is `undefined` at the first link
799
+ * and running it DOES throw at the second.
800
+ *
801
+ * This marker carries it. {@link shortCircuited} produces it only where
802
+ * {@link continuesOptionalChain} says the parent node is the next link of the
803
+ * same chain, so the parent always consumes it, and turns it back into
804
+ * `undefined` at the last link — which is where JavaScript's own
805
+ * short-circuit lands. It never escapes {@link fold}'s return to a caller.
806
+ */
807
+ const CHAIN_SHORT_CIRCUIT = Symbol("chant.fold.optional-chain-short-circuit");
808
+
809
+ /** True when `value` is the {@link CHAIN_SHORT_CIRCUIT} marker. */
810
+ function isChainShortCircuit(value: FoldedValue): boolean {
811
+ return (value as unknown) === CHAIN_SHORT_CIRCUIT;
812
+ }
813
+
814
+ /**
815
+ * The value a chain that short-circuited at or before `node` has AT `node`:
816
+ * the {@link CHAIN_SHORT_CIRCUIT} marker while another link follows,
817
+ * `undefined` once `node` is the last one.
818
+ */
819
+ function shortCircuited(node: ts.Expression): FoldedValue {
820
+ return continuesOptionalChain(node) ? (CHAIN_SHORT_CIRCUIT as unknown as FoldedValue) : undefined;
821
+ }
822
+
823
+ /**
824
+ * True when `node`'s parent is the next link of the SAME optional chain, so a
825
+ * short-circuit at `node` must keep travelling rather than becoming
826
+ * `undefined` here.
827
+ *
828
+ * TypeScript flags every access/call node after a `?.` as part of that chain
829
+ * and stops flagging at the first construct that ends it, so this needs no
830
+ * bookkeeping of its own: `(a?.b).c` — where the parentheses end the chain
831
+ * and running really does throw on `.c` — is not a continuation, and neither
832
+ * is `(a?.b as X).c`. A `NonNullChain` (`a?.b!.c`) is transparent, exactly as
833
+ * {@link fold}'s own non-null unwrapping is.
834
+ */
835
+ function continuesOptionalChain(node: ts.Node): boolean {
836
+ const parent: ts.Node | undefined = node.parent;
837
+ if (parent === undefined) return false;
838
+ if (ts.isNonNullExpression(parent) && ts.isOptionalChain(parent)) return continuesOptionalChain(parent);
839
+ return (
840
+ (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent) || ts.isCallExpression(parent)) &&
841
+ parent.expression === node &&
842
+ ts.isOptionalChain(parent)
843
+ );
844
+ }
845
+
846
+ /**
847
+ * chant #2328 — a property or element read whose object folded to `null` or
848
+ * `undefined`. Folding it to `undefined` (what both branches did from #1026
849
+ * until now) drops the property from the output and lets the build carry on,
850
+ * while RUNNING the same expression throws `TypeError: Cannot read properties
851
+ * of undefined` — the fold/run disagreement #1535 already ruled unacceptable
852
+ * one branch over, where an attribute read on a resource envelope folded away
853
+ * and a trust policy shipped as `Principal: {}`.
854
+ *
855
+ * So it refuses, and the file falls back to run — where the real `TypeError`
856
+ * happens at the line that caused it, naming the property the way it would
857
+ * without `--fold` at all. The usual cause is a typo in a nested path
858
+ * (`cfg.nett.vpcId`); a genuinely optional read says so with `?.`, which
859
+ * short-circuits above instead of reaching this message.
860
+ */
861
+ function nullishAccessMessage(member: string, obj: null | undefined): string {
862
+ return (
863
+ `property "${member}" read on ${String(obj)} is not foldable — running this expression throws a TypeError, ` +
864
+ `so the file falls back to run (write \`?.\` if the value is genuinely optional)`
865
+ );
866
+ }
867
+
787
868
  /**
788
869
  * True when `node` is an identifier, or a dotted/bracketed access chain
789
870
  * rooted at an identifier, that neither `consts` nor `externals` can resolve
@@ -1074,7 +1155,14 @@ export function fold(
1074
1155
  };
1075
1156
  }
1076
1157
  const obj = fold(node.expression, consts, intrinsics, externals);
1077
- if (obj === null || obj === undefined) return undefined;
1158
+ // chant #2328 — see {@link nullishAccessMessage}. An earlier `?.` that
1159
+ // short-circuited carries the whole chain to `undefined`; a `?.` here does
1160
+ // the same for a nullish object; a plain `.` on one is the refusal.
1161
+ if (isChainShortCircuit(obj)) return shortCircuited(node);
1162
+ if (obj === null || obj === undefined) {
1163
+ if (node.questionDotToken) return shortCircuited(node);
1164
+ throw foldError(node, nullishAccessMessage(node.name.text, obj));
1165
+ }
1078
1166
  if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, node.name.text);
1079
1167
  return (obj as { [key: string]: FoldedValue })[node.name.text];
1080
1168
  }
@@ -1085,7 +1173,13 @@ export function fold(
1085
1173
  return { __attrRef: { entity: node.expression.text, attribute: key } };
1086
1174
  }
1087
1175
  const obj = fold(node.expression, consts, intrinsics, externals);
1088
- if (obj === null || obj === undefined) return undefined;
1176
+ // chant #2328 — identical to the property-access branch above; `a?.["k"]`
1177
+ // is the bracketed spelling of the same short-circuit.
1178
+ if (isChainShortCircuit(obj)) return shortCircuited(node);
1179
+ if (obj === null || obj === undefined) {
1180
+ if (node.questionDotToken) return shortCircuited(node);
1181
+ throw foldError(node, nullishAccessMessage(key, obj));
1182
+ }
1089
1183
  if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, key);
1090
1184
  return (obj as { [key: string]: FoldedValue })[key];
1091
1185
  }
@@ -1310,7 +1404,13 @@ export function fold(
1310
1404
  if (ts.isPropertyAccessExpression(node.expression)) {
1311
1405
  const methodName = node.expression.name.text;
1312
1406
  const receiver = fold(node.expression.expression, consts, intrinsics, externals);
1407
+ // chant #2328 — a call is a link of an optional chain like any other:
1408
+ // `a?.b()` and `a?.b.c()` on a nullish `a` are `undefined` in
1409
+ // JavaScript, not a call on nothing. Only a non-optional receiver keeps
1410
+ // the refusal this branch has made since #1966.
1411
+ if (isChainShortCircuit(receiver)) return shortCircuited(node);
1313
1412
  if (receiver === null || receiver === undefined) {
1413
+ if (node.expression.questionDotToken) return shortCircuited(node);
1314
1414
  throw foldError(node, `cannot call ".${methodName}(...)" on ${String(receiver)}`);
1315
1415
  }
1316
1416
  if (isFoldSymbolicEnvelope(receiver)) {