@intentius/chant 0.62.0 → 0.64.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 (115) 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/discovery/fold-import.d.ts +12 -0
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/fold/fold.d.ts +10 -0
  15. package/dist/fold/fold.d.ts.map +1 -1
  16. package/dist/fold/subset.d.ts +36 -2
  17. package/dist/fold/subset.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/index.d.ts +1 -0
  23. package/dist/lifecycle/index.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-digest.d.ts +33 -0
  25. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  26. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  27. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  28. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  29. package/dist/op/activities/lifecycle.d.ts +27 -0
  30. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  31. package/dist/op/activities/reconcile.d.ts +196 -27
  32. package/dist/op/activities/reconcile.d.ts.map +1 -1
  33. package/dist/op/builders.d.ts +6 -0
  34. package/dist/op/builders.d.ts.map +1 -1
  35. package/dist/op/composites/apply-op.d.ts +6 -0
  36. package/dist/op/composites/apply-op.d.ts.map +1 -1
  37. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  38. package/dist/op/gate-summary.d.ts +16 -0
  39. package/dist/op/gate-summary.d.ts.map +1 -1
  40. package/dist/op/gate.d.ts +104 -13
  41. package/dist/op/gate.d.ts.map +1 -1
  42. package/dist/op/index.d.ts +3 -2
  43. package/dist/op/index.d.ts.map +1 -1
  44. package/dist/op/local-executor.d.ts +17 -0
  45. package/dist/op/local-executor.d.ts.map +1 -1
  46. package/dist/op/local-output.d.ts.map +1 -1
  47. package/dist/op/op-ir.d.ts +8 -1
  48. package/dist/op/op-ir.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/types.d.ts +19 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  54. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  55. package/dist/terraform/graph.d.ts +18 -2
  56. package/dist/terraform/graph.d.ts.map +1 -1
  57. package/dist/terraform/parse.d.ts.map +1 -1
  58. package/dist/terraform/types.d.ts +7 -0
  59. package/dist/terraform/types.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/cli/handlers/operator.test.ts +130 -0
  62. package/src/cli/handlers/operator.ts +56 -2
  63. package/src/cli/handlers/run.ts +19 -0
  64. package/src/cli/main.ts +2 -0
  65. package/src/cli/registry.ts +2 -0
  66. package/src/components/cli-support.ts +29 -4
  67. package/src/components/driver-output.ts +10 -0
  68. package/src/components/driver.test.ts +31 -0
  69. package/src/components/driver.ts +54 -8
  70. package/src/discovery/fold-import.test.ts +55 -0
  71. package/src/discovery/fold-import.ts +12 -0
  72. package/src/fold/fold.test.ts +152 -0
  73. package/src/fold/fold.ts +112 -2
  74. package/src/fold/subset-doc-parity.test.ts +35 -1
  75. package/src/fold/subset-public-export.test.ts +27 -0
  76. package/src/fold/subset.ts +36 -2
  77. package/src/index.ts +5 -0
  78. package/src/lifecycle/gate-ledger.test.ts +133 -1
  79. package/src/lifecycle/gate-ledger.ts +108 -0
  80. package/src/lifecycle/index.ts +1 -0
  81. package/src/lifecycle/plan-digest.test.ts +49 -0
  82. package/src/lifecycle/plan-digest.ts +86 -0
  83. package/src/lifecycle/run-ledger.ts +1 -0
  84. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  85. package/src/op/activities/lexicon-upgrade.ts +19 -3
  86. package/src/op/activities/lifecycle.ts +51 -2
  87. package/src/op/activities/reconcile.test.ts +512 -26
  88. package/src/op/activities/reconcile.ts +307 -34
  89. package/src/op/builders.ts +7 -1
  90. package/src/op/composites/apply-op.ts +16 -0
  91. package/src/op/composites/composites.test.ts +15 -2
  92. package/src/op/composites/reconcile-op.test.ts +18 -0
  93. package/src/op/composites/reconcile-op.ts +7 -1
  94. package/src/op/gate-summary.test.ts +33 -0
  95. package/src/op/gate-summary.ts +31 -0
  96. package/src/op/gate.test.ts +111 -1
  97. package/src/op/gate.ts +181 -23
  98. package/src/op/index.ts +5 -2
  99. package/src/op/local-executor.test.ts +226 -3
  100. package/src/op/local-executor.ts +61 -12
  101. package/src/op/local-output.test.ts +38 -0
  102. package/src/op/local-output.ts +24 -1
  103. package/src/op/op-ir.test.ts +22 -0
  104. package/src/op/op-ir.ts +9 -0
  105. package/src/op/runtime.ts +2 -0
  106. package/src/op/types.ts +19 -0
  107. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  108. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  109. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  110. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  111. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  112. package/src/terraform/graph.test.ts +148 -1
  113. package/src/terraform/graph.ts +144 -6
  114. package/src/terraform/parse.ts +4 -1
  115. 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
@@ -48,6 +48,18 @@ import { intrinsicCallFoldsEagerly, type IntrinsicDef } from "../lexicon";
48
48
  import type { BuildParamValue } from "../build-params";
49
49
 
50
50
  /**
51
+ * Implements judgments J2 (the per-file verdict: F-Scan, F-NoExports, F-Bind,
52
+ * F-Import, F-Namespace, F-Declarator, F-Call, F-Total, F-Reason,
53
+ * F-IsolatedRefusal), J3 (the identity-taint fixpoint: F-Capture, F-CallLeak,
54
+ * F-Memo, F-Count, F-Seed, F-Succ, F-Taint, F-Fix, F-Cycle — see
55
+ * {@link planFoldTaint}), J4's observables (F-Obs-Counters via
56
+ * {@link foldExecutionCounts}, F-Obs-Report via the `[fold:*]` decision
57
+ * lines), and the trust rule F-Host-Trust of `spec/hosts.md` (see
58
+ * {@link isTrustedExecutableBinding}) of the TypeScript-as-Data specification
59
+ * at https://github.com/INTENTIUS/typescript-as-data, normative for the subset
60
+ * since INTENTIUS/typescript-as-data#33. Subset changes go spec-first; see
61
+ * ../fold/subset.ts's module doc for the process.
62
+ *
51
63
  * Bridges the static folder ({@link ../fold/fold}, #1026) into discovery
52
64
  * (#1022/#1023, epic #1019): attempts to fold one source file into real
53
65
  * `Declarable`/`CompositeInstance` instances with zero execution of the
@@ -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