@aigne/ash 0.0.1

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 (146) hide show
  1. package/DESIGN.md +41 -0
  2. package/dist/ai-dev-loop/ash-run-result.cjs +12 -0
  3. package/dist/ai-dev-loop/ash-run-result.d.cts +28 -0
  4. package/dist/ai-dev-loop/ash-run-result.d.cts.map +1 -0
  5. package/dist/ai-dev-loop/ash-run-result.d.mts +28 -0
  6. package/dist/ai-dev-loop/ash-run-result.d.mts.map +1 -0
  7. package/dist/ai-dev-loop/ash-run-result.mjs +11 -0
  8. package/dist/ai-dev-loop/ash-run-result.mjs.map +1 -0
  9. package/dist/ai-dev-loop/ash-typed-error.cjs +51 -0
  10. package/dist/ai-dev-loop/ash-typed-error.d.cts +54 -0
  11. package/dist/ai-dev-loop/ash-typed-error.d.cts.map +1 -0
  12. package/dist/ai-dev-loop/ash-typed-error.d.mts +54 -0
  13. package/dist/ai-dev-loop/ash-typed-error.d.mts.map +1 -0
  14. package/dist/ai-dev-loop/ash-typed-error.mjs +50 -0
  15. package/dist/ai-dev-loop/ash-typed-error.mjs.map +1 -0
  16. package/dist/ai-dev-loop/ash-validate.cjs +27 -0
  17. package/dist/ai-dev-loop/ash-validate.d.cts +7 -0
  18. package/dist/ai-dev-loop/ash-validate.d.cts.map +1 -0
  19. package/dist/ai-dev-loop/ash-validate.d.mts +7 -0
  20. package/dist/ai-dev-loop/ash-validate.d.mts.map +1 -0
  21. package/dist/ai-dev-loop/ash-validate.mjs +28 -0
  22. package/dist/ai-dev-loop/ash-validate.mjs.map +1 -0
  23. package/dist/ai-dev-loop/dev-loop.cjs +134 -0
  24. package/dist/ai-dev-loop/dev-loop.d.cts +28 -0
  25. package/dist/ai-dev-loop/dev-loop.d.cts.map +1 -0
  26. package/dist/ai-dev-loop/dev-loop.d.mts +28 -0
  27. package/dist/ai-dev-loop/dev-loop.d.mts.map +1 -0
  28. package/dist/ai-dev-loop/dev-loop.mjs +135 -0
  29. package/dist/ai-dev-loop/dev-loop.mjs.map +1 -0
  30. package/dist/ai-dev-loop/index.cjs +24 -0
  31. package/dist/ai-dev-loop/index.d.cts +9 -0
  32. package/dist/ai-dev-loop/index.d.mts +9 -0
  33. package/dist/ai-dev-loop/index.mjs +10 -0
  34. package/dist/ai-dev-loop/live-mode.cjs +17 -0
  35. package/dist/ai-dev-loop/live-mode.d.cts +24 -0
  36. package/dist/ai-dev-loop/live-mode.d.cts.map +1 -0
  37. package/dist/ai-dev-loop/live-mode.d.mts +24 -0
  38. package/dist/ai-dev-loop/live-mode.d.mts.map +1 -0
  39. package/dist/ai-dev-loop/live-mode.mjs +17 -0
  40. package/dist/ai-dev-loop/live-mode.mjs.map +1 -0
  41. package/dist/ai-dev-loop/meta-tools.cjs +123 -0
  42. package/dist/ai-dev-loop/meta-tools.d.cts +24 -0
  43. package/dist/ai-dev-loop/meta-tools.d.cts.map +1 -0
  44. package/dist/ai-dev-loop/meta-tools.d.mts +24 -0
  45. package/dist/ai-dev-loop/meta-tools.d.mts.map +1 -0
  46. package/dist/ai-dev-loop/meta-tools.mjs +120 -0
  47. package/dist/ai-dev-loop/meta-tools.mjs.map +1 -0
  48. package/dist/ai-dev-loop/structured-runner.cjs +154 -0
  49. package/dist/ai-dev-loop/structured-runner.d.cts +12 -0
  50. package/dist/ai-dev-loop/structured-runner.d.cts.map +1 -0
  51. package/dist/ai-dev-loop/structured-runner.d.mts +12 -0
  52. package/dist/ai-dev-loop/structured-runner.d.mts.map +1 -0
  53. package/dist/ai-dev-loop/structured-runner.mjs +155 -0
  54. package/dist/ai-dev-loop/structured-runner.mjs.map +1 -0
  55. package/dist/ai-dev-loop/system-prompt.cjs +55 -0
  56. package/dist/ai-dev-loop/system-prompt.d.cts +20 -0
  57. package/dist/ai-dev-loop/system-prompt.d.cts.map +1 -0
  58. package/dist/ai-dev-loop/system-prompt.d.mts +20 -0
  59. package/dist/ai-dev-loop/system-prompt.d.mts.map +1 -0
  60. package/dist/ai-dev-loop/system-prompt.mjs +54 -0
  61. package/dist/ai-dev-loop/system-prompt.mjs.map +1 -0
  62. package/dist/ast.d.cts +140 -0
  63. package/dist/ast.d.cts.map +1 -0
  64. package/dist/ast.d.mts +140 -0
  65. package/dist/ast.d.mts.map +1 -0
  66. package/dist/compiler.cjs +802 -0
  67. package/dist/compiler.d.cts +103 -0
  68. package/dist/compiler.d.cts.map +1 -0
  69. package/dist/compiler.d.mts +103 -0
  70. package/dist/compiler.d.mts.map +1 -0
  71. package/dist/compiler.mjs +802 -0
  72. package/dist/compiler.mjs.map +1 -0
  73. package/dist/index.cjs +14 -0
  74. package/dist/index.d.cts +7 -0
  75. package/dist/index.d.mts +7 -0
  76. package/dist/index.mjs +7 -0
  77. package/dist/lexer.cjs +451 -0
  78. package/dist/lexer.d.cts +14 -0
  79. package/dist/lexer.d.cts.map +1 -0
  80. package/dist/lexer.d.mts +14 -0
  81. package/dist/lexer.d.mts.map +1 -0
  82. package/dist/lexer.mjs +451 -0
  83. package/dist/lexer.mjs.map +1 -0
  84. package/dist/parser.cjs +734 -0
  85. package/dist/parser.d.cts +40 -0
  86. package/dist/parser.d.cts.map +1 -0
  87. package/dist/parser.d.mts +40 -0
  88. package/dist/parser.d.mts.map +1 -0
  89. package/dist/parser.mjs +734 -0
  90. package/dist/parser.mjs.map +1 -0
  91. package/dist/reference.cjs +130 -0
  92. package/dist/reference.d.cts +11 -0
  93. package/dist/reference.d.cts.map +1 -0
  94. package/dist/reference.d.mts +11 -0
  95. package/dist/reference.d.mts.map +1 -0
  96. package/dist/reference.mjs +130 -0
  97. package/dist/reference.mjs.map +1 -0
  98. package/dist/template.cjs +85 -0
  99. package/dist/template.mjs +84 -0
  100. package/dist/template.mjs.map +1 -0
  101. package/dist/type-checker.cjs +582 -0
  102. package/dist/type-checker.d.cts +31 -0
  103. package/dist/type-checker.d.cts.map +1 -0
  104. package/dist/type-checker.d.mts +31 -0
  105. package/dist/type-checker.d.mts.map +1 -0
  106. package/dist/type-checker.mjs +573 -0
  107. package/dist/type-checker.mjs.map +1 -0
  108. package/package.json +29 -0
  109. package/src/ai-dev-loop/ash-run-result.test.ts +113 -0
  110. package/src/ai-dev-loop/ash-run-result.ts +46 -0
  111. package/src/ai-dev-loop/ash-typed-error.test.ts +136 -0
  112. package/src/ai-dev-loop/ash-typed-error.ts +50 -0
  113. package/src/ai-dev-loop/ash-validate.test.ts +54 -0
  114. package/src/ai-dev-loop/ash-validate.ts +34 -0
  115. package/src/ai-dev-loop/dev-loop.test.ts +364 -0
  116. package/src/ai-dev-loop/dev-loop.ts +156 -0
  117. package/src/ai-dev-loop/dry-run.test.ts +107 -0
  118. package/src/ai-dev-loop/e2e-multi-fix.test.ts +473 -0
  119. package/src/ai-dev-loop/e2e.test.ts +324 -0
  120. package/src/ai-dev-loop/index.ts +15 -0
  121. package/src/ai-dev-loop/invariants.test.ts +253 -0
  122. package/src/ai-dev-loop/live-mode.test.ts +63 -0
  123. package/src/ai-dev-loop/live-mode.ts +33 -0
  124. package/src/ai-dev-loop/meta-tools.test.ts +120 -0
  125. package/src/ai-dev-loop/meta-tools.ts +142 -0
  126. package/src/ai-dev-loop/structured-runner.test.ts +159 -0
  127. package/src/ai-dev-loop/structured-runner.ts +209 -0
  128. package/src/ai-dev-loop/system-prompt.test.ts +102 -0
  129. package/src/ai-dev-loop/system-prompt.ts +81 -0
  130. package/src/ast.ts +186 -0
  131. package/src/compiler.test.ts +2933 -0
  132. package/src/compiler.ts +1103 -0
  133. package/src/e2e.test.ts +552 -0
  134. package/src/index.ts +16 -0
  135. package/src/lexer.test.ts +538 -0
  136. package/src/lexer.ts +222 -0
  137. package/src/parser.test.ts +1024 -0
  138. package/src/parser.ts +835 -0
  139. package/src/reference.test.ts +166 -0
  140. package/src/reference.ts +125 -0
  141. package/src/template.test.ts +210 -0
  142. package/src/template.ts +139 -0
  143. package/src/type-checker.test.ts +1494 -0
  144. package/src/type-checker.ts +785 -0
  145. package/tsconfig.json +9 -0
  146. package/tsdown.config.ts +12 -0
