@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,166 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ASH_REFERENCE } from "./reference.js";
|
|
3
|
+
import { compileSource } from "./compiler.js";
|
|
4
|
+
|
|
5
|
+
describe("ASH_REFERENCE", () => {
|
|
6
|
+
it("is a non-empty string", () => {
|
|
7
|
+
expect(typeof ASH_REFERENCE).toBe("string");
|
|
8
|
+
expect(ASH_REFERENCE.length).toBeGreaterThan(500);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// ── All builtin commands documented ──
|
|
12
|
+
|
|
13
|
+
const commands = [
|
|
14
|
+
"find",
|
|
15
|
+
"where",
|
|
16
|
+
"map",
|
|
17
|
+
"save",
|
|
18
|
+
"publish",
|
|
19
|
+
"tee",
|
|
20
|
+
"fanout",
|
|
21
|
+
"output",
|
|
22
|
+
"input",
|
|
23
|
+
"count",
|
|
24
|
+
"group-by",
|
|
25
|
+
"action",
|
|
26
|
+
"route",
|
|
27
|
+
"lookup",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const cmd of commands) {
|
|
31
|
+
it(`documents command: ${cmd}`, () => {
|
|
32
|
+
expect(ASH_REFERENCE).toContain(cmd);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── All annotations documented ──
|
|
37
|
+
|
|
38
|
+
const annotations = ["@approval", "@retry", "@timeout", "@readonly", "@on_error"];
|
|
39
|
+
|
|
40
|
+
for (const ann of annotations) {
|
|
41
|
+
it(`documents annotation: ${ann}`, () => {
|
|
42
|
+
expect(ASH_REFERENCE).toContain(ann);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Key language features documented ──
|
|
47
|
+
|
|
48
|
+
it("documents variables (let + $ref)", () => {
|
|
49
|
+
expect(ASH_REFERENCE).toContain("let ");
|
|
50
|
+
expect(ASH_REFERENCE).toMatch(/\$\w+/); // $variable reference
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("documents comments (#)", () => {
|
|
54
|
+
expect(ASH_REFERENCE).toContain("#");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("documents jobs", () => {
|
|
58
|
+
expect(ASH_REFERENCE).toContain("job ");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("documents pipe operator", () => {
|
|
62
|
+
expect(ASH_REFERENCE).toContain("|");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("documents comparison operators", () => {
|
|
66
|
+
expect(ASH_REFERENCE).toContain("==");
|
|
67
|
+
expect(ASH_REFERENCE).toContain("!=");
|
|
68
|
+
expect(ASH_REFERENCE).toContain(">");
|
|
69
|
+
expect(ASH_REFERENCE).toContain("<");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("documents map object transform syntax { key field }", () => {
|
|
73
|
+
expect(ASH_REFERENCE).toMatch(/map\s*\{/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("documents find...where inline query", () => {
|
|
77
|
+
expect(ASH_REFERENCE).toMatch(/find\s+\/\S+\s+where/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Type system documented ──
|
|
81
|
+
|
|
82
|
+
it("documents stream types", () => {
|
|
83
|
+
expect(ASH_REFERENCE).toContain("object_stream");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("explains that save/publish produce no output", () => {
|
|
87
|
+
// Should mention that save and publish are terminal (output none)
|
|
88
|
+
expect(ASH_REFERENCE).toMatch(/save.*none|none.*save/i);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Contains at least one complete example ──
|
|
92
|
+
|
|
93
|
+
it("contains a complete working example in a code block", () => {
|
|
94
|
+
// Extract code blocks from the reference
|
|
95
|
+
const codeBlocks = ASH_REFERENCE.match(/```ash\n([\s\S]*?)```/g);
|
|
96
|
+
expect(codeBlocks).not.toBeNull();
|
|
97
|
+
expect(codeBlocks!.length).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("all code block examples compile without errors", () => {
|
|
101
|
+
const codeBlockMatches = ASH_REFERENCE.matchAll(/```ash\n([\s\S]*?)```/g);
|
|
102
|
+
let blockCount = 0;
|
|
103
|
+
for (const match of codeBlockMatches) {
|
|
104
|
+
const source = match[1].trim();
|
|
105
|
+
if (source.length === 0) continue;
|
|
106
|
+
blockCount++;
|
|
107
|
+
const result = compileSource(source);
|
|
108
|
+
const errors = result.diagnostics.filter(d => d.severity !== "warning");
|
|
109
|
+
expect(errors, `Code block should compile: ${source.slice(0, 80)}...`).toEqual([]);
|
|
110
|
+
}
|
|
111
|
+
expect(blockCount).toBeGreaterThan(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── v3 features documented ──
|
|
115
|
+
|
|
116
|
+
it("documents action /path command", () => {
|
|
117
|
+
expect(ASH_REFERENCE).toMatch(/action\s+\/\S+/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("documents route { ... } command", () => {
|
|
121
|
+
expect(ASH_REFERENCE).toMatch(/route\b/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("documents lookup /path on key command", () => {
|
|
125
|
+
expect(ASH_REFERENCE).toMatch(/lookup\s+\/\S+\s+on\s+\w+/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("documents expression syntax (arithmetic + $var)", () => {
|
|
129
|
+
expect(ASH_REFERENCE).toMatch(/\*\s*\d|\d\s*\*/); // multiplication
|
|
130
|
+
expect(ASH_REFERENCE).toMatch(/\$\w+/); // $variable in expressions
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("documents map { key: expr } colon syntax", () => {
|
|
134
|
+
expect(ASH_REFERENCE).toMatch(/map\s*\{[^}]*:/); // colon inside map braces
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("documents param name = default declaration", () => {
|
|
138
|
+
expect(ASH_REFERENCE).toMatch(/param\s+\w+\s*=/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("documents on /path:event trigger declaration", () => {
|
|
142
|
+
expect(ASH_REFERENCE).toMatch(/on\s+\/\S+:\w+/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("documents @on_error annotation", () => {
|
|
146
|
+
expect(ASH_REFERENCE).toContain("@on_error");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("documents runtime let with expressions", () => {
|
|
150
|
+
// let with pipeline assignment
|
|
151
|
+
expect(ASH_REFERENCE).toMatch(/let\s+\w+\s*=\s*find/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── No stale references ──
|
|
155
|
+
|
|
156
|
+
it("does not reference removed 'echo' command", () => {
|
|
157
|
+
// echo was removed — reference must not mention it as a command
|
|
158
|
+
expect(ASH_REFERENCE).not.toMatch(/\becho\b/i);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Reasonable size ──
|
|
162
|
+
|
|
163
|
+
it("is concise enough for LLM context (under 6000 chars)", () => {
|
|
164
|
+
expect(ASH_REFERENCE.length).toBeLessThan(6000);
|
|
165
|
+
});
|
|
166
|
+
});
|
package/src/reference.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASH language reference — designed for LLM consumption.
|
|
3
|
+
*
|
|
4
|
+
* Exported as a string constant so any consumer (AFS provider, AI dev loop,
|
|
5
|
+
* CLI help) can use the same single source of truth.
|
|
6
|
+
*/
|
|
7
|
+
export const ASH_REFERENCE = `# ASH Language Reference
|
|
8
|
+
|
|
9
|
+
ASH is a deterministic pipeline DSL. No imports, no eval, no control flow — compiler-safe by design.
|
|
10
|
+
|
|
11
|
+
## Structure
|
|
12
|
+
|
|
13
|
+
\`\`\`ash
|
|
14
|
+
# Comments start with #
|
|
15
|
+
param threshold = 70
|
|
16
|
+
|
|
17
|
+
let total = find /data/users | count
|
|
18
|
+
|
|
19
|
+
output "Starting ETL"
|
|
20
|
+
|
|
21
|
+
@caps(read /data/* write /data/*)
|
|
22
|
+
job extract {
|
|
23
|
+
find /data/users where active == true
|
|
24
|
+
| where score > $threshold
|
|
25
|
+
| map { fullName: name + " " + surname, score: score * 2 + $bonus }
|
|
26
|
+
| tee /data/backup
|
|
27
|
+
| save /data/clean
|
|
28
|
+
}
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
**Top-level statements**: \`param\`, \`let\`, \`output\`, \`job\`
|
|
32
|
+
**Pipes**: stages separated by \`|\`
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | Input | Output | Description |
|
|
37
|
+
|---------|-------|--------|-------------|
|
|
38
|
+
| \`find /path\` | none | object_stream | Read records from path. Each record contains \`path\`, \`name\`, \`kind\` + provider-specific content fields (e.g. \`battery_level\`, \`state\`, \`label\`). |
|
|
39
|
+
| \`find /path where f == v\` | none | object_stream | Read with inline filter (query pushdown). Same output schema as \`find\`. |
|
|
40
|
+
| \`where field op value\` | object_stream | object_stream | Filter records (\`==\` \`!=\` \`>\` \`<\` \`>=\` \`<=\`) |
|
|
41
|
+
| \`map field\` | object_stream | object_stream | Extract single field |
|
|
42
|
+
| \`map { out1 in1, out2 in2 }\` | object_stream | object_stream | Object transform (rename/reshape) |
|
|
43
|
+
| \`map { key: expr }\` | object_stream | object_stream | Expression transform (\`score: score * 2\`) |
|
|
44
|
+
| \`save /path\` | object_stream | none | Write records to path (terminal) |
|
|
45
|
+
| \`publish /topic\` | object_stream | none | Publish records to topic (terminal) |
|
|
46
|
+
| \`tee /path\` | object_stream | object_stream | Side-write copy, stream continues |
|
|
47
|
+
| \`count\` | object_stream | object_stream | Produce \`{ count: N }\` → schema: \`{ count: number }\` |
|
|
48
|
+
| \`group-by field\` | object_stream | object_stream | Produce \`[{ key, items }]\` → schema: \`{ key: T, items: T[] }\` |
|
|
49
|
+
| \`output "msg"\` | object_stream | object_stream | Emit text message, pass-through |
|
|
50
|
+
| \`output expr\` | object_stream | object_stream | Evaluate expression per record, emit as text, pass-through |
|
|
51
|
+
| \`input "prompt"\` | none | object_stream | Prompt for input → schema: \`{ prompt: string, response: string }\` |
|
|
52
|
+
| \`fanout { branch1, branch2 }\` | object_stream | object_stream | Split stream to parallel branches |
|
|
53
|
+
| \`action /path\` | object_stream | object_stream | Execute external action via \`world.exec\` |
|
|
54
|
+
| \`route field { "v1" -> job j1, ... }\` | object_stream | none | Dispatch items by field value to named jobs |
|
|
55
|
+
| \`lookup /path on key\` | object_stream | object_stream | Left-join with data at path on key field |
|
|
56
|
+
|
|
57
|
+
**Type rule**: each stage's output type must match the next stage's input type. \`save\`, \`publish\`, and \`route\` output \`none\` — nothing can follow them in a pipeline.
|
|
58
|
+
|
|
59
|
+
## Params & Variables
|
|
60
|
+
|
|
61
|
+
\`\`\`ash
|
|
62
|
+
param min_score = 70
|
|
63
|
+
let bonus = 10
|
|
64
|
+
let total = find /data/users | count
|
|
65
|
+
job q { find /data/users | where score > $min_score | map { adjusted: score * 2 + $bonus } | save /data/out }
|
|
66
|
+
\`\`\`
|
|
67
|
+
|
|
68
|
+
\`param\` declares a named default that callers can override at execution time.
|
|
69
|
+
\`let\` binds a name to a literal or a pipeline result. Reference with \`$name\` in where clauses and expressions.
|
|
70
|
+
|
|
71
|
+
## Expressions
|
|
72
|
+
|
|
73
|
+
Arithmetic: \`score * 2 + $bonus\`, \`price - discount\`
|
|
74
|
+
String concat: \`name + " " + surname\`
|
|
75
|
+
Usable in \`map { key: expr }\` and runtime \`let\`.
|
|
76
|
+
|
|
77
|
+
## Triggers
|
|
78
|
+
|
|
79
|
+
\`\`\`ash
|
|
80
|
+
job ingest on /data/raw:created { find /data/raw | save /data/clean }
|
|
81
|
+
job ticker on cron("*/5 * * * *") { find /metrics | save /snapshots }
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
\`on /path:event\` — runs when the named event fires on the given path.
|
|
85
|
+
\`on cron("expr")\` — runs on a cron schedule. Standard 5-field cron expression (minute hour day-of-month month day-of-week).
|
|
86
|
+
|
|
87
|
+
## Annotations
|
|
88
|
+
|
|
89
|
+
Annotations decorate jobs with metadata. Place them on lines before \`job\`:
|
|
90
|
+
|
|
91
|
+
| Annotation | Description |
|
|
92
|
+
|------------|-------------|
|
|
93
|
+
| \`@approval(human)\` | Require human approval before execution |
|
|
94
|
+
| \`@approval(auto)\` | Auto-approved |
|
|
95
|
+
| \`@retry(N)\` | Retry up to N times on failure |
|
|
96
|
+
| \`@timeout(ms)\` | Timeout in milliseconds |
|
|
97
|
+
| \`@readonly\` | Job must not contain \`save\` or \`publish\` |
|
|
98
|
+
| \`@on_error(strategy)\` | Error handling: \`skip\` (continue), \`save /path\` (write errors), \`fail\` (default) |
|
|
99
|
+
| \`@caps(op /path, ...)\` | Capability declaration: restricts which paths the job can access |
|
|
100
|
+
| \`@budget(dim limit, ...)\` | Runtime resource limits per job execution |
|
|
101
|
+
|
|
102
|
+
\`\`\`ash
|
|
103
|
+
@caps(read /data/*, write /out/*, exec /api/enrich)
|
|
104
|
+
@on_error(skip)
|
|
105
|
+
@retry(3)
|
|
106
|
+
job resilient { find /data/raw | action /api/enrich | save /out/enriched }
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
\`@caps\` operations: \`read\` (find, lookup), \`write\` (save, publish, tee), \`exec\` (action).
|
|
110
|
+
Paths support \`*\` wildcard: \`/data/*\` matches \`/data/users\`, \`/data/orders/active\`.
|
|
111
|
+
Compiler statically verifies all stage paths are within declared caps.
|
|
112
|
+
|
|
113
|
+
\`@budget\` dimensions: \`actions\` (exec calls), \`writes\` (save/publish/tee), \`records\` (stream items), \`tokens\` (LLM consumption), \`cost\` (dollar cost).
|
|
114
|
+
Runtime throws when any dimension exceeds its limit. Combine with \`@on_error(skip)\` for soft enforcement.
|
|
115
|
+
|
|
116
|
+
\`\`\`ash
|
|
117
|
+
@budget(actions 50, records 10000)
|
|
118
|
+
@caps(read /data/*, write /out/*, exec /api/enrich)
|
|
119
|
+
job bounded { find /data/raw | action /api/enrich | save /out/enriched }
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
## Paths
|
|
123
|
+
|
|
124
|
+
All paths start with \`/\`. Convention: \`/domain/collection\` (e.g. \`/world/users\`, \`/data/output\`).
|
|
125
|
+
`;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getNestedValue,
|
|
4
|
+
resolveTemplate,
|
|
5
|
+
resolveActionParams,
|
|
6
|
+
resolveTemplatePath,
|
|
7
|
+
hasTemplates,
|
|
8
|
+
} from "./template";
|
|
9
|
+
|
|
10
|
+
describe("getNestedValue", () => {
|
|
11
|
+
it("accesses top-level field", () => {
|
|
12
|
+
expect(getNestedValue({ name: "world" }, "name")).toBe("world");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accesses nested field via dot notation", () => {
|
|
16
|
+
expect(getNestedValue({ data: { id: 42 } }, "data.id")).toBe(42);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accesses array element by index", () => {
|
|
20
|
+
expect(getNestedValue({ items: [{ name: "first" }] }, "items.0.name")).toBe("first");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns undefined for missing field", () => {
|
|
24
|
+
expect(getNestedValue({}, "missing")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns undefined for missing nested field", () => {
|
|
28
|
+
expect(getNestedValue({ data: {} }, "data.missing.deep")).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns undefined when traversing through non-object", () => {
|
|
32
|
+
expect(getNestedValue({ x: "string" }, "x.length")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns undefined for null object", () => {
|
|
36
|
+
expect(getNestedValue(null, "x")).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("resolveTemplate", () => {
|
|
41
|
+
// Basic resolution
|
|
42
|
+
it("resolves simple field in string", () => {
|
|
43
|
+
expect(resolveTemplate("hello ${name}", { name: "world" })).toBe("hello world");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("stringifies number in mixed context", () => {
|
|
47
|
+
expect(resolveTemplate("id=${data.id}", { data: { id: 42 } })).toBe("id=42");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves missing field to empty string", () => {
|
|
51
|
+
expect(resolveTemplate("${missing}", {})).toBe("");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves multiple templates", () => {
|
|
55
|
+
expect(resolveTemplate("${a} and ${b}", { a: "x", b: "y" })).toBe("x and y");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves array index access", () => {
|
|
59
|
+
expect(resolveTemplate("${items.0.name}", { items: [{ name: "first" }] })).toBe("first");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Type preservation (whole-value)
|
|
63
|
+
it("preserves number type for whole-value", () => {
|
|
64
|
+
const result = resolveTemplate("${count}", { count: 42 });
|
|
65
|
+
expect(result).toBe(42);
|
|
66
|
+
expect(typeof result).toBe("number");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves object type for whole-value", () => {
|
|
70
|
+
const obj = { a: 1 };
|
|
71
|
+
expect(resolveTemplate("${obj}", { obj })).toEqual({ a: 1 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("preserves array type for whole-value", () => {
|
|
75
|
+
expect(resolveTemplate("${arr}", { arr: [1, 2, 3] })).toEqual([1, 2, 3]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves boolean type for whole-value", () => {
|
|
79
|
+
const result = resolveTemplate("${flag}", { flag: true });
|
|
80
|
+
expect(result).toBe(true);
|
|
81
|
+
expect(typeof result).toBe("boolean");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Mixed context stringify
|
|
85
|
+
it("JSON.stringifies object in mixed context", () => {
|
|
86
|
+
expect(resolveTemplate("val=${obj}", { obj: { a: 1 } })).toBe('val={"a":1}');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("JSON.stringifies array in mixed context", () => {
|
|
90
|
+
expect(resolveTemplate("arr=${arr}", { arr: [1, 2] })).toBe("arr=[1,2]");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Escape handling
|
|
94
|
+
it("preserves escaped template as literal", () => {
|
|
95
|
+
// After lexer processing, \$ becomes \\$ in token value
|
|
96
|
+
expect(resolveTemplate("\\${literal}", { literal: "x" })).toBe("${literal}");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("resolves non-escaped but preserves escaped in same string", () => {
|
|
100
|
+
expect(resolveTemplate("${name} and \\${literal}", { name: "Bob", literal: "x" })).toBe(
|
|
101
|
+
"Bob and ${literal}",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Non-string passthrough
|
|
106
|
+
it("returns non-string values as-is", () => {
|
|
107
|
+
expect(resolveTemplate(42, {})).toBe(42);
|
|
108
|
+
expect(resolveTemplate(true, {})).toBe(true);
|
|
109
|
+
expect(resolveTemplate(null, {})).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Null/undefined in resolved values
|
|
113
|
+
it("resolves null field to empty string", () => {
|
|
114
|
+
expect(resolveTemplate("${x}", { x: null })).toBe("");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("resolves null in mixed to empty string", () => {
|
|
118
|
+
expect(resolveTemplate("val=${x}", { x: null })).toBe("val=");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Size limit
|
|
122
|
+
it("throws on resolved value exceeding 64KB", () => {
|
|
123
|
+
const bigString = "x".repeat(65537);
|
|
124
|
+
expect(() => resolveTemplate("${big}", { big: bigString })).toThrow("64KB");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("allows resolved value at exactly 64KB", () => {
|
|
128
|
+
const okString = "x".repeat(65536);
|
|
129
|
+
expect(resolveTemplate("${ok}", { ok: okString })).toBe(okString);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("resolveActionParams", () => {
|
|
134
|
+
it("resolves all template params", () => {
|
|
135
|
+
const params = { text: "Hello ${name}", count: "${n}" };
|
|
136
|
+
const record = { name: "Bob", n: 5 };
|
|
137
|
+
const result = resolveActionParams(params, record);
|
|
138
|
+
expect(result).toEqual({ text: "Hello Bob", count: 5 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("passes through non-string values", () => {
|
|
142
|
+
const params = { limit: 10, flag: true };
|
|
143
|
+
const result = resolveActionParams(params, {});
|
|
144
|
+
expect(result).toEqual({ limit: 10, flag: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("handles empty params", () => {
|
|
148
|
+
expect(resolveActionParams({}, {})).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("resolveTemplatePath", () => {
|
|
153
|
+
it("resolves template in path", () => {
|
|
154
|
+
expect(resolveTemplatePath("/msgs/${id}", { id: "123" })).toBe("/msgs/123");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("resolves nested field in path", () => {
|
|
158
|
+
expect(resolveTemplatePath("/conv/${data.convId}/msgs", { data: { convId: "42" } })).toBe(
|
|
159
|
+
"/conv/42/msgs",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("resolves multiple templates in path", () => {
|
|
164
|
+
expect(
|
|
165
|
+
resolveTemplatePath("/store/${cat}/.actions/${op}", { cat: "news", op: "save" }),
|
|
166
|
+
).toBe("/store/news/.actions/save");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("throws on empty segment from empty field", () => {
|
|
170
|
+
expect(() => resolveTemplatePath("/${empty}/.actions/run", { empty: "" })).toThrow(
|
|
171
|
+
"empty segment",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws on .. in resolved path", () => {
|
|
176
|
+
expect(() => resolveTemplatePath("/base/${field}/run", { field: ".." })).toThrow(
|
|
177
|
+
"path traversal",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns static path unchanged", () => {
|
|
182
|
+
expect(resolveTemplatePath("/static/path", {})).toBe("/static/path");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("resolves number field to string in path", () => {
|
|
186
|
+
expect(resolveTemplatePath("/items/${id}", { id: 42 })).toBe("/items/42");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("hasTemplates", () => {
|
|
191
|
+
it("detects template in string", () => {
|
|
192
|
+
expect(hasTemplates("hello ${name}")).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns false for no template", () => {
|
|
196
|
+
expect(hasTemplates("hello world")).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns false for escaped template", () => {
|
|
200
|
+
expect(hasTemplates("\\${literal}")).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns false for non-string", () => {
|
|
204
|
+
expect(hasTemplates(42)).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("detects template among escaped", () => {
|
|
208
|
+
expect(hasTemplates("\\${esc} and ${real}")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASH Template Parameter Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves ${field} references in action parameters and paths
|
|
5
|
+
* against the current stream record.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_RESOLVED_SIZE = 65536; // 64KB
|
|
9
|
+
|
|
10
|
+
// Match ${identifier.path} but not \${...}
|
|
11
|
+
const TEMPLATE_RE = /(?<!\\)\$\{([^}]+)\}/g;
|
|
12
|
+
// Match whole-value template: entire string is a single ${field}
|
|
13
|
+
const WHOLE_VALUE_RE = /^\$\{([^}]+)\}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Access a nested value via dot-separated path.
|
|
17
|
+
* Supports object fields and array indices (numeric keys).
|
|
18
|
+
*
|
|
19
|
+
* getNestedValue({data: {id: 42}}, "data.id") → 42
|
|
20
|
+
* getNestedValue({items: [{name: "a"}]}, "items.0.name") → "a"
|
|
21
|
+
*/
|
|
22
|
+
export function getNestedValue(obj: unknown, path: string): unknown {
|
|
23
|
+
const keys = path.split(".");
|
|
24
|
+
let current: unknown = obj;
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
if (current === null || current === undefined) return undefined;
|
|
27
|
+
if (typeof current !== "object") return undefined;
|
|
28
|
+
current = (current as Record<string, unknown>)[key];
|
|
29
|
+
}
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a single template value against a stream record.
|
|
35
|
+
*
|
|
36
|
+
* - Non-string values pass through unchanged
|
|
37
|
+
* - Whole-value "${field}" preserves the field's type
|
|
38
|
+
* - Mixed "text ${field}" stringifies substitutions (JSON.stringify for objects)
|
|
39
|
+
* - Escaped \${...} produces literal ${...}
|
|
40
|
+
* - Missing fields resolve to ""
|
|
41
|
+
* - Resolved strings > 64KB throw
|
|
42
|
+
*/
|
|
43
|
+
export function resolveTemplate(value: unknown, record: Record<string, unknown>): unknown {
|
|
44
|
+
if (typeof value !== "string") return value;
|
|
45
|
+
|
|
46
|
+
// Whole-value: single ${field} with nothing else → preserve type
|
|
47
|
+
const wholeMatch = value.match(WHOLE_VALUE_RE);
|
|
48
|
+
if (wholeMatch) {
|
|
49
|
+
const resolved = getNestedValue(record, wholeMatch[1]);
|
|
50
|
+
if (resolved === undefined || resolved === null) return "";
|
|
51
|
+
// Size check for string values
|
|
52
|
+
if (typeof resolved === "string" && resolved.length > MAX_RESOLVED_SIZE) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Template resolution error: resolved value exceeds 64KB limit (${resolved.length} bytes)`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Mixed template: replace ${...} with stringified values
|
|
61
|
+
const result = value.replace(TEMPLATE_RE, (_, field: string) => {
|
|
62
|
+
const val = getNestedValue(record, field);
|
|
63
|
+
if (val === undefined || val === null) return "";
|
|
64
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
65
|
+
return String(val);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Strip escape: \${ → ${
|
|
69
|
+
const final = result.replace(/\\\$/g, "$");
|
|
70
|
+
|
|
71
|
+
// Size check
|
|
72
|
+
if (final.length > MAX_RESOLVED_SIZE) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Template resolution error: resolved value exceeds 64KB limit (${final.length} bytes)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return final;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve all template references in an action's parameters.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveActionParams(
|
|
85
|
+
params: Record<string, unknown>,
|
|
86
|
+
record: Record<string, unknown>,
|
|
87
|
+
): Record<string, unknown> {
|
|
88
|
+
const resolved: Record<string, unknown> = {};
|
|
89
|
+
for (const [key, value] of Object.entries(params)) {
|
|
90
|
+
resolved[key] = resolveTemplate(value, record);
|
|
91
|
+
}
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve template references in a path string.
|
|
97
|
+
* Always returns a string. Validates the resolved path:
|
|
98
|
+
* - No empty segments (//)
|
|
99
|
+
* - No path traversal (..)
|
|
100
|
+
*/
|
|
101
|
+
export function resolveTemplatePath(
|
|
102
|
+
path: string,
|
|
103
|
+
record: Record<string, unknown>,
|
|
104
|
+
): string {
|
|
105
|
+
if (!path.includes("${")) return path;
|
|
106
|
+
|
|
107
|
+
const resolved = path.replace(TEMPLATE_RE, (_, field: string) => {
|
|
108
|
+
const val = getNestedValue(record, field);
|
|
109
|
+
if (val === undefined || val === null) return "";
|
|
110
|
+
return String(val);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Strip escape: \${ → ${
|
|
114
|
+
const final = resolved.replace(/\\\$/g, "$");
|
|
115
|
+
|
|
116
|
+
// Validate resolved path
|
|
117
|
+
if (final.includes("//")) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Template path resolution error: resolved path '${final}' contains empty segment (//). Check that template fields are not empty.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (final.split("/").some((seg) => seg === "..")) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Template path resolution error: resolved path '${final}' contains path traversal (..)`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return final;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if a value contains any unescaped ${...} template references.
|
|
133
|
+
*/
|
|
134
|
+
export function hasTemplates(value: unknown): boolean {
|
|
135
|
+
if (typeof value !== "string") return false;
|
|
136
|
+
// Reset regex lastIndex since it's global
|
|
137
|
+
TEMPLATE_RE.lastIndex = 0;
|
|
138
|
+
return TEMPLATE_RE.test(value);
|
|
139
|
+
}
|