@vibe-lang/runtime 0.2.8 → 0.2.10

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/ast/index.ts +6 -0
  3. package/src/lexer/index.ts +2 -0
  4. package/src/parser/index.ts +14 -0
  5. package/src/parser/parse.ts +138 -1
  6. package/src/parser/test/errors/missing-tokens.test.ts +25 -0
  7. package/src/parser/test/errors/model-declaration.test.ts +14 -8
  8. package/src/parser/test/errors/unclosed-delimiters.test.ts +200 -34
  9. package/src/parser/test/errors/unexpected-tokens.test.ts +15 -1
  10. package/src/parser/test/literals.test.ts +29 -0
  11. package/src/parser/test/model-declaration.test.ts +14 -0
  12. package/src/parser/visitor.ts +9 -0
  13. package/src/runtime/async/dependencies.ts +8 -1
  14. package/src/runtime/async/test/dependencies.test.ts +27 -1
  15. package/src/runtime/exec/statements.ts +51 -1
  16. package/src/runtime/index.ts +2 -3
  17. package/src/runtime/modules.ts +1 -1
  18. package/src/runtime/stdlib/index.ts +7 -11
  19. package/src/runtime/stdlib/tools/index.ts +5 -122
  20. package/src/runtime/stdlib/utils/index.ts +58 -0
  21. package/src/runtime/step.ts +4 -0
  22. package/src/runtime/test/core-functions.test.ts +19 -10
  23. package/src/runtime/test/throw.test.ts +220 -0
  24. package/src/runtime/test/tool-execution.test.ts +30 -30
  25. package/src/runtime/types.ts +4 -1
  26. package/src/runtime/validation.ts +6 -0
  27. package/src/semantic/analyzer-context.ts +2 -0
  28. package/src/semantic/analyzer-visitors.ts +149 -2
  29. package/src/semantic/analyzer.ts +1 -0
  30. package/src/semantic/test/fixtures/exports.vibe +25 -0
  31. package/src/semantic/test/function-return-check.test.ts +215 -0
  32. package/src/semantic/test/imports.test.ts +66 -2
  33. package/src/semantic/test/prompt-validation.test.ts +44 -0
