@intentius/chant 0.61.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 (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  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/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.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/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -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)) {
@@ -3,7 +3,7 @@ import * as ts from "typescript";
3
3
  import { readFileSync } from "fs";
4
4
  import { fileURLToPath } from "url";
5
5
  import { join } from "path";
6
- import { collectConsts } from "./fold";
6
+ import { collectConsts, fold, FoldError } from "./fold";
7
7
  import { findSubsetViolation } from "./subset";
8
8
 
9
9
  /**
@@ -50,6 +50,20 @@ import { findSubsetViolation } from "./subset";
50
50
  * module boundary, or not at all (subset.ts's module doc, point 1) — a
51
51
  * documented, intentional asymmetry, not something this guard can
52
52
  * usefully narrow further without a binding resolver of its own.
53
+ *
54
+ * One doc claim below is checked against `fold()` instead of
55
+ * `findSubsetViolation` — chant #2306. "Unregistered tagged template
56
+ * intrinsics" is a claim `findSubsetViolation` structurally cannot decide:
57
+ * subset.ts treats every tagged template's interior as shape-valid
58
+ * regardless of tag registration (its own module doc explains why — EVL has
59
+ * no intrinsic registry at lint time), so registered vs. unregistered is
60
+ * indistinguishable at that layer no matter which heading the claim sits
61
+ * under. The registry check lives in `fold()` (`foldTaggedTemplate`), so
62
+ * that is what this one test calls instead. This is also why the doc bullet
63
+ * survived as long as it did in unfenced prose (chant #2306's own issue
64
+ * comment): moving it under a `###` heading with a fenced block, the shape
65
+ * every other case here uses, was necessary but not sufficient — the
66
+ * fixture still has to be run through the right function.
53
67
  */
54
68
 
55
69
  const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
@@ -208,3 +222,23 @@ describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx c
208
222
  expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
209
223
  });
210
224
  });
225
+
226
+ describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (#2306)", () => {
227
+ test("Unregistered tagged template intrinsics", () => {
228
+ // findSubsetViolation cannot decide this one — see this file's module
229
+ // doc. Fold the doc's own example with an empty intrinsics list (no tag
230
+ // registered at all, the same as no lexicon opting `unknownTag` in) and
231
+ // check the real rejection, `foldTaggedTemplate` in ./fold.ts, fires.
232
+ const consts = parseConsts(extractFencedBlock("Unregistered tagged template intrinsics"));
233
+ const arg = resourceArg(consts, "store");
234
+ let error: unknown;
235
+ try {
236
+ fold(arg, consts, []);
237
+ } catch (e) {
238
+ error = e;
239
+ }
240
+ expect(error).toBeInstanceOf(FoldError);
241
+ expect((error as FoldError).message).toContain("unregistered tagged template intrinsic");
242
+ expect((error as FoldError).message).toContain("unknownTag");
243
+ });
244
+ });
@@ -44,6 +44,16 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
44
44
  * re-folding the initializer would construct a duplicate of a resource
45
45
  * discovery has already registered. Shape cannot see the difference, and
46
46
  * the rejection is a fall-back-to-run, never a wrong value.
47
+ *
48
+ * chant #2328 adds another, in the same direction and for the same
49
+ * reason: a property or element read whose OBJECT resolves to `null` or
50
+ * `undefined` (`cfg.nett.vpcId`, a typo in a nested path). Running it
51
+ * throws a `TypeError`, so `fold()` refuses rather than answering
52
+ * `undefined` and letting the build carry on with the property dropped.
53
+ * What the object resolves to is exactly the resolution this module
54
+ * does not do, so `cfg.nett.vpcId` stays shape-valid here — and a
55
+ * genuinely optional `cfg.net?.vpcId`, which folds to `undefined`
56
+ * because JavaScript defines it that way, is shape-valid in both.
47
57
  * 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
48
58
  * manifest, which isn't available to a syntax-only lint rule. `fold()`
