@renseiai/agentfactory 0.8.7 → 0.8.8

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 (119) hide show
  1. package/dist/src/config/repository-config.d.ts +14 -0
  2. package/dist/src/config/repository-config.d.ts.map +1 -1
  3. package/dist/src/config/repository-config.js +20 -0
  4. package/dist/src/governor/event-types.d.ts +18 -1
  5. package/dist/src/governor/event-types.d.ts.map +1 -1
  6. package/dist/src/governor/event-types.js +4 -0
  7. package/dist/src/merge-queue/adapters/github-native.d.ts +22 -0
  8. package/dist/src/merge-queue/adapters/github-native.d.ts.map +1 -0
  9. package/dist/src/merge-queue/adapters/github-native.js +243 -0
  10. package/dist/src/merge-queue/adapters/github-native.test.d.ts +2 -0
  11. package/dist/src/merge-queue/adapters/github-native.test.d.ts.map +1 -0
  12. package/dist/src/merge-queue/adapters/github-native.test.js +384 -0
  13. package/dist/src/merge-queue/index.d.ts +18 -0
  14. package/dist/src/merge-queue/index.d.ts.map +1 -0
  15. package/dist/src/merge-queue/index.js +28 -0
  16. package/dist/src/merge-queue/merge-queue.integration.test.d.ts +2 -0
  17. package/dist/src/merge-queue/merge-queue.integration.test.d.ts.map +1 -0
  18. package/dist/src/merge-queue/merge-queue.integration.test.js +128 -0
  19. package/dist/src/merge-queue/types.d.ts +48 -0
  20. package/dist/src/merge-queue/types.d.ts.map +1 -0
  21. package/dist/src/merge-queue/types.js +8 -0
  22. package/dist/src/orchestrator/artifact-tracker.d.ts +93 -0
  23. package/dist/src/orchestrator/artifact-tracker.d.ts.map +1 -0
  24. package/dist/src/orchestrator/artifact-tracker.js +235 -0
  25. package/dist/src/orchestrator/artifact-tracker.test.d.ts +2 -0
  26. package/dist/src/orchestrator/artifact-tracker.test.d.ts.map +1 -0
  27. package/dist/src/orchestrator/artifact-tracker.test.js +189 -0
  28. package/dist/src/orchestrator/context-manager.d.ts +72 -0
  29. package/dist/src/orchestrator/context-manager.d.ts.map +1 -0
  30. package/dist/src/orchestrator/context-manager.js +120 -0
  31. package/dist/src/orchestrator/context-manager.test.d.ts +2 -0
  32. package/dist/src/orchestrator/context-manager.test.d.ts.map +1 -0
  33. package/dist/src/orchestrator/context-manager.test.js +137 -0
  34. package/dist/src/orchestrator/index.d.ts +8 -2
  35. package/dist/src/orchestrator/index.d.ts.map +1 -1
  36. package/dist/src/orchestrator/index.js +8 -1
  37. package/dist/src/orchestrator/orchestrator.d.ts +12 -0
  38. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
  39. package/dist/src/orchestrator/orchestrator.js +258 -2
  40. package/dist/src/orchestrator/state-recovery.d.ts +21 -2
  41. package/dist/src/orchestrator/state-recovery.d.ts.map +1 -1
  42. package/dist/src/orchestrator/state-recovery.js +54 -2
  43. package/dist/src/orchestrator/state-recovery.test.js +106 -2
  44. package/dist/src/orchestrator/state-types.d.ts +62 -0
  45. package/dist/src/orchestrator/state-types.d.ts.map +1 -1
  46. package/dist/src/orchestrator/state-types.js +5 -1
  47. package/dist/src/orchestrator/summary-builder.d.ts +47 -0
  48. package/dist/src/orchestrator/summary-builder.d.ts.map +1 -0
  49. package/dist/src/orchestrator/summary-builder.js +240 -0
  50. package/dist/src/orchestrator/summary-builder.test.d.ts +2 -0
  51. package/dist/src/orchestrator/summary-builder.test.d.ts.map +1 -0
  52. package/dist/src/orchestrator/summary-builder.test.js +236 -0
  53. package/dist/src/orchestrator/types.d.ts +2 -0
  54. package/dist/src/orchestrator/types.d.ts.map +1 -1
  55. package/dist/src/orchestrator/work-types.d.ts +1 -1
  56. package/dist/src/orchestrator/work-types.d.ts.map +1 -1
  57. package/dist/src/templates/registry.test.js +2 -2
  58. package/dist/src/templates/types.d.ts +2 -0
  59. package/dist/src/templates/types.d.ts.map +1 -1
  60. package/dist/src/templates/types.js +1 -0
  61. package/dist/src/workflow/branching-router.d.ts +38 -0
  62. package/dist/src/workflow/branching-router.d.ts.map +1 -0
  63. package/dist/src/workflow/branching-router.js +52 -0
  64. package/dist/src/workflow/branching-router.test.d.ts +2 -0
  65. package/dist/src/workflow/branching-router.test.d.ts.map +1 -0
  66. package/dist/src/workflow/branching-router.test.js +209 -0
  67. package/dist/src/workflow/duration.d.ts +28 -0
  68. package/dist/src/workflow/duration.d.ts.map +1 -0
  69. package/dist/src/workflow/duration.js +57 -0
  70. package/dist/src/workflow/duration.test.d.ts +2 -0
  71. package/dist/src/workflow/duration.test.d.ts.map +1 -0
  72. package/dist/src/workflow/duration.test.js +74 -0
  73. package/dist/src/workflow/expression/ast.d.ts +53 -0
  74. package/dist/src/workflow/expression/ast.d.ts.map +1 -0
  75. package/dist/src/workflow/expression/ast.js +8 -0
  76. package/dist/src/workflow/expression/context.d.ts +40 -0
  77. package/dist/src/workflow/expression/context.d.ts.map +1 -0
  78. package/dist/src/workflow/expression/context.js +37 -0
  79. package/dist/src/workflow/expression/evaluator.d.ts +28 -0
  80. package/dist/src/workflow/expression/evaluator.d.ts.map +1 -0
  81. package/dist/src/workflow/expression/evaluator.js +165 -0
  82. package/dist/src/workflow/expression/evaluator.test.d.ts +2 -0
  83. package/dist/src/workflow/expression/evaluator.test.d.ts.map +1 -0
  84. package/dist/src/workflow/expression/evaluator.test.js +792 -0
  85. package/dist/src/workflow/expression/expression.test.d.ts +2 -0
  86. package/dist/src/workflow/expression/expression.test.d.ts.map +1 -0
  87. package/dist/src/workflow/expression/expression.test.js +516 -0
  88. package/dist/src/workflow/expression/helpers.d.ts +21 -0
  89. package/dist/src/workflow/expression/helpers.d.ts.map +1 -0
  90. package/dist/src/workflow/expression/helpers.js +56 -0
  91. package/dist/src/workflow/expression/index.d.ts +55 -0
  92. package/dist/src/workflow/expression/index.d.ts.map +1 -0
  93. package/dist/src/workflow/expression/index.js +71 -0
  94. package/dist/src/workflow/expression/lexer.d.ts +37 -0
  95. package/dist/src/workflow/expression/lexer.d.ts.map +1 -0
  96. package/dist/src/workflow/expression/lexer.js +166 -0
  97. package/dist/src/workflow/expression/parser.d.ts +23 -0
  98. package/dist/src/workflow/expression/parser.d.ts.map +1 -0
  99. package/dist/src/workflow/expression/parser.js +181 -0
  100. package/dist/src/workflow/index.d.ts +10 -3
  101. package/dist/src/workflow/index.d.ts.map +1 -1
  102. package/dist/src/workflow/index.js +6 -1
  103. package/dist/src/workflow/retry-resolver.d.ts +51 -0
  104. package/dist/src/workflow/retry-resolver.d.ts.map +1 -0
  105. package/dist/src/workflow/retry-resolver.js +70 -0
  106. package/dist/src/workflow/retry-resolver.test.d.ts +2 -0
  107. package/dist/src/workflow/retry-resolver.test.d.ts.map +1 -0
  108. package/dist/src/workflow/retry-resolver.test.js +149 -0
  109. package/dist/src/workflow/transition-engine.d.ts +3 -1
  110. package/dist/src/workflow/transition-engine.d.ts.map +1 -1
  111. package/dist/src/workflow/transition-engine.js +14 -7
  112. package/dist/src/workflow/transition-engine.test.js +123 -11
  113. package/dist/src/workflow/workflow-registry.d.ts +41 -0
  114. package/dist/src/workflow/workflow-registry.d.ts.map +1 -1
  115. package/dist/src/workflow/workflow-registry.js +66 -0
  116. package/dist/src/workflow/workflow-types.d.ts +181 -8
  117. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  118. package/dist/src/workflow/workflow-types.js +31 -6
  119. package/package.json +2 -2
@@ -0,0 +1,792 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { evaluate, EvaluationError } from './evaluator.js';
3
+ import { buildEvaluationContext } from './context.js';
4
+ import { createBuiltinHelpers } from './helpers.js';
5
+ import { evaluateCondition } from './index.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Test helpers
8
+ // ---------------------------------------------------------------------------
9
+ /** Minimal empty context for tests that don't need variables/functions. */
10
+ function emptyContext() {
11
+ return { variables: {}, functions: {} };
12
+ }
13
+ /** Create a context with the given variables and optional functions. */
14
+ function ctx(variables, functions = {}) {
15
+ return { variables, functions };
16
+ }
17
+ /** Create a minimal GovernorIssue for testing. */
18
+ function makeIssue(overrides = {}) {
19
+ return {
20
+ id: 'issue-1',
21
+ identifier: 'SUP-100',
22
+ title: 'Test Issue',
23
+ description: 'Some description with @hotfix directive',
24
+ status: 'In Progress',
25
+ labels: ['bug', 'priority-high'],
26
+ createdAt: Date.now(),
27
+ ...overrides,
28
+ };
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // 1. Literal evaluation
32
+ // ---------------------------------------------------------------------------
33
+ describe('evaluate — literals', () => {
34
+ it('returns boolean true for BooleanLiteral(true)', () => {
35
+ const ast = { type: 'BooleanLiteral', value: true };
36
+ expect(evaluate(ast, emptyContext())).toBe(true);
37
+ });
38
+ it('returns boolean false for BooleanLiteral(false)', () => {
39
+ const ast = { type: 'BooleanLiteral', value: false };
40
+ expect(evaluate(ast, emptyContext())).toBe(false);
41
+ });
42
+ it('returns the string for StringLiteral', () => {
43
+ const ast = { type: 'StringLiteral', value: 'hello' };
44
+ expect(evaluate(ast, emptyContext())).toBe('hello');
45
+ });
46
+ it('returns the number for NumberLiteral', () => {
47
+ const ast = { type: 'NumberLiteral', value: 42 };
48
+ expect(evaluate(ast, emptyContext())).toBe(42);
49
+ });
50
+ it('returns zero for NumberLiteral(0)', () => {
51
+ const ast = { type: 'NumberLiteral', value: 0 };
52
+ expect(evaluate(ast, emptyContext())).toBe(0);
53
+ });
54
+ it('returns empty string for StringLiteral("")', () => {
55
+ const ast = { type: 'StringLiteral', value: '' };
56
+ expect(evaluate(ast, emptyContext())).toBe('');
57
+ });
58
+ });
59
+ // ---------------------------------------------------------------------------
60
+ // 2. Variable lookup
61
+ // ---------------------------------------------------------------------------
62
+ describe('evaluate — variable lookup', () => {
63
+ it('returns the variable value when it exists', () => {
64
+ const ast = { type: 'VariableRef', name: 'x' };
65
+ expect(evaluate(ast, ctx({ x: 42 }))).toBe(42);
66
+ });
67
+ it('returns string variable value', () => {
68
+ const ast = { type: 'VariableRef', name: 'status' };
69
+ expect(evaluate(ast, ctx({ status: 'active' }))).toBe('active');
70
+ });
71
+ it('returns boolean variable value', () => {
72
+ const ast = { type: 'VariableRef', name: 'isReady' };
73
+ expect(evaluate(ast, ctx({ isReady: true }))).toBe(true);
74
+ });
75
+ it('returns false for undefined variables (no crash)', () => {
76
+ const ast = { type: 'VariableRef', name: 'nonexistent' };
77
+ expect(evaluate(ast, emptyContext())).toBe(false);
78
+ });
79
+ it('returns the actual value when variable is explicitly false', () => {
80
+ const ast = { type: 'VariableRef', name: 'done' };
81
+ expect(evaluate(ast, ctx({ done: false }))).toBe(false);
82
+ });
83
+ it('returns the actual value when variable is explicitly 0', () => {
84
+ const ast = { type: 'VariableRef', name: 'count' };
85
+ expect(evaluate(ast, ctx({ count: 0 }))).toBe(0);
86
+ });
87
+ it('returns the actual value when variable is null', () => {
88
+ const ast = { type: 'VariableRef', name: 'data' };
89
+ expect(evaluate(ast, ctx({ data: null }))).toBe(null);
90
+ });
91
+ });
92
+ // ---------------------------------------------------------------------------
93
+ // 3. Boolean operators
94
+ // ---------------------------------------------------------------------------
95
+ describe('evaluate — boolean operators', () => {
96
+ describe('and', () => {
97
+ it('returns right value when both sides are truthy', () => {
98
+ const ast = {
99
+ type: 'BinaryOp',
100
+ operator: 'and',
101
+ left: { type: 'BooleanLiteral', value: true },
102
+ right: { type: 'BooleanLiteral', value: true },
103
+ };
104
+ expect(evaluate(ast, emptyContext())).toBe(true);
105
+ });
106
+ it('returns left falsy value (short-circuit)', () => {
107
+ const ast = {
108
+ type: 'BinaryOp',
109
+ operator: 'and',
110
+ left: { type: 'BooleanLiteral', value: false },
111
+ right: { type: 'BooleanLiteral', value: true },
112
+ };
113
+ expect(evaluate(ast, emptyContext())).toBe(false);
114
+ });
115
+ it('short-circuits — does not evaluate right side when left is falsy', () => {
116
+ let rightEvaluated = false;
117
+ const context = ctx({}, {
118
+ sideEffect: () => {
119
+ rightEvaluated = true;
120
+ return true;
121
+ },
122
+ });
123
+ const ast = {
124
+ type: 'BinaryOp',
125
+ operator: 'and',
126
+ left: { type: 'BooleanLiteral', value: false },
127
+ right: { type: 'FunctionCall', name: 'sideEffect', args: [] },
128
+ };
129
+ evaluate(ast, context);
130
+ expect(rightEvaluated).toBe(false);
131
+ });
132
+ it('evaluates right side when left is truthy', () => {
133
+ let rightEvaluated = false;
134
+ const context = ctx({}, {
135
+ sideEffect: () => {
136
+ rightEvaluated = true;
137
+ return true;
138
+ },
139
+ });
140
+ const ast = {
141
+ type: 'BinaryOp',
142
+ operator: 'and',
143
+ left: { type: 'BooleanLiteral', value: true },
144
+ right: { type: 'FunctionCall', name: 'sideEffect', args: [] },
145
+ };
146
+ evaluate(ast, context);
147
+ expect(rightEvaluated).toBe(true);
148
+ });
149
+ });
150
+ describe('or', () => {
151
+ it('returns left truthy value (short-circuit)', () => {
152
+ const ast = {
153
+ type: 'BinaryOp',
154
+ operator: 'or',
155
+ left: { type: 'BooleanLiteral', value: true },
156
+ right: { type: 'BooleanLiteral', value: false },
157
+ };
158
+ expect(evaluate(ast, emptyContext())).toBe(true);
159
+ });
160
+ it('returns right value when left is falsy', () => {
161
+ const ast = {
162
+ type: 'BinaryOp',
163
+ operator: 'or',
164
+ left: { type: 'BooleanLiteral', value: false },
165
+ right: { type: 'BooleanLiteral', value: true },
166
+ };
167
+ expect(evaluate(ast, emptyContext())).toBe(true);
168
+ });
169
+ it('short-circuits — does not evaluate right side when left is truthy', () => {
170
+ let rightEvaluated = false;
171
+ const context = ctx({}, {
172
+ sideEffect: () => {
173
+ rightEvaluated = true;
174
+ return false;
175
+ },
176
+ });
177
+ const ast = {
178
+ type: 'BinaryOp',
179
+ operator: 'or',
180
+ left: { type: 'BooleanLiteral', value: true },
181
+ right: { type: 'FunctionCall', name: 'sideEffect', args: [] },
182
+ };
183
+ evaluate(ast, context);
184
+ expect(rightEvaluated).toBe(false);
185
+ });
186
+ it('returns false when both sides are falsy', () => {
187
+ const ast = {
188
+ type: 'BinaryOp',
189
+ operator: 'or',
190
+ left: { type: 'BooleanLiteral', value: false },
191
+ right: { type: 'BooleanLiteral', value: false },
192
+ };
193
+ expect(evaluate(ast, emptyContext())).toBe(false);
194
+ });
195
+ });
196
+ describe('not', () => {
197
+ it('negates true to false', () => {
198
+ const ast = {
199
+ type: 'UnaryOp',
200
+ operator: 'not',
201
+ operand: { type: 'BooleanLiteral', value: true },
202
+ };
203
+ expect(evaluate(ast, emptyContext())).toBe(false);
204
+ });
205
+ it('negates false to true', () => {
206
+ const ast = {
207
+ type: 'UnaryOp',
208
+ operator: 'not',
209
+ operand: { type: 'BooleanLiteral', value: false },
210
+ };
211
+ expect(evaluate(ast, emptyContext())).toBe(true);
212
+ });
213
+ it('coerces truthy values before negating', () => {
214
+ const ast = {
215
+ type: 'UnaryOp',
216
+ operator: 'not',
217
+ operand: { type: 'StringLiteral', value: 'hello' },
218
+ };
219
+ expect(evaluate(ast, emptyContext())).toBe(false);
220
+ });
221
+ it('coerces falsy values before negating (empty string)', () => {
222
+ const ast = {
223
+ type: 'UnaryOp',
224
+ operator: 'not',
225
+ operand: { type: 'StringLiteral', value: '' },
226
+ };
227
+ expect(evaluate(ast, emptyContext())).toBe(true);
228
+ });
229
+ it('coerces falsy values before negating (0)', () => {
230
+ const ast = {
231
+ type: 'UnaryOp',
232
+ operator: 'not',
233
+ operand: { type: 'NumberLiteral', value: 0 },
234
+ };
235
+ expect(evaluate(ast, emptyContext())).toBe(true);
236
+ });
237
+ it('double not returns original truthiness', () => {
238
+ const ast = {
239
+ type: 'UnaryOp',
240
+ operator: 'not',
241
+ operand: {
242
+ type: 'UnaryOp',
243
+ operator: 'not',
244
+ operand: { type: 'BooleanLiteral', value: true },
245
+ },
246
+ };
247
+ expect(evaluate(ast, emptyContext())).toBe(true);
248
+ });
249
+ });
250
+ });
251
+ // ---------------------------------------------------------------------------
252
+ // 4. Comparisons
253
+ // ---------------------------------------------------------------------------
254
+ describe('evaluate — comparisons', () => {
255
+ describe('eq', () => {
256
+ it('returns true for equal numbers', () => {
257
+ const ast = {
258
+ type: 'BinaryOp',
259
+ operator: 'eq',
260
+ left: { type: 'NumberLiteral', value: 5 },
261
+ right: { type: 'NumberLiteral', value: 5 },
262
+ };
263
+ expect(evaluate(ast, emptyContext())).toBe(true);
264
+ });
265
+ it('returns false for unequal numbers', () => {
266
+ const ast = {
267
+ type: 'BinaryOp',
268
+ operator: 'eq',
269
+ left: { type: 'NumberLiteral', value: 5 },
270
+ right: { type: 'NumberLiteral', value: 3 },
271
+ };
272
+ expect(evaluate(ast, emptyContext())).toBe(false);
273
+ });
274
+ it('returns true for equal strings', () => {
275
+ const ast = {
276
+ type: 'BinaryOp',
277
+ operator: 'eq',
278
+ left: { type: 'StringLiteral', value: 'active' },
279
+ right: { type: 'StringLiteral', value: 'active' },
280
+ };
281
+ expect(evaluate(ast, emptyContext())).toBe(true);
282
+ });
283
+ it('returns false for different types (strict equality)', () => {
284
+ const ast = {
285
+ type: 'BinaryOp',
286
+ operator: 'eq',
287
+ left: { type: 'NumberLiteral', value: 0 },
288
+ right: { type: 'BooleanLiteral', value: false },
289
+ };
290
+ expect(evaluate(ast, emptyContext())).toBe(false);
291
+ });
292
+ it('compares variable to string literal', () => {
293
+ const ast = {
294
+ type: 'BinaryOp',
295
+ operator: 'eq',
296
+ left: { type: 'VariableRef', name: 'status' },
297
+ right: { type: 'StringLiteral', value: 'active' },
298
+ };
299
+ expect(evaluate(ast, ctx({ status: 'active' }))).toBe(true);
300
+ });
301
+ });
302
+ describe('neq', () => {
303
+ it('returns true for unequal values', () => {
304
+ const ast = {
305
+ type: 'BinaryOp',
306
+ operator: 'neq',
307
+ left: { type: 'StringLiteral', value: 'open' },
308
+ right: { type: 'StringLiteral', value: 'closed' },
309
+ };
310
+ expect(evaluate(ast, emptyContext())).toBe(true);
311
+ });
312
+ it('returns false for equal values', () => {
313
+ const ast = {
314
+ type: 'BinaryOp',
315
+ operator: 'neq',
316
+ left: { type: 'NumberLiteral', value: 5 },
317
+ right: { type: 'NumberLiteral', value: 5 },
318
+ };
319
+ expect(evaluate(ast, emptyContext())).toBe(false);
320
+ });
321
+ });
322
+ describe('gt', () => {
323
+ it('returns true when left > right (numbers)', () => {
324
+ const ast = {
325
+ type: 'BinaryOp',
326
+ operator: 'gt',
327
+ left: { type: 'NumberLiteral', value: 5 },
328
+ right: { type: 'NumberLiteral', value: 3 },
329
+ };
330
+ expect(evaluate(ast, emptyContext())).toBe(true);
331
+ });
332
+ it('returns false when left <= right (numbers)', () => {
333
+ const ast = {
334
+ type: 'BinaryOp',
335
+ operator: 'gt',
336
+ left: { type: 'NumberLiteral', value: 3 },
337
+ right: { type: 'NumberLiteral', value: 5 },
338
+ };
339
+ expect(evaluate(ast, emptyContext())).toBe(false);
340
+ });
341
+ it('returns false when equal (numbers)', () => {
342
+ const ast = {
343
+ type: 'BinaryOp',
344
+ operator: 'gt',
345
+ left: { type: 'NumberLiteral', value: 3 },
346
+ right: { type: 'NumberLiteral', value: 3 },
347
+ };
348
+ expect(evaluate(ast, emptyContext())).toBe(false);
349
+ });
350
+ it('works with string comparison', () => {
351
+ const ast = {
352
+ type: 'BinaryOp',
353
+ operator: 'gt',
354
+ left: { type: 'StringLiteral', value: 'b' },
355
+ right: { type: 'StringLiteral', value: 'a' },
356
+ };
357
+ expect(evaluate(ast, emptyContext())).toBe(true);
358
+ });
359
+ });
360
+ describe('lt', () => {
361
+ it('returns true when left < right (numbers)', () => {
362
+ const ast = {
363
+ type: 'BinaryOp',
364
+ operator: 'lt',
365
+ left: { type: 'NumberLiteral', value: 2 },
366
+ right: { type: 'NumberLiteral', value: 10 },
367
+ };
368
+ expect(evaluate(ast, emptyContext())).toBe(true);
369
+ });
370
+ it('returns false when left >= right (numbers)', () => {
371
+ const ast = {
372
+ type: 'BinaryOp',
373
+ operator: 'lt',
374
+ left: { type: 'NumberLiteral', value: 10 },
375
+ right: { type: 'NumberLiteral', value: 2 },
376
+ };
377
+ expect(evaluate(ast, emptyContext())).toBe(false);
378
+ });
379
+ });
380
+ describe('gte', () => {
381
+ it('returns true when left > right', () => {
382
+ const ast = {
383
+ type: 'BinaryOp',
384
+ operator: 'gte',
385
+ left: { type: 'NumberLiteral', value: 5 },
386
+ right: { type: 'NumberLiteral', value: 3 },
387
+ };
388
+ expect(evaluate(ast, emptyContext())).toBe(true);
389
+ });
390
+ it('returns true when left == right', () => {
391
+ const ast = {
392
+ type: 'BinaryOp',
393
+ operator: 'gte',
394
+ left: { type: 'NumberLiteral', value: 5 },
395
+ right: { type: 'NumberLiteral', value: 5 },
396
+ };
397
+ expect(evaluate(ast, emptyContext())).toBe(true);
398
+ });
399
+ it('returns false when left < right', () => {
400
+ const ast = {
401
+ type: 'BinaryOp',
402
+ operator: 'gte',
403
+ left: { type: 'NumberLiteral', value: 3 },
404
+ right: { type: 'NumberLiteral', value: 5 },
405
+ };
406
+ expect(evaluate(ast, emptyContext())).toBe(false);
407
+ });
408
+ });
409
+ describe('lte', () => {
410
+ it('returns true when left < right', () => {
411
+ const ast = {
412
+ type: 'BinaryOp',
413
+ operator: 'lte',
414
+ left: { type: 'NumberLiteral', value: 3 },
415
+ right: { type: 'NumberLiteral', value: 5 },
416
+ };
417
+ expect(evaluate(ast, emptyContext())).toBe(true);
418
+ });
419
+ it('returns true when left == right', () => {
420
+ const ast = {
421
+ type: 'BinaryOp',
422
+ operator: 'lte',
423
+ left: { type: 'NumberLiteral', value: 5 },
424
+ right: { type: 'NumberLiteral', value: 5 },
425
+ };
426
+ expect(evaluate(ast, emptyContext())).toBe(true);
427
+ });
428
+ it('returns false when left > right', () => {
429
+ const ast = {
430
+ type: 'BinaryOp',
431
+ operator: 'lte',
432
+ left: { type: 'NumberLiteral', value: 7 },
433
+ right: { type: 'NumberLiteral', value: 5 },
434
+ };
435
+ expect(evaluate(ast, emptyContext())).toBe(false);
436
+ });
437
+ });
438
+ });
439
+ // ---------------------------------------------------------------------------
440
+ // 5. Function calls
441
+ // ---------------------------------------------------------------------------
442
+ describe('evaluate — function calls', () => {
443
+ it('calls a registered function with evaluated args', () => {
444
+ const context = ctx({}, {
445
+ add: (...args) => args[0] + args[1],
446
+ });
447
+ const ast = {
448
+ type: 'FunctionCall',
449
+ name: 'add',
450
+ args: [
451
+ { type: 'NumberLiteral', value: 2 },
452
+ { type: 'NumberLiteral', value: 3 },
453
+ ],
454
+ };
455
+ expect(evaluate(ast, context)).toBe(5);
456
+ });
457
+ it('calls a no-arg function', () => {
458
+ const context = ctx({}, {
459
+ getTrue: () => true,
460
+ });
461
+ const ast = {
462
+ type: 'FunctionCall',
463
+ name: 'getTrue',
464
+ args: [],
465
+ };
466
+ expect(evaluate(ast, context)).toBe(true);
467
+ });
468
+ it('evaluates arguments before passing to function', () => {
469
+ const context = ctx({ x: 10 }, {
470
+ double: (...args) => args[0] * 2,
471
+ });
472
+ const ast = {
473
+ type: 'FunctionCall',
474
+ name: 'double',
475
+ args: [{ type: 'VariableRef', name: 'x' }],
476
+ };
477
+ expect(evaluate(ast, context)).toBe(20);
478
+ });
479
+ it('throws EvaluationError for unknown function', () => {
480
+ const ast = {
481
+ type: 'FunctionCall',
482
+ name: 'unknownFn',
483
+ args: [],
484
+ };
485
+ expect(() => evaluate(ast, emptyContext())).toThrow(EvaluationError);
486
+ expect(() => evaluate(ast, emptyContext())).toThrow(/Unknown function 'unknownFn'/);
487
+ });
488
+ it('includes available function names in error message', () => {
489
+ const context = ctx({}, { hasLabel: () => true, hasDirective: () => false });
490
+ const ast = {
491
+ type: 'FunctionCall',
492
+ name: 'unknownFn',
493
+ args: [],
494
+ };
495
+ expect(() => evaluate(ast, context)).toThrow(/hasLabel/);
496
+ expect(() => evaluate(ast, context)).toThrow(/hasDirective/);
497
+ });
498
+ });
499
+ // ---------------------------------------------------------------------------
500
+ // 6. Built-in helpers
501
+ // ---------------------------------------------------------------------------
502
+ describe('built-in helpers', () => {
503
+ describe('hasLabel', () => {
504
+ it('returns true when the issue has the label', () => {
505
+ const issue = makeIssue({ labels: ['bug', 'enhancement'] });
506
+ const helpers = createBuiltinHelpers(issue);
507
+ expect(helpers.hasLabel('bug')).toBe(true);
508
+ });
509
+ it('returns false when the issue does not have the label', () => {
510
+ const issue = makeIssue({ labels: ['bug'] });
511
+ const helpers = createBuiltinHelpers(issue);
512
+ expect(helpers.hasLabel('feature')).toBe(false);
513
+ });
514
+ it('returns false for non-string argument', () => {
515
+ const issue = makeIssue({ labels: ['bug'] });
516
+ const helpers = createBuiltinHelpers(issue);
517
+ expect(helpers.hasLabel(123)).toBe(false);
518
+ });
519
+ it('returns false when labels array is empty', () => {
520
+ const issue = makeIssue({ labels: [] });
521
+ const helpers = createBuiltinHelpers(issue);
522
+ expect(helpers.hasLabel('bug')).toBe(false);
523
+ });
524
+ });
525
+ describe('hasDirective', () => {
526
+ it('returns true when description contains the directive', () => {
527
+ const issue = makeIssue({ description: 'Please fix @hotfix this soon' });
528
+ const helpers = createBuiltinHelpers(issue);
529
+ expect(helpers.hasDirective('hotfix')).toBe(true);
530
+ });
531
+ it('returns false when description does not contain the directive', () => {
532
+ const issue = makeIssue({ description: 'Just a regular description' });
533
+ const helpers = createBuiltinHelpers(issue);
534
+ expect(helpers.hasDirective('hotfix')).toBe(false);
535
+ });
536
+ it('returns false when description is undefined', () => {
537
+ const issue = makeIssue({ description: undefined });
538
+ const helpers = createBuiltinHelpers(issue);
539
+ expect(helpers.hasDirective('hotfix')).toBe(false);
540
+ });
541
+ it('returns false for non-string argument', () => {
542
+ const issue = makeIssue({ description: '@hotfix needed' });
543
+ const helpers = createBuiltinHelpers(issue);
544
+ expect(helpers.hasDirective(42)).toBe(false);
545
+ });
546
+ });
547
+ describe('isParentIssue', () => {
548
+ it('returns true when hasSubIssues is true', () => {
549
+ const issue = makeIssue();
550
+ const helpers = createBuiltinHelpers(issue, { hasSubIssues: true });
551
+ expect(helpers.isParentIssue()).toBe(true);
552
+ });
553
+ it('returns false when hasSubIssues is false', () => {
554
+ const issue = makeIssue();
555
+ const helpers = createBuiltinHelpers(issue, { hasSubIssues: false });
556
+ expect(helpers.isParentIssue()).toBe(false);
557
+ });
558
+ it('returns false when hasSubIssues is not specified', () => {
559
+ const issue = makeIssue();
560
+ const helpers = createBuiltinHelpers(issue);
561
+ expect(helpers.isParentIssue()).toBe(false);
562
+ });
563
+ });
564
+ });
565
+ // ---------------------------------------------------------------------------
566
+ // 7. Complex expressions (end-to-end with evaluateCondition)
567
+ // ---------------------------------------------------------------------------
568
+ describe('evaluateCondition — end-to-end', () => {
569
+ it('evaluates "{{ isParentIssue }}" with variable binding', () => {
570
+ const context = ctx({ isParentIssue: true });
571
+ expect(evaluateCondition('{{ isParentIssue }}', context)).toBe(true);
572
+ });
573
+ it('evaluates "{{ not isParentIssue }}" to false when isParentIssue is true', () => {
574
+ const context = ctx({ isParentIssue: true });
575
+ expect(evaluateCondition('{{ not isParentIssue }}', context)).toBe(false);
576
+ });
577
+ it('evaluates "{{ researchCompleted and not backlogCreationCompleted }}"', () => {
578
+ const context = ctx({
579
+ researchCompleted: true,
580
+ backlogCreationCompleted: false,
581
+ });
582
+ expect(evaluateCondition('{{ researchCompleted and not backlogCreationCompleted }}', context)).toBe(true);
583
+ });
584
+ it('evaluates "{{ researchCompleted and not backlogCreationCompleted }}" when both true', () => {
585
+ const context = ctx({
586
+ researchCompleted: true,
587
+ backlogCreationCompleted: true,
588
+ });
589
+ expect(evaluateCondition('{{ researchCompleted and not backlogCreationCompleted }}', context)).toBe(false);
590
+ });
591
+ it('evaluates "{{ hasLabel(\'bug\') and priority gt 3 }}"', () => {
592
+ const context = ctx({ priority: 5 }, { hasLabel: (...args) => args[0] === 'bug' });
593
+ expect(evaluateCondition("{{ hasLabel('bug') and priority gt 3 }}", context)).toBe(true);
594
+ });
595
+ it('evaluates "{{ hasLabel(\'bug\') or hasDirective(\'hotfix\') }}"', () => {
596
+ const context = ctx({}, {
597
+ hasLabel: (...args) => args[0] === 'bug',
598
+ hasDirective: (...args) => args[0] === 'hotfix',
599
+ });
600
+ expect(evaluateCondition("{{ hasLabel('bug') or hasDirective('hotfix') }}", context)).toBe(true);
601
+ });
602
+ it('evaluates complex nested expression with parentheses', () => {
603
+ const context = ctx({ priority: 5 }, {
604
+ hasLabel: (...args) => args[0] === 'bug',
605
+ hasDirective: () => false,
606
+ });
607
+ expect(evaluateCondition("{{ (hasLabel('bug') or hasDirective('hotfix')) and priority gt 2 }}", context)).toBe(true);
608
+ });
609
+ it('evaluates expression without delimiters', () => {
610
+ const context = ctx({ x: true });
611
+ expect(evaluateCondition('x', context)).toBe(true);
612
+ });
613
+ it('coerces non-boolean results to boolean', () => {
614
+ const context = ctx({ name: 'hello' });
615
+ expect(evaluateCondition('{{ name }}', context)).toBe(true);
616
+ });
617
+ it('coerces falsy non-boolean results to false', () => {
618
+ const context = ctx({ count: 0 });
619
+ expect(evaluateCondition('{{ count }}', context)).toBe(false);
620
+ });
621
+ it('returns false for undefined variable in evaluateCondition', () => {
622
+ expect(evaluateCondition('{{ unknownVar }}', emptyContext())).toBe(false);
623
+ });
624
+ it('evaluates "{{ status eq \'In Progress\' }}" with real context', () => {
625
+ const issue = makeIssue({ status: 'In Progress' });
626
+ const context = buildEvaluationContext(issue);
627
+ expect(evaluateCondition("{{ status eq 'In Progress' }}", context)).toBe(true);
628
+ });
629
+ it('evaluates full workflow condition with built-in helpers', () => {
630
+ const issue = makeIssue({
631
+ labels: ['bug'],
632
+ description: 'Something with @hotfix',
633
+ status: 'Icebox',
634
+ });
635
+ const context = buildEvaluationContext(issue, { researchCompleted: true }, { hasSubIssues: false });
636
+ expect(evaluateCondition("{{ hasLabel('bug') and researchCompleted }}", context)).toBe(true);
637
+ });
638
+ });
639
+ // ---------------------------------------------------------------------------
640
+ // 8. Error cases
641
+ // ---------------------------------------------------------------------------
642
+ describe('evaluate — error cases', () => {
643
+ it('throws EvaluationError on type mismatch in gt (number vs string)', () => {
644
+ const ast = {
645
+ type: 'BinaryOp',
646
+ operator: 'gt',
647
+ left: { type: 'NumberLiteral', value: 5 },
648
+ right: { type: 'StringLiteral', value: 'hello' },
649
+ };
650
+ expect(() => evaluate(ast, emptyContext())).toThrow(EvaluationError);
651
+ expect(() => evaluate(ast, emptyContext())).toThrow(/Cannot compare/);
652
+ });
653
+ it('throws EvaluationError on type mismatch in lt (boolean vs number)', () => {
654
+ const ast = {
655
+ type: 'BinaryOp',
656
+ operator: 'lt',
657
+ left: { type: 'BooleanLiteral', value: true },
658
+ right: { type: 'NumberLiteral', value: 5 },
659
+ };
660
+ expect(() => evaluate(ast, emptyContext())).toThrow(EvaluationError);
661
+ expect(() => evaluate(ast, emptyContext())).toThrow(/Cannot compare/);
662
+ });
663
+ it('throws EvaluationError on type mismatch in gte', () => {
664
+ const ast = {
665
+ type: 'BinaryOp',
666
+ operator: 'gte',
667
+ left: { type: 'StringLiteral', value: 'a' },
668
+ right: { type: 'NumberLiteral', value: 1 },
669
+ };
670
+ expect(() => evaluate(ast, emptyContext())).toThrow(EvaluationError);
671
+ });
672
+ it('throws EvaluationError on type mismatch in lte', () => {
673
+ const ast = {
674
+ type: 'BinaryOp',
675
+ operator: 'lte',
676
+ left: { type: 'BooleanLiteral', value: false },
677
+ right: { type: 'StringLiteral', value: 'z' },
678
+ };
679
+ expect(() => evaluate(ast, emptyContext())).toThrow(EvaluationError);
680
+ });
681
+ it('error message includes type information', () => {
682
+ const ast = {
683
+ type: 'BinaryOp',
684
+ operator: 'gt',
685
+ left: { type: 'NumberLiteral', value: 5 },
686
+ right: { type: 'StringLiteral', value: 'hello' },
687
+ };
688
+ try {
689
+ evaluate(ast, emptyContext());
690
+ expect.fail('Should have thrown');
691
+ }
692
+ catch (err) {
693
+ expect(err).toBeInstanceOf(EvaluationError);
694
+ const evalErr = err;
695
+ expect(evalErr.message).toMatch(/number/);
696
+ expect(evalErr.message).toMatch(/string/);
697
+ }
698
+ });
699
+ it('EvaluationError has correct name property', () => {
700
+ const err = new EvaluationError('test');
701
+ expect(err.name).toBe('EvaluationError');
702
+ expect(err).toBeInstanceOf(Error);
703
+ });
704
+ it('unknown function error includes "(none)" when no functions registered', () => {
705
+ const ast = {
706
+ type: 'FunctionCall',
707
+ name: 'missing',
708
+ args: [],
709
+ };
710
+ expect(() => evaluate(ast, emptyContext())).toThrow('(none)');
711
+ });
712
+ });
713
+ // ---------------------------------------------------------------------------
714
+ // 9. Context builder
715
+ // ---------------------------------------------------------------------------
716
+ describe('buildEvaluationContext', () => {
717
+ it('sets isParentIssue to true when hasSubIssues is true', () => {
718
+ const issue = makeIssue();
719
+ const context = buildEvaluationContext(issue, undefined, { hasSubIssues: true });
720
+ expect(context.variables.isParentIssue).toBe(true);
721
+ });
722
+ it('sets isParentIssue to false when hasSubIssues is false', () => {
723
+ const issue = makeIssue();
724
+ const context = buildEvaluationContext(issue, undefined, { hasSubIssues: false });
725
+ expect(context.variables.isParentIssue).toBe(false);
726
+ });
727
+ it('sets isParentIssue to false by default', () => {
728
+ const issue = makeIssue();
729
+ const context = buildEvaluationContext(issue);
730
+ expect(context.variables.isParentIssue).toBe(false);
731
+ });
732
+ it('sets labels from issue', () => {
733
+ const issue = makeIssue({ labels: ['bug', 'urgent'] });
734
+ const context = buildEvaluationContext(issue);
735
+ expect(context.variables.labels).toEqual(['bug', 'urgent']);
736
+ });
737
+ it('sets status from issue', () => {
738
+ const issue = makeIssue({ status: 'In Progress' });
739
+ const context = buildEvaluationContext(issue);
740
+ expect(context.variables.status).toBe('In Progress');
741
+ });
742
+ it('sets priority to default of 0', () => {
743
+ const issue = makeIssue();
744
+ const context = buildEvaluationContext(issue);
745
+ expect(context.variables.priority).toBe(0);
746
+ });
747
+ it('merges phaseState variables', () => {
748
+ const issue = makeIssue();
749
+ const phaseState = {
750
+ researchCompleted: true,
751
+ backlogCreationCompleted: false,
752
+ };
753
+ const context = buildEvaluationContext(issue, phaseState);
754
+ expect(context.variables.researchCompleted).toBe(true);
755
+ expect(context.variables.backlogCreationCompleted).toBe(false);
756
+ });
757
+ it('registers hasLabel function', () => {
758
+ const issue = makeIssue({ labels: ['bug'] });
759
+ const context = buildEvaluationContext(issue);
760
+ expect(context.functions.hasLabel).toBeDefined();
761
+ expect(context.functions.hasLabel('bug')).toBe(true);
762
+ expect(context.functions.hasLabel('feature')).toBe(false);
763
+ });
764
+ it('registers hasDirective function', () => {
765
+ const issue = makeIssue({ description: 'Fix @hotfix this' });
766
+ const context = buildEvaluationContext(issue);
767
+ expect(context.functions.hasDirective).toBeDefined();
768
+ expect(context.functions.hasDirective('hotfix')).toBe(true);
769
+ expect(context.functions.hasDirective('skipci')).toBe(false);
770
+ });
771
+ it('registers isParentIssue function', () => {
772
+ const issue = makeIssue();
773
+ const context = buildEvaluationContext(issue, undefined, { hasSubIssues: true });
774
+ expect(context.functions.isParentIssue).toBeDefined();
775
+ expect(context.functions.isParentIssue()).toBe(true);
776
+ });
777
+ it('works end-to-end with evaluateCondition', () => {
778
+ const issue = makeIssue({
779
+ labels: ['bug', 'priority-high'],
780
+ status: 'Icebox',
781
+ description: 'Needs @hotfix urgently',
782
+ });
783
+ const context = buildEvaluationContext(issue, { researchCompleted: true }, { hasSubIssues: false });
784
+ expect(evaluateCondition("{{ hasLabel('bug') }}", context)).toBe(true);
785
+ expect(evaluateCondition("{{ hasLabel('feature') }}", context)).toBe(false);
786
+ expect(evaluateCondition("{{ hasDirective('hotfix') }}", context)).toBe(true);
787
+ expect(evaluateCondition("{{ isParentIssue() }}", context)).toBe(false);
788
+ expect(evaluateCondition("{{ status eq 'Icebox' }}", context)).toBe(true);
789
+ expect(evaluateCondition('{{ researchCompleted }}', context)).toBe(true);
790
+ expect(evaluateCondition("{{ hasLabel('bug') and researchCompleted }}", context)).toBe(true);
791
+ });
792
+ });