@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.
- package/DESIGN.md +41 -0
- package/dist/ai-dev-loop/ash-run-result.cjs +12 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.mjs +11 -0
- package/dist/ai-dev-loop/ash-run-result.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.cjs +51 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs +50 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-validate.cjs +27 -0
- package/dist/ai-dev-loop/ash-validate.d.cts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.d.mts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.mjs +28 -0
- package/dist/ai-dev-loop/ash-validate.mjs.map +1 -0
- package/dist/ai-dev-loop/dev-loop.cjs +134 -0
- package/dist/ai-dev-loop/dev-loop.d.cts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.cts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.d.mts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.mts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.mjs +135 -0
- package/dist/ai-dev-loop/dev-loop.mjs.map +1 -0
- package/dist/ai-dev-loop/index.cjs +24 -0
- package/dist/ai-dev-loop/index.d.cts +9 -0
- package/dist/ai-dev-loop/index.d.mts +9 -0
- package/dist/ai-dev-loop/index.mjs +10 -0
- package/dist/ai-dev-loop/live-mode.cjs +17 -0
- package/dist/ai-dev-loop/live-mode.d.cts +24 -0
- package/dist/ai-dev-loop/live-mode.d.cts.map +1 -0
- package/dist/ai-dev-loop/live-mode.d.mts +24 -0
- package/dist/ai-dev-loop/live-mode.d.mts.map +1 -0
- package/dist/ai-dev-loop/live-mode.mjs +17 -0
- package/dist/ai-dev-loop/live-mode.mjs.map +1 -0
- package/dist/ai-dev-loop/meta-tools.cjs +123 -0
- package/dist/ai-dev-loop/meta-tools.d.cts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.cts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.d.mts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.mts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.mjs +120 -0
- package/dist/ai-dev-loop/meta-tools.mjs.map +1 -0
- package/dist/ai-dev-loop/structured-runner.cjs +154 -0
- package/dist/ai-dev-loop/structured-runner.d.cts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.cts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.d.mts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.mts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.mjs +155 -0
- package/dist/ai-dev-loop/structured-runner.mjs.map +1 -0
- package/dist/ai-dev-loop/system-prompt.cjs +55 -0
- package/dist/ai-dev-loop/system-prompt.d.cts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.cts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.d.mts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.mts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.mjs +54 -0
- package/dist/ai-dev-loop/system-prompt.mjs.map +1 -0
- package/dist/ast.d.cts +140 -0
- package/dist/ast.d.cts.map +1 -0
- package/dist/ast.d.mts +140 -0
- package/dist/ast.d.mts.map +1 -0
- package/dist/compiler.cjs +802 -0
- package/dist/compiler.d.cts +103 -0
- package/dist/compiler.d.cts.map +1 -0
- package/dist/compiler.d.mts +103 -0
- package/dist/compiler.d.mts.map +1 -0
- package/dist/compiler.mjs +802 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/lexer.cjs +451 -0
- package/dist/lexer.d.cts +14 -0
- package/dist/lexer.d.cts.map +1 -0
- package/dist/lexer.d.mts +14 -0
- package/dist/lexer.d.mts.map +1 -0
- package/dist/lexer.mjs +451 -0
- package/dist/lexer.mjs.map +1 -0
- package/dist/parser.cjs +734 -0
- package/dist/parser.d.cts +40 -0
- package/dist/parser.d.cts.map +1 -0
- package/dist/parser.d.mts +40 -0
- package/dist/parser.d.mts.map +1 -0
- package/dist/parser.mjs +734 -0
- package/dist/parser.mjs.map +1 -0
- package/dist/reference.cjs +130 -0
- package/dist/reference.d.cts +11 -0
- package/dist/reference.d.cts.map +1 -0
- package/dist/reference.d.mts +11 -0
- package/dist/reference.d.mts.map +1 -0
- package/dist/reference.mjs +130 -0
- package/dist/reference.mjs.map +1 -0
- package/dist/template.cjs +85 -0
- package/dist/template.mjs +84 -0
- package/dist/template.mjs.map +1 -0
- package/dist/type-checker.cjs +582 -0
- package/dist/type-checker.d.cts +31 -0
- package/dist/type-checker.d.cts.map +1 -0
- package/dist/type-checker.d.mts +31 -0
- package/dist/type-checker.d.mts.map +1 -0
- package/dist/type-checker.mjs +573 -0
- package/dist/type-checker.mjs.map +1 -0
- package/package.json +29 -0
- package/src/ai-dev-loop/ash-run-result.test.ts +113 -0
- package/src/ai-dev-loop/ash-run-result.ts +46 -0
- package/src/ai-dev-loop/ash-typed-error.test.ts +136 -0
- package/src/ai-dev-loop/ash-typed-error.ts +50 -0
- package/src/ai-dev-loop/ash-validate.test.ts +54 -0
- package/src/ai-dev-loop/ash-validate.ts +34 -0
- package/src/ai-dev-loop/dev-loop.test.ts +364 -0
- package/src/ai-dev-loop/dev-loop.ts +156 -0
- package/src/ai-dev-loop/dry-run.test.ts +107 -0
- package/src/ai-dev-loop/e2e-multi-fix.test.ts +473 -0
- package/src/ai-dev-loop/e2e.test.ts +324 -0
- package/src/ai-dev-loop/index.ts +15 -0
- package/src/ai-dev-loop/invariants.test.ts +253 -0
- package/src/ai-dev-loop/live-mode.test.ts +63 -0
- package/src/ai-dev-loop/live-mode.ts +33 -0
- package/src/ai-dev-loop/meta-tools.test.ts +120 -0
- package/src/ai-dev-loop/meta-tools.ts +142 -0
- package/src/ai-dev-loop/structured-runner.test.ts +159 -0
- package/src/ai-dev-loop/structured-runner.ts +209 -0
- package/src/ai-dev-loop/system-prompt.test.ts +102 -0
- package/src/ai-dev-loop/system-prompt.ts +81 -0
- package/src/ast.ts +186 -0
- package/src/compiler.test.ts +2933 -0
- package/src/compiler.ts +1103 -0
- package/src/e2e.test.ts +552 -0
- package/src/index.ts +16 -0
- package/src/lexer.test.ts +538 -0
- package/src/lexer.ts +222 -0
- package/src/parser.test.ts +1024 -0
- package/src/parser.ts +835 -0
- package/src/reference.test.ts +166 -0
- package/src/reference.ts +125 -0
- package/src/template.test.ts +210 -0
- package/src/template.ts +139 -0
- package/src/type-checker.test.ts +1494 -0
- package/src/type-checker.ts +785 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +12 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { AshLexer } from "./lexer.js";
|
|
3
|
+
import type { Token } from "./lexer.js";
|
|
4
|
+
|
|
5
|
+
const lex = new AshLexer();
|
|
6
|
+
|
|
7
|
+
function types(tokens: Token[]): string[] {
|
|
8
|
+
return tokens.map((t) => t.type);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("Phase 0: ASH Lexer", () => {
|
|
12
|
+
// ── Happy ──
|
|
13
|
+
|
|
14
|
+
it("tokenize single keyword `job` → JOB token", () => {
|
|
15
|
+
const tokens = lex.tokenize("job");
|
|
16
|
+
expect(tokens[0]).toMatchObject({ type: "JOB", value: "job" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("tokenize all keywords: find, where, map, save, publish, tee, fanout", () => {
|
|
20
|
+
const keywords = ["find", "where", "map", "save", "publish", "tee", "fanout"];
|
|
21
|
+
for (const kw of keywords) {
|
|
22
|
+
const tokens = lex.tokenize(kw);
|
|
23
|
+
expect(tokens[0].type).toBe(kw.toUpperCase());
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("tokenize pipe `|` → PIPE token", () => {
|
|
28
|
+
const tokens = lex.tokenize("|");
|
|
29
|
+
expect(tokens[0]).toMatchObject({ type: "PIPE", value: "|" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("tokenize assign `=` → ASSIGN token", () => {
|
|
33
|
+
const tokens = lex.tokenize("=");
|
|
34
|
+
expect(tokens[0]).toMatchObject({ type: "ASSIGN", value: "=" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("tokenize punctuation: . , () [] {}", () => {
|
|
38
|
+
const tokens = lex.tokenize(". , ( ) [ ] { }");
|
|
39
|
+
const t = types(tokens);
|
|
40
|
+
expect(t).toContain("DOT");
|
|
41
|
+
expect(t).toContain("COMMA");
|
|
42
|
+
expect(t).toContain("LPAREN");
|
|
43
|
+
expect(t).toContain("RPAREN");
|
|
44
|
+
expect(t).toContain("LBRACKET");
|
|
45
|
+
expect(t).toContain("RBRACKET");
|
|
46
|
+
expect(t).toContain("LBRACE");
|
|
47
|
+
expect(t).toContain("RBRACE");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("tokenize string literal `\"hello world\"` with space", () => {
|
|
51
|
+
const tokens = lex.tokenize('"hello world"');
|
|
52
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "hello world" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("tokenize numbers: integer 42, float 3.14", () => {
|
|
56
|
+
expect(lex.tokenize("42")[0]).toMatchObject({ type: "NUMBER", value: "42" });
|
|
57
|
+
expect(lex.tokenize("3.14")[0]).toMatchObject({ type: "NUMBER", value: "3.14" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("tokenize path `/world/users/active` → PATH token", () => {
|
|
61
|
+
const tokens = lex.tokenize("/world/users/active");
|
|
62
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/world/users/active" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("tokenize annotation `@` → AT token", () => {
|
|
66
|
+
const tokens = lex.tokenize("@");
|
|
67
|
+
expect(tokens[0]).toMatchObject({ type: "AT", value: "@" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("tokenize identifier `myVar123`", () => {
|
|
71
|
+
const tokens = lex.tokenize("myVar123");
|
|
72
|
+
expect(tokens[0]).toMatchObject({ type: "IDENTIFIER", value: "myVar123" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("tokenize full pipeline", () => {
|
|
76
|
+
const tokens = lex.tokenize("find /world/users | where age > 18 | save /world/results");
|
|
77
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
78
|
+
expect(t).toEqual(["FIND", "PATH", "PIPE", "WHERE", "IDENTIFIER", "GT", "NUMBER", "PIPE", "SAVE", "PATH"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("multiline source has correct line/column", () => {
|
|
82
|
+
const tokens = lex.tokenize("job\nfind");
|
|
83
|
+
const job = tokens[0];
|
|
84
|
+
expect(job.line).toBe(1);
|
|
85
|
+
const find = tokens.find((t) => t.type === "FIND")!;
|
|
86
|
+
expect(find.line).toBe(2);
|
|
87
|
+
expect(find.column).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("NEWLINE token between statements", () => {
|
|
91
|
+
const tokens = lex.tokenize("job\nfind");
|
|
92
|
+
expect(types(tokens)).toContain("NEWLINE");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("last token is always EOF", () => {
|
|
96
|
+
expect(lex.tokenize("job").at(-1)!.type).toBe("EOF");
|
|
97
|
+
expect(lex.tokenize("").at(-1)!.type).toBe("EOF");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── Bad ──
|
|
101
|
+
|
|
102
|
+
it("unterminated string → error with position", () => {
|
|
103
|
+
expect(() => lex.tokenize('"hello')).toThrow(/unterminated.*string/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("`#` is a comment, not an error", () => {
|
|
107
|
+
const tokens = lex.tokenize("# this is a comment");
|
|
108
|
+
expect(types(tokens)).toEqual(["EOF"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Edge ──
|
|
112
|
+
|
|
113
|
+
it("empty source → only EOF", () => {
|
|
114
|
+
const tokens = lex.tokenize("");
|
|
115
|
+
expect(tokens).toHaveLength(1);
|
|
116
|
+
expect(tokens[0].type).toBe("EOF");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("whitespace only → NEWLINE(s) + EOF or just EOF", () => {
|
|
120
|
+
const tokens = lex.tokenize(" \t ");
|
|
121
|
+
expect(tokens).toHaveLength(1); // just EOF, no newlines
|
|
122
|
+
expect(tokens[0].type).toBe("EOF");
|
|
123
|
+
|
|
124
|
+
const tokens2 = lex.tokenize("\n\n");
|
|
125
|
+
const t2 = types(tokens2);
|
|
126
|
+
expect(t2.filter((x) => x === "NEWLINE")).toHaveLength(2);
|
|
127
|
+
expect(t2.at(-1)).toBe("EOF");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("string with escaped quote", () => {
|
|
131
|
+
const tokens = lex.tokenize('"say \\"hi\\""');
|
|
132
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: 'say "hi"' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("bare `/` followed by space → SLASH token (not path)", () => {
|
|
136
|
+
const tokens = lex.tokenize("/ ");
|
|
137
|
+
expect(tokens[0]).toMatchObject({ type: "SLASH", value: "/" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("MAX_SAFE_INTEGER tokenized correctly", () => {
|
|
141
|
+
const n = String(Number.MAX_SAFE_INTEGER);
|
|
142
|
+
const tokens = lex.tokenize(n);
|
|
143
|
+
expect(tokens[0]).toMatchObject({ type: "NUMBER", value: n });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keyword prefix identifier `finder` → IDENTIFIER not FIND", () => {
|
|
147
|
+
const tokens = lex.tokenize("finder");
|
|
148
|
+
expect(tokens[0]).toMatchObject({ type: "IDENTIFIER", value: "finder" });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── Security ──
|
|
152
|
+
|
|
153
|
+
it("string containing ${process.exit()} → treated as string data", () => {
|
|
154
|
+
const tokens = lex.tokenize('"${process.exit()}"');
|
|
155
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "${process.exit()}" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("path with /../../../etc/passwd → treated as PATH token", () => {
|
|
159
|
+
const tokens = lex.tokenize("/../../../etc/passwd");
|
|
160
|
+
expect(tokens[0]).toMatchObject({ type: "PATH" });
|
|
161
|
+
expect(tokens[0].value).toContain("/../");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── Output / Input keywords ──
|
|
165
|
+
|
|
166
|
+
it("tokenize `input` → INPUT token", () => {
|
|
167
|
+
const tokens = lex.tokenize("input");
|
|
168
|
+
expect(tokens[0]).toMatchObject({ type: "INPUT", value: "input" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("tokenize output with string: `output \"hello\"` → OUTPUT STRING", () => {
|
|
172
|
+
const tokens = lex.tokenize('output "hello"');
|
|
173
|
+
expect(types(tokens).filter((x) => x !== "EOF")).toEqual(["OUTPUT", "STRING"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("tokenize input with string: `input \"name?\"` → INPUT STRING", () => {
|
|
177
|
+
const tokens = lex.tokenize('input "name?"');
|
|
178
|
+
expect(types(tokens).filter((x) => x !== "EOF")).toEqual(["INPUT", "STRING"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("keyword prefix `outputter` → IDENTIFIER not OUTPUT", () => {
|
|
182
|
+
const tokens = lex.tokenize("outputter");
|
|
183
|
+
expect(tokens[0]).toMatchObject({ type: "IDENTIFIER", value: "outputter" });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Data Loss ──
|
|
187
|
+
|
|
188
|
+
it("mixed line endings \\r\\n and \\n → consistent line numbers", () => {
|
|
189
|
+
const tokens = lex.tokenize("job\r\nfind\nmap");
|
|
190
|
+
const find = tokens.find((t) => t.type === "FIND")!;
|
|
191
|
+
const map = tokens.find((t) => t.type === "MAP")!;
|
|
192
|
+
expect(find.line).toBe(2);
|
|
193
|
+
expect(map.line).toBe(3);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("Phase 5: Comments", () => {
|
|
198
|
+
// ── Happy ──
|
|
199
|
+
|
|
200
|
+
it("full line comment → skipped, no token produced", () => {
|
|
201
|
+
const tokens = lex.tokenize("# full line comment");
|
|
202
|
+
expect(types(tokens)).toEqual(["EOF"]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("inline comment after code → only code tokens produced", () => {
|
|
206
|
+
const tokens = lex.tokenize("find /world/x # inline comment");
|
|
207
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
208
|
+
expect(t).toEqual(["FIND", "PATH"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("line numbers correct after comment lines", () => {
|
|
212
|
+
const tokens = lex.tokenize("# comment\njob");
|
|
213
|
+
const job = tokens.find((t) => t.type === "JOB")!;
|
|
214
|
+
expect(job.line).toBe(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("multiple consecutive comment lines → all skipped", () => {
|
|
218
|
+
const tokens = lex.tokenize("# line 1\n# line 2\n# line 3\njob");
|
|
219
|
+
const nonNewlineEof = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
220
|
+
expect(nonNewlineEof).toEqual(["JOB"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("comment + job mixed program → correct parse and compile", () => {
|
|
224
|
+
const source = '# header\njob etl {\n find /world/x # query\n | save /world/y\n}\n# footer';
|
|
225
|
+
const tokens = lex.tokenize(source);
|
|
226
|
+
// Should have JOB, IDENTIFIER, LBRACE, FIND, PATH, NEWLINE, PIPE, SAVE, PATH, NEWLINE, RBRACE, etc.
|
|
227
|
+
const meaningful = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
228
|
+
expect(meaningful).toContain("JOB");
|
|
229
|
+
expect(meaningful).toContain("FIND");
|
|
230
|
+
expect(meaningful).toContain("SAVE");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── Bad ──
|
|
234
|
+
|
|
235
|
+
it("# inside string is NOT a comment", () => {
|
|
236
|
+
const tokens = lex.tokenize('"hello # world"');
|
|
237
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "hello # world" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── Edge ──
|
|
241
|
+
|
|
242
|
+
it("only comments → empty program (just EOF)", () => {
|
|
243
|
+
const tokens = lex.tokenize("# only comments\n# nothing else");
|
|
244
|
+
const t = types(tokens).filter((x) => x !== "NEWLINE");
|
|
245
|
+
expect(t).toEqual(["EOF"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("comment right after { or }", () => {
|
|
249
|
+
const tokens = lex.tokenize("{# comment\n}# comment");
|
|
250
|
+
const t = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
251
|
+
expect(t).toEqual(["LBRACE", "RBRACE"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("comment after pipe | → next line continues pipeline", () => {
|
|
255
|
+
const tokens = lex.tokenize("find /world/x | # filter next\nwhere active == true");
|
|
256
|
+
const t = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
257
|
+
expect(t).toEqual(["FIND", "PATH", "PIPE", "WHERE", "IDENTIFIER", "EQ", "IDENTIFIER"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("empty comment # → skipped", () => {
|
|
261
|
+
const tokens = lex.tokenize("#\njob");
|
|
262
|
+
const t = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
263
|
+
expect(t).toEqual(["JOB"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── Security ──
|
|
267
|
+
|
|
268
|
+
it("code in comment # process.exit() → completely ignored", () => {
|
|
269
|
+
const tokens = lex.tokenize("# process.exit()\njob");
|
|
270
|
+
const t = types(tokens).filter((x) => x !== "NEWLINE" && x !== "EOF");
|
|
271
|
+
expect(t).toEqual(["JOB"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Data Loss ──
|
|
275
|
+
|
|
276
|
+
it("comments do not break any existing v0 tests (regression)", () => {
|
|
277
|
+
// A v0 program without comments still works
|
|
278
|
+
const tokens = lex.tokenize('job etl { find /world/x | where active == true | save /world/y }');
|
|
279
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
280
|
+
expect(t).toContain("JOB");
|
|
281
|
+
expect(t).toContain("FIND");
|
|
282
|
+
expect(t).toContain("WHERE");
|
|
283
|
+
expect(t).toContain("SAVE");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("Phase 7: let/$ tokens", () => {
|
|
288
|
+
it("tokenize `let` → LET token", () => {
|
|
289
|
+
const tokens = lex.tokenize("let");
|
|
290
|
+
expect(tokens[0]).toMatchObject({ type: "LET", value: "let" });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("tokenize `$varname` → DOLLAR + IDENTIFIER", () => {
|
|
294
|
+
const tokens = lex.tokenize("$threshold");
|
|
295
|
+
expect(tokens[0]).toMatchObject({ type: "DOLLAR", value: "$" });
|
|
296
|
+
expect(tokens[1]).toMatchObject({ type: "IDENTIFIER", value: "threshold" });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("let x = 42 → LET IDENTIFIER ASSIGN NUMBER", () => {
|
|
300
|
+
const tokens = lex.tokenize("let x = 42");
|
|
301
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
302
|
+
expect(t).toEqual(["LET", "IDENTIFIER", "ASSIGN", "NUMBER"]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("Phase v3-0: action token", () => {
|
|
307
|
+
it("tokenize `action` → ACTION token", () => {
|
|
308
|
+
const tokens = lex.tokenize("action");
|
|
309
|
+
expect(tokens[0]).toMatchObject({ type: "ACTION", value: "action" });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("tokenize `action /tools/classify` → ACTION PATH", () => {
|
|
313
|
+
const tokens = lex.tokenize("action /tools/classify");
|
|
314
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
315
|
+
expect(t).toEqual(["ACTION", "PATH"]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("tokenize `action /tools/email { template: \"welcome\" }` → ACTION PATH LBRACE ... RBRACE", () => {
|
|
319
|
+
const tokens = lex.tokenize('action /tools/email { template: "welcome" }');
|
|
320
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
321
|
+
expect(t).toEqual(["ACTION", "PATH", "LBRACE", "IDENTIFIER", "COLON", "STRING", "RBRACE"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("tokenize `:` → COLON token", () => {
|
|
325
|
+
const tokens = lex.tokenize(":");
|
|
326
|
+
expect(tokens[0]).toMatchObject({ type: "COLON", value: ":" });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("keyword prefix `actionable` → IDENTIFIER not ACTION", () => {
|
|
330
|
+
const tokens = lex.tokenize("actionable");
|
|
331
|
+
expect(tokens[0]).toMatchObject({ type: "IDENTIFIER", value: "actionable" });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("action in pipeline: `find /data | action /tools/x | save /out`", () => {
|
|
335
|
+
const tokens = lex.tokenize("find /data | action /tools/x | save /out");
|
|
336
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
337
|
+
expect(t).toEqual(["FIND", "PATH", "PIPE", "ACTION", "PATH", "PIPE", "SAVE", "PATH"]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("Phase v3-1: expression operator tokens", () => {
|
|
342
|
+
it("tokenize `+` → PLUS token", () => {
|
|
343
|
+
const tokens = lex.tokenize("+");
|
|
344
|
+
expect(tokens[0]).toMatchObject({ type: "PLUS", value: "+" });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("tokenize `-` → MINUS token", () => {
|
|
348
|
+
const tokens = lex.tokenize("-");
|
|
349
|
+
expect(tokens[0]).toMatchObject({ type: "MINUS", value: "-" });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("tokenize `*` → STAR token", () => {
|
|
353
|
+
const tokens = lex.tokenize("*");
|
|
354
|
+
expect(tokens[0]).toMatchObject({ type: "STAR", value: "*" });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("tokenize `/` alone (EOF after) → SLASH token", () => {
|
|
358
|
+
const tokens = lex.tokenize("/");
|
|
359
|
+
expect(tokens[0]).toMatchObject({ type: "SLASH", value: "/" });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("tokenize `/ 2` → SLASH NUMBER", () => {
|
|
363
|
+
const tokens = lex.tokenize("/ 2");
|
|
364
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
365
|
+
expect(t).toEqual(["SLASH", "NUMBER"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("tokenize `/world/data` → PATH (still works)", () => {
|
|
369
|
+
const tokens = lex.tokenize("/world/data");
|
|
370
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/world/data" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("operators in context: `map { x: a + b }` tokenizes correctly", () => {
|
|
374
|
+
const tokens = lex.tokenize("map { x: a + b }");
|
|
375
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
376
|
+
expect(t).toEqual(["MAP", "LBRACE", "IDENTIFIER", "COLON", "IDENTIFIER", "PLUS", "IDENTIFIER", "RBRACE"]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("operators in context: `map { x: a * 0.8 + b }` tokenizes correctly", () => {
|
|
380
|
+
const tokens = lex.tokenize("map { x: a * 0.8 + b }");
|
|
381
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
382
|
+
expect(t).toEqual(["MAP", "LBRACE", "IDENTIFIER", "COLON", "IDENTIFIER", "STAR", "NUMBER", "PLUS", "IDENTIFIER", "RBRACE"]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("operators in context: `map { x: a / b - c }` with SLASH not path", () => {
|
|
386
|
+
const tokens = lex.tokenize("map { x: a / b - c }");
|
|
387
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
388
|
+
expect(t).toEqual(["MAP", "LBRACE", "IDENTIFIER", "COLON", "IDENTIFIER", "SLASH", "IDENTIFIER", "MINUS", "IDENTIFIER", "RBRACE"]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("group-by still works with - in compound keyword", () => {
|
|
392
|
+
const tokens = lex.tokenize("group-by dept");
|
|
393
|
+
expect(tokens[0]).toMatchObject({ type: "GROUP_BY", value: "group-by" });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("paths with dot prefix still work: `/../etc`", () => {
|
|
397
|
+
const tokens = lex.tokenize("/../etc");
|
|
398
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/../etc" });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("Phase v3-2: route tokens", () => {
|
|
403
|
+
it("tokenize `route` → ROUTE token", () => {
|
|
404
|
+
const tokens = lex.tokenize("route");
|
|
405
|
+
expect(tokens[0]).toMatchObject({ type: "ROUTE", value: "route" });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("tokenize `->` → ARROW token", () => {
|
|
409
|
+
const tokens = lex.tokenize("->");
|
|
410
|
+
expect(tokens[0]).toMatchObject({ type: "ARROW", value: "->" });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("`-` alone → MINUS, not ARROW", () => {
|
|
414
|
+
const tokens = lex.tokenize("- 1");
|
|
415
|
+
expect(tokens[0]).toMatchObject({ type: "MINUS", value: "-" });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("route branch context tokenizes correctly", () => {
|
|
419
|
+
const tokens = lex.tokenize('route category { "urgent" -> job handle }');
|
|
420
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
421
|
+
expect(t).toEqual(["ROUTE", "IDENTIFIER", "LBRACE", "STRING", "ARROW", "JOB", "IDENTIFIER", "RBRACE"]);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("Phase 8: count/group-by tokens", () => {
|
|
426
|
+
it("tokenize `count` → COUNT token", () => {
|
|
427
|
+
const tokens = lex.tokenize("count");
|
|
428
|
+
expect(tokens[0]).toMatchObject({ type: "COUNT", value: "count" });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("tokenize `group` → IDENTIFIER (not keyword alone)", () => {
|
|
432
|
+
const tokens = lex.tokenize("group");
|
|
433
|
+
expect(tokens[0]).toMatchObject({ type: "IDENTIFIER", value: "group" });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("tokenize pipeline with count: find | count", () => {
|
|
437
|
+
const tokens = lex.tokenize("find /world/x | count");
|
|
438
|
+
const t = types(tokens).filter((x) => x !== "EOF");
|
|
439
|
+
expect(t).toEqual(["FIND", "PATH", "PIPE", "COUNT"]);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("Phase v3-2: lookup tokens", () => {
|
|
444
|
+
it("tokenizes `lookup` as LOOKUP keyword", () => {
|
|
445
|
+
const tokens = lex.tokenize("lookup");
|
|
446
|
+
expect(tokens[0]).toMatchObject({ type: "LOOKUP", value: "lookup" });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("tokenizes `lookup /data/customers on customer_id`", () => {
|
|
450
|
+
const tokens = lex.tokenize("lookup /data/customers on customer_id");
|
|
451
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
452
|
+
expect(t).toEqual(["LOOKUP", "PATH", "ON", "IDENTIFIER"]);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("Template Parameters: Lexer", () => {
|
|
457
|
+
// STRING templates
|
|
458
|
+
it("string with ${name} preserves template in token value", () => {
|
|
459
|
+
const tokens = lex.tokenize('"hello ${name}"');
|
|
460
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "hello ${name}" });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("string with escaped \\${literal} preserves \\$ in token value", () => {
|
|
464
|
+
const tokens = lex.tokenize('"hello \\${literal}"');
|
|
465
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "hello \\${literal}" });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("string with multiple templates preserves all", () => {
|
|
469
|
+
const tokens = lex.tokenize('"${a} and ${b}"');
|
|
470
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "${a} and ${b}" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("string with nested field template ${data.id}", () => {
|
|
474
|
+
const tokens = lex.tokenize('"id=${data.id}"');
|
|
475
|
+
expect(tokens[0]).toMatchObject({ type: "STRING", value: "id=${data.id}" });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// PATH templates
|
|
479
|
+
it("path with mid-path template: /store/${category}/.actions/save", () => {
|
|
480
|
+
const tokens = lex.tokenize("/store/${category}/.actions/save");
|
|
481
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/store/${category}/.actions/save" });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("path with nested field template: /msgs/${data.messageId}", () => {
|
|
485
|
+
const tokens = lex.tokenize("/msgs/${data.messageId}");
|
|
486
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/msgs/${data.messageId}" });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("path with multiple templates: /conv/${convId}/msgs/${msgId}", () => {
|
|
490
|
+
const tokens = lex.tokenize("/conv/${convId}/msgs/${msgId}");
|
|
491
|
+
expect(tokens[0]).toMatchObject({ type: "PATH", value: "/conv/${convId}/msgs/${msgId}" });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("path with template followed by params: action /send/${id}/.actions/do { text: \"hi\" }", () => {
|
|
495
|
+
const tokens = lex.tokenize('action /send/${id}/.actions/do { text: "hi" }');
|
|
496
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
497
|
+
expect(t).toEqual(["ACTION", "PATH", "LBRACE", "IDENTIFIER", "COLON", "STRING", "RBRACE"]);
|
|
498
|
+
expect(tokens[1].value).toBe("/send/${id}/.actions/do");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("Phase v3-3: param tokens", () => {
|
|
503
|
+
it("tokenizes `param` as PARAM keyword", () => {
|
|
504
|
+
const tokens = lex.tokenize("param");
|
|
505
|
+
expect(tokens[0]).toMatchObject({ type: "PARAM", value: "param" });
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("tokenizes `param source = /data/users`", () => {
|
|
509
|
+
const tokens = lex.tokenize("param source = /data/users");
|
|
510
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
511
|
+
expect(t).toEqual(["PARAM", "IDENTIFIER", "ASSIGN", "PATH"]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("tokenizes `param threshold = 80`", () => {
|
|
515
|
+
const tokens = lex.tokenize("param threshold = 80");
|
|
516
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
517
|
+
expect(t).toEqual(["PARAM", "IDENTIFIER", "ASSIGN", "NUMBER"]);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("Phase v3-3: on trigger tokens", () => {
|
|
522
|
+
it("tokenizes `on` as ON keyword", () => {
|
|
523
|
+
const tokens = lex.tokenize("on");
|
|
524
|
+
expect(tokens[0]).toMatchObject({ type: "ON", value: "on" });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("tokenizes `job handle on /data/inbox:created { }` trigger context", () => {
|
|
528
|
+
const tokens = lex.tokenize("job handle on /data/inbox:created { }");
|
|
529
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
530
|
+
expect(t).toEqual(["JOB", "IDENTIFIER", "ON", "PATH", "COLON", "IDENTIFIER", "LBRACE", "RBRACE"]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("trigger with deep path: `on /a/b/c/d:updated`", () => {
|
|
534
|
+
const tokens = lex.tokenize("on /a/b/c/d:updated");
|
|
535
|
+
const t = types(tokens).filter(x => x !== "EOF");
|
|
536
|
+
expect(t).toEqual(["ON", "PATH", "COLON", "IDENTIFIER"]);
|
|
537
|
+
});
|
|
538
|
+
});
|