@@ -0,0 +1,220 @@
1
+ // Tests for throw statement - throw "message" returns immediately with error value
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+ import { parse } from '../../parser/parse';
5
+ import { Runtime } from '../index';
6
+ import type { AIProvider, AIResponse } from '../types';
7
+ import { isVibeValue } from '../types';
8
+
9
+ // Mock provider for tests that don't need AI
10
+ function createMockProvider(): AIProvider {
11
+ return {
12
+ async chat(): Promise<AIResponse> {
13
+ return { content: 'mock response', toolCalls: [] };
14
+ },
15
+ };
16
+ }
17
+
18
+ describe('Throw Statement', () => {
19
+ test('throw with string literal creates error value', async () => {
20
+ const ast = parse(`
21
+ function fail(): text {
22
+ throw "Something went wrong"
23
+ return "never reached"
24
+ }
25
+
26
+ let result = fail()
27
+ `);
28
+ const runtime = new Runtime(ast, createMockProvider());
29
+ await runtime.run();
30
+
31
+ const result = runtime.getRawValue('result');
32
+ expect(isVibeValue(result)).toBe(true);
33
+ if (isVibeValue(result)) {
34
+ expect(result.err).toBe(true);
35
+ expect(result.errDetails?.message).toBe('Something went wrong');
36
+ }
37
+ });
38
+
39
+ test('throw returns immediately from function', async () => {
40
+ const ast = parse(`
41
+ let sideEffect = 0
42
+
43
+ function testThrow(): number {
44
+ sideEffect = 1
45
+ throw "error"
46
+ sideEffect = 2
47
+ return 42
48
+ }
49
+
50
+ let result = testThrow()
51
+ `);
52
+ const runtime = new Runtime(ast, createMockProvider());
53
+ await runtime.run();
54
+
55
+ // Side effect should only be 1 (before throw)
56
+ expect(runtime.getValue('sideEffect')).toBe(1);
57
+
58
+ const result = runtime.getRawValue('result');
59
+ expect(isVibeValue(result)).toBe(true);
60
+ if (isVibeValue(result)) {
61
+ expect(result.err).toBe(true);
62
+ }
63
+ });
64
+
65
+ test('throw with expression evaluates message', async () => {
66
+ const ast = parse(`
67
+ function divide(a: number, b: number): number {
68
+ if b == 0 {
69
+ throw "Cannot divide " + a + " by zero"
70
+ }
71
+ return a / b
72
+ }
73
+
74
+ let result = divide(10, 0)
75
+ `);
76
+ const runtime = new Runtime(ast, createMockProvider());
77
+ await runtime.run();
78
+
79
+ const result = runtime.getRawValue('result');
80
+ expect(isVibeValue(result)).toBe(true);
81
+ if (isVibeValue(result)) {
82
+ expect(result.err).toBe(true);
83
+ expect(result.errDetails?.message).toBe('Cannot divide 10 by zero');
84
+ }
85
+ });
86
+
87
+ test('successful function call returns normal value', async () => {
88
+ const ast = parse(`
89
+ function divide(a: number, b: number): number {
90
+ if b == 0 {
91
+ throw "Division by zero"
92
+ }
93
+ return a / b
94
+ }
95
+
96
+ let result = divide(10, 2)
97
+ `);
98
+ const runtime = new Runtime(ast, createMockProvider());
99
+ await runtime.run();
100
+
101
+ const result = runtime.getRawValue('result');
102
+ expect(isVibeValue(result)).toBe(true);
103
+ if (isVibeValue(result)) {
104
+ expect(result.err).toBe(false);
105
+ expect(result.value).toBe(5);
106
+ }
107
+ });
108
+
109
+ test('error propagates through expressions', async () => {
110
+ const ast = parse(`
111
+ function fail(): number {
112
+ throw "error"
113
+ }
114
+
115
+ let a = fail()
116
+ let b = a + 10
117
+ `);
118
+ const runtime = new Runtime(ast, createMockProvider());
119
+ await runtime.run();
120
+
121
+ const a = runtime.getRawValue('a');
122
+ const b = runtime.getRawValue('b');
123
+
124
+ expect(isVibeValue(a)).toBe(true);
125
+ expect(isVibeValue(b)).toBe(true);
126
+
127
+ if (isVibeValue(a) && isVibeValue(b)) {
128
+ expect(a.err).toBe(true);
129
+ expect(b.err).toBe(true); // Error propagates
130
+ expect(b.errDetails?.message).toBe('error');
131
+ }
132
+ });
133
+
134
+ test('caller can check for error with .err', async () => {
135
+ const ast = parse(`
136
+ function mayFail(shouldFail: boolean): text {
137
+ if shouldFail {
138
+ throw "Failed!"
139
+ }
140
+ return "Success"
141
+ }
142
+
143
+ let result1 = mayFail(true)
144
+ let hasError1 = result1.err
145
+
146
+ let result2 = mayFail(false)
147
+ let hasError2 = result2.err
148
+ `);
149
+ const runtime = new Runtime(ast, createMockProvider());
150
+ await runtime.run();
151
+
152
+ expect(runtime.getValue('hasError1')).toBe(true);
153
+ expect(runtime.getValue('hasError2')).toBe(false);
154
+ });
155
+
156
+ test('throw at top level completes with error', async () => {
157
+ const ast = parse(`
158
+ let x = 1
159
+ throw "Top level error"
160
+ let y = 2
161
+ `);
162
+ const runtime = new Runtime(ast, createMockProvider());
163
+ await runtime.run();
164
+
165
+ expect(runtime.getValue('x')).toBe(1);
166
+ // y should not be set because throw exits
167
+ expect(runtime.getValue('y')).toBeUndefined();
168
+
169
+ // lastResult should be the error
170
+ const lastResult = runtime.getState().lastResult;
171
+ expect(isVibeValue(lastResult)).toBe(true);
172
+ if (isVibeValue(lastResult)) {
173
+ expect(lastResult.err).toBe(true);
174
+ expect(lastResult.errDetails?.message).toBe('Top level error');
175
+ }
176
+ });
177
+
178
+ test('throw in nested function unwinds correctly', async () => {
179
+ const ast = parse(`
180
+ function inner(): number {
181
+ throw "inner error"
182
+ }
183
+
184
+ function outer(): number {
185
+ let x = inner()
186
+ return x + 1
187
+ }
188
+
189
+ let result = outer()
190
+ `);
191
+ const runtime = new Runtime(ast, createMockProvider());
192
+ await runtime.run();
193
+
194
+ const result = runtime.getRawValue('result');
195
+ expect(isVibeValue(result)).toBe(true);
196
+ if (isVibeValue(result)) {
197
+ expect(result.err).toBe(true);
198
+ expect(result.errDetails?.message).toBe('inner error');
199
+ }
200
+ });
201
+
202
+ test('throw with variable message', async () => {
203
+ const ast = parse(`
204
+ function failWith(msg: text): text {
205
+ throw msg
206
+ }
207
+
208
+ let result = failWith("custom error message")
209
+ `);
210
+ const runtime = new Runtime(ast, createMockProvider());
211
+ await runtime.run();
212
+
213
+ const result = runtime.getRawValue('result');
214
+ expect(isVibeValue(result)).toBe(true);
215
+ if (isVibeValue(result)) {
216
+ expect(result.err).toBe(true);
217
+ expect(result.errDetails?.message).toBe('custom error message');
218
+ }
219
+ });
220
+ });
@@ -177,9 +177,9 @@ let x = 1
177
177
  expect(runtime.getValue('x')).toBe(1);
178
178
  });
179
179
 
180
- test('uuid function works when imported from system', async () => {
180
+ test('uuid function works when imported from system/utils', async () => {
181
181
  const ast = parse(`
182
- import { uuid } from "system"
182
+ import { uuid } from "system/utils"
183
183
  let id = uuid()
184
184
  `);
185
185
  const runtime = new Runtime(ast, createMockProvider());
@@ -190,41 +190,41 @@ let id = uuid()
190
190
  });
191
191
 
192
192
  // ============================================================================
193
- // Tools cannot be called directly - only used by AI models
193
+ // Utility functions can be called directly from system/utils
194
194
  // ============================================================================
195
195
 
196
- test('tools cannot be called directly from vibe scripts', async () => {
196
+ test('now can be called directly from system/utils', async () => {
197
197
  const ast = parse(`
198
- import { now } from "system/tools"
198
+ import { now } from "system/utils"
199
199
  let timestamp = now()
200
200
  `);
201
201
  const runtime = new Runtime(ast, createMockProvider());
202
- await expect(runtime.run()).rejects.toThrow(
203
- "Cannot call tool 'now' directly"
204
- );
202
+ await runtime.run();
203
+ const timestamp = runtime.getValue('timestamp');
204
+ expect(typeof timestamp).toBe('number');
205
+ expect(timestamp).toBeGreaterThan(0);
205
206
  });
206
207
 
207
- test('jsonParse tool cannot be called directly', async () => {
208
+ test('jsonParse can be called directly from system/utils', async () => {
208
209
  const ast = parse(`
209
- import { jsonParse } from "system/tools"
210
+ import { jsonParse } from "system/utils"
210
211
  let parsed = jsonParse('{"key": "value"}')
212
+ let val = ts(parsed) { return parsed.key }
211
213
  `);
212
214
  const runtime = new Runtime(ast, createMockProvider());
213
- await expect(runtime.run()).rejects.toThrow(
214
- "Cannot call tool 'jsonParse' directly"
215
- );
215
+ await runtime.run();
216
+ expect(runtime.getValue('val')).toBe('value');
216
217
  });
217
218
 
218
- test('jsonStringify tool cannot be called directly', async () => {
219
+ test('jsonStringify can be called directly from system/utils', async () => {
219
220
  const ast = parse(`
220
- import { jsonStringify } from "system/tools"
221
+ import { jsonStringify } from "system/utils"
221
222
  let obj:json = {name: "test"}
222
223
  let str = jsonStringify(obj)
223
224
  `);
224
225
  const runtime = new Runtime(ast, createMockProvider());
225
- await expect(runtime.run()).rejects.toThrow(
226
- "Cannot call tool 'jsonStringify' directly"
227
- );
226
+ await runtime.run();
227
+ expect(runtime.getValue('str')).toBe('{"name":"test"}');
228
228
  });
229
229
 
230
230
  test('allTools array can be imported from system/tools', async () => {
@@ -235,7 +235,7 @@ let toolCount = ts(allTools) { return allTools.length }
235
235
  const runtime = new Runtime(ast, createMockProvider());
236
236
  await runtime.run();
237
237
  const toolCount = runtime.getValue('toolCount');
238
- expect(toolCount).toBe(19); // File, search, directory, utility, and system tools for AI
238
+ expect(toolCount).toBe(13); // File, search, directory, and system tools for AI
239
239
  });
240
240
  });
241
241
 
@@ -332,9 +332,9 @@ let x = 1
332
332
  const runtime = new Runtime(ast, createMockProvider());
333
333
  await runtime.run();
334
334
 
335
- // Verify model has all 19 tools
335
+ // Verify model has all 13 AI tools (utility functions now in system/utils)
336
336
  const model = runtime.getValue('m') as { tools?: unknown[] };
337
- expect(model.tools).toHaveLength(19);
337
+ expect(model.tools).toHaveLength(13);
338
338
  });
339
339
 
340
340
  test('model without tools parameter has undefined tools', async () => {
@@ -355,7 +355,7 @@ let x = 1
355
355
  });
356
356
 
357
357
  describe('Runtime - Tool Bundles', () => {
358
- test('allTools contains all 19 tools including bash and runCode', async () => {
358
+ test('allTools contains all 13 AI tools including bash and runCode', async () => {
359
359
  const ast = parse(`
360
360
  import { allTools } from "system/tools"
361
361
  let count = ts(allTools) { return allTools.length }
@@ -364,7 +364,7 @@ let names = ts(allTools) { return allTools.map(t => t.name).sort() }
364
364
  const runtime = new Runtime(ast, createMockProvider());
365
365
  await runtime.run();
366
366
 
367
- expect(runtime.getValue('count')).toBe(19);
367
+ expect(runtime.getValue('count')).toBe(13);
368
368
  const names = runtime.getValue('names') as string[];
369
369
  expect(names).toContain('bash');
370
370
  expect(names).toContain('runCode');
@@ -381,7 +381,7 @@ let names = ts(readonlyTools) { return readonlyTools.map(t => t.name).sort() }
381
381
  const runtime = new Runtime(ast, createMockProvider());
382
382
  await runtime.run();
383
383
 
384
- expect(runtime.getValue('count')).toBe(12);
384
+ expect(runtime.getValue('count')).toBe(6);
385
385
  const names = runtime.getValue('names') as string[];
386
386
  // Should include read-only tools
387
387
  expect(names).toContain('readFile');
@@ -409,7 +409,7 @@ let names = ts(safeTools) { return safeTools.map(t => t.name).sort() }
409
409
  const runtime = new Runtime(ast, createMockProvider());
410
410
  await runtime.run();
411
411
 
412
- expect(runtime.getValue('count')).toBe(17);
412
+ expect(runtime.getValue('count')).toBe(11);
413
413
  const names = runtime.getValue('names') as string[];
414
414
  // Should NOT include bash or runCode
415
415
  expect(names).not.toContain('bash');
@@ -430,7 +430,7 @@ let hasRunCode = ts(runCode) { return runCode.name === "runCode" }
430
430
  const runtime = new Runtime(ast, createMockProvider());
431
431
  await runtime.run();
432
432
 
433
- expect(runtime.getValue('readCount')).toBe(12);
433
+ expect(runtime.getValue('readCount')).toBe(6);
434
434
  expect(runtime.getValue('hasBash')).toBe(true);
435
435
  expect(runtime.getValue('hasRunCode')).toBe(true);
436
436
  });
@@ -445,8 +445,8 @@ let names = ts(combined) { return combined.map(t => t.name) }
445
445
  const runtime = new Runtime(ast, createMockProvider());
446
446
  await runtime.run();
447
447
 
448
- // readonlyTools (12) + [bash] (1) = 13
449
- expect(runtime.getValue('count')).toBe(13);
448
+ // readonlyTools (6) + [bash] (1) = 7
449
+ expect(runtime.getValue('count')).toBe(7);
450
450
  const names = runtime.getValue('names') as string[];
451
451
  expect(names).toContain('readFile');
452
452
  expect(names).toContain('bash');
@@ -461,7 +461,7 @@ let count = ts(custom) { return custom.length }
461
461
  const runtime = new Runtime(ast, createMockProvider());
462
462
  await runtime.run();
463
463
 
464
- // readonlyTools (12) + bash (1) + runCode (1) = 14
465
- expect(runtime.getValue('count')).toBe(14);
464
+ // readonlyTools (6) + bash (1) + runCode (1) = 8
465
+ expect(runtime.getValue('count')).toBe(8);
466
466
  });
467
467
  });
@@ -794,4 +794,7 @@ export type Instruction =
794
794
  | { op: 'destructure_assign'; fields: ExpectedField[]; isConst: boolean; location: SourceLocation }
795
795
 
796
796
  // Break loop - exit innermost loop with cleanup
797
- | { op: 'break_loop'; savedKeys: string[]; contextMode?: ContextMode; label?: string; entryIndex: number; scopeType: 'for' | 'while'; location: SourceLocation };
797
+ | { op: 'break_loop'; savedKeys: string[]; contextMode?: ContextMode; label?: string; entryIndex: number; scopeType: 'for' | 'while'; location: SourceLocation }
798
+
799
+ // Throw error - unwind to function boundary and return error value
800
+ | { op: 'throw_error'; location: SourceLocation };
@@ -24,6 +24,12 @@ export function validateAndCoerce(
24
24
  location?: SourceLocation,
25
25
  source?: 'ai' | 'user'
26
26
  ): { value: unknown; inferredType: VibeType } {
27
+ // If value is an error VibeValue, pass it through without validation
28
+ // Errors should propagate unchanged regardless of type annotation
29
+ if (isVibeValue(value) && value.err) {
30
+ return { value, inferredType: type };
31
+ }
32
+
27
33
  // Resolve VibeValue unless this is a direct AI result to an UNTYPED variable
28
34
  // (source === 'ai' means the value came directly from an AI call)
29
35
  // For typed variables, always resolve so type validation can work on the primitive value
@@ -7,6 +7,7 @@
7
7
  import type { SourceLocation } from '../errors';
8
8
  import type { SymbolTable, SymbolKind } from './symbol-table';
9
9
  import type { TsFunctionSignature } from './ts-signatures';
10
+ import type { VibeType } from '../ast';
10
11
 
11
12
  /**
12
13
  * Context interface passed to analyzer helper functions.
@@ -59,4 +60,5 @@ export interface AnalyzerState {
59
60
  inFunction: boolean;
60
61
  atTopLevel: boolean;
61
62
  loopDepth: number;
63
+ currentFunctionReturnType: VibeType; // Track return type for prompt context validation
62
64
  }
@@ -10,6 +10,8 @@ import { isValidType } from './types';
10
10
  import { isCoreFunction } from '../runtime/stdlib/core';
11
11
  import { extractFunctionSignature } from './ts-signatures';
12
12
  import { resolve, dirname } from 'path';
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import { parse } from '../parser/parse';
13
15
  import {
14
16
  validateModelConfig,
15
17
  validateToolDeclaration,
@@ -169,7 +171,15 @@ export function createVisitors(
169
171
  if (!state.inFunction) {
170
172
  ctx.error('return outside of function', node.location);
171
173
  }
172
- if (node.value) visitExpression(node.value);
174
+ if (node.value) {
175
+ // If returning from a prompt-typed function, validate string literals as prompt context
176
+ const isPromptReturn = state.currentFunctionReturnType === 'prompt';
177
+ if (isPromptReturn && (node.value.type === 'StringLiteral' || node.value.type === 'TemplateLiteral')) {
178
+ validateStringInterpolation(ctx, node.value.value, true, node.value.location);
179
+ } else {
180
+ visitExpression(node.value);
181
+ }
182
+ }
173
183
  break;
174
184
 
175
185
  case 'BreakStatement':
@@ -178,6 +188,11 @@ export function createVisitors(
178
188
  }
179
189
  break;
180
190
 
191
+ case 'ThrowStatement':
192
+ // Visit the message expression for any nested checks
193
+ visitExpression(node.message);
194
+ break;
195
+
181
196
  case 'IfStatement':
182
197
  visitExpression(node.condition);
183
198
  validateConditionType(ctx, node.condition, 'if', getExprType);
@@ -233,6 +248,7 @@ export function createVisitors(
233
248
  returnType: node.returnType,
234
249
  });
235
250
  validateToolDeclaration(ctx, node);
251
+ visitTool(node);
236
252
  break;
237
253
 
238
254
  case 'AsyncStatement':
@@ -471,9 +487,12 @@ export function createVisitors(
471
487
  return;
472
488
  }
473
489
 
490
+ // Check for system module imports
491
+ const isSystemModule = node.source === 'system/utils' || node.source === 'system/tools';
474
492
  const isToolImport = node.source === 'system/tools';
475
493
 
476
- if (node.sourceType === 'ts' && ctx.basePath) {
494
+ // Extract TypeScript signatures for non-system TS imports
495
+ if (node.sourceType === 'ts' && ctx.basePath && !isSystemModule) {
477
496
  const sourcePath = resolve(dirname(ctx.basePath), node.source);
478
497
  for (const spec of node.specifiers) {
479
498
  try {
@@ -487,6 +506,12 @@ export function createVisitors(
487
506
  }
488
507
  }
489
508
 
509
+ // Validate Vibe imports - check that imported symbols exist in source file
510
+ let exportedNames: Set<string> | null = null;
511
+ if (node.sourceType === 'vibe' && ctx.basePath) {
512
+ exportedNames = getExportedNamesFromVibeFile(ctx.basePath, node.source);
513
+ }
514
+
490
515
  for (const spec of node.specifiers) {
491
516
  const existing = ctx.symbols.lookup(spec.local);
492
517
  if (existing) {
@@ -502,6 +527,14 @@ export function createVisitors(
502
527
  );
503
528
  }
504
529
  } else {
530
+ // Validate that imported name exists in source file (for Vibe imports)
531
+ if (exportedNames !== null && !exportedNames.has(spec.imported)) {
532
+ ctx.error(
533
+ `'${spec.imported}' is not exported from '${node.source}'`,
534
+ node.location
535
+ );
536
+ }
537
+
505
538
  // Tool bundles (allTools, readonlyTools, safeTools) are imports, individual tools are 'tool' kind
506
539
  const toolBundles = ['allTools', 'readonlyTools', 'safeTools'];
507
540
  const importKind = isToolImport && !toolBundles.includes(spec.local) ? 'tool' : 'import';
@@ -510,9 +543,88 @@ export function createVisitors(
510
543
  }
511
544
  }
512
545
 
546
+ /**
547
+ * Get the set of exported names from a Vibe source file
548
+ * Returns null if the file cannot be read/parsed
549
+ */
550
+ function getExportedNamesFromVibeFile(basePath: string, importSource: string): Set<string> | null {
551
+ try {
552
+ const sourcePath = resolve(dirname(basePath), importSource);
553
+ if (!existsSync(sourcePath)) {
554
+ return null; // File doesn't exist - let runtime handle this error
555
+ }
556
+
557
+ const sourceContent = readFileSync(sourcePath, 'utf-8');
558
+ const sourceAst = parse(sourceContent, { file: sourcePath });
559
+
560
+ const exportedNames = new Set<string>();
561
+ for (const statement of sourceAst.body) {
562
+ if (statement.type === 'ExportDeclaration') {
563
+ const decl = statement.declaration;
564
+ if ('name' in decl) {
565
+ exportedNames.add(decl.name);
566
+ }
567
+ }
568
+ }
569
+
570
+ return exportedNames;
571
+ } catch {
572
+ return null; // Parse error or other issue - skip validation
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Check if a statement always returns or throws on all code paths.
578
+ * Used to verify functions with return types exit properly.
579
+ *
580
+ * Note: In Vibe, if a function/tool body ends with an expression statement,
581
+ * that expression's value is implicitly returned.
582
+ */
583
+ function alwaysReturnsOrThrows(stmt: AST.Statement, isLastInBlock: boolean = false): boolean {
584
+ switch (stmt.type) {
585
+ case 'ReturnStatement':
586
+ case 'ThrowStatement':
587
+ return true;
588
+
589
+ case 'ExpressionStatement':
590
+ // An expression at the end of a function/tool body is an implicit return
591
+ return isLastInBlock;
592
+
593
+ case 'IfStatement':
594
+ // Must have else branch and both branches must return
595
+ if (!stmt.alternate) {
596
+ return false;
597
+ }
598
+ return alwaysReturnsOrThrows(stmt.consequent, isLastInBlock) &&
599
+ alwaysReturnsOrThrows(stmt.alternate, isLastInBlock);
600
+
601
+ case 'BlockStatement':
602
+ // Check if any statement in the block guarantees return/throw
603
+ // For the last statement, also consider implicit returns
604
+ for (let i = 0; i < stmt.body.length; i++) {
605
+ const s = stmt.body[i];
606
+ const isLast = i === stmt.body.length - 1;
607
+ if (alwaysReturnsOrThrows(s, isLast && isLastInBlock)) {
608
+ return true;
609
+ }
610
+ }
611
+ return false;
612
+
613
+ case 'ForInStatement':
614
+ case 'WhileStatement':
615
+ // Loops don't guarantee execution (might iterate 0 times)
616
+ return false;
617
+
618
+ default:
619
+ return false;
620
+ }
621
+ }
622
+
513
623
  function visitFunction(node: AST.FunctionDeclaration): void {
514
624
  const wasInFunction = state.inFunction;
625
+ const prevReturnType = state.currentFunctionReturnType;
515
626
  state.inFunction = true;
627
+ state.currentFunctionReturnType = node.returnType;
516
628
  ctx.symbols.enterScope();
517
629
 
518
630
  for (const param of node.params) {
@@ -526,8 +638,43 @@ export function createVisitors(
526
638
 
527
639
  visitStatement(node.body);
528
640
 
641
+ // Check that typed functions always return or throw
642
+ if (node.returnType && !alwaysReturnsOrThrows(node.body, true)) {
643
+ ctx.error(
644
+ `Function '${node.name}' has return type '${node.returnType}' but not all code paths return or throw`,
645
+ node.location
646
+ );
647
+ }
648
+
649
+ ctx.symbols.exitScope();
650
+ state.inFunction = wasInFunction;
651
+ state.currentFunctionReturnType = prevReturnType;
652
+ }
653
+
654
+ function visitTool(node: AST.ToolDeclaration): void {
655
+ const wasInFunction = state.inFunction;
656
+ const prevReturnType = state.currentFunctionReturnType;
657
+ state.inFunction = true;
658
+ state.currentFunctionReturnType = node.returnType;
659
+ ctx.symbols.enterScope();
660
+
661
+ for (const param of node.params) {
662
+ ctx.declare(param.name, 'parameter', node.location, { typeAnnotation: param.typeAnnotation });
663
+ }
664
+
665
+ visitStatement(node.body);
666
+
667
+ // Tools always have a return type - check that all code paths return or throw
668
+ if (!alwaysReturnsOrThrows(node.body, true)) {
669
+ ctx.error(
670
+ `Tool '${node.name}' has return type '${node.returnType}' but not all code paths return or throw`,
671
+ node.location
672
+ );
673
+ }
674
+
529
675
  ctx.symbols.exitScope();
530
676
  state.inFunction = wasInFunction;
677
+ state.currentFunctionReturnType = prevReturnType;
531
678
  }
532
679
 
533
680
  return { visitStatement, visitExpression };
@@ -34,6 +34,7 @@ export class SemanticAnalyzer {
34
34
  inFunction: false,
35
35
  atTopLevel: true,
36
36
  loopDepth: 0,
37
+ currentFunctionReturnType: null,
37
38
  };
38
39
 
39
40
  // Create visitors with context
@@ -0,0 +1,25 @@
1
+ // Test fixture with exports for import validation tests
2
+
3
+ export function greet(name: text): text {
4
+ return "Hello " + name
5
+ }
6
+
7
+ export function add(a: number, b: number): number {
8
+ return a + b
9
+ }
10
+
11
+ export const VERSION = "1.0.0"
12
+
13
+ export model testModel = {
14
+ name: "gpt-4",
15
+ apiKey: "test-key",
16
+ url: "https://api.example.com"
17
+ }
18
+
19
+ // This function is NOT exported
20
+ function privateHelper(): text {
21
+ return "private"
22
+ }
23
+
24
+ // This constant is NOT exported
25
+ const INTERNAL_SECRET = "hidden"