49
59
  * alone checks it; this module treats any tag name as shape-valid and
package/src/lexicon.ts CHANGED
@@ -465,6 +465,25 @@ export interface ComponentPipelineOptions {
465
465
  image?: string;
466
466
  /** Top-level CI `variables:` block. */
467
467
  variables?: Record<string, string>;
468
+ /**
469
+ * The stage every generated Op job runs in, and (on GitLab) the default
470
+ * base of the generated file's own name (#2293). GitLab-only: a GitHub or
471
+ * Forgejo Op workflow is one file per Op with no shared stage, so this does
472
+ * nothing there. Default `"ops"` — every trigger kind (cron, merge-request,
473
+ * push) a project mixes into one Op pipeline lands under the same stage,
474
+ * unlike the pre-#2293 constant `"scheduled-ops"`, which named only the
475
+ * cron-only pipeline the generator used to emit. See `opsFileName` for the
476
+ * file name, which follows this unless overridden separately.
477
+ */
478
+ opsStage?: string;
479
+ /**
480
+ * The generated Op pipeline file's name — GitLab only (#2293). Defaults to
481
+ * `` `${opsStage}.gitlab-ci.yml` `` (`ops.gitlab-ci.yml` with no `opsStage`
482
+ * override), so setting `opsStage` alone renames both the stage GitLab's UI
483
+ * groups jobs under and the file a consumer's `include:` line names. Set
484
+ * this too when the file should land under some other name than its stage.
485
+ */
486
+ opsFileName?: string;
468
487
  }
469
488
 
470
489
  /** The synthesized CI pipeline for a component graph (generate mode). */
@@ -638,6 +657,38 @@ export interface ScheduledOpSpec {
638
657
  * own gate.
639
658
  */
640
659
  environment?: OpEnvironment;
660
+ /**
661
+ * Variables and secrets for this Op's generated job alone (#2290). Same
662
+ * shape as {@link ComponentPipelineOptions.variables} — chant draws no
663
+ * type-level line between a plain value and a credential, both are strings
664
+ * a generator drops into the job's environment — but scoped to one Op's job
665
+ * rather than the whole generated file, for the reason `setup` and
666
+ * `permissions` are per-Op already: what a job may hold is a property of
667
+ * that job. A `live-check` plan job that makes no cloud call declares none
668
+ * of this and inherits none of it.
669
+ *
670
+ * **The rule where both are set** (#2290): `ComponentPipelineOptions.variables`
671
+ * (forge-wide) keeps landing on the workflow/file-level `env:`
672
+ * (github/forgejo) or top-level `variables:` (gitlab) exactly as it always
673
+ * has — every caller that declares nothing here sees byte-identical output.
674
+ * This field lands one level down, on the job itself — github/forgejo emit
675
+ * it as the job's own `env:` mapping, gitlab merges it into the job's own
676
+ * `variables:` — and a key present in both wins at the job, the same
677
+ * last-one-wins precedence GitHub Actions and GitLab CI already give
678
+ * step/job env over workflow env. Declaring a credential here rather than
679
+ * in the forge-wide options is therefore how a caller keeps it off every
680
+ * *other* Op's job: nothing about the forge-wide options changes shape,
681
+ * only which of a project's own Ops asks for the credential at all.
682
+ *
683
+ * Supported everywhere a per-Op `env:`/`variables:` block is expressible:
684
+ * github and forgejo both emit job-level `env:`; gitlab merges these into
685
+ * the job's own `variables:` block (already used there for the gated
686
+ * apply's `CHANT_GATE_SUMMARY`, so the merge is native rather than bolted
687
+ * on). No generator refuses this option — unlike `setup`'s `uses:` shape or
688
+ * an additive `permissions` scope, every forge chant targets has some
689
+ * per-job environment mapping to put a value in.
690
+ */
691
+ variables?: Record<string, string>;
641
692
  }
642
693
 
643
694
  /**