@checkstack/automation-common 0.2.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.
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * Tests for the variable-scope resolver.
3
+ *
4
+ * Each scenario builds a minimal `AutomationDefinition` + registry-info
5
+ * fixture, picks a target action path, runs the resolver, and asserts on
6
+ * the entries returned (or the flattened paths for ordering tests).
7
+ */
8
+ import { describe, expect, it } from "bun:test";
9
+ import type {
10
+ ActionInfo,
11
+ ActionInput,
12
+ ArtifactTypeInfo,
13
+ AutomationDefinition,
14
+ TriggerInfo,
15
+ } from "./schemas";
16
+
17
+ function provider(action: string, id?: string): ActionInput {
18
+ return {
19
+ action,
20
+ config: {},
21
+ enabled: true,
22
+ continue_on_error: false,
23
+ ...(id ? { id } : {}),
24
+ };
25
+ }
26
+
27
+ function variables(record: Record<string, unknown>): ActionInput {
28
+ return { variables: record, enabled: true, continue_on_error: false };
29
+ }
30
+ import {
31
+ appendArrayIndex,
32
+ appendTemplateSegment,
33
+ flattenScope,
34
+ isTemplateIdentifier,
35
+ resolveVariableScope,
36
+ type ActionPath,
37
+ } from "./variable-scope";
38
+
39
+ const triggerInfo: TriggerInfo = {
40
+ qualifiedId: "incident.created",
41
+ displayName: "Incident Created",
42
+ category: "Incidents",
43
+ ownerPluginId: "incident",
44
+ payloadSchema: {
45
+ type: "object",
46
+ properties: {
47
+ incidentId: { type: "string", description: "Incident ID" },
48
+ title: { type: "string" },
49
+ severity: { type: "string", enum: ["low", "high"] },
50
+ },
51
+ required: ["incidentId", "title"],
52
+ },
53
+ };
54
+
55
+ const otherTrigger: TriggerInfo = {
56
+ qualifiedId: "incident.resolved",
57
+ displayName: "Incident Resolved",
58
+ category: "Incidents",
59
+ ownerPluginId: "incident",
60
+ payloadSchema: {
61
+ type: "object",
62
+ properties: {
63
+ incidentId: { type: "string" },
64
+ resolvedAt: { type: "string" },
65
+ },
66
+ required: ["incidentId"],
67
+ },
68
+ };
69
+
70
+ const jiraIssueArtifact: ArtifactTypeInfo = {
71
+ qualifiedId: "jira.issue",
72
+ displayName: "Jira Issue",
73
+ ownerPluginId: "integration-jira",
74
+ schema: {
75
+ type: "object",
76
+ properties: {
77
+ key: { type: "string" },
78
+ url: { type: "string" },
79
+ },
80
+ required: ["key"],
81
+ },
82
+ };
83
+
84
+ const createJiraAction: ActionInfo = {
85
+ qualifiedId: "integration-jira.create_issue",
86
+ displayName: "Create Jira Issue",
87
+ category: "Jira",
88
+ ownerPluginId: "integration-jira",
89
+ configSchema: { type: "object" },
90
+ produces: "jira.issue",
91
+ consumes: [],
92
+ };
93
+
94
+ const notifyAction: ActionInfo = {
95
+ qualifiedId: "automation.notify_user",
96
+ displayName: "Notify User",
97
+ category: "Notification",
98
+ ownerPluginId: "automation",
99
+ configSchema: { type: "object" },
100
+ consumes: [],
101
+ };
102
+
103
+ // Artifact whose qualified id contains both a hyphen and a dot — the kind of
104
+ // id that breaks dot-notation template paths and forces bracket notation.
105
+ const dottedArtifact: ArtifactTypeInfo = {
106
+ qualifiedId: "integration-jira.issue",
107
+ displayName: "Jira Issue (dotted id)",
108
+ ownerPluginId: "integration-jira",
109
+ schema: {
110
+ type: "object",
111
+ properties: {
112
+ issueKey: { type: "string" },
113
+ },
114
+ required: ["issueKey"],
115
+ },
116
+ };
117
+
118
+ const dottedArtifactNoSchema: ArtifactTypeInfo = {
119
+ qualifiedId: "integration-jira.issue",
120
+ displayName: "Jira Issue (no schema)",
121
+ ownerPluginId: "integration-jira",
122
+ schema: { type: "object" },
123
+ };
124
+
125
+ const createDottedArtifactAction: ActionInfo = {
126
+ qualifiedId: "integration-jira.create_issue_dotted",
127
+ displayName: "Create Jira Issue (dotted artifact)",
128
+ category: "Jira",
129
+ ownerPluginId: "integration-jira",
130
+ configSchema: { type: "object" },
131
+ produces: "integration-jira.issue",
132
+ consumes: [],
133
+ };
134
+
135
+ // Artifact whose schema has array properties: an array of objects (comments)
136
+ // and an array of scalars (tags). Exercises the array-element descent.
137
+ const arrayArtifact: ArtifactTypeInfo = {
138
+ qualifiedId: "integration-jira.issue",
139
+ displayName: "Jira Issue (with arrays)",
140
+ ownerPluginId: "integration-jira",
141
+ schema: {
142
+ type: "object",
143
+ properties: {
144
+ comments: {
145
+ type: "array",
146
+ items: {
147
+ type: "object",
148
+ properties: {
149
+ author: { type: "string" },
150
+ },
151
+ },
152
+ },
153
+ tags: { type: "array", items: { type: "string" } },
154
+ },
155
+ },
156
+ };
157
+
158
+ function basicDefinition(
159
+ overrides: Partial<AutomationDefinition> = {},
160
+ ): AutomationDefinition {
161
+ return {
162
+ name: "Test automation",
163
+ triggers: [{ event: "incident.created" }],
164
+ conditions: [],
165
+ actions: [],
166
+ mode: "single",
167
+ max_runs: 1,
168
+ ...overrides,
169
+ };
170
+ }
171
+
172
+ describe("resolveVariableScope", () => {
173
+ it("returns no entries when path is empty", () => {
174
+ const scope = resolveVariableScope({
175
+ definition: basicDefinition(),
176
+ triggers: [triggerInfo],
177
+ actions: [],
178
+ artifactTypes: [],
179
+ path: [],
180
+ });
181
+ expect(scope.entries).toEqual([]);
182
+ });
183
+
184
+ it("exposes trigger.event + trigger.payload.* for a single subscribed trigger", () => {
185
+ const scope = resolveVariableScope({
186
+ definition: basicDefinition(),
187
+ triggers: [triggerInfo],
188
+ actions: [],
189
+ artifactTypes: [],
190
+ path: [{ slot: "root", index: 0 }],
191
+ });
192
+ const flat = flattenScope(scope).map((e) => e.path);
193
+ expect(flat).toContain("trigger");
194
+ expect(flat).toContain("trigger.event");
195
+ expect(flat).toContain("trigger.payload");
196
+ expect(flat).toContain("trigger.payload.incidentId");
197
+ expect(flat).toContain("trigger.payload.title");
198
+ expect(flat).toContain("trigger.payload.severity");
199
+ });
200
+
201
+ it("exposes trigger.actor.* on every automation (independent of triggers)", () => {
202
+ // With a matched trigger.
203
+ const matched = flattenScope(
204
+ resolveVariableScope({
205
+ definition: basicDefinition(),
206
+ triggers: [triggerInfo],
207
+ actions: [],
208
+ artifactTypes: [],
209
+ path: [{ slot: "root", index: 0 }],
210
+ }),
211
+ );
212
+ const byPath = new Map(matched.map((e) => [e.path, e]));
213
+ expect(byPath.has("trigger.actor")).toBe(true);
214
+ expect(byPath.has("trigger.actor.type")).toBe(true);
215
+ expect(byPath.has("trigger.actor.id")).toBe(true);
216
+ expect(byPath.has("trigger.actor.name")).toBe(true);
217
+ // actor is universal — never gated on a subset of triggers.
218
+ expect(byPath.get("trigger.actor.type")?.conditionalOnTriggers).toBeUndefined();
219
+ expect(byPath.get("trigger.actor.type")?.type).toBe(
220
+ '"system" | "user" | "application" | "service"',
221
+ );
222
+
223
+ // Still present when no trigger matches the registry (no payload schema).
224
+ const unmatched = flattenScope(
225
+ resolveVariableScope({
226
+ definition: basicDefinition({
227
+ triggers: [{ event: "does.not.exist" }],
228
+ actions: [],
229
+ }),
230
+ triggers: [],
231
+ actions: [],
232
+ artifactTypes: [],
233
+ path: [{ slot: "root", index: 0 }],
234
+ }),
235
+ ).map((e) => e.path);
236
+ expect(unmatched).toContain("trigger.actor");
237
+ expect(unmatched).toContain("trigger.actor.type");
238
+ });
239
+
240
+ it("exposes trigger.id typed as the literal union of the automation's trigger ids", () => {
241
+ const definition = basicDefinition({
242
+ // Two triggers on the SAME event, distinguished by explicit ids.
243
+ triggers: [
244
+ { event: "incident.created", id: "majors" },
245
+ { event: "incident.created", id: "minors" },
246
+ ],
247
+ actions: [],
248
+ });
249
+ const flat = flattenScope(
250
+ resolveVariableScope({
251
+ definition,
252
+ triggers: [triggerInfo],
253
+ actions: [],
254
+ artifactTypes: [],
255
+ path: [{ slot: "root", index: 0 }],
256
+ }),
257
+ );
258
+ const idEntry = flat.find((e) => e.path === "trigger.id");
259
+ expect(idEntry).toBeDefined();
260
+ expect(idEntry?.type).toBe('"majors" | "minors"');
261
+ expect(idEntry?.conditionalOnTriggers).toBeUndefined();
262
+ });
263
+
264
+ it("derives trigger.id from the event when no explicit id is set", () => {
265
+ const flat = flattenScope(
266
+ resolveVariableScope({
267
+ definition: basicDefinition(),
268
+ triggers: [triggerInfo],
269
+ actions: [],
270
+ artifactTypes: [],
271
+ path: [{ slot: "root", index: 0 }],
272
+ }),
273
+ );
274
+ const idEntry = flat.find((e) => e.path === "trigger.id");
275
+ expect(idEntry?.type).toBe('"incident_created"');
276
+ });
277
+
278
+ it("unions payload fields across multiple subscribed triggers and annotates conditional entries", () => {
279
+ const definition = basicDefinition({
280
+ triggers: [{ event: "incident.created" }, { event: "incident.resolved" }],
281
+ actions: [],
282
+ });
283
+ const scope = resolveVariableScope({
284
+ definition,
285
+ triggers: [triggerInfo, otherTrigger],
286
+ actions: [],
287
+ artifactTypes: [],
288
+ path: [{ slot: "root", index: 0 }],
289
+ });
290
+ const flat = flattenScope(scope);
291
+ const byPath = new Map(flat.map((e) => [e.path, e]));
292
+
293
+ // All payload fields from all triggers surface — picker shows everything.
294
+ expect(byPath.has("trigger.payload.incidentId")).toBe(true);
295
+ expect(byPath.has("trigger.payload.title")).toBe(true);
296
+ expect(byPath.has("trigger.payload.resolvedAt")).toBe(true);
297
+
298
+ // Universal field (on both triggers): no conditional annotation.
299
+ expect(byPath.get("trigger.payload.incidentId")?.conditionalOnTriggers).toBeUndefined();
300
+
301
+ // Per-trigger fields: annotated with the contributing trigger id.
302
+ expect(byPath.get("trigger.payload.title")?.conditionalOnTriggers).toEqual([
303
+ "incident.created",
304
+ ]);
305
+ expect(byPath.get("trigger.payload.resolvedAt")?.conditionalOnTriggers).toEqual([
306
+ "incident.resolved",
307
+ ]);
308
+
309
+ // trigger.event becomes a string-literal union of the subscribed ids.
310
+ expect(byPath.get("trigger.event")?.type).toBe(
311
+ `"incident.created" | "incident.resolved"`,
312
+ );
313
+ });
314
+
315
+ it("degrades to `unknown` payload when no triggers match the registry", () => {
316
+ const definition = basicDefinition({
317
+ triggers: [{ event: "nonexistent.event" }],
318
+ actions: [],
319
+ });
320
+ const scope = resolveVariableScope({
321
+ definition,
322
+ triggers: [],
323
+ actions: [],
324
+ artifactTypes: [],
325
+ path: [{ slot: "root", index: 0 }],
326
+ });
327
+ const payload = flattenScope(scope).find(
328
+ (e) => e.path === "trigger.payload",
329
+ );
330
+ expect(payload?.type).toBe("unknown");
331
+ });
332
+
333
+ it("accumulates `var.*` entries from upstream variables actions in the same sequence", () => {
334
+ const definition = basicDefinition({
335
+ triggers: [{ event: "incident.created" }],
336
+ actions: [
337
+ variables({ foo: "hello", count: 5 }),
338
+ provider("automation.notify_user"),
339
+ ],
340
+ });
341
+ const scope = resolveVariableScope({
342
+ definition,
343
+ triggers: [triggerInfo],
344
+ actions: [notifyAction],
345
+ artifactTypes: [],
346
+ path: [{ slot: "root", index: 1 }],
347
+ });
348
+ const flat = flattenScope(scope).map((e) => e.path);
349
+ expect(flat).toContain("var.foo");
350
+ expect(flat).toContain("var.count");
351
+ });
352
+
353
+ it("does NOT expose vars from later actions or other branches", () => {
354
+ const definition = basicDefinition({
355
+ triggers: [{ event: "incident.created" }],
356
+ actions: [
357
+ provider("automation.notify_user"),
358
+ variables({ later: "value" }),
359
+ ],
360
+ });
361
+ const scope = resolveVariableScope({
362
+ definition,
363
+ triggers: [triggerInfo],
364
+ actions: [notifyAction],
365
+ artifactTypes: [],
366
+ // Target is the FIRST action, before the variables block.
367
+ path: [{ slot: "root", index: 0 }],
368
+ });
369
+ const flat = flattenScope(scope).map((e) => e.path);
370
+ expect(flat).not.toContain("var.later");
371
+ });
372
+
373
+ it("accumulates artifacts produced by upstream provider actions", () => {
374
+ const definition = basicDefinition({
375
+ triggers: [{ event: "incident.created" }],
376
+ actions: [
377
+ provider("integration-jira.create_issue", "make_issue"),
378
+ provider("automation.notify_user"),
379
+ ],
380
+ });
381
+ const scope = resolveVariableScope({
382
+ definition,
383
+ triggers: [triggerInfo],
384
+ actions: [createJiraAction, notifyAction],
385
+ artifactTypes: [jiraIssueArtifact],
386
+ path: [{ slot: "root", index: 1 }],
387
+ });
388
+ const flat = flattenScope(scope).map((e) => e.path);
389
+ expect(flat).toContain("artifact.make_issue");
390
+ expect(flat).toContain("artifact.make_issue.jira.issue");
391
+ expect(flat).toContain("artifact.make_issue.jira.issue.key");
392
+ expect(flat).toContain("artifact.make_issue.jira.issue.url");
393
+ });
394
+
395
+ it("does not expose artifacts from sibling branches inside a choose", () => {
396
+ const definition = basicDefinition({
397
+ triggers: [{ event: "incident.created" }],
398
+ actions: [
399
+ {
400
+ choose: [
401
+ {
402
+ when: "true",
403
+ sequence: [
404
+ provider("integration-jira.create_issue"),
405
+ ],
406
+ },
407
+ {
408
+ when: "false",
409
+ sequence: [
410
+ provider("automation.notify_user"),
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ ],
416
+ });
417
+ // Target: the notify_user inside the second when-branch.
418
+ const path: ActionPath = [
419
+ { slot: "root", index: 0 },
420
+ { slot: "choose-when", whenIndex: 1, index: 0 },
421
+ ];
422
+ const scope = resolveVariableScope({
423
+ definition,
424
+ triggers: [triggerInfo],
425
+ actions: [createJiraAction, notifyAction],
426
+ artifactTypes: [jiraIssueArtifact],
427
+ path,
428
+ });
429
+ const flat = flattenScope(scope).map((e) => e.path);
430
+ // The Jira issue was produced in the FIRST when-branch — must not leak
431
+ // into the second branch's scope, because at runtime only one branch
432
+ // executes.
433
+ expect(flat).not.toContain("artifact.make_issue");
434
+ });
435
+
436
+ it("exposes repeat.index inside a count-mode repeat", () => {
437
+ const definition = basicDefinition({
438
+ triggers: [{ event: "incident.created" }],
439
+ actions: [
440
+ {
441
+ repeat: {
442
+ count: 3,
443
+ sequence: [
444
+ provider("automation.notify_user"),
445
+ ],
446
+ },
447
+ },
448
+ ],
449
+ });
450
+ const path: ActionPath = [
451
+ { slot: "root", index: 0 },
452
+ { slot: "repeat", index: 0 },
453
+ ];
454
+ const scope = resolveVariableScope({
455
+ definition,
456
+ triggers: [triggerInfo],
457
+ actions: [notifyAction],
458
+ artifactTypes: [],
459
+ path,
460
+ });
461
+ const flat = flattenScope(scope).map((e) => e.path);
462
+ expect(flat).toContain("repeat.index");
463
+ expect(flat).not.toContain("repeat.item");
464
+ });
465
+
466
+ it("exposes repeat.item inside a for_each repeat", () => {
467
+ const definition = basicDefinition({
468
+ triggers: [{ event: "incident.created" }],
469
+ actions: [
470
+ {
471
+ repeat: {
472
+ for_each: "{{ trigger.payload.items }}",
473
+ sequence: [
474
+ provider("automation.notify_user"),
475
+ ],
476
+ },
477
+ },
478
+ ],
479
+ });
480
+ const path: ActionPath = [
481
+ { slot: "root", index: 0 },
482
+ { slot: "repeat", index: 0 },
483
+ ];
484
+ const scope = resolveVariableScope({
485
+ definition,
486
+ triggers: [triggerInfo],
487
+ actions: [notifyAction],
488
+ artifactTypes: [],
489
+ path,
490
+ });
491
+ const flat = flattenScope(scope).map((e) => e.path);
492
+ expect(flat).toContain("repeat.index");
493
+ expect(flat).toContain("repeat.item");
494
+ });
495
+
496
+ it("walks into parallel branches and exposes upstream vars from parent sequence", () => {
497
+ const definition = basicDefinition({
498
+ triggers: [{ event: "incident.created" }],
499
+ actions: [
500
+ variables({ outer: "set-before-parallel" }),
501
+ {
502
+ parallel: [
503
+ provider("automation.notify_user"),
504
+ ],
505
+ },
506
+ ],
507
+ });
508
+ const path: ActionPath = [
509
+ { slot: "root", index: 1 },
510
+ { slot: "parallel", index: 0 },
511
+ ];
512
+ const scope = resolveVariableScope({
513
+ definition,
514
+ triggers: [triggerInfo],
515
+ actions: [notifyAction],
516
+ artifactTypes: [],
517
+ path,
518
+ });
519
+ const flat = flattenScope(scope).map((e) => e.path);
520
+ expect(flat).toContain("var.outer");
521
+ });
522
+ });
523
+
524
+ describe("resolveVariableScope — condition-aware narrowing", () => {
525
+ const multiTriggerDef = (
526
+ actions: AutomationDefinition["actions"],
527
+ ): AutomationDefinition =>
528
+ basicDefinition({
529
+ triggers: [
530
+ { event: "incident.created" },
531
+ { event: "incident.resolved" },
532
+ ],
533
+ actions,
534
+ });
535
+
536
+ it("narrows trigger.payload to the matching variant inside `when: trigger.event == \"…\"`", () => {
537
+ const def = multiTriggerDef([
538
+ {
539
+ choose: [
540
+ {
541
+ when: 'trigger.event == "incident.created"',
542
+ sequence: [provider("automation.notify_user")],
543
+ },
544
+ ],
545
+ enabled: true,
546
+ continue_on_error: false,
547
+ },
548
+ ]);
549
+ const scope = resolveVariableScope({
550
+ definition: def,
551
+ triggers: [triggerInfo, otherTrigger],
552
+ actions: [notifyAction],
553
+ artifactTypes: [],
554
+ path: [
555
+ { slot: "root", index: 0 },
556
+ { slot: "choose-when", whenIndex: 0, index: 0 },
557
+ ],
558
+ });
559
+ const flat = flattenScope(scope);
560
+ const byPath = new Map(flat.map((e) => [e.path, e]));
561
+
562
+ // trigger.event collapses to the single literal — no longer a union.
563
+ expect(byPath.get("trigger.event")?.type).toBe('"incident.created"');
564
+ // `title` is no longer conditional inside this branch — it's
565
+ // unconditional now that we know which trigger fired.
566
+ expect(byPath.get("trigger.payload.title")?.conditionalOnTriggers).toBeUndefined();
567
+ // `resolvedAt` (only on incident.resolved) is gone entirely from scope.
568
+ expect(byPath.has("trigger.payload.resolvedAt")).toBe(false);
569
+ });
570
+
571
+ it("narrows for `\"X\" == trigger.event` (literal on the left)", () => {
572
+ const def = multiTriggerDef([
573
+ {
574
+ choose: [
575
+ {
576
+ when: '"incident.resolved" == trigger.event',
577
+ sequence: [provider("automation.notify_user")],
578
+ },
579
+ ],
580
+ enabled: true,
581
+ continue_on_error: false,
582
+ },
583
+ ]);
584
+ const scope = resolveVariableScope({
585
+ definition: def,
586
+ triggers: [triggerInfo, otherTrigger],
587
+ actions: [notifyAction],
588
+ artifactTypes: [],
589
+ path: [
590
+ { slot: "root", index: 0 },
591
+ { slot: "choose-when", whenIndex: 0, index: 0 },
592
+ ],
593
+ });
594
+ const flat = flattenScope(scope).map((e) => e.path);
595
+ expect(flat).toContain("trigger.payload.resolvedAt");
596
+ expect(flat).not.toContain("trigger.payload.title");
597
+ });
598
+
599
+ it("supports `||` of equality checks (union of narrowings)", () => {
600
+ const def = multiTriggerDef([
601
+ {
602
+ choose: [
603
+ {
604
+ when:
605
+ 'trigger.event == "incident.created" || trigger.event == "incident.resolved"',
606
+ sequence: [provider("automation.notify_user")],
607
+ },
608
+ ],
609
+ enabled: true,
610
+ continue_on_error: false,
611
+ },
612
+ ]);
613
+ const scope = resolveVariableScope({
614
+ definition: def,
615
+ triggers: [triggerInfo, otherTrigger],
616
+ actions: [notifyAction],
617
+ artifactTypes: [],
618
+ path: [
619
+ { slot: "root", index: 0 },
620
+ { slot: "choose-when", whenIndex: 0, index: 0 },
621
+ ],
622
+ });
623
+ const flat = flattenScope(scope).map((e) => e.path);
624
+ // Both event-specific fields appear because the condition allows
625
+ // either trigger.
626
+ expect(flat).toContain("trigger.payload.title");
627
+ expect(flat).toContain("trigger.payload.resolvedAt");
628
+ });
629
+
630
+ it("supports `!=` to exclude a trigger from the universe", () => {
631
+ const def = multiTriggerDef([
632
+ {
633
+ choose: [
634
+ {
635
+ when: 'trigger.event != "incident.created"',
636
+ sequence: [provider("automation.notify_user")],
637
+ },
638
+ ],
639
+ enabled: true,
640
+ continue_on_error: false,
641
+ },
642
+ ]);
643
+ const scope = resolveVariableScope({
644
+ definition: def,
645
+ triggers: [triggerInfo, otherTrigger],
646
+ actions: [notifyAction],
647
+ artifactTypes: [],
648
+ path: [
649
+ { slot: "root", index: 0 },
650
+ { slot: "choose-when", whenIndex: 0, index: 0 },
651
+ ],
652
+ });
653
+ const flat = flattenScope(scope).map((e) => e.path);
654
+ expect(flat).not.toContain("trigger.payload.title");
655
+ expect(flat).toContain("trigger.payload.resolvedAt");
656
+ });
657
+
658
+ it("supports `{ and: [...] }` combinator — intersection of narrowings", () => {
659
+ const def = multiTriggerDef([
660
+ {
661
+ choose: [
662
+ {
663
+ when: {
664
+ and: [
665
+ 'trigger.event != "incident.resolved"',
666
+ 'trigger.event == "incident.created"',
667
+ ],
668
+ },
669
+ sequence: [provider("automation.notify_user")],
670
+ },
671
+ ],
672
+ enabled: true,
673
+ continue_on_error: false,
674
+ },
675
+ ]);
676
+ const scope = resolveVariableScope({
677
+ definition: def,
678
+ triggers: [triggerInfo, otherTrigger],
679
+ actions: [notifyAction],
680
+ artifactTypes: [],
681
+ path: [
682
+ { slot: "root", index: 0 },
683
+ { slot: "choose-when", whenIndex: 0, index: 0 },
684
+ ],
685
+ });
686
+ const flat = flattenScope(scope).map((e) => e.path);
687
+ expect(flat).toContain("trigger.payload.title");
688
+ expect(flat).not.toContain("trigger.payload.resolvedAt");
689
+ });
690
+
691
+ it("falls back to the full union when the condition doesn't gate on trigger.event", () => {
692
+ const def = multiTriggerDef([
693
+ {
694
+ choose: [
695
+ {
696
+ // Condition doesn't reference `trigger.event` at all.
697
+ when: 'trigger.payload.incidentId == "abc"',
698
+ sequence: [provider("automation.notify_user")],
699
+ },
700
+ ],
701
+ enabled: true,
702
+ continue_on_error: false,
703
+ },
704
+ ]);
705
+ const scope = resolveVariableScope({
706
+ definition: def,
707
+ triggers: [triggerInfo, otherTrigger],
708
+ actions: [notifyAction],
709
+ artifactTypes: [],
710
+ path: [
711
+ { slot: "root", index: 0 },
712
+ { slot: "choose-when", whenIndex: 0, index: 0 },
713
+ ],
714
+ });
715
+ const byPath = new Map(
716
+ flattenScope(scope).map((e) => [e.path, e]),
717
+ );
718
+ // Both event-specific fields surface, still annotated.
719
+ expect(byPath.get("trigger.payload.title")?.conditionalOnTriggers).toEqual([
720
+ "incident.created",
721
+ ]);
722
+ expect(byPath.get("trigger.payload.resolvedAt")?.conditionalOnTriggers).toEqual([
723
+ "incident.resolved",
724
+ ]);
725
+ });
726
+
727
+ it("compounds narrowing across nested choose branches", () => {
728
+ const def = multiTriggerDef([
729
+ {
730
+ choose: [
731
+ {
732
+ // Outer narrows to {created, resolved} (no change).
733
+ when:
734
+ 'trigger.event == "incident.created" || trigger.event == "incident.resolved"',
735
+ sequence: [
736
+ {
737
+ choose: [
738
+ {
739
+ // Inner narrows further to {created}.
740
+ when: 'trigger.event == "incident.created"',
741
+ sequence: [provider("automation.notify_user")],
742
+ },
743
+ ],
744
+ enabled: true,
745
+ continue_on_error: false,
746
+ },
747
+ ],
748
+ },
749
+ ],
750
+ enabled: true,
751
+ continue_on_error: false,
752
+ },
753
+ ]);
754
+ const scope = resolveVariableScope({
755
+ definition: def,
756
+ triggers: [triggerInfo, otherTrigger],
757
+ actions: [notifyAction],
758
+ artifactTypes: [],
759
+ path: [
760
+ { slot: "root", index: 0 },
761
+ { slot: "choose-when", whenIndex: 0, index: 0 },
762
+ { slot: "choose-when", whenIndex: 0, index: 0 },
763
+ ],
764
+ });
765
+ const flat = flattenScope(scope).map((e) => e.path);
766
+ expect(flat).toContain("trigger.payload.title");
767
+ expect(flat).not.toContain("trigger.payload.resolvedAt");
768
+ });
769
+ });
770
+
771
+ describe("isTemplateIdentifier", () => {
772
+ it("accepts bare identifiers", () => {
773
+ expect(isTemplateIdentifier("issueKey")).toBe(true);
774
+ expect(isTemplateIdentifier("_private")).toBe(true);
775
+ expect(isTemplateIdentifier("$ref")).toBe(true);
776
+ expect(isTemplateIdentifier("a1_b2")).toBe(true);
777
+ });
778
+
779
+ it("rejects non-identifier segments", () => {
780
+ expect(isTemplateIdentifier("integration-jira.issue")).toBe(false);
781
+ expect(isTemplateIdentifier("weird-name")).toBe(false);
782
+ expect(isTemplateIdentifier("has.dot")).toBe(false);
783
+ expect(isTemplateIdentifier("1leading")).toBe(false);
784
+ expect(isTemplateIdentifier("")).toBe(false);
785
+ });
786
+ });
787
+
788
+ describe("appendArrayIndex", () => {
789
+ it("appends a bare numeric index in bracket notation", () => {
790
+ expect(appendArrayIndex({ base: "artifacts", index: 0 })).toBe(
791
+ "artifacts[0]",
792
+ );
793
+ expect(
794
+ appendArrayIndex({
795
+ base: 'artifacts["integration-jira.issue"].tags',
796
+ index: 0,
797
+ }),
798
+ ).toBe('artifacts["integration-jira.issue"].tags[0]');
799
+ });
800
+ });
801
+
802
+ describe("appendTemplateSegment", () => {
803
+ it("uses dot notation for identifier segments", () => {
804
+ expect(appendTemplateSegment({ base: "variables", segment: "foo" })).toBe(
805
+ "variables.foo",
806
+ );
807
+ expect(
808
+ appendTemplateSegment({ base: "trigger.payload", segment: "title" }),
809
+ ).toBe("trigger.payload.title");
810
+ });
811
+
812
+ it("uses JSON-quoted bracket notation for dotted segments", () => {
813
+ expect(
814
+ appendTemplateSegment({
815
+ base: "artifacts",
816
+ segment: "integration-jira.issue",
817
+ }),
818
+ ).toBe('artifacts["integration-jira.issue"]');
819
+ });
820
+
821
+ it("uses bracket notation for hyphenated segments", () => {
822
+ expect(
823
+ appendTemplateSegment({ base: "variables", segment: "weird-name" }),
824
+ ).toBe('variables["weird-name"]');
825
+ });
826
+
827
+ it("uses bracket notation for leading-digit segments", () => {
828
+ expect(appendTemplateSegment({ base: "variables", segment: "1st" })).toBe(
829
+ 'variables["1st"]',
830
+ );
831
+ });
832
+ });
833
+
834
+ describe("resolveVariableScope — templateRef", () => {
835
+ it("brackets a dotted/hyphenated artifact id and dots its schema children", () => {
836
+ const definition = basicDefinition({
837
+ triggers: [{ event: "incident.created" }],
838
+ actions: [
839
+ provider("integration-jira.create_issue_dotted", "make_issue"),
840
+ provider("automation.notify_user"),
841
+ ],
842
+ });
843
+ const scope = resolveVariableScope({
844
+ definition,
845
+ triggers: [triggerInfo],
846
+ actions: [createDottedArtifactAction, notifyAction],
847
+ artifactTypes: [dottedArtifact],
848
+ path: [{ slot: "root", index: 1 }],
849
+ });
850
+ const byPath = new Map(flattenScope(scope).map((e) => [e.path, e]));
851
+
852
+ // path is nested under the action id then the local artifact name.
853
+ const child = byPath.get("artifact.make_issue.issue.issueKey");
854
+ expect(child).toBeDefined();
855
+ expect(child?.path).toBe("artifact.make_issue.issue.issueKey");
856
+ // id + local name + field are all identifiers, so plain dot notation.
857
+ expect(child?.templateRef).toBe("artifacts.make_issue.issue.issueKey");
858
+
859
+ // The action-id node and the local-name node.
860
+ const node = byPath.get("artifact.make_issue");
861
+ expect(node?.templateRef).toBe("artifacts.make_issue");
862
+ const localNode = byPath.get("artifact.make_issue.issue");
863
+ expect(localNode?.templateRef).toBe("artifacts.make_issue.issue");
864
+ });
865
+
866
+ it("nests under action id + local name even with no schema fields", () => {
867
+ const definition = basicDefinition({
868
+ triggers: [{ event: "incident.created" }],
869
+ actions: [
870
+ provider("integration-jira.create_issue_dotted", "make_issue"),
871
+ provider("automation.notify_user"),
872
+ ],
873
+ });
874
+ const scope = resolveVariableScope({
875
+ definition,
876
+ triggers: [triggerInfo],
877
+ actions: [createDottedArtifactAction, notifyAction],
878
+ artifactTypes: [dottedArtifactNoSchema],
879
+ path: [{ slot: "root", index: 1 }],
880
+ });
881
+ const byPath = new Map(flattenScope(scope).map((e) => [e.path, e]));
882
+ const node = byPath.get("artifact.make_issue");
883
+ expect(node).toBeDefined();
884
+ expect(node?.templateRef).toBe("artifacts.make_issue");
885
+ // The local-name node exists but has no schema-field children.
886
+ const localNode = byPath.get("artifact.make_issue.issue");
887
+ expect(localNode?.templateRef).toBe("artifacts.make_issue.issue");
888
+ expect(localNode?.children).toBeUndefined();
889
+ });
890
+
891
+ it("maps the var namespace to plural variables in templateRef", () => {
892
+ const definition = basicDefinition({
893
+ triggers: [{ event: "incident.created" }],
894
+ actions: [
895
+ variables({ foo: "hello" }),
896
+ provider("automation.notify_user"),
897
+ ],
898
+ });
899
+ const scope = resolveVariableScope({
900
+ definition,
901
+ triggers: [triggerInfo],
902
+ actions: [notifyAction],
903
+ artifactTypes: [],
904
+ path: [{ slot: "root", index: 1 }],
905
+ });
906
+ const entry = flattenScope(scope).find((e) => e.path === "var.foo");
907
+ expect(entry?.path).toBe("var.foo");
908
+ expect(entry?.templateRef).toBe("variables.foo");
909
+ });
910
+
911
+ it("brackets a hyphenated variable name in templateRef", () => {
912
+ const definition = basicDefinition({
913
+ triggers: [{ event: "incident.created" }],
914
+ actions: [
915
+ variables({ "weird-name": "hello" }),
916
+ provider("automation.notify_user"),
917
+ ],
918
+ });
919
+ const scope = resolveVariableScope({
920
+ definition,
921
+ triggers: [triggerInfo],
922
+ actions: [notifyAction],
923
+ artifactTypes: [],
924
+ path: [{ slot: "root", index: 1 }],
925
+ });
926
+ const entry = flattenScope(scope).find(
927
+ (e) => e.path === "var.weird-name",
928
+ );
929
+ expect(entry?.path).toBe("var.weird-name");
930
+ expect(entry?.templateRef).toBe('variables["weird-name"]');
931
+ });
932
+
933
+ it("descends into an array-of-objects: whole array + element children", () => {
934
+ const definition = basicDefinition({
935
+ triggers: [{ event: "incident.created" }],
936
+ actions: [
937
+ provider("integration-jira.create_issue_dotted", "make_issue"),
938
+ provider("automation.notify_user"),
939
+ ],
940
+ });
941
+ const scope = resolveVariableScope({
942
+ definition,
943
+ triggers: [triggerInfo],
944
+ actions: [createDottedArtifactAction, notifyAction],
945
+ artifactTypes: [arrayArtifact],
946
+ path: [{ slot: "root", index: 1 }],
947
+ });
948
+ const flat = flattenScope(scope);
949
+ const byRef = new Map(flat.map((e) => [e.templateRef, e]));
950
+
951
+ // The whole-array node is referenceable and keeps the array type label.
952
+ const commentsNode = byRef.get("artifacts.make_issue.issue.comments");
953
+ expect(commentsNode).toBeDefined();
954
+ expect(commentsNode?.referenceable).toBe(true);
955
+ expect(commentsNode?.type).toBe("object[]");
956
+
957
+ // An element-object child descends via the representative index 0.
958
+ const authorNode = byRef.get(
959
+ "artifacts.make_issue.issue.comments[0].author",
960
+ );
961
+ expect(authorNode).toBeDefined();
962
+ expect(authorNode?.type).toBe("string");
963
+ });
964
+
965
+ it("descends into an array-of-scalars: whole array + element leaf", () => {
966
+ const definition = basicDefinition({
967
+ triggers: [{ event: "incident.created" }],
968
+ actions: [
969
+ provider("integration-jira.create_issue_dotted", "make_issue"),
970
+ provider("automation.notify_user"),
971
+ ],
972
+ });
973
+ const scope = resolveVariableScope({
974
+ definition,
975
+ triggers: [triggerInfo],
976
+ actions: [createDottedArtifactAction, notifyAction],
977
+ artifactTypes: [arrayArtifact],
978
+ path: [{ slot: "root", index: 1 }],
979
+ });
980
+ const byRef = new Map(
981
+ flattenScope(scope).map((e) => [e.templateRef, e]),
982
+ );
983
+
984
+ const tagsNode = byRef.get("artifacts.make_issue.issue.tags");
985
+ expect(tagsNode).toBeDefined();
986
+ expect(tagsNode?.referenceable).toBe(true);
987
+ expect(tagsNode?.type).toBe("string[]");
988
+
989
+ const tagsElem = byRef.get("artifacts.make_issue.issue.tags[0]");
990
+ expect(tagsElem).toBeDefined();
991
+ expect(tagsElem?.type).toBe("string");
992
+ // A scalar element is a leaf — no children.
993
+ expect(tagsElem?.children).toBeUndefined();
994
+ });
995
+
996
+ it("keeps trigger/repeat templateRef equal to path for static identifier paths", () => {
997
+ const definition = basicDefinition({
998
+ triggers: [{ event: "incident.created" }],
999
+ actions: [
1000
+ {
1001
+ repeat: {
1002
+ for_each: "{{ trigger.payload.items }}",
1003
+ sequence: [provider("automation.notify_user")],
1004
+ },
1005
+ },
1006
+ ],
1007
+ });
1008
+ const scope = resolveVariableScope({
1009
+ definition,
1010
+ triggers: [triggerInfo],
1011
+ actions: [notifyAction],
1012
+ artifactTypes: [],
1013
+ path: [
1014
+ { slot: "root", index: 0 },
1015
+ { slot: "repeat", index: 0 },
1016
+ ],
1017
+ });
1018
+ const byPath = new Map(flattenScope(scope).map((e) => [e.path, e]));
1019
+ expect(byPath.get("trigger.event")?.templateRef).toBe("trigger.event");
1020
+ expect(byPath.get("trigger.payload")?.templateRef).toBe("trigger.payload");
1021
+ expect(byPath.get("trigger.payload.title")?.templateRef).toBe(
1022
+ "trigger.payload.title",
1023
+ );
1024
+ expect(byPath.get("repeat.index")?.templateRef).toBe("repeat.index");
1025
+ expect(byPath.get("repeat.item")?.templateRef).toBe("repeat.item");
1026
+ });
1027
+ });
1028
+
1029
+ describe("flattenScope ordering", () => {
1030
+ it("emits parents before children depth-first", () => {
1031
+ const scope = resolveVariableScope({
1032
+ definition: basicDefinition(),
1033
+ triggers: [triggerInfo],
1034
+ actions: [],
1035
+ artifactTypes: [],
1036
+ path: [{ slot: "root", index: 0 }],
1037
+ });
1038
+ const flat = flattenScope(scope).map((e) => e.path);
1039
+ const triggerIdx = flat.indexOf("trigger");
1040
+ const payloadIdx = flat.indexOf("trigger.payload");
1041
+ const titleIdx = flat.indexOf("trigger.payload.title");
1042
+ expect(triggerIdx).toBeLessThan(payloadIdx);
1043
+ expect(payloadIdx).toBeLessThan(titleIdx);
1044
+ });
1045
+ });