@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.
- package/dist/cli/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- 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 () => {
|
package/src/components/driver.ts
CHANGED
|
@@ -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<{
|
|
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 {
|
|
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
|
|
543
|
-
if (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 {
|
|
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
|
|
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
|
package/src/fold/fold.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -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
|
/**
|