@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,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
+ });
@@ -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
+ });
@@ -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
+ }