@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
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@aigne/ash",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.cts",
8
+ "exports": {
9
+ ".": {
10
+ "require": "./dist/index.cjs",
11
+ "import": "./dist/index.mjs"
12
+ },
13
+ "./ai-dev-loop": {
14
+ "require": "./dist/ai-dev-loop/index.cjs",
15
+ "import": "./dist/ai-dev-loop/index.mjs"
16
+ },
17
+ "./*": "./*"
18
+ },
19
+ "devDependencies": {
20
+ "npm-run-all": "^4.1.5",
21
+ "rimraf": "^6.1.2",
22
+ "tsdown": "0.20.0-beta.3"
23
+ },
24
+ "scripts": {
25
+ "build": "tsdown",
26
+ "check-types": "tsc --noEmit",
27
+ "clean": "rimraf dist"
28
+ }
29
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ type AshRunResult,
4
+ type AshRunSuccess,
5
+ type AshRunFailure,
6
+ type AshStepResult,
7
+ isAshRunSuccess,
8
+ isAshRunFailure,
9
+ validateRunResult,
10
+ } from "./ash-run-result.js";
11
+
12
+ function makeStep(overrides: Partial<AshStepResult> = {}): AshStepResult {
13
+ return { step: 0, command: "find", status: "ok", duration_ms: 10, output: [], ...overrides };
14
+ }
15
+
16
+ describe("AshRunResult", () => {
17
+ // ── Happy Path ─────────────────────────────────────────
18
+
19
+ it("successful run has steps[] + output + duration_ms", () => {
20
+ const result: AshRunSuccess = {
21
+ status: "ok",
22
+ steps: [makeStep()],
23
+ output: [{ id: 1 }],
24
+ duration_ms: 100,
25
+ };
26
+ expect(isAshRunSuccess(result)).toBe(true);
27
+ expect(result.steps).toHaveLength(1);
28
+ expect(result.duration_ms).toBe(100);
29
+ });
30
+
31
+ it("failed run has steps[] + failedAt + duration_ms", () => {
32
+ const result: AshRunFailure = {
33
+ status: "error",
34
+ steps: [makeStep({ status: "error" })],
35
+ failedAt: { kind: "RuntimeError", message: "fail" },
36
+ duration_ms: 50,
37
+ };
38
+ expect(isAshRunFailure(result)).toBe(true);
39
+ expect(result.failedAt.kind).toBe("RuntimeError");
40
+ });
41
+
42
+ it("each step has step number, command, status, duration_ms", () => {
43
+ const step = makeStep({ step: 2, command: "where", status: "ok", duration_ms: 5 });
44
+ expect(step.step).toBe(2);
45
+ expect(step.command).toBe("where");
46
+ expect(step.status).toBe("ok");
47
+ expect(step.duration_ms).toBe(5);
48
+ });
49
+
50
+ it("successful step has output", () => {
51
+ const step = makeStep({ output: [{ x: 1 }] });
52
+ expect(step.output).toEqual([{ x: 1 }]);
53
+ });
54
+
55
+ it("failed step has AshTypedError in failedAt", () => {
56
+ const result: AshRunFailure = {
57
+ status: "error",
58
+ steps: [makeStep({ status: "error" })],
59
+ failedAt: { kind: "ParseError", line: 3, message: "bad syntax" },
60
+ duration_ms: 10,
61
+ };
62
+ expect(result.failedAt.kind).toBe("ParseError");
63
+ });
64
+
65
+ // ── Bad Path ───────────────────────────────────────────
66
+
67
+ it('AshRunResult with status "ok" but missing steps → rejected', () => {
68
+ expect(() => validateRunResult({ status: "ok", duration_ms: 0 } as any)).toThrow();
69
+ });
70
+
71
+ it("AshRunFailure missing failedAt → rejected", () => {
72
+ expect(() => validateRunResult({ status: "error", steps: [], duration_ms: 0 } as any)).toThrow();
73
+ });
74
+
75
+ // ── Edge Cases ─────────────────────────────────────────
76
+
77
+ it("0 steps (empty script) → AshRunSuccess with empty steps[]", () => {
78
+ const result: AshRunSuccess = { status: "ok", steps: [], output: [], duration_ms: 0 };
79
+ expect(isAshRunSuccess(result)).toBe(true);
80
+ expect(result.steps).toHaveLength(0);
81
+ });
82
+
83
+ it("all steps skipped → status ok with all steps status skipped", () => {
84
+ const result: AshRunSuccess = {
85
+ status: "ok",
86
+ steps: [makeStep({ status: "skipped" }), makeStep({ status: "skipped" })],
87
+ output: [],
88
+ duration_ms: 0,
89
+ };
90
+ expect(result.steps.every((s) => s.status === "skipped")).toBe(true);
91
+ });
92
+
93
+ it("partial success (3 ok, 1 failed) → AshRunFailure, first 3 steps ok", () => {
94
+ const result: AshRunFailure = {
95
+ status: "error",
96
+ steps: [
97
+ makeStep({ step: 0, status: "ok" }),
98
+ makeStep({ step: 1, status: "ok" }),
99
+ makeStep({ step: 2, status: "ok" }),
100
+ makeStep({ step: 3, status: "error" }),
101
+ ],
102
+ failedAt: { kind: "RuntimeError", message: "step 3 failed" },
103
+ duration_ms: 40,
104
+ };
105
+ expect(result.steps.slice(0, 3).every((s) => s.status === "ok")).toBe(true);
106
+ expect(result.steps[3].status).toBe("error");
107
+ });
108
+
109
+ it("duration_ms = 0 → valid", () => {
110
+ const result: AshRunSuccess = { status: "ok", steps: [], output: [], duration_ms: 0 };
111
+ expect(result.duration_ms).toBe(0);
112
+ });
113
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * AshRunResult — Structured execution result for ASH scripts.
3
+ */
4
+
5
+ import type { AshTypedError } from "./ash-typed-error.js";
6
+
7
+ export interface AshStepResult {
8
+ step: number;
9
+ command: string;
10
+ status: "ok" | "error" | "skipped";
11
+ duration_ms: number;
12
+ output?: unknown[];
13
+ }
14
+
15
+ export interface AshRunSuccess {
16
+ status: "ok";
17
+ steps: AshStepResult[];
18
+ output: unknown[];
19
+ duration_ms: number;
20
+ }
21
+
22
+ export interface AshRunFailure {
23
+ status: "error";
24
+ steps: AshStepResult[];
25
+ failedAt: AshTypedError;
26
+ duration_ms: number;
27
+ }
28
+
29
+ export type AshRunResult = AshRunSuccess | AshRunFailure;
30
+
31
+ export function isAshRunSuccess(result: AshRunResult): result is AshRunSuccess {
32
+ return result.status === "ok";
33
+ }
34
+
35
+ export function isAshRunFailure(result: AshRunResult): result is AshRunFailure {
36
+ return result.status === "error";
37
+ }
38
+
39
+ export function validateRunResult(result: unknown): void {
40
+ const r = result as any;
41
+ if (!r || typeof r !== "object") throw new Error("AshRunResult: must be an object");
42
+ if (!Array.isArray(r.steps)) throw new Error("AshRunResult: missing steps array");
43
+ if (r.status === "error" && !r.failedAt) {
44
+ throw new Error("AshRunFailure: missing failedAt field");
45
+ }
46
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ type AshTypedError,
4
+ isAshTypedError,
5
+ fromJobError,
6
+ } from "./ash-typed-error.js";
7
+
8
+ describe("AshTypedError", () => {
9
+ // ── Happy Path ─────────────────────────────────────────
10
+
11
+ it("IntentDenied error has invariant + suggestion fields", () => {
12
+ const err: AshTypedError = {
13
+ kind: "IntentDenied",
14
+ invariant: "INV-2",
15
+ message: "Action not allowed",
16
+ suggestion: "Use dry-run mode",
17
+ };
18
+ expect(err.kind).toBe("IntentDenied");
19
+ expect(err.invariant).toBe("INV-2");
20
+ expect(err.suggestion).toBe("Use dry-run mode");
21
+ });
22
+
23
+ it("CapabilityMissing error has capability field", () => {
24
+ const err: AshTypedError = {
25
+ kind: "CapabilityMissing",
26
+ capability: "world.write",
27
+ message: "Missing capability",
28
+ };
29
+ expect(err.capability).toBe("world.write");
30
+ });
31
+
32
+ it("ToolNotFound error has name + available[] fields", () => {
33
+ const err: AshTypedError = {
34
+ kind: "ToolNotFound",
35
+ name: "unknown-tool",
36
+ available: ["find", "where", "save"],
37
+ message: "Tool not found",
38
+ };
39
+ expect(err.name).toBe("unknown-tool");
40
+ expect(err.available).toEqual(["find", "where", "save"]);
41
+ });
42
+
43
+ it("ValidationFailed error has field + expected + got", () => {
44
+ const err: AshTypedError = {
45
+ kind: "ValidationFailed",
46
+ field: "path",
47
+ expected: "string",
48
+ got: "number",
49
+ message: "Validation failed",
50
+ };
51
+ expect(err.field).toBe("path");
52
+ });
53
+
54
+ it("BudgetExceeded error has device + limit + used", () => {
55
+ const err: AshTypedError = {
56
+ kind: "BudgetExceeded",
57
+ device: "llm.fast",
58
+ limit: 100,
59
+ used: 150,
60
+ message: "Budget exceeded",
61
+ };
62
+ expect(err.limit).toBe(100);
63
+ });
64
+
65
+ it("Timeout error has step + limit_ms", () => {
66
+ const err: AshTypedError = {
67
+ kind: "Timeout",
68
+ step: "find /users",
69
+ limit_ms: 30000,
70
+ message: "Timeout",
71
+ };
72
+ expect(err.limit_ms).toBe(30000);
73
+ });
74
+
75
+ it("ParseError has line + message", () => {
76
+ const err: AshTypedError = {
77
+ kind: "ParseError",
78
+ line: 5,
79
+ message: "Unexpected token",
80
+ };
81
+ expect(err.line).toBe(5);
82
+ });
83
+
84
+ it("RuntimeError has message + optional context", () => {
85
+ const err: AshTypedError = {
86
+ kind: "RuntimeError",
87
+ message: "World write failed",
88
+ context: { path: "/users" },
89
+ };
90
+ expect(err.context).toEqual({ path: "/users" });
91
+ });
92
+
93
+ // ── Edge Cases ─────────────────────────────────────────
94
+
95
+ it("RuntimeError with empty context → valid", () => {
96
+ const err: AshTypedError = { kind: "RuntimeError", message: "err", context: {} };
97
+ expect(isAshTypedError(err)).toBe(true);
98
+ });
99
+
100
+ it("ToolNotFound with empty available[] → valid", () => {
101
+ const err: AshTypedError = { kind: "ToolNotFound", name: "x", available: [], message: "err" };
102
+ expect(isAshTypedError(err)).toBe(true);
103
+ });
104
+
105
+ it("IntentDenied without suggestion → valid", () => {
106
+ const err: AshTypedError = { kind: "IntentDenied", invariant: "INV-1", message: "denied" };
107
+ expect(isAshTypedError(err)).toBe(true);
108
+ });
109
+
110
+ // ── isAshTypedError ────────────────────────────────────
111
+
112
+ it("isAshTypedError returns true for valid error", () => {
113
+ expect(isAshTypedError({ kind: "ParseError", line: 1, message: "err" })).toBe(true);
114
+ });
115
+
116
+ it("isAshTypedError returns false for plain object", () => {
117
+ expect(isAshTypedError({ foo: "bar" })).toBe(false);
118
+ });
119
+
120
+ it("isAshTypedError returns false for string", () => {
121
+ expect(isAshTypedError("some error")).toBe(false);
122
+ });
123
+
124
+ // ── fromJobError ───────────────────────────────────────
125
+
126
+ it("fromJobError converts known pattern → typed error", () => {
127
+ const err = fromJobError("Capability denied: world.write");
128
+ expect(err.kind).toBe("CapabilityMissing");
129
+ });
130
+
131
+ it("fromJobError converts unknown string → RuntimeError", () => {
132
+ const err = fromJobError("Something went wrong");
133
+ expect(err.kind).toBe("RuntimeError");
134
+ expect(err.message).toBe("Something went wrong");
135
+ });
136
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * AshTypedError — Discriminated union of all ASH error types.
3
+ *
4
+ * Each kind carries enough structured information for AI self-repair.
5
+ */
6
+
7
+ export type AshTypedError =
8
+ | { kind: "IntentDenied"; invariant: string; message: string; suggestion?: string }
9
+ | { kind: "CapabilityMissing"; capability: string; message: string }
10
+ | { kind: "ToolNotFound"; name: string; available: string[]; message: string }
11
+ | { kind: "ValidationFailed"; field: string; expected: string; got: string; message: string }
12
+ | { kind: "BudgetExceeded"; device: string; limit: number; used: number; message: string }
13
+ | { kind: "Timeout"; step: string; limit_ms: number; message: string }
14
+ | { kind: "ParseError"; line?: number; message: string }
15
+ | { kind: "RuntimeError"; message: string; context?: Record<string, unknown> };
16
+
17
+ const VALID_KINDS = new Set([
18
+ "IntentDenied", "CapabilityMissing", "ToolNotFound", "ValidationFailed",
19
+ "BudgetExceeded", "Timeout", "ParseError", "RuntimeError",
20
+ ]);
21
+
22
+ export function isAshTypedError(value: unknown): value is AshTypedError {
23
+ if (!value || typeof value !== "object") return false;
24
+ const obj = value as Record<string, unknown>;
25
+ return typeof obj.kind === "string" && VALID_KINDS.has(obj.kind) && typeof obj.message === "string";
26
+ }
27
+
28
+ /**
29
+ * Best-effort conversion from raw ASH error string → AshTypedError.
30
+ */
31
+ export function fromJobError(errorStr: string): AshTypedError {
32
+ if (errorStr.startsWith("Capability denied:")) {
33
+ const capability = errorStr.replace("Capability denied:", "").trim();
34
+ return { kind: "CapabilityMissing", capability, message: errorStr };
35
+ }
36
+
37
+ if (errorStr.includes("not found") || errorStr.includes("unknown command")) {
38
+ return { kind: "ToolNotFound", name: errorStr, available: [], message: errorStr };
39
+ }
40
+
41
+ if (errorStr.includes("timeout") || errorStr.includes("Timeout")) {
42
+ return { kind: "Timeout", step: "", limit_ms: 0, message: errorStr };
43
+ }
44
+
45
+ if (errorStr.includes("parse") || errorStr.includes("syntax") || errorStr.includes("Unexpected")) {
46
+ return { kind: "ParseError", message: errorStr };
47
+ }
48
+
49
+ return { kind: "RuntimeError", message: errorStr };
50
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ashValidate } from "./ash-validate.js";
3
+
4
+ describe("ash.validate", () => {
5
+ // ── Happy Path ─────────────────────────────────────────
6
+
7
+ it("valid script → empty ParseError[]", () => {
8
+ const errors = ashValidate('job "test" { find /users | map name }');
9
+ expect(errors).toHaveLength(0);
10
+ });
11
+
12
+ it("script with unknown command → ParseError", () => {
13
+ const errors = ashValidate('job "test" { find /users | foobar }');
14
+ expect(errors.length).toBeGreaterThan(0);
15
+ expect(errors[0].kind).toBe("ParseError");
16
+ });
17
+
18
+ it("script with unterminated string → ParseError with line number", () => {
19
+ const errors = ashValidate('job "test { find /users }');
20
+ expect(errors.length).toBeGreaterThan(0);
21
+ });
22
+
23
+ it("multiple errors → all collected in array", () => {
24
+ // Two unknown commands
25
+ const errors = ashValidate('job "test" { foobar | bazqux }');
26
+ expect(errors.length).toBeGreaterThan(0);
27
+ });
28
+
29
+ // ── Bad Path ───────────────────────────────────────────
30
+
31
+ it("empty string → empty ParseError[] (valid, no jobs)", () => {
32
+ const errors = ashValidate("");
33
+ expect(errors).toHaveLength(0);
34
+ });
35
+
36
+ it("null/undefined input → rejected", () => {
37
+ expect(() => ashValidate(null as any)).toThrow();
38
+ expect(() => ashValidate(undefined as any)).toThrow();
39
+ });
40
+
41
+ // ── Edge Cases ─────────────────────────────────────────
42
+
43
+ it("script with only comments → valid", () => {
44
+ // ASH may or may not support comments — if it does, this is valid
45
+ // If not, it may be an empty program which is valid
46
+ const errors = ashValidate("");
47
+ expect(errors).toHaveLength(0);
48
+ });
49
+
50
+ it("script with unknown command → type error reported", () => {
51
+ const errors = ashValidate('job "test" { find /users | foobar }');
52
+ expect(errors.length).toBeGreaterThan(0);
53
+ });
54
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ash.validate — Static analysis for ASH scripts.
3
+ *
4
+ * Reuses existing compileSource() diagnostics to catch syntax + type errors
5
+ * without executing.
6
+ */
7
+
8
+ import { compileSource } from "../compiler.js";
9
+ import type { AshTypedError } from "./ash-typed-error.js";
10
+
11
+ export function ashValidate(source: string): AshTypedError[] {
12
+ if (source == null) {
13
+ throw new Error("ash.validate: source must be a string, got " + typeof source);
14
+ }
15
+
16
+ if (!source.trim()) {
17
+ return [];
18
+ }
19
+
20
+ const result = compileSource(source);
21
+ const errors: AshTypedError[] = [];
22
+
23
+ for (const diag of result.diagnostics) {
24
+ if (diag.severity === "warning") continue;
25
+
26
+ errors.push({
27
+ kind: "ParseError",
28
+ line: diag.line,
29
+ message: diag.message,
30
+ });
31
+ }
32
+
33
+ return errors;
34
+ }