@@ -0,0 +1,1024 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AshLexer } from "./lexer.js";
3
+ import { AshParser } from "./parser.js";
4
+ import type { Program } from "./ast.js";
5
+
6
+ const lexer = new AshLexer();
7
+ const parser = new AshParser();
8
+
9
+ function parse(source: string): Program {
10
+ return parser.parse(lexer.tokenize(source));
11
+ }
12
+
13
+ describe("Phase 1: ASH Parser", () => {
14
+ // ── Happy ──
15
+
16
+ it("parse minimal job: `job cleanup { find /world/tmp }`", () => {
17
+ const prog = parse("job cleanup { find /world/tmp }");
18
+ expect(prog.jobs).toHaveLength(1);
19
+ expect(prog.jobs[0].name).toBe("cleanup");
20
+ expect(prog.jobs[0].pipeline).toHaveLength(1);
21
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "find", path: "/world/tmp" });
22
+ });
23
+
24
+ it("parse pipeline with where", () => {
25
+ const prog = parse('job q { find /world/users | where status == "active" | map name }');
26
+ const stages = prog.jobs[0].pipeline;
27
+ expect(stages).toHaveLength(3);
28
+ expect(stages[0]).toMatchObject({ kind: "find" });
29
+ expect(stages[1]).toMatchObject({ kind: "where", left: "status", op: "==", right: "active" });
30
+ expect(stages[2]).toMatchObject({ kind: "map", field: "name" });
31
+ });
32
+
33
+ it("parse save expression", () => {
34
+ const prog = parse("job s { save /world/output }");
35
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "save", path: "/world/output" });
36
+ });
37
+
38
+ it("parse publish expression", () => {
39
+ const prog = parse("job p { publish /topic/results }");
40
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "publish", path: "/topic/results" });
41
+ });
42
+
43
+ it("parse tee in pipeline", () => {
44
+ const prog = parse("job t { find /world/data | tee /world/backup | save /world/main }");
45
+ expect(prog.jobs[0].pipeline).toHaveLength(3);
46
+ expect(prog.jobs[0].pipeline[1]).toMatchObject({ kind: "tee", path: "/world/backup" });
47
+ });
48
+
49
+ it("parse fanout with multiple branches", () => {
50
+ const prog = parse("job f { fanout { save /world/a, save /world/b } }");
51
+ const fanout = prog.jobs[0].pipeline[0];
52
+ expect(fanout.kind).toBe("fanout");
53
+ if (fanout.kind === "fanout") {
54
+ expect(fanout.branches).toHaveLength(2);
55
+ }
56
+ });
57
+
58
+ it("parse annotation @approval(human) on job", () => {
59
+ const prog = parse("@approval(human)\njob secured { find /world/data }");
60
+ expect(prog.jobs[0].annotations).toHaveLength(1);
61
+ expect(prog.jobs[0].annotations[0]).toMatchObject({ name: "approval", args: ["human"] });
62
+ });
63
+
64
+ it("parse multiple jobs", () => {
65
+ const prog = parse("job a { find /world/x }\njob b { find /world/y }");
66
+ expect(prog.jobs).toHaveLength(2);
67
+ expect(prog.jobs[0].name).toBe("a");
68
+ expect(prog.jobs[1].name).toBe("b");
69
+ });
70
+
71
+ it("parse where comparison operators: ==, !=, >, <, >=, <=", () => {
72
+ const ops = ["==", "!=", ">", "<", ">=", "<="];
73
+ for (const op of ops) {
74
+ const prog = parse(`job q { where x ${op} 5 }`);
75
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "where", op });
76
+ }
77
+ });
78
+
79
+ it("parse map dot field access `map user.name`", () => {
80
+ const prog = parse("job m { map user.name }");
81
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "map", field: "user.name" });
82
+ });
83
+
84
+ it("parse find nested path", () => {
85
+ const prog = parse("job f { find /world/org/team/members }");
86
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "find", path: "/world/org/team/members" });
87
+ });
88
+
89
+ // ── Output / Input ──
90
+
91
+ it("parse output as top-level statement", () => {
92
+ const prog = parse('output "hello world"');
93
+ expect(prog.statements).toHaveLength(1);
94
+ expect(prog.statements[0]).toMatchObject({ kind: "output", message: "hello world" });
95
+ });
96
+
97
+ it("parse output inside job pipeline", () => {
98
+ const prog = parse('job q { output "starting" | find /world/x | save /world/out }');
99
+ expect(prog.statements).toHaveLength(1);
100
+ const job = prog.statements[0];
101
+ expect(job.kind).toBe("job");
102
+ if (job.kind === "job") {
103
+ expect(job.pipeline[0]).toMatchObject({ kind: "output", message: "starting" });
104
+ }
105
+ });
106
+
107
+ it("parse input as pipeline source", () => {
108
+ const prog = parse('job q { input "Enter name:" | save /world/out }');
109
+ expect(prog.statements).toHaveLength(1);
110
+ const job = prog.statements[0];
111
+ if (job.kind === "job") {
112
+ expect(job.pipeline[0]).toMatchObject({ kind: "input", prompt: "Enter name:" });
113
+ }
114
+ });
115
+
116
+ it("parse mixed top-level: output + job + output", () => {
117
+ const prog = parse(`
118
+ output "=== Start ==="
119
+ job etl { find /world/data | save /world/out }
120
+ output "Done."
121
+ `);
122
+ expect(prog.statements).toHaveLength(3);
123
+ expect(prog.statements[0]).toMatchObject({ kind: "output" });
124
+ expect(prog.statements[1]).toMatchObject({ kind: "job" });
125
+ expect(prog.statements[2]).toMatchObject({ kind: "output" });
126
+ });
127
+
128
+ it("backward compat: Program.jobs still works for job-only programs", () => {
129
+ const prog = parse("job a { find /world/x }");
130
+ expect(prog.jobs).toHaveLength(1);
131
+ expect(prog.jobs[0].name).toBe("a");
132
+ });
133
+
134
+ // ── Bad ──
135
+
136
+ it("job missing body braces → syntax error", () => {
137
+ expect(() => parse("job x find /world/a")).toThrow();
138
+ });
139
+
140
+ it("pipe right side empty → unexpected EOF", () => {
141
+ expect(() => parse("job x { find /world/a | }")).toThrow();
142
+ });
143
+
144
+ it("pipe left side empty → unexpected PIPE", () => {
145
+ expect(() => parse("job x { | where x > 1 }")).toThrow();
146
+ });
147
+
148
+ it("annotation parenthesis unclosed → error", () => {
149
+ expect(() => parse("@approval(human\njob x { find /world/a }")).toThrow();
150
+ });
151
+
152
+ it("top-level statement without job → error", () => {
153
+ expect(() => parse("find /world/a")).toThrow();
154
+ });
155
+
156
+ // ── Edge ──
157
+
158
+ it("empty body job `job empty { }` → zero pipeline stages", () => {
159
+ const prog = parse("job empty { }");
160
+ expect(prog.jobs[0].pipeline).toHaveLength(0);
161
+ });
162
+
163
+ it("10+ stage pipeline → correctly parsed", () => {
164
+ const stages = Array(10).fill("map x").join(" | ");
165
+ const prog = parse(`job deep { ${stages} }`);
166
+ expect(prog.jobs[0].pipeline).toHaveLength(10);
167
+ });
168
+
169
+ it("annotation without args `@readonly` → parsed correctly", () => {
170
+ const prog = parse("@readonly\njob r { find /world/x }");
171
+ expect(prog.jobs[0].annotations[0]).toMatchObject({ name: "readonly", args: [] });
172
+ });
173
+
174
+ it("where comparing two fields `where a.x == b.y`", () => {
175
+ const prog = parse("job q { where a.x == b.y }");
176
+ const w = prog.jobs[0].pipeline[0];
177
+ expect(w).toMatchObject({ kind: "where", left: "a.x", right: "b.y" });
178
+ });
179
+
180
+ // ── Security ──
181
+
182
+ it("job name is reserved word `job find { }` → rejected", () => {
183
+ expect(() => parse("job find { }")).toThrow(/reserved keyword/);
184
+ });
185
+
186
+ // ── Data Loss ──
187
+
188
+ it("parser error includes line/column", () => {
189
+ try {
190
+ parse("job x find");
191
+ expect.unreachable();
192
+ } catch (e: any) {
193
+ expect(e.message).toMatch(/line|column/i);
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("Phase 6: find...where inline + QueryAST", () => {
199
+ // ── Happy ──
200
+
201
+ it("find /path where condition → FindExpression with inline query", () => {
202
+ const prog = parse("job q { find /world/users where active == true }");
203
+ const find = prog.jobs[0].pipeline[0];
204
+ expect(find.kind).toBe("find");
205
+ if (find.kind === "find") {
206
+ expect(find.path).toBe("/world/users");
207
+ expect(find.query).toBeDefined();
208
+ expect(find.query).toMatchObject({ field: "active", op: "==", value: "true" });
209
+ }
210
+ });
211
+
212
+ it("find without inline where → no query (backward compat)", () => {
213
+ const prog = parse("job q { find /world/users }");
214
+ const find = prog.jobs[0].pipeline[0];
215
+ if (find.kind === "find") {
216
+ expect(find.query).toBeUndefined();
217
+ }
218
+ });
219
+
220
+ it("find /path where condition | more stages → inline query + pipeline continues", () => {
221
+ const prog = parse("job q { find /world/users where active == true | map name | save /world/out }");
222
+ const stages = prog.jobs[0].pipeline;
223
+ expect(stages).toHaveLength(3);
224
+ expect(stages[0].kind).toBe("find");
225
+ if (stages[0].kind === "find") {
226
+ expect(stages[0].query).toBeDefined();
227
+ }
228
+ expect(stages[1].kind).toBe("map");
229
+ expect(stages[2].kind).toBe("save");
230
+ });
231
+
232
+ it("QueryAST from where clause can be serialized to JSON", () => {
233
+ const prog = parse("job q { find /world/x where score > 80 }");
234
+ const find = prog.jobs[0].pipeline[0];
235
+ if (find.kind === "find" && find.query) {
236
+ const json = JSON.stringify(find.query);
237
+ expect(json).toBeTruthy();
238
+ const parsed = JSON.parse(json);
239
+ expect(parsed.field).toBe("score");
240
+ expect(parsed.op).toBe(">");
241
+ }
242
+ });
243
+
244
+ // ── Bad ──
245
+
246
+ it("find /path where (missing condition) → syntax error", () => {
247
+ expect(() => parse("job q { find /world/x where }")).toThrow();
248
+ });
249
+
250
+ // ── Edge ──
251
+
252
+ it("find...where inline + pipe where → both parsed", () => {
253
+ const prog = parse("job q { find /world/x where active == true | where score > 80 }");
254
+ const stages = prog.jobs[0].pipeline;
255
+ expect(stages).toHaveLength(2);
256
+ if (stages[0].kind === "find") {
257
+ expect(stages[0].query).toBeDefined();
258
+ }
259
+ expect(stages[1].kind).toBe("where");
260
+ });
261
+
262
+ it("where with dotted field find /path where user.name == Alice", () => {
263
+ const prog = parse('job q { find /world/x where user.name == "Alice" }');
264
+ const find = prog.jobs[0].pipeline[0];
265
+ if (find.kind === "find" && find.query) {
266
+ expect(find.query.field).toBe("user.name");
267
+ }
268
+ });
269
+
270
+ // ── Data Loss ──
271
+
272
+ it("v0 find | where syntax still works (regression 0)", () => {
273
+ const prog = parse("job q { find /world/x | where active == true }");
274
+ const stages = prog.jobs[0].pipeline;
275
+ expect(stages).toHaveLength(2);
276
+ expect(stages[0].kind).toBe("find");
277
+ expect(stages[1].kind).toBe("where");
278
+ if (stages[0].kind === "find") {
279
+ expect(stages[0].query).toBeUndefined();
280
+ }
281
+ });
282
+ });
283
+
284
+ describe("Phase 7: Variables + Map Transformers", () => {
285
+ it("let x = 42 → LetStatement in top-level", () => {
286
+ const prog = parse('let x = 42\njob q { find /world/d | save /world/out }');
287
+ expect(prog.statements.length).toBeGreaterThanOrEqual(2);
288
+ expect(prog.statements[0]).toMatchObject({ kind: "let", name: "x" });
289
+ });
290
+
291
+ it("let with string value", () => {
292
+ const prog = parse('let path = "/world/test"\njob q { find /world/d }');
293
+ const letStmt = prog.statements[0];
294
+ if (letStmt.kind === "let") {
295
+ expect(letStmt.value).toBe("/world/test");
296
+ }
297
+ });
298
+
299
+ it("$variable in where clause", () => {
300
+ const prog = parse('let threshold = 80\njob q { find /world/d | where score > $threshold | save /world/out }');
301
+ const where = prog.jobs[0].pipeline[1];
302
+ if (where.kind === "where") {
303
+ expect(where.right).toBe("$threshold");
304
+ }
305
+ });
306
+
307
+ it("map single field still works (backward compat)", () => {
308
+ const prog = parse("job q { map name }");
309
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "map", field: "name" });
310
+ });
311
+
312
+ it("map { key field } → object mapping", () => {
313
+ const prog = parse('job q { map { fullName name, dept department } }');
314
+ const m = prog.jobs[0].pipeline[0];
315
+ expect(m.kind).toBe("map");
316
+ if (m.kind === "map") {
317
+ expect(m.mappings).toBeDefined();
318
+ expect(m.mappings).toEqual({ fullName: "name", dept: "department" });
319
+ }
320
+ });
321
+
322
+ // Bad
323
+ it("let missing = → syntax error", () => {
324
+ expect(() => parse("let x 42")).toThrow();
325
+ });
326
+
327
+ it("let duplicate name → error", () => {
328
+ expect(() => parse("let x = 1\nlet x = 2\njob q { find /world/d }")).toThrow();
329
+ });
330
+
331
+ // Edge
332
+ it("let keyword conflict → error", () => {
333
+ expect(() => parse("let find = 1\njob q { find /world/d }")).toThrow();
334
+ });
335
+ });
336
+
337
+ describe("Phase v3-0: action parsing", () => {
338
+ it("parse `action /path` → ActionExpression with no params", () => {
339
+ const prog = parse("job q { action /tools/classify }");
340
+ const stage = prog.jobs[0].pipeline[0];
341
+ expect(stage).toMatchObject({ kind: "action", path: "/tools/classify" });
342
+ if (stage.kind === "action") {
343
+ expect(stage.params).toBeUndefined();
344
+ }
345
+ });
346
+
347
+ it("parse `action /path { key: \"value\" }` → ActionExpression with string param", () => {
348
+ const prog = parse('job q { action /tools/email { template: "welcome" } }');
349
+ const stage = prog.jobs[0].pipeline[0];
350
+ expect(stage).toMatchObject({ kind: "action", path: "/tools/email" });
351
+ if (stage.kind === "action") {
352
+ expect(stage.params).toEqual({ template: "welcome" });
353
+ }
354
+ });
355
+
356
+ it("parse `action /path { key: 42 }` → ActionExpression with number param", () => {
357
+ const prog = parse("job q { action /tools/score { threshold: 42 } }");
358
+ const stage = prog.jobs[0].pipeline[0];
359
+ if (stage.kind === "action") {
360
+ expect(stage.params).toEqual({ threshold: 42 });
361
+ }
362
+ });
363
+
364
+ it("parse `action /path { a: \"x\", b: 2 }` → ActionExpression with multiple params", () => {
365
+ const prog = parse('job q { action /tools/x { a: "x", b: 2 } }');
366
+ const stage = prog.jobs[0].pipeline[0];
367
+ if (stage.kind === "action") {
368
+ expect(stage.params).toEqual({ a: "x", b: 2 });
369
+ }
370
+ });
371
+
372
+ it("parse action in pipeline: find | action | save", () => {
373
+ const prog = parse("job q { find /data | action /tools/transform | save /out }");
374
+ const stages = prog.jobs[0].pipeline;
375
+ expect(stages).toHaveLength(3);
376
+ expect(stages[0].kind).toBe("find");
377
+ expect(stages[1]).toMatchObject({ kind: "action", path: "/tools/transform" });
378
+ expect(stages[2].kind).toBe("save");
379
+ });
380
+
381
+ it("parse action with deep path `/a/b/c/d/e`", () => {
382
+ const prog = parse("job q { action /a/b/c/d/e }");
383
+ const stage = prog.jobs[0].pipeline[0];
384
+ expect(stage).toMatchObject({ kind: "action", path: "/a/b/c/d/e" });
385
+ });
386
+
387
+ // Bad
388
+ it("`action` without path → syntax error", () => {
389
+ expect(() => parse("job q { action }")).toThrow();
390
+ });
391
+
392
+ it("`action { }` (missing path) → syntax error", () => {
393
+ expect(() => parse("job q { action { } }")).toThrow();
394
+ });
395
+
396
+ it("`action /path { key }` (missing colon/value) → syntax error", () => {
397
+ expect(() => parse("job q { action /path { key } }")).toThrow();
398
+ });
399
+
400
+ it("`action /path { key: }` (missing value) → syntax error", () => {
401
+ expect(() => parse("job q { action /path { key: } }")).toThrow();
402
+ });
403
+
404
+ // ── Relative actions ──
405
+
406
+ it("parse `action turn_off` → relative ActionExpression", () => {
407
+ const prog = parse("job q { find /ha/lights | action turn_off }");
408
+ const action = prog.jobs[0].pipeline[1];
409
+ expect(action).toMatchObject({ kind: "action", path: "turn_off", relative: true });
410
+ });
411
+
412
+ it("parse `action /tesla/.actions/honk` → absolute (no relative flag)", () => {
413
+ const prog = parse("job q { action /tesla/.actions/honk }");
414
+ const action = prog.jobs[0].pipeline[0];
415
+ expect(action).toMatchObject({ kind: "action", path: "/tesla/.actions/honk" });
416
+ expect((action as any).relative).toBeUndefined();
417
+ });
418
+
419
+ it("parse `action turn_off { brightness: 0 }` → relative with params", () => {
420
+ const prog = parse("job q { find /ha/lights | action turn_off { brightness: 0 } }");
421
+ const action = prog.jobs[0].pipeline[1];
422
+ expect(action).toMatchObject({
423
+ kind: "action",
424
+ path: "turn_off",
425
+ relative: true,
426
+ params: { brightness: 0 },
427
+ });
428
+ });
429
+
430
+ // ── json() wrapper for complex params ──
431
+
432
+ it("json([...]) array → parsed JS array", () => {
433
+ const prog = parse('job q { action /tools/run { tools: json([{"path": "/a", "ops": ["read"]}]) } }');
434
+ const stage = prog.jobs[0].pipeline[0];
435
+ if (stage.kind === "action") {
436
+ expect(stage.params).toEqual({ tools: [{ path: "/a", ops: ["read"] }] });
437
+ }
438
+ });
439
+
440
+ it("json({...}) object → parsed JS object", () => {
441
+ const prog = parse('job q { action /tools/run { budget: json({"max_rounds": 4}) } }');
442
+ const stage = prog.jobs[0].pipeline[0];
443
+ if (stage.kind === "action") {
444
+ expect(stage.params).toEqual({ budget: { max_rounds: 4 } });
445
+ }
446
+ });
447
+
448
+ it("json() nested structures → deep structure", () => {
449
+ const prog = parse('job q { action /tools/run { cfg: json({"a": [1, 2], "b": {"c": true}}) } }');
450
+ const stage = prog.jobs[0].pipeline[0];
451
+ if (stage.kind === "action") {
452
+ expect(stage.params).toEqual({ cfg: { a: [1, 2], b: { c: true } } });
453
+ }
454
+ });
455
+
456
+ it("mixed primitives + json() → both present", () => {
457
+ const prog = parse('job q { action /tools/run { a: "str", b: 42, c: json([1, 2]) } }');
458
+ const stage = prog.jobs[0].pipeline[0];
459
+ if (stage.kind === "action") {
460
+ expect(stage.params).toEqual({ a: "str", b: 42, c: [1, 2] });
461
+ }
462
+ });
463
+
464
+ it("json({}) and json([]) → empty structures", () => {
465
+ const prog = parse("job q { action /tools/run { a: json({}), b: json([]) } }");
466
+ const stage = prog.jobs[0].pipeline[0];
467
+ if (stage.kind === "action") {
468
+ expect(stage.params).toEqual({ a: {}, b: [] });
469
+ }
470
+ });
471
+
472
+ it("json() with null and boolean values", () => {
473
+ const prog = parse('job q { action /tools/run { x: json({"a": null, "b": true, "c": false}) } }');
474
+ const stage = prog.jobs[0].pipeline[0];
475
+ if (stage.kind === "action") {
476
+ expect(stage.params).toEqual({ x: { a: null, b: true, c: false } });
477
+ }
478
+ });
479
+
480
+ it("unterminated json( → parse error", () => {
481
+ expect(() => parse('job q { action /tools/run { x: json({"a": 1} } }')).toThrow();
482
+ });
483
+
484
+ it("json() with string values", () => {
485
+ const prog = parse('job q { action /tools/run { x: json({"name": "hello"}) } }');
486
+ const stage = prog.jobs[0].pipeline[0];
487
+ if (stage.kind === "action") {
488
+ expect(stage.params).toEqual({ x: { name: "hello" } });
489
+ }
490
+ });
491
+ });
492
+
493
+ describe("Phase v3-1: expression parsing", () => {
494
+ // ── Happy Path ──
495
+
496
+ it("parse `map { score: age * 2 }` → MapExpression with BinaryExpr", () => {
497
+ const prog = parse("job q { map { score: age * 2 } }");
498
+ const m = prog.jobs[0].pipeline[0];
499
+ expect(m.kind).toBe("map");
500
+ if (m.kind === "map") {
501
+ expect(m.exprMappings).toBeDefined();
502
+ expect(m.exprMappings!.score).toMatchObject({
503
+ kind: "binary", op: "*",
504
+ left: { kind: "field_access", path: "age" },
505
+ right: { kind: "literal", value: 2 },
506
+ });
507
+ }
508
+ });
509
+
510
+ it('parse `map { label: name + " suffix" }` → string concat', () => {
511
+ const prog = parse('job q { map { label: name + " suffix" } }');
512
+ const m = prog.jobs[0].pipeline[0];
513
+ if (m.kind === "map" && m.exprMappings) {
514
+ expect(m.exprMappings.label).toMatchObject({
515
+ kind: "binary", op: "+",
516
+ left: { kind: "field_access", path: "name" },
517
+ right: { kind: "literal", value: " suffix" },
518
+ });
519
+ }
520
+ });
521
+
522
+ it("parse `map { x: a + b * c }` → correct precedence (* before +)", () => {
523
+ const prog = parse("job q { map { x: a + b * c } }");
524
+ const m = prog.jobs[0].pipeline[0];
525
+ if (m.kind === "map" && m.exprMappings) {
526
+ const expr = m.exprMappings.x;
527
+ // Should be: a + (b * c)
528
+ expect(expr.kind).toBe("binary");
529
+ if (expr.kind === "binary") {
530
+ expect(expr.op).toBe("+");
531
+ expect(expr.left).toMatchObject({ kind: "field_access", path: "a" });
532
+ expect(expr.right).toMatchObject({
533
+ kind: "binary", op: "*",
534
+ left: { kind: "field_access", path: "b" },
535
+ right: { kind: "field_access", path: "c" },
536
+ });
537
+ }
538
+ }
539
+ });
540
+
541
+ it("parse `map { x: (a + b) * c }` → parenthesized override", () => {
542
+ const prog = parse("job q { map { x: (a + b) * c } }");
543
+ const m = prog.jobs[0].pipeline[0];
544
+ if (m.kind === "map" && m.exprMappings) {
545
+ const expr = m.exprMappings.x;
546
+ expect(expr.kind).toBe("binary");
547
+ if (expr.kind === "binary") {
548
+ expect(expr.op).toBe("*");
549
+ expect(expr.left).toMatchObject({
550
+ kind: "binary", op: "+",
551
+ left: { kind: "field_access", path: "a" },
552
+ right: { kind: "field_access", path: "b" },
553
+ });
554
+ expect(expr.right).toMatchObject({ kind: "field_access", path: "c" });
555
+ }
556
+ }
557
+ });
558
+
559
+ it("parse `map { x: $var + 1 }` → variable reference in expression", () => {
560
+ const prog = parse("let v = 10\njob q { map { x: $v + 1 } }");
561
+ const m = prog.jobs[0].pipeline[0];
562
+ if (m.kind === "map" && m.exprMappings) {
563
+ expect(m.exprMappings.x).toMatchObject({
564
+ kind: "binary", op: "+",
565
+ left: { kind: "var_ref", name: "v" },
566
+ right: { kind: "literal", value: 1 },
567
+ });
568
+ }
569
+ });
570
+
571
+ it("parse `map { x: obj.nested.field * 2 }` → dot-notation field", () => {
572
+ const prog = parse("job q { map { x: obj.nested.field * 2 } }");
573
+ const m = prog.jobs[0].pipeline[0];
574
+ if (m.kind === "map" && m.exprMappings) {
575
+ expect(m.exprMappings.x).toMatchObject({
576
+ kind: "binary", op: "*",
577
+ left: { kind: "field_access", path: "obj.nested.field" },
578
+ right: { kind: "literal", value: 2 },
579
+ });
580
+ }
581
+ });
582
+
583
+ it('parse `map name + " " + surname` → single-expression map (no braces)', () => {
584
+ const prog = parse('job q { map name + " " + surname }');
585
+ const m = prog.jobs[0].pipeline[0];
586
+ if (m.kind === "map") {
587
+ expect(m.expression).toBeDefined();
588
+ expect(m.expression!.kind).toBe("binary");
589
+ }
590
+ });
591
+
592
+ it("parse `map { score: raw * 0.8 + bonus, label: name }` → multi-key expression", () => {
593
+ const prog = parse("job q { map { score: raw * 0.8 + bonus, label: name } }");
594
+ const m = prog.jobs[0].pipeline[0];
595
+ if (m.kind === "map" && m.exprMappings) {
596
+ expect(Object.keys(m.exprMappings)).toEqual(["score", "label"]);
597
+ expect(m.exprMappings.label).toMatchObject({ kind: "field_access", path: "name" });
598
+ }
599
+ });
600
+
601
+ // ── Bad Path ──
602
+
603
+ it("parse `map { x: foo() }` → function call rejected (LPAREN after field)", () => {
604
+ // foo() would parse foo as field_access, then ( would be unexpected in map context
605
+ // The parser would stop at ( and the } check would fail
606
+ expect(() => parse("job q { map { x: foo() } }")).toThrow();
607
+ });
608
+
609
+ it("parse `map { x: a ? b : c }` → ternary rejected", () => {
610
+ expect(() => parse("job q { map { x: a ? b : c } }")).toThrow();
611
+ });
612
+
613
+ // ── Edge Cases / Backward Compat ──
614
+
615
+ it("backward compat: `map field` still works", () => {
616
+ const prog = parse("job q { map name }");
617
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "map", field: "name" });
618
+ });
619
+
620
+ it("backward compat: `map { out input }` (no colon) still works", () => {
621
+ const prog = parse("job q { map { fullName name, dept department } }");
622
+ const m = prog.jobs[0].pipeline[0];
623
+ if (m.kind === "map") {
624
+ expect(m.mappings).toEqual({ fullName: "name", dept: "department" });
625
+ }
626
+ });
627
+
628
+ it("backward compat: `map user.name` dotted field still works", () => {
629
+ const prog = parse("job q { map user.name }");
630
+ expect(prog.jobs[0].pipeline[0]).toMatchObject({ kind: "map", field: "user.name" });
631
+ });
632
+
633
+ it("parse `map { x: 0 }` → literal number expression", () => {
634
+ const prog = parse("job q { map { x: 0 } }");
635
+ const m = prog.jobs[0].pipeline[0];
636
+ if (m.kind === "map" && m.exprMappings) {
637
+ expect(m.exprMappings.x).toMatchObject({ kind: "literal", value: 0 });
638
+ }
639
+ });
640
+
641
+ it('parse `map { x: "hello" }` → literal string expression', () => {
642
+ const prog = parse('job q { map { x: "hello" } }');
643
+ const m = prog.jobs[0].pipeline[0];
644
+ if (m.kind === "map" && m.exprMappings) {
645
+ expect(m.exprMappings.x).toMatchObject({ kind: "literal", value: "hello" });
646
+ }
647
+ });
648
+
649
+ it("parse `map { x: ((a + b)) }` → nested parens", () => {
650
+ const prog = parse("job q { map { x: ((a + b)) } }");
651
+ const m = prog.jobs[0].pipeline[0];
652
+ if (m.kind === "map" && m.exprMappings) {
653
+ expect(m.exprMappings.x).toMatchObject({
654
+ kind: "binary", op: "+",
655
+ left: { kind: "field_access", path: "a" },
656
+ right: { kind: "field_access", path: "b" },
657
+ });
658
+ }
659
+ });
660
+ });
661
+
662
+ describe("Phase v3-2: route parsing", () => {
663
+ it('parse `route field { "val" -> job target }` basic route', () => {
664
+ const prog = parse('job main { find /data | route category { "urgent" -> job urgent_handler } }\njob urgent_handler { save /world/urgent }');
665
+ const stage = prog.jobs[0].pipeline[1];
666
+ expect(stage.kind).toBe("route");
667
+ if (stage.kind === "route") {
668
+ expect(stage.field).toBe("category");
669
+ expect(stage.branches).toHaveLength(1);
670
+ expect(stage.branches[0]).toEqual({ value: "urgent", targetJob: "urgent_handler" });
671
+ }
672
+ });
673
+
674
+ it('parse route with fallback `_ -> job default`', () => {
675
+ const prog = parse('job main { find /data | route type { "a" -> job handle_a, _ -> job fallback } }\njob handle_a { save /out/a }\njob fallback { save /out/default }');
676
+ const stage = prog.jobs[0].pipeline[1];
677
+ if (stage.kind === "route") {
678
+ expect(stage.branches).toHaveLength(1);
679
+ expect(stage.fallback).toBe("fallback");
680
+ }
681
+ });
682
+
683
+ it('parse route with multiple branches', () => {
684
+ const prog = parse('job main { find /data | route status { "open" -> job handle_open, "closed" -> job handle_closed, _ -> job handle_other } }\njob handle_open { save /out/open }\njob handle_closed { save /out/closed }\njob handle_other { save /out/other }');
685
+ const stage = prog.jobs[0].pipeline[1];
686
+ if (stage.kind === "route") {
687
+ expect(stage.branches).toHaveLength(2);
688
+ expect(stage.fallback).toBe("handle_other");
689
+ }
690
+ });
691
+
692
+ it('parse route with nested field `route result.category { ... }`', () => {
693
+ const prog = parse('job main { find /data | route result.category { "x" -> job handle_x } }\njob handle_x { save /out/x }');
694
+ const stage = prog.jobs[0].pipeline[1];
695
+ if (stage.kind === "route") {
696
+ expect(stage.field).toBe("result.category");
697
+ }
698
+ });
699
+
700
+ // Bad
701
+ it("route without field → error", () => {
702
+ expect(() => parse("job main { route { } }")).toThrow();
703
+ });
704
+
705
+ it("route with empty branches → error", () => {
706
+ expect(() => parse("job main { route field { } }")).toThrow();
707
+ });
708
+
709
+ it("route branch missing -> → error", () => {
710
+ expect(() => parse('job main { route field { "val" job target } }')).toThrow();
711
+ });
712
+ });
713
+
714
+ describe("Phase 8: count/group-by parsing", () => {
715
+ it("parse `count` in pipeline", () => {
716
+ const prog = parse("job q { find /world/x | count }");
717
+ expect(prog.jobs[0].pipeline[1]).toMatchObject({ kind: "count" });
718
+ });
719
+
720
+ it("parse `group-by field` in pipeline", () => {
721
+ const prog = parse("job q { find /world/x | group-by dept }");
722
+ expect(prog.jobs[0].pipeline[1]).toMatchObject({ kind: "group-by", field: "dept" });
723
+ });
724
+
725
+ it("parse `group-by` with dotted field", () => {
726
+ const prog = parse("job q { find /world/x | group-by user.dept }");
727
+ const gb = prog.jobs[0].pipeline[1];
728
+ if (gb.kind === "group-by") {
729
+ expect(gb.field).toBe("user.dept");
730
+ }
731
+ });
732
+
733
+ it("count | save pipeline", () => {
734
+ const prog = parse("job q { find /world/x | count | save /world/out }");
735
+ expect(prog.jobs[0].pipeline).toHaveLength(3);
736
+ expect(prog.jobs[0].pipeline[1]).toMatchObject({ kind: "count" });
737
+ expect(prog.jobs[0].pipeline[2]).toMatchObject({ kind: "save" });
738
+ });
739
+
740
+ it("group-by missing field → error", () => {
741
+ expect(() => parse("job q { group-by }")).toThrow();
742
+ });
743
+
744
+ it("where | count | save full chain", () => {
745
+ const prog = parse("job q { find /world/x | where active == true | count | save /world/out }");
746
+ expect(prog.jobs[0].pipeline).toHaveLength(4);
747
+ });
748
+ });
749
+
750
+ describe("Phase v3-2: lookup parsing", () => {
751
+ it("parses `lookup /data/customers on customer_id`", () => {
752
+ const prog = parse("job q { find /data/orders | lookup /data/customers on customer_id | save /data/out }");
753
+ const lookup = prog.jobs[0].pipeline[1];
754
+ expect(lookup).toMatchObject({ kind: "lookup", path: "/data/customers", joinKey: "customer_id" });
755
+ });
756
+
757
+ it("parses lookup with dotted join key", () => {
758
+ const prog = parse("job q { find /data/orders | lookup /data/users on meta.user_id | save /out }");
759
+ const lookup = prog.jobs[0].pipeline[1];
760
+ expect(lookup).toMatchObject({ kind: "lookup", joinKey: "meta.user_id" });
761
+ });
762
+
763
+ it("lookup without path → syntax error", () => {
764
+ expect(() => parse("job q { lookup }")).toThrow(/path/i);
765
+ });
766
+
767
+ it("lookup /path without 'on' → syntax error", () => {
768
+ expect(() => parse("job q { lookup /data/x | save /out }")).toThrow(/on/i);
769
+ });
770
+
771
+ it("lookup in full pipeline: find | lookup | map | save", () => {
772
+ const prog = parse("job q { find /orders | lookup /customers on cust_id | map name | save /out }");
773
+ expect(prog.jobs[0].pipeline).toHaveLength(4);
774
+ expect(prog.jobs[0].pipeline[1]).toMatchObject({ kind: "lookup" });
775
+ });
776
+ });
777
+
778
+ describe("Phase v3-2: runtime let parsing", () => {
779
+ it("parses `let total = find /data | count`", () => {
780
+ const prog = parse("let total = find /data | count\njob q { find /world/x | save /out }");
781
+ const letStmt = prog.statements[0];
782
+ expect(letStmt.kind).toBe("let");
783
+ expect((letStmt as any).name).toBe("total");
784
+ expect((letStmt as any).pipeline).toBeDefined();
785
+ expect((letStmt as any).pipeline).toHaveLength(2);
786
+ expect((letStmt as any).pipeline[0]).toMatchObject({ kind: "find" });
787
+ expect((letStmt as any).pipeline[1]).toMatchObject({ kind: "count" });
788
+ });
789
+
790
+ it("static let still works: let x = 42", () => {
791
+ const prog = parse("let x = 42\njob q { find /data | save /out }");
792
+ const letStmt = prog.statements[0];
793
+ expect(letStmt.kind).toBe("let");
794
+ expect((letStmt as any).pipeline).toBeUndefined();
795
+ expect((letStmt as any).value).toBe(42);
796
+ });
797
+
798
+ it("runtime let: let count_result = find /data/items | where active == true | count", () => {
799
+ const prog = parse("let count_result = find /data/items | where active == true | count\njob q { find /data | save /out }");
800
+ const letStmt = prog.statements[0] as any;
801
+ expect(letStmt.pipeline).toHaveLength(3);
802
+ });
803
+ });
804
+
805
+ describe("Phase v3-3: param parsing", () => {
806
+ it("parses `param source = /data/users`", () => {
807
+ const prog = parse("param source = /data/users\njob q { find /data | save /out }");
808
+ const param = prog.statements[0] as any;
809
+ expect(param.kind).toBe("param");
810
+ expect(param.name).toBe("source");
811
+ expect(param.defaultValue).toBe("/data/users");
812
+ });
813
+
814
+ it("parses `param threshold = 80` numeric default", () => {
815
+ const prog = parse("param threshold = 80\njob q { find /data | save /out }");
816
+ expect((prog.statements[0] as any).defaultValue).toBe(80);
817
+ });
818
+
819
+ it('parses `param label = "default"` string default', () => {
820
+ const prog = parse('param label = "default"\njob q { find /data | save /out }');
821
+ expect((prog.statements[0] as any).defaultValue).toBe("default");
822
+ });
823
+
824
+ it("multiple params in sequence", () => {
825
+ const prog = parse('param source = /data/users\nparam threshold = 80\nparam label = "test"\njob q { find /data | save /out }');
826
+ expect(prog.statements.filter((s: any) => s.kind === "param")).toHaveLength(3);
827
+ });
828
+
829
+ it("param without name → syntax error", () => {
830
+ expect(() => parse("param = 42")).toThrow(/name/i);
831
+ });
832
+
833
+ it("param without = → syntax error", () => {
834
+ expect(() => parse("param source 42")).toThrow();
835
+ });
836
+
837
+ it("param with reserved keyword name → syntax error", () => {
838
+ expect(() => parse("param job = 42")).toThrow(/reserved/i);
839
+ });
840
+
841
+ it("duplicate param name → syntax error", () => {
842
+ expect(() => parse("param x = 1\nparam x = 2\njob q { find /data }")).toThrow(/[Dd]uplicate param/);
843
+ });
844
+
845
+ it("param name conflicts with prior let → syntax error", () => {
846
+ expect(() => parse("let x = 1\nparam x = 2\njob q { find /data }")).toThrow(/conflicts/i);
847
+ });
848
+
849
+ it("let name conflicts with prior param → syntax error", () => {
850
+ expect(() => parse("param x = 1\nlet x = 2\njob q { find /data }")).toThrow(/conflicts/i);
851
+ });
852
+
853
+ it("different param and let names → ok", () => {
854
+ const prog = parse("param a = 1\nlet b = 2\njob q { find /data }");
855
+ expect(prog.statements.filter((s: any) => s.kind === "param")).toHaveLength(1);
856
+ expect(prog.statements.filter((s: any) => s.kind === "let")).toHaveLength(1);
857
+ });
858
+ });
859
+
860
+ describe("Phase v3-3: on trigger parsing", () => {
861
+ it("parses `job handle on /data/inbox:created { find /data/inbox }`", () => {
862
+ const p = parse("job handle on /data/inbox:created { find /data/inbox }");
863
+ const job = p.jobs[0];
864
+ expect(job.name).toBe("handle");
865
+ expect(job.trigger).toEqual({ kind: "event", path: "/data/inbox", event: "created" });
866
+ expect(job.pipeline.length).toBe(1);
867
+ expect(job.pipeline[0].kind).toBe("find");
868
+ });
869
+
870
+ it("parses trigger with deep path: `job sync on /a/b/c/d:updated { find /a/b/c/d }`", () => {
871
+ const p = parse("job sync on /a/b/c/d:updated { find /a/b/c/d }");
872
+ const job = p.jobs[0];
873
+ expect(job.trigger).toEqual({ kind: "event", path: "/a/b/c/d", event: "updated" });
874
+ });
875
+
876
+ it("job without trigger has no trigger field", () => {
877
+ const p = parse("job plain { find /data }");
878
+ expect(p.jobs[0].trigger).toBeUndefined();
879
+ });
880
+
881
+ it("multiple jobs with different triggers", () => {
882
+ const p = parse(`
883
+ job a on /inbox:created { find /inbox }
884
+ job b on /outbox:sent { find /outbox }
885
+ `);
886
+ expect(p.jobs[0].trigger).toEqual({ kind: "event", path: "/inbox", event: "created" });
887
+ expect(p.jobs[1].trigger).toEqual({ kind: "event", path: "/outbox", event: "sent" });
888
+ });
889
+
890
+ it("trigger + annotations work together", () => {
891
+ const p = parse(`
892
+ @retry(3)
893
+ job handler on /data:updated { find /data }
894
+ `);
895
+ const job = p.jobs[0];
896
+ expect(job.annotations[0].name).toBe("retry");
897
+ expect(job.trigger).toEqual({ kind: "event", path: "/data", event: "updated" });
898
+ });
899
+
900
+ it("`job name on` without path → syntax error", () => {
901
+ expect(() => parse("job handler on { find /data }")).toThrow(/path/i);
902
+ });
903
+
904
+ it("`job name on /path` without :event → syntax error", () => {
905
+ expect(() => parse("job handler on /data { find /data }")).toThrow(/':event'/i);
906
+ });
907
+
908
+ it("`job name on /path:` without event name → syntax error", () => {
909
+ expect(() => parse("job handler on /data: { find /data }")).toThrow(/event name/i);
910
+ });
911
+
912
+ it("parses cron trigger: `job ticker on cron(\"*/5 * * * *\") { ... }`", () => {
913
+ const p = parse('job ticker on cron("*/5 * * * *") { find /metrics | save /snapshots }');
914
+ const job = p.jobs[0];
915
+ expect(job.name).toBe("ticker");
916
+ expect(job.trigger).toEqual({ kind: "cron", expression: "*/5 * * * *" });
917
+ expect(job.pipeline.length).toBe(2);
918
+ });
919
+
920
+ it("cron trigger with annotations", () => {
921
+ const p = parse('@caps(read /data/*)\njob poller on cron("0 * * * *") { find /data/status }');
922
+ const job = p.jobs[0];
923
+ expect(job.annotations[0].name).toBe("caps");
924
+ expect(job.trigger).toEqual({ kind: "cron", expression: "0 * * * *" });
925
+ });
926
+
927
+ it("cron trigger missing parens → syntax error", () => {
928
+ expect(() => parse('job ticker on cron "*/5 * * * *" { find /data }')).toThrow(/'\('/i);
929
+ });
930
+
931
+ it("cron trigger missing expression → syntax error", () => {
932
+ expect(() => parse("job ticker on cron() { find /data }")).toThrow(/cron expression/i);
933
+ });
934
+
935
+ it("cron trigger missing closing paren → syntax error", () => {
936
+ expect(() => parse('job ticker on cron("*/5 * * * *" { find /data }')).toThrow(/'\)'/i);
937
+ });
938
+
939
+ it("mixed event and cron triggers in same script", () => {
940
+ const p = parse(`
941
+ job handler on /data/inbox:created { find /data/inbox }
942
+ job ticker on cron("0 */6 * * *") { find /metrics }
943
+ `);
944
+ expect(p.jobs[0].trigger).toEqual({ kind: "event", path: "/data/inbox", event: "created" });
945
+ expect(p.jobs[1].trigger).toEqual({ kind: "cron", expression: "0 */6 * * *" });
946
+ });
947
+ });
948
+
949
+ // ── Script-level annotation propagation ──
950
+
951
+ describe("Script-level annotations propagate to all jobs", () => {
952
+ it("single-job script — no behavior change", () => {
953
+ const ast = parse('@caps(exec /api/*)\njob a { action /api/send }');
954
+ expect(ast.jobs).toHaveLength(1);
955
+ expect(ast.jobs[0].annotations.some(a => a.name === "caps")).toBe(true);
956
+ });
957
+
958
+ it("multi-job with script-level @caps → both jobs get @caps", () => {
959
+ const ast = parse('@caps(exec /api/*)\njob a { action /api/send }\njob b { action /api/recv }');
960
+ expect(ast.jobs).toHaveLength(2);
961
+ expect(ast.jobs[0].annotations.some(a => a.name === "caps")).toBe(true);
962
+ expect(ast.jobs[1].annotations.some(a => a.name === "caps")).toBe(true);
963
+ // Both should have the same args
964
+ const capsA = ast.jobs[0].annotations.find(a => a.name === "caps")!;
965
+ const capsB = ast.jobs[1].annotations.find(a => a.name === "caps")!;
966
+ expect(capsA.args).toEqual(["exec", "/api/*"]);
967
+ expect(capsB.args).toEqual(["exec", "/api/*"]);
968
+ });
969
+
970
+ it("multi-job with script-level @caps + @budget → both jobs get both", () => {
971
+ const ast = parse('@caps(exec /api/*)\n@budget(actions 10)\njob a { action /api/x }\njob b { action /api/y }');
972
+ expect(ast.jobs).toHaveLength(2);
973
+ for (const job of ast.jobs) {
974
+ expect(job.annotations.some(a => a.name === "caps")).toBe(true);
975
+ expect(job.annotations.some(a => a.name === "budget")).toBe(true);
976
+ }
977
+ });
978
+
979
+ it("second job with own @caps → overrides script-level", () => {
980
+ const ast = parse('@caps(exec /api/*)\njob a { action /api/x }\n@caps(exec /ha/*)\njob b { action /ha/y }');
981
+ expect(ast.jobs).toHaveLength(2);
982
+ const capsA = ast.jobs[0].annotations.find(a => a.name === "caps")!;
983
+ const capsB = ast.jobs[1].annotations.find(a => a.name === "caps")!;
984
+ expect(capsA.args).toEqual(["exec", "/api/*"]);
985
+ expect(capsB.args).toEqual(["exec", "/ha/*"]);
986
+ });
987
+
988
+ it("script-level @caps + job-specific @retry → job gets both", () => {
989
+ const ast = parse('@caps(exec /api/*)\njob a { action /api/x }\n@retry(3)\njob b { action /api/y }');
990
+ expect(ast.jobs).toHaveLength(2);
991
+ // Job b should have inherited @caps + its own @retry
992
+ const jobB = ast.jobs[1];
993
+ expect(jobB.annotations.some(a => a.name === "caps")).toBe(true);
994
+ expect(jobB.annotations.some(a => a.name === "retry")).toBe(true);
995
+ });
996
+
997
+ it("annotations between jobs do NOT become script-level", () => {
998
+ const ast = parse('job a { output "hi" }\n@caps(exec /api/*)\njob b { action /api/x }');
999
+ expect(ast.jobs).toHaveLength(2);
1000
+ // Job a has no annotations (no script-level, no job-specific)
1001
+ expect(ast.jobs[0].annotations).toHaveLength(0);
1002
+ // Job b has its own @caps
1003
+ expect(ast.jobs[1].annotations.some(a => a.name === "caps")).toBe(true);
1004
+ });
1005
+
1006
+ it("@readonly before first job stays job-specific (not script-level)", () => {
1007
+ const ast = parse('@readonly\njob safe { find /data }\njob writer { action /api/send }');
1008
+ expect(ast.jobs).toHaveLength(2);
1009
+ // @readonly only on first job
1010
+ expect(ast.jobs[0].annotations.some(a => a.name === "readonly")).toBe(true);
1011
+ expect(ast.jobs[1].annotations.some(a => a.name === "readonly")).toBe(false);
1012
+ });
1013
+
1014
+ it("@readonly + script-level @caps → @readonly on first job, @caps on both", () => {
1015
+ const ast = parse('@caps(exec /api/*)\n@readonly\njob safe { find /data }\njob writer { action /api/send }');
1016
+ expect(ast.jobs).toHaveLength(2);
1017
+ // @caps on both (script-level)
1018
+ expect(ast.jobs[0].annotations.some(a => a.name === "caps")).toBe(true);
1019
+ expect(ast.jobs[1].annotations.some(a => a.name === "caps")).toBe(true);
1020
+ // @readonly only on first job
1021
+ expect(ast.jobs[0].annotations.some(a => a.name === "readonly")).toBe(true);
1022
+ expect(ast.jobs[1].annotations.some(a => a.name === "readonly")).toBe(false);
1023
+ });
1024
+ });