tova 0.3.0 → 0.3.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.
@@ -8,6 +8,7 @@ import {
8
8
  typeAnnotationToType, typeFromString, typesCompatible,
9
9
  isNumericType, isFloatNarrowing,
10
10
  } from './types.js';
11
+ import { ErrorCode, WarningCode } from '../diagnostics/error-codes.js';
11
12
 
12
13
  const _JS_GLOBALS = new Set([
13
14
  'console', 'document', 'window', 'globalThis', 'self',
@@ -29,6 +30,22 @@ const _JS_GLOBALS = new Set([
29
30
  'Buffer', 'atob', 'btoa',
30
31
  ]);
31
32
 
33
+ function levenshtein(a, b) {
34
+ if (a.length === 0) return b.length;
35
+ if (b.length === 0) return a.length;
36
+ const m = [];
37
+ for (let i = 0; i <= b.length; i++) m[i] = [i];
38
+ for (let j = 0; j <= a.length; j++) m[0][j] = j;
39
+ for (let i = 1; i <= b.length; i++) {
40
+ for (let j = 1; j <= a.length; j++) {
41
+ m[i][j] = b[i-1] === a[j-1]
42
+ ? m[i-1][j-1]
43
+ : Math.min(m[i-1][j-1] + 1, m[i][j-1] + 1, m[i-1][j] + 1);
44
+ }
45
+ }
46
+ return m[b.length][a.length];
47
+ }
48
+
32
49
  const _TOVA_RUNTIME = new Set([
33
50
  'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
34
51
  'db', 'server', 'client', 'shared',
@@ -48,6 +65,9 @@ export class Analyzer {
48
65
  this._functionReturnTypeStack = []; // Stack of expected return types for type checking
49
66
  this._asyncDepth = 0; // Track nesting inside async functions for await validation
50
67
 
68
+ // Propagate strict mode to the type system
69
+ Type.strictMode = this.strict;
70
+
51
71
  // Type registry for LSP
52
72
  this.typeRegistry = {
53
73
  types: new Map(), // type name → ADTType | RecordType
@@ -125,64 +145,134 @@ export class Analyzer {
125
145
  'hypot', 'lerp', 'divmod', 'avg',
126
146
  // Date/Time
127
147
  'now', 'now_iso',
148
+ // Scripting
149
+ 'env', 'set_env', 'args', 'exit',
150
+ 'exists', 'is_dir', 'is_file', 'ls', 'glob_files',
151
+ 'mkdir', 'rm', 'cp', 'mv', 'cwd', 'chdir',
152
+ 'read_text', 'read_bytes', 'write_text',
153
+ 'sh', 'exec',
154
+ // Scripting: new
155
+ 'read_stdin', 'read_lines',
156
+ 'script_path', 'script_dir',
157
+ 'parse_args',
158
+ 'color', 'bold', 'dim',
159
+ // Scripting: signals, file stat, path utils, symlinks, async shell
160
+ 'on_signal',
161
+ 'file_stat', 'file_size',
162
+ 'path_join', 'path_dirname', 'path_basename', 'path_resolve', 'path_ext', 'path_relative',
163
+ 'symlink', 'readlink', 'is_symlink',
164
+ 'spawn',
165
+ // Namespace modules
166
+ 'math', 'str', 'arr', 'dt', 're', 'json', 'fs', 'url',
167
+ // Advanced collections
168
+ 'OrderedDict', 'DefaultDict', 'Counter', 'Deque', 'collections',
128
169
  ];
129
170
  for (const name of builtins) {
130
171
  this.globalScope.define(name, new Symbol(name, 'builtin', null, false, { line: 0, column: 0, file: '<builtin>' }));
131
172
  }
132
173
  }
133
174
 
134
- error(message, loc) {
175
+ error(message, loc, hint = null, opts = {}) {
135
176
  const l = loc || { line: 0, column: 0, file: this.filename };
136
- this.errors.push({
177
+ const e = {
137
178
  message,
138
179
  file: l.file || this.filename,
139
180
  line: l.line,
140
181
  column: l.column,
141
- });
182
+ };
183
+ if (hint) e.hint = hint;
184
+ if (opts.code) e.code = opts.code;
185
+ if (opts.length) e.length = opts.length;
186
+ if (opts.fix) e.fix = opts.fix;
187
+ this.errors.push(e);
142
188
  }
143
189
 
144
- warn(message, loc) {
190
+ warn(message, loc, hint = null, opts = {}) {
145
191
  const l = loc || { line: 0, column: 0, file: this.filename };
146
- this.warnings.push({
192
+ const w = {
147
193
  message,
148
194
  file: l.file || this.filename,
149
195
  line: l.line,
150
196
  column: l.column,
151
- });
197
+ };
198
+ if (hint) w.hint = hint;
199
+ if (opts.code) w.code = opts.code;
200
+ if (opts.length) w.length = opts.length;
201
+ if (opts.fix) w.fix = opts.fix;
202
+ this.warnings.push(w);
152
203
  }
153
204
 
154
- strictError(message, loc) {
205
+ strictError(message, loc, hint = null, opts = {}) {
155
206
  if (this.strict) {
156
- this.error(message, loc);
207
+ this.error(message, loc, hint, opts);
157
208
  } else {
158
- this.warn(message, loc);
209
+ this.warn(message, loc, hint, opts);
159
210
  }
160
211
  }
161
212
 
162
- analyze() {
163
- // Pre-pass: collect named server block functions for inter-server RPC validation
164
- this.serverBlockFunctions = new Map(); // blockName -> [functionName, ...]
165
- const collectFns = (stmts) => {
166
- const fns = [];
167
- for (const stmt of stmts) {
168
- if (stmt.type === 'FunctionDeclaration') {
169
- fns.push(stmt.name);
170
- } else if (stmt.type === 'RouteGroupDeclaration') {
171
- fns.push(...collectFns(stmt.body));
172
- }
213
+ // ─── Naming convention helpers ─────────────────────────────
214
+
215
+ _isSnakeCase(name) {
216
+ if (name.startsWith('_')) return true;
217
+ if (name.length === 1) return true;
218
+ if (/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/.test(name)) return true; // UPPER_SNAKE_CASE
219
+ return /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/.test(name);
220
+ }
221
+
222
+ _isPascalCase(name) {
223
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name);
224
+ }
225
+
226
+ _isUpperSnakeCase(name) {
227
+ return /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/.test(name);
228
+ }
229
+
230
+ _toSnakeCase(name) {
231
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
232
+ }
233
+
234
+ _toPascalCase(name) {
235
+ return name.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase());
236
+ }
237
+
238
+ _checkNamingConvention(name, kind, loc) {
239
+ if (!name || name.startsWith('_') || name.length === 1) return;
240
+ if (this._isUpperSnakeCase(name)) return; // constants are valid for variables
241
+
242
+ if (kind === 'type' || kind === 'component' || kind === 'store') {
243
+ if (!this._isPascalCase(name)) {
244
+ const suggested = this._toPascalCase(name);
245
+ this.warn(
246
+ `${kind[0].toUpperCase() + kind.slice(1)} '${name}' should use PascalCase`,
247
+ loc,
248
+ `Rename '${name}' to '${suggested}'`,
249
+ { code: 'W100', length: name.length, fix: { description: `Rename to '${suggested}'`, replacement: suggested } }
250
+ );
173
251
  }
174
- return fns;
175
- };
176
- for (const node of this.ast.body) {
177
- if (node.type === 'ServerBlock' && node.name) {
178
- const fns = collectFns(node.body);
179
- if (this.serverBlockFunctions.has(node.name)) {
180
- this.serverBlockFunctions.get(node.name).push(...fns);
181
- } else {
182
- this.serverBlockFunctions.set(node.name, fns);
183
- }
252
+ } else {
253
+ // function, variable, parameter
254
+ if (!this._isSnakeCase(name)) {
255
+ const suggested = this._toSnakeCase(name);
256
+ this.warn(
257
+ `${kind[0].toUpperCase() + kind.slice(1)} '${name}' should use snake_case`,
258
+ loc,
259
+ `Rename '${name}' to '${suggested}'`,
260
+ { code: 'W100', length: name.length, fix: { description: `Rename to '${suggested}'`, replacement: suggested } }
261
+ );
184
262
  }
185
263
  }
264
+ }
265
+
266
+ analyze() {
267
+ // Pre-pass: collect named server block functions for inter-server RPC validation
268
+ const hasServerBlocks = this.ast.body.some(n => n.type === 'ServerBlock');
269
+ if (hasServerBlocks) {
270
+ const { collectServerBlockFunctions, installServerAnalyzer } = import.meta.require('./server-analyzer.js');
271
+ installServerAnalyzer(Analyzer);
272
+ this.serverBlockFunctions = collectServerBlockFunctions(this.ast);
273
+ } else {
274
+ this.serverBlockFunctions = new Map();
275
+ }
186
276
 
187
277
  this.visitProgram(this.ast);
188
278
 
@@ -216,7 +306,34 @@ export class Analyzer {
216
306
  if (sym.kind === 'parameter') continue;
217
307
 
218
308
  if (!sym.used && sym.loc && sym.loc.line > 0) {
219
- this.warn(`'${name}' is declared but never used`, sym.loc);
309
+ this.warn(`'${name}' is declared but never used`, sym.loc, "prefix with _ to suppress", {
310
+ code: 'W001',
311
+ length: name.length,
312
+ fix: { description: `Prefix with _ to suppress: _${name}`, replacement: `_${name}` },
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ // Check unused functions at module/server/client/shared level
319
+ for (const scope of this._allScopes) {
320
+ if (scope.context !== 'module' && scope.context !== 'server' &&
321
+ scope.context !== 'client' && scope.context !== 'shared') continue;
322
+
323
+ for (const [name, sym] of scope.symbols) {
324
+ if (sym.kind !== 'function') continue;
325
+ if (name.startsWith('_')) continue;
326
+ if (sym.isPublic) continue;
327
+ if (sym.extern) continue;
328
+ if (sym._variantOf) continue; // ADT variant constructors
329
+ if (name === 'main') continue;
330
+
331
+ if (!sym.used && sym.loc && sym.loc.line > 0) {
332
+ this.warn(`Function '${name}' is declared but never used`, sym.loc, "prefix with _ to suppress", {
333
+ code: 'W002',
334
+ length: name.length,
335
+ fix: { description: `Prefix with _ to suppress: _${name}`, replacement: `_${name}` },
336
+ });
220
337
  }
221
338
  }
222
339
  }
@@ -281,7 +398,27 @@ export class Analyzer {
281
398
  const fnSym = this.currentScope.lookup(name);
282
399
  if (fnSym && fnSym.kind === 'function') {
283
400
  if (fnSym._variantOf) return fnSym._variantOf;
284
- if (fnSym.type) return this._typeAnnotationToString(fnSym.type);
401
+ if (fnSym.type) {
402
+ let retType = this._typeAnnotationToString(fnSym.type);
403
+ // For generic functions, infer type params from call arguments
404
+ if (fnSym._typeParams && fnSym._typeParams.length > 0 && fnSym._paramTypes) {
405
+ const typeParamBindings = new Map();
406
+ for (let i = 0; i < expr.arguments.length && i < fnSym._paramTypes.length; i++) {
407
+ const arg = expr.arguments[i];
408
+ if (arg.type === 'NamedArgument' || arg.type === 'SpreadExpression') continue;
409
+ const paramTypeAnn = fnSym._paramTypes[i];
410
+ if (!paramTypeAnn) continue;
411
+ const actualType = this._inferType(arg);
412
+ if (actualType) {
413
+ this._inferTypeParamBindings(paramTypeAnn, actualType, fnSym._typeParams, typeParamBindings);
414
+ }
415
+ }
416
+ if (typeParamBindings.size > 0) {
417
+ retType = this._substituteTypeParams(retType, typeParamBindings);
418
+ }
419
+ }
420
+ return retType;
421
+ }
285
422
  }
286
423
  }
287
424
  return null;
@@ -311,11 +448,96 @@ export class Analyzer {
311
448
  return null;
312
449
  case 'LogicalExpression':
313
450
  return 'Bool';
451
+ case 'PipeExpression':
452
+ return this._inferPipeType(expr);
453
+ case 'MemberExpression':
454
+ // Infer .length as Int
455
+ if (expr.property === 'length') return 'Int';
456
+ return null;
314
457
  default:
315
458
  return null;
316
459
  }
317
460
  }
318
461
 
462
+ /**
463
+ * Infer the result type of a pipe expression like `arr |> filter(fn(x) x > 0) |> map(fn(x) x * 2)`.
464
+ * The left side provides the input type; the right side determines the output type.
465
+ */
466
+ _inferPipeType(expr) {
467
+ const inputType = this._inferType(expr.left);
468
+ const right = expr.right;
469
+
470
+ if (right.type === 'CallExpression' && right.callee.type === 'Identifier') {
471
+ const fnName = right.callee.name;
472
+
473
+ // Collection operations that preserve the array type
474
+ if (['filter', 'sorted', 'reversed', 'unique', 'take', 'drop', 'skip'].includes(fnName)) {
475
+ return inputType; // Same type as input
476
+ }
477
+
478
+ // map: transforms element type based on the mapper function
479
+ if (fnName === 'map') {
480
+ if (inputType && inputType.startsWith('[') && inputType.endsWith(']')) {
481
+ // Try to infer the return type from the mapper function
482
+ const mapperArg = right.arguments.length > 0 ? right.arguments[0] : null;
483
+ if (mapperArg) {
484
+ const mapperRetType = this._inferLambdaReturnType(mapperArg, inputType.slice(1, -1));
485
+ if (mapperRetType) return `[${mapperRetType}]`;
486
+ }
487
+ }
488
+ return inputType; // Fallback: preserve input type
489
+ }
490
+
491
+ // flat_map / flatten: reduce nesting
492
+ if (fnName === 'flat_map' || fnName === 'flatMap') {
493
+ if (inputType && inputType.startsWith('[') && inputType.endsWith(']')) {
494
+ return inputType; // Simplified: same element type
495
+ }
496
+ }
497
+ if (fnName === 'flatten') {
498
+ if (inputType && inputType.startsWith('[[') && inputType.endsWith(']]')) {
499
+ return inputType.slice(1, -1); // [[T]] -> [T]
500
+ }
501
+ return inputType;
502
+ }
503
+
504
+ // Reduction operations
505
+ if (fnName === 'reduce' || fnName === 'fold') return null; // Can't easily infer
506
+ if (fnName === 'join') return 'String';
507
+ if (fnName === 'count' || fnName === 'len' || fnName === 'length') return 'Int';
508
+ if (fnName === 'sum') return inputType === '[Float]' ? 'Float' : 'Int';
509
+ if (fnName === 'any' || fnName === 'all' || fnName === 'every' || fnName === 'some') return 'Bool';
510
+ if (fnName === 'first' || fnName === 'last' || fnName === 'find') {
511
+ if (inputType && inputType.startsWith('[') && inputType.endsWith(']')) {
512
+ return inputType.slice(1, -1); // [T] -> T
513
+ }
514
+ }
515
+
516
+ // For user-defined functions, fall back to checking their return type
517
+ const fnSym = this.currentScope.lookup(fnName);
518
+ if (fnSym && fnSym.kind === 'function' && fnSym.type) {
519
+ return this._typeAnnotationToString(fnSym.type);
520
+ }
521
+ }
522
+
523
+ // If we can't infer the right side, return null
524
+ return null;
525
+ }
526
+
527
+ /**
528
+ * Try to infer the return type of a lambda expression given the input element type.
529
+ */
530
+ _inferLambdaReturnType(lambdaExpr, inputElementType) {
531
+ if (!lambdaExpr) return null;
532
+ if (lambdaExpr.type === 'LambdaExpression') {
533
+ // For simple expression bodies, infer the result type
534
+ if (lambdaExpr.body && lambdaExpr.body.type !== 'BlockStatement') {
535
+ return this._inferType(lambdaExpr.body);
536
+ }
537
+ }
538
+ return null;
539
+ }
540
+
319
541
  _typeAnnotationToString(ann) {
320
542
  if (!ann) return null;
321
543
  if (typeof ann === 'string') return ann;
@@ -332,6 +554,8 @@ export class Analyzer {
332
554
  return `(${ann.elementTypes.map(t => this._typeAnnotationToString(t) || 'Any').join(', ')})`;
333
555
  case 'FunctionTypeAnnotation':
334
556
  return 'Function';
557
+ case 'UnionTypeAnnotation':
558
+ return ann.members.map(m => this._typeAnnotationToString(m) || 'Any').join(' | ');
335
559
  default:
336
560
  return null;
337
561
  }
@@ -364,14 +588,29 @@ export class Analyzer {
364
588
  if (!expected || !actual) return true;
365
589
  if (expected === 'Any' || actual === 'Any') return true;
366
590
  if (expected === '_' || actual === '_') return true;
591
+ // Resolve type aliases before comparison
592
+ expected = this._resolveTypeAlias(expected);
593
+ actual = this._resolveTypeAlias(actual);
367
594
  // Exact match
368
595
  if (expected === actual) return true;
369
- // Numeric compatibility: Int and Float are interchangeable
370
- const numerics = new Set(['Int', 'Float']);
371
- if (numerics.has(expected) && numerics.has(actual)) return true;
596
+ // Numeric compatibility: Int -> Float widening is safe; Float -> Int requires explicit conversion
597
+ if (expected === 'Float' && actual === 'Int') return true;
598
+ if (expected === 'Int' && actual === 'Float') return false; // caller should emit warning/error
372
599
  // Nil is compatible with Option
373
600
  if (actual === 'Nil' && (expected === 'Option' || expected.startsWith('Option'))) return true;
374
601
  if ((expected === 'Nil') && (actual === 'Option' || actual.startsWith('Option'))) return true;
602
+ // Union type compatibility: actual must be assignable to one of the expected union members
603
+ if (expected.includes(' | ')) {
604
+ const members = expected.split(' | ').map(m => m.trim());
605
+ return members.some(m => this._typesCompatible(m, actual));
606
+ }
607
+ // If actual is a union, every member must be compatible with expected
608
+ if (actual.includes(' | ')) {
609
+ const members = actual.split(' | ').map(m => m.trim());
610
+ return members.every(m => this._typesCompatible(expected, m));
611
+ }
612
+ // Nil is compatible with union types that include Nil
613
+ if (actual === 'Nil' && expected.includes('Nil')) return true;
375
614
  // Array compatibility: check element types
376
615
  if (expected.startsWith('[') && actual.startsWith('[')) {
377
616
  const expEl = expected.slice(1, -1);
@@ -416,8 +655,41 @@ export class Analyzer {
416
655
  if (!node) return;
417
656
 
418
657
  switch (node.type) {
419
- case 'ServerBlock': return this.visitServerBlock(node);
420
- case 'ClientBlock': return this.visitClientBlock(node);
658
+ case 'ServerBlock':
659
+ case 'RouteDeclaration':
660
+ case 'MiddlewareDeclaration':
661
+ case 'HealthCheckDeclaration':
662
+ case 'CorsDeclaration':
663
+ case 'ErrorHandlerDeclaration':
664
+ case 'WebSocketDeclaration':
665
+ case 'StaticDeclaration':
666
+ case 'DiscoverDeclaration':
667
+ case 'AuthDeclaration':
668
+ case 'MaxBodyDeclaration':
669
+ case 'RouteGroupDeclaration':
670
+ case 'RateLimitDeclaration':
671
+ case 'LifecycleHookDeclaration':
672
+ case 'SubscribeDeclaration':
673
+ case 'EnvDeclaration':
674
+ case 'ScheduleDeclaration':
675
+ case 'UploadDeclaration':
676
+ case 'SessionDeclaration':
677
+ case 'DbDeclaration':
678
+ case 'TlsDeclaration':
679
+ case 'CompressionDeclaration':
680
+ case 'BackgroundJobDeclaration':
681
+ case 'CacheDeclaration':
682
+ case 'SseDeclaration':
683
+ case 'ModelDeclaration':
684
+ return this._visitServerNode(node);
685
+ case 'AiConfigDeclaration': return; // handled at block level
686
+ case 'ClientBlock':
687
+ case 'StateDeclaration':
688
+ case 'ComputedDeclaration':
689
+ case 'EffectDeclaration':
690
+ case 'ComponentDeclaration':
691
+ case 'StoreDeclaration':
692
+ return this._visitClientNode(node);
421
693
  case 'SharedBlock': return this.visitSharedBlock(node);
422
694
  case 'Assignment': return this.visitAssignment(node);
423
695
  case 'VarDeclaration': return this.visitVarDeclaration(node);
@@ -430,6 +702,7 @@ export class Analyzer {
430
702
  case 'IfStatement': return this.visitIfStatement(node);
431
703
  case 'ForStatement': return this.visitForStatement(node);
432
704
  case 'WhileStatement': return this.visitWhileStatement(node);
705
+ case 'LoopStatement': return this.visitLoopStatement(node);
433
706
  case 'TryCatchStatement': return this.visitTryCatchStatement(node);
434
707
  case 'ReturnStatement': return this.visitReturnStatement(node);
435
708
  case 'ExpressionStatement': return this.visitExpression(node.expression);
@@ -439,37 +712,6 @@ export class Analyzer {
439
712
  case 'ContinueStatement': return this.visitContinueStatement(node);
440
713
  case 'GuardStatement': return this.visitGuardStatement(node);
441
714
  case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
442
- case 'StateDeclaration': return this.visitStateDeclaration(node);
443
- case 'ComputedDeclaration': return this.visitComputedDeclaration(node);
444
- case 'EffectDeclaration': return this.visitEffectDeclaration(node);
445
- case 'ComponentDeclaration': return this.visitComponentDeclaration(node);
446
- case 'StoreDeclaration': return this.visitStoreDeclaration(node);
447
- case 'RouteDeclaration': return this.visitRouteDeclaration(node);
448
- case 'MiddlewareDeclaration': return this.visitMiddlewareDeclaration(node);
449
- case 'HealthCheckDeclaration': return this.visitHealthCheckDeclaration(node);
450
- case 'CorsDeclaration': return this.visitCorsDeclaration(node);
451
- case 'ErrorHandlerDeclaration': return this.visitErrorHandlerDeclaration(node);
452
- case 'WebSocketDeclaration': return this.visitWebSocketDeclaration(node);
453
- case 'StaticDeclaration': return this.visitStaticDeclaration(node);
454
- case 'DiscoverDeclaration': return this.visitDiscoverDeclaration(node);
455
- case 'AuthDeclaration': return this.visitAuthDeclaration(node);
456
- case 'MaxBodyDeclaration': return this.visitMaxBodyDeclaration(node);
457
- case 'RouteGroupDeclaration': return this.visitRouteGroupDeclaration(node);
458
- case 'RateLimitDeclaration': return this.visitRateLimitDeclaration(node);
459
- case 'LifecycleHookDeclaration': return this.visitLifecycleHookDeclaration(node);
460
- case 'SubscribeDeclaration': return this.visitSubscribeDeclaration(node);
461
- case 'EnvDeclaration': return this.visitEnvDeclaration(node);
462
- case 'ScheduleDeclaration': return this.visitScheduleDeclaration(node);
463
- case 'UploadDeclaration': return this.visitUploadDeclaration(node);
464
- case 'SessionDeclaration': return this.visitSessionDeclaration(node);
465
- case 'DbDeclaration': return this.visitDbDeclaration(node);
466
- case 'TlsDeclaration': return this.visitTlsDeclaration(node);
467
- case 'CompressionDeclaration': return this.visitCompressionDeclaration(node);
468
- case 'BackgroundJobDeclaration': return this.visitBackgroundJobDeclaration(node);
469
- case 'CacheDeclaration': return this.visitCacheDeclaration(node);
470
- case 'SseDeclaration': return this.visitSseDeclaration(node);
471
- case 'ModelDeclaration': return this.visitModelDeclaration(node);
472
- case 'AiConfigDeclaration': return; // handled at block level
473
715
  case 'DataBlock': return this.visitDataBlock(node);
474
716
  case 'SourceDeclaration': return;
475
717
  case 'PipelineDeclaration': return;
@@ -477,6 +719,7 @@ export class Analyzer {
477
719
  case 'RefreshPolicy': return;
478
720
  case 'RefinementType': return;
479
721
  case 'TestBlock': return this.visitTestBlock(node);
722
+ case 'BenchBlock': return this.visitTestBlock(node);
480
723
  case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
481
724
  case 'ImplDeclaration': return this.visitImplDeclaration(node);
482
725
  case 'TraitDeclaration': return this.visitTraitDeclaration(node);
@@ -489,6 +732,24 @@ export class Analyzer {
489
732
  }
490
733
  }
491
734
 
735
+ _visitServerNode(node) {
736
+ if (!Analyzer.prototype._serverAnalyzerInstalled) {
737
+ const { installServerAnalyzer } = import.meta.require('./server-analyzer.js');
738
+ installServerAnalyzer(Analyzer);
739
+ }
740
+ const methodName = 'visit' + node.type;
741
+ return this[methodName](node);
742
+ }
743
+
744
+ _visitClientNode(node) {
745
+ if (!Analyzer.prototype._clientAnalyzerInstalled) {
746
+ const { installClientAnalyzer } = import.meta.require('./client-analyzer.js');
747
+ installClientAnalyzer(Analyzer);
748
+ }
749
+ const methodName = 'visit' + node.type;
750
+ return this[methodName](node);
751
+ }
752
+
492
753
  visitExpression(node) {
493
754
  if (!node) return;
494
755
 
@@ -603,7 +864,7 @@ export class Analyzer {
603
864
  return;
604
865
  case 'AwaitExpression':
605
866
  if (this._asyncDepth === 0) {
606
- this.error("'await' can only be used inside an async function", node.loc);
867
+ this.error("'await' can only be used inside an async function", node.loc, "add 'async' to the enclosing function declaration", { code: 'E300' });
607
868
  }
608
869
  this.visitExpression(node.argument);
609
870
  return;
@@ -623,7 +884,8 @@ export class Analyzer {
623
884
  this.visitNode(node.elseBody);
624
885
  return;
625
886
  case 'JSXElement':
626
- return this.visitJSXElement(node);
887
+ case 'JSXFragment':
888
+ return this._visitClientNode(node);
627
889
  // Column expressions (for table operations) — no semantic analysis needed
628
890
  case 'ColumnExpression':
629
891
  return;
@@ -637,49 +899,6 @@ export class Analyzer {
637
899
 
638
900
  // ─── Block visitors ───────────────────────────────────────
639
901
 
640
- visitServerBlock(node) {
641
- const prevScope = this.currentScope;
642
- const prevServerBlockName = this._currentServerBlockName;
643
- this._currentServerBlockName = node.name || null;
644
- this.currentScope = this.currentScope.child('server');
645
-
646
- try {
647
- // Register peer server block names as valid identifiers in this scope
648
- if (node.name && this.serverBlockFunctions.size > 0) {
649
- for (const [peerName] of this.serverBlockFunctions) {
650
- if (peerName !== node.name) {
651
- try {
652
- this.currentScope.define(peerName,
653
- new Symbol(peerName, 'builtin', null, false, { line: 0, column: 0, file: '<peer-server>' }));
654
- } catch (e) {
655
- // Ignore if already defined
656
- }
657
- }
658
- }
659
- }
660
-
661
- // Register AI provider names as variables (named: claude, gpt, etc.; default: ai)
662
- for (const stmt of node.body) {
663
- if (stmt.type === 'AiConfigDeclaration') {
664
- const aiName = stmt.name || 'ai';
665
- try {
666
- this.currentScope.define(aiName,
667
- new Symbol(aiName, 'builtin', null, false, stmt.loc));
668
- } catch (e) {
669
- // Ignore if already defined
670
- }
671
- }
672
- }
673
-
674
- for (const stmt of node.body) {
675
- this.visitNode(stmt);
676
- }
677
- } finally {
678
- this.currentScope = prevScope;
679
- this._currentServerBlockName = prevServerBlockName;
680
- }
681
- }
682
-
683
902
  visitDataBlock(node) {
684
903
  // Register source and pipeline names in global scope
685
904
  for (const stmt of node.body) {
@@ -699,17 +918,7 @@ export class Analyzer {
699
918
  }
700
919
  }
701
920
 
702
- visitClientBlock(node) {
703
- const prevScope = this.currentScope;
704
- this.currentScope = this.currentScope.child('client');
705
- try {
706
- for (const stmt of node.body) {
707
- this.visitNode(stmt);
708
- }
709
- } finally {
710
- this.currentScope = prevScope;
711
- }
712
- }
921
+ // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
713
922
 
714
923
  visitSharedBlock(node) {
715
924
  const prevScope = this.currentScope;
@@ -745,17 +954,21 @@ export class Analyzer {
745
954
  const existing = this._lookupAssignTarget(target);
746
955
  if (existing) {
747
956
  if (!existing.mutable) {
748
- this.error(`Cannot reassign immutable variable '${target}'. Use 'var' for mutable variables.`, node.loc);
957
+ this.error(`Cannot reassign immutable variable '${target}'. Use 'var' for mutable variables.`, node.loc, null, {
958
+ code: 'E202',
959
+ length: target.length,
960
+ fix: { description: `Change to 'var ${target}' at the original declaration to make it mutable` },
961
+ });
749
962
  }
750
963
  // Type check reassignment
751
964
  if (existing.inferredType && i < node.values.length) {
752
965
  const newType = this._inferType(node.values[i]);
753
966
  if (!this._typesCompatible(existing.inferredType, newType)) {
754
- this.strictError(`Type mismatch: '${target}' is ${existing.inferredType}, but assigned ${newType}`, node.loc);
967
+ this.strictError(`Type mismatch: '${target}' is ${existing.inferredType}, but assigned ${newType}`, node.loc, this._conversionHint(existing.inferredType, newType), { code: 'E102' });
755
968
  }
756
969
  // Float narrowing warning in strict mode
757
970
  if (this.strict && newType === 'Float' && existing.inferredType === 'Int') {
758
- this.warn(`Potential data loss: assigning Float to Int variable '${target}'`, node.loc);
971
+ this.warn(`Potential data loss: assigning Float to Int variable '${target}'`, node.loc, "use floor() or round() for explicit conversion", { code: 'W204' });
759
972
  }
760
973
  }
761
974
  existing.used = true;
@@ -764,7 +977,7 @@ export class Analyzer {
764
977
  const inferredType = i < node.values.length ? this._inferType(node.values[i]) : null;
765
978
  // Warn if this shadows a variable from an outer function scope
766
979
  if (this._existsInOuterScope(target)) {
767
- this.warn(`Variable '${target}' shadows a binding in an outer scope`, node.loc);
980
+ this.warn(`Variable '${target}' shadows a binding in an outer scope`, node.loc, null, { code: 'W101', length: target.length });
768
981
  }
769
982
  try {
770
983
  const sym = new Symbol(target, 'variable', null, false, node.loc);
@@ -773,6 +986,7 @@ export class Analyzer {
773
986
  } catch (e) {
774
987
  this.error(e.message);
775
988
  }
989
+ this._checkNamingConvention(target, 'variable', node.loc);
776
990
  }
777
991
  }
778
992
  }
@@ -792,6 +1006,7 @@ export class Analyzer {
792
1006
  } catch (e) {
793
1007
  this.error(e.message);
794
1008
  }
1009
+ this._checkNamingConvention(target, 'variable', node.loc);
795
1010
  }
796
1011
  }
797
1012
 
@@ -828,11 +1043,16 @@ export class Analyzer {
828
1043
  sym._totalParamCount = node.params.length;
829
1044
  sym._requiredParamCount = node.params.filter(p => !p.defaultValue).length;
830
1045
  sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
1046
+ sym._typeParams = node.typeParams || [];
1047
+ sym.isPublic = node.isPublic || false;
831
1048
  this.currentScope.define(node.name, sym);
832
1049
  } catch (e) {
833
1050
  this.error(e.message);
834
1051
  }
835
1052
 
1053
+ // Naming convention check (skip variant constructors — handled in visitTypeDeclaration)
1054
+ this._checkNamingConvention(node.name, 'function', node.loc);
1055
+
836
1056
  const prevScope = this.currentScope;
837
1057
  this.currentScope = this.currentScope.child('function');
838
1058
  if (node.loc) {
@@ -861,6 +1081,7 @@ export class Analyzer {
861
1081
  } catch (e) {
862
1082
  this.error(e.message);
863
1083
  }
1084
+ this._checkNamingConvention(param.name, 'parameter', param.loc);
864
1085
  }
865
1086
  if (param.defaultValue) {
866
1087
  this.visitExpression(param.defaultValue);
@@ -872,7 +1093,7 @@ export class Analyzer {
872
1093
  // Return path analysis: check that all paths return a value
873
1094
  if (expectedReturn && node.body.type === 'BlockStatement') {
874
1095
  if (!this._definitelyReturns(node.body)) {
875
- this.warn(`Function '${node.name}' declares return type ${expectedReturn} but not all code paths return a value`, node.loc);
1096
+ this.warn(`Function '${node.name}' declares return type ${expectedReturn} but not all code paths return a value`, node.loc, null, { code: 'W205' });
876
1097
  }
877
1098
  }
878
1099
  } finally {
@@ -928,6 +1149,8 @@ export class Analyzer {
928
1149
  }
929
1150
 
930
1151
  visitTypeDeclaration(node) {
1152
+ this._checkNamingConvention(node.name, 'type', node.loc);
1153
+
931
1154
  // Build ADT type structure
932
1155
  const variants = new Map();
933
1156
  for (const variant of node.variants) {
@@ -970,6 +1193,19 @@ export class Analyzer {
970
1193
  }
971
1194
  }
972
1195
  }
1196
+
1197
+ // Validate derive traits
1198
+ if (node.derive) {
1199
+ const builtinTraits = new Set(['Eq', 'Show', 'JSON']);
1200
+ for (const trait of node.derive) {
1201
+ if (!builtinTraits.has(trait)) {
1202
+ const traitSym = this.currentScope.lookup(trait);
1203
+ if (!traitSym || !traitSym._interfaceMethods) {
1204
+ this.warn(`Unknown trait '${trait}' in derive clause`, node.loc, null, { code: 'W303' });
1205
+ }
1206
+ }
1207
+ }
1208
+ }
973
1209
  }
974
1210
 
975
1211
  visitImportDeclaration(node) {
@@ -1010,8 +1246,16 @@ export class Analyzer {
1010
1246
  this.currentScope.startLoc = { line: node.loc.line, column: node.loc.column };
1011
1247
  }
1012
1248
  try {
1249
+ let terminated = false;
1013
1250
  for (const stmt of node.body) {
1251
+ if (terminated) {
1252
+ this.warn("Unreachable code after return/break/continue", stmt.loc || node.loc, null, { code: 'W201' });
1253
+ break; // Only warn once per block
1254
+ }
1014
1255
  this.visitNode(stmt);
1256
+ if (stmt.type === 'ReturnStatement' || stmt.type === 'BreakStatement' || stmt.type === 'ContinueStatement') {
1257
+ terminated = true;
1258
+ }
1015
1259
  }
1016
1260
  } finally {
1017
1261
  if (node.loc) {
@@ -1022,21 +1266,193 @@ export class Analyzer {
1022
1266
  }
1023
1267
 
1024
1268
  visitIfStatement(node) {
1269
+ // Constant conditional check
1270
+ if (node.condition && node.condition.type === 'BooleanLiteral') {
1271
+ if (node.condition.value === true) {
1272
+ this.warn("Condition is always true", node.condition.loc || node.loc, null, { code: 'W202' });
1273
+ } else {
1274
+ this.warn("Condition is always false — branch never executes", node.condition.loc || node.loc, null, { code: 'W203' });
1275
+ }
1276
+ }
1277
+
1025
1278
  this.visitExpression(node.condition);
1026
- this.visitNode(node.consequent);
1279
+
1280
+ // Type narrowing: detect patterns like typeOf(x) == "String", x != nil, x.isOk()
1281
+ const narrowing = this._extractNarrowingInfo(node.condition);
1282
+
1283
+ // Visit consequent with narrowed type
1284
+ if (narrowing) {
1285
+ const prevScope = this.currentScope;
1286
+ this.currentScope = this.currentScope.child('block');
1287
+ const sym = this.currentScope.lookup(narrowing.varName);
1288
+ if (sym) {
1289
+ // Store narrowed type info in the scope
1290
+ const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, false, sym.loc);
1291
+ narrowedSym.inferredType = narrowing.narrowedType;
1292
+ narrowedSym._narrowed = true;
1293
+ try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
1294
+ }
1295
+ for (const stmt of node.consequent.body) {
1296
+ this.visitNode(stmt);
1297
+ }
1298
+ this.currentScope = prevScope;
1299
+ } else {
1300
+ this.visitNode(node.consequent);
1301
+ }
1302
+
1027
1303
  for (const alt of node.alternates) {
1028
1304
  this.visitExpression(alt.condition);
1029
1305
  this.visitNode(alt.body);
1030
1306
  }
1307
+
1308
+ // Visit else body with inverse narrowing
1031
1309
  if (node.elseBody) {
1032
- this.visitNode(node.elseBody);
1310
+ if (narrowing && narrowing.inverseType) {
1311
+ const prevScope = this.currentScope;
1312
+ this.currentScope = this.currentScope.child('block');
1313
+ const sym = this.currentScope.lookup(narrowing.varName);
1314
+ if (sym) {
1315
+ const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, false, sym.loc);
1316
+ narrowedSym.inferredType = narrowing.inverseType;
1317
+ narrowedSym._narrowed = true;
1318
+ try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
1319
+ }
1320
+ for (const stmt of node.elseBody.body) {
1321
+ this.visitNode(stmt);
1322
+ }
1323
+ this.currentScope = prevScope;
1324
+ } else {
1325
+ this.visitNode(node.elseBody);
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ _extractNarrowingInfo(condition) {
1331
+ if (!condition) return null;
1332
+
1333
+ // Pattern: typeOf(x) == "String" or typeOf(x) == "Int"
1334
+ if (condition.type === 'BinaryExpression' && condition.operator === '==') {
1335
+ const { left, right } = condition;
1336
+
1337
+ // typeOf(x) == "TypeName"
1338
+ if (left.type === 'CallExpression' &&
1339
+ left.callee.type === 'Identifier' &&
1340
+ (left.callee.name === 'typeOf' || left.callee.name === 'type_of') &&
1341
+ left.arguments.length === 1 &&
1342
+ left.arguments[0].type === 'Identifier' &&
1343
+ right.type === 'StringLiteral') {
1344
+ const varName = left.arguments[0].name;
1345
+ const typeName = right.value;
1346
+ // Map JS typeof strings to Tova types
1347
+ const typeMap = { 'string': 'String', 'number': 'Int', 'boolean': 'Bool', 'function': 'Function' };
1348
+ const narrowedType = typeMap[typeName] || typeName;
1349
+ return { varName, narrowedType, inverseType: null };
1350
+ }
1351
+
1352
+ // "TypeName" == typeOf(x) (reversed)
1353
+ if (right.type === 'CallExpression' &&
1354
+ right.callee.type === 'Identifier' &&
1355
+ (right.callee.name === 'typeOf' || right.callee.name === 'type_of') &&
1356
+ right.arguments.length === 1 &&
1357
+ right.arguments[0].type === 'Identifier' &&
1358
+ left.type === 'StringLiteral') {
1359
+ const varName = right.arguments[0].name;
1360
+ const typeName = left.value;
1361
+ const typeMap = { 'string': 'String', 'number': 'Int', 'boolean': 'Bool', 'function': 'Function' };
1362
+ const narrowedType = typeMap[typeName] || typeName;
1363
+ return { varName, narrowedType, inverseType: null };
1364
+ }
1365
+ }
1366
+
1367
+ // Pattern: x != nil (narrow to non-nil)
1368
+ if (condition.type === 'BinaryExpression' && condition.operator === '!=' &&
1369
+ condition.right.type === 'NilLiteral' &&
1370
+ condition.left.type === 'Identifier') {
1371
+ // Try to compute a precise narrowed type by stripping Nil from the variable's type
1372
+ const varName = condition.left.name;
1373
+ const sym = this.currentScope.lookup(varName);
1374
+ let narrowedType = 'nonnil';
1375
+ if (sym && sym.inferredType) {
1376
+ const stripped = this._stripNilFromType(sym.inferredType);
1377
+ if (stripped) narrowedType = stripped;
1378
+ }
1379
+ return { varName, narrowedType, inverseType: 'Nil' };
1380
+ }
1381
+
1382
+ // Pattern: nil != x (reversed)
1383
+ if (condition.type === 'BinaryExpression' && condition.operator === '!=' &&
1384
+ condition.left.type === 'NilLiteral' &&
1385
+ condition.right.type === 'Identifier') {
1386
+ const varName = condition.right.name;
1387
+ const sym = this.currentScope.lookup(varName);
1388
+ let narrowedType = 'nonnil';
1389
+ if (sym && sym.inferredType) {
1390
+ const stripped = this._stripNilFromType(sym.inferredType);
1391
+ if (stripped) narrowedType = stripped;
1392
+ }
1393
+ return { varName, narrowedType, inverseType: 'Nil' };
1394
+ }
1395
+
1396
+ // Pattern: x == nil (narrow to Nil in consequent, non-nil in else)
1397
+ if (condition.type === 'BinaryExpression' && condition.operator === '==' &&
1398
+ condition.right.type === 'NilLiteral' &&
1399
+ condition.left.type === 'Identifier') {
1400
+ const varName = condition.left.name;
1401
+ const sym = this.currentScope.lookup(varName);
1402
+ let inverseType = 'nonnil';
1403
+ if (sym && sym.inferredType) {
1404
+ const stripped = this._stripNilFromType(sym.inferredType);
1405
+ if (stripped) inverseType = stripped;
1406
+ }
1407
+ return { varName, narrowedType: 'Nil', inverseType };
1033
1408
  }
1409
+
1410
+ // Pattern: x.isOk() (narrow to Ok variant)
1411
+ if (condition.type === 'CallExpression' &&
1412
+ condition.callee.type === 'MemberExpression' &&
1413
+ condition.callee.object.type === 'Identifier' &&
1414
+ condition.callee.property === 'isOk') {
1415
+ return { varName: condition.callee.object.name, narrowedType: 'Result<Ok>', inverseType: 'Result<Err>' };
1416
+ }
1417
+
1418
+ // Pattern: x.isSome() (narrow to Some variant)
1419
+ if (condition.type === 'CallExpression' &&
1420
+ condition.callee.type === 'MemberExpression' &&
1421
+ condition.callee.object.type === 'Identifier' &&
1422
+ condition.callee.property === 'isSome') {
1423
+ return { varName: condition.callee.object.name, narrowedType: 'Option<Some>', inverseType: 'Option<None>' };
1424
+ }
1425
+
1426
+ return null;
1427
+ }
1428
+
1429
+ /**
1430
+ * Strip Nil from a union type string. E.g., "String | Nil" -> "String".
1431
+ * For Option types, returns the inner type.
1432
+ */
1433
+ _stripNilFromType(typeStr) {
1434
+ if (!typeStr) return null;
1435
+ // Handle union types: "String | Nil" -> "String"
1436
+ if (typeStr.includes(' | ')) {
1437
+ const parts = typeStr.split(' | ').map(p => p.trim()).filter(p => p !== 'Nil');
1438
+ if (parts.length === 0) return null;
1439
+ if (parts.length === 1) return parts[0];
1440
+ return parts.join(' | ');
1441
+ }
1442
+ // Handle Option types: "Option<String>" -> "String"
1443
+ if (typeStr.startsWith('Option<') && typeStr.endsWith('>')) {
1444
+ return typeStr.slice(7, -1);
1445
+ }
1446
+ if (typeStr === 'Option') return 'Any';
1447
+ // If not a union/option, narrowing past nil means the variable keeps its type
1448
+ return typeStr;
1034
1449
  }
1035
1450
 
1036
1451
  visitForStatement(node) {
1037
1452
  const prevScope = this.currentScope;
1038
1453
  this.currentScope = this.currentScope.child('block');
1039
1454
  this.currentScope._isLoop = true;
1455
+ if (node.label) this.currentScope._loopLabel = node.label;
1040
1456
 
1041
1457
  try {
1042
1458
  this.visitExpression(node.iterable);
@@ -1052,6 +1468,10 @@ export class Analyzer {
1052
1468
  }
1053
1469
  }
1054
1470
 
1471
+ if (node.guard) {
1472
+ this.visitExpression(node.guard);
1473
+ }
1474
+
1055
1475
  this.visitNode(node.body);
1056
1476
  } finally {
1057
1477
  this.currentScope = prevScope;
@@ -1063,10 +1483,28 @@ export class Analyzer {
1063
1483
  }
1064
1484
 
1065
1485
  visitWhileStatement(node) {
1486
+ // while false is suspicious (loop body never executes)
1487
+ if (node.condition && node.condition.type === 'BooleanLiteral' && node.condition.value === false) {
1488
+ this.warn("Condition is always false — loop never executes", node.condition.loc || node.loc, null, { code: 'W203' });
1489
+ }
1490
+
1066
1491
  this.visitExpression(node.condition);
1067
1492
  const prevScope = this.currentScope;
1068
1493
  this.currentScope = this.currentScope.child('block');
1069
1494
  this.currentScope._isLoop = true;
1495
+ if (node.label) this.currentScope._loopLabel = node.label;
1496
+ try {
1497
+ this.visitNode(node.body);
1498
+ } finally {
1499
+ this.currentScope = prevScope;
1500
+ }
1501
+ }
1502
+
1503
+ visitLoopStatement(node) {
1504
+ const prevScope = this.currentScope;
1505
+ this.currentScope = this.currentScope.child('block');
1506
+ this.currentScope._isLoop = true;
1507
+ if (node.label) this.currentScope._loopLabel = node.label;
1070
1508
  try {
1071
1509
  this.visitNode(node.body);
1072
1510
  } finally {
@@ -1112,7 +1550,7 @@ export class Analyzer {
1112
1550
  }
1113
1551
  // Return must be inside a function
1114
1552
  if (this._functionReturnTypeStack.length === 0) {
1115
- this.error("'return' can only be used inside a function", node.loc);
1553
+ this.error("'return' can only be used inside a function", node.loc, null, { code: 'E301' });
1116
1554
  return;
1117
1555
  }
1118
1556
  // Check return type against declared function return type
@@ -1121,7 +1559,7 @@ export class Analyzer {
1121
1559
  if (expectedReturn) {
1122
1560
  const actualType = node.value ? this._inferType(node.value) : 'Nil';
1123
1561
  if (!this._typesCompatible(expectedReturn, actualType)) {
1124
- this.error(`Type mismatch: function expects return type ${expectedReturn}, but got ${actualType}`, node.loc);
1562
+ this.error(`Type mismatch: function expects return type ${expectedReturn}, but got ${actualType}`, node.loc, this._conversionHint(expectedReturn, actualType), { code: 'E101' });
1125
1563
  }
1126
1564
  }
1127
1565
  }
@@ -1132,7 +1570,7 @@ export class Analyzer {
1132
1570
  if (node.target.type === 'Identifier') {
1133
1571
  const sym = this.currentScope.lookup(node.target.name);
1134
1572
  if (sym && !sym.mutable && sym.kind !== 'builtin') {
1135
- this.error(`Cannot use '${node.operator}' on immutable variable '${node.target.name}'`, node.loc);
1573
+ this.error(`Cannot use '${node.operator}' on immutable variable '${node.target.name}'`, node.loc, `declare with 'var' to make mutable`, { code: 'E202' });
1136
1574
  }
1137
1575
  // Type check compound assignment
1138
1576
  if (sym && sym.inferredType) {
@@ -1166,519 +1604,97 @@ export class Analyzer {
1166
1604
  this.visitExpression(node.value);
1167
1605
  }
1168
1606
 
1169
- // ─── Client-specific visitors ─────────────────────────────
1170
-
1171
- visitStateDeclaration(node) {
1172
- const ctx = this.currentScope.getContext();
1173
- if (ctx !== 'client') {
1174
- this.error(`'state' can only be used inside a client block`, node.loc);
1175
- }
1176
- try {
1177
- this.currentScope.define(node.name,
1178
- new Symbol(node.name, 'state', node.typeAnnotation, true, node.loc));
1179
- } catch (e) {
1180
- this.error(e.message);
1181
- }
1182
- this.visitExpression(node.initialValue);
1183
- }
1184
-
1185
- visitComputedDeclaration(node) {
1186
- const ctx = this.currentScope.getContext();
1187
- if (ctx !== 'client') {
1188
- this.error(`'computed' can only be used inside a client block`, node.loc);
1189
- }
1190
- try {
1191
- this.currentScope.define(node.name,
1192
- new Symbol(node.name, 'computed', null, false, node.loc));
1193
- } catch (e) {
1194
- this.error(e.message);
1195
- }
1196
- this.visitExpression(node.expression);
1197
- }
1198
-
1199
- visitEffectDeclaration(node) {
1200
- const ctx = this.currentScope.getContext();
1201
- if (ctx !== 'client') {
1202
- this.error(`'effect' can only be used inside a client block`, node.loc);
1203
- }
1204
- this.visitNode(node.body);
1205
- }
1206
-
1207
- visitComponentDeclaration(node) {
1208
- const ctx = this.currentScope.getContext();
1209
- if (ctx !== 'client') {
1210
- this.error(`'component' can only be used inside a client block`, node.loc);
1211
- }
1212
- try {
1213
- this.currentScope.define(node.name,
1214
- new Symbol(node.name, 'component', null, false, node.loc));
1215
- } catch (e) {
1216
- this.error(e.message);
1217
- }
1607
+ // Client-specific visitors (visitState, visitComputed, etc.) are in client-analyzer.js (lazy-loaded)
1218
1608
 
1609
+ visitTestBlock(node) {
1219
1610
  const prevScope = this.currentScope;
1220
- this.currentScope = this.currentScope.child('function');
1221
- for (const param of node.params) {
1222
- try {
1223
- this.currentScope.define(param.name,
1224
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1225
- } catch (e) {
1226
- this.error(e.message);
1227
- }
1228
- }
1611
+ this.currentScope = this.currentScope.child('block');
1229
1612
  try {
1230
- for (const child of node.body) {
1231
- this.visitNode(child);
1613
+ for (const stmt of node.body) {
1614
+ this.visitNode(stmt);
1232
1615
  }
1233
1616
  } finally {
1234
1617
  this.currentScope = prevScope;
1235
1618
  }
1236
1619
  }
1237
1620
 
1238
- visitStoreDeclaration(node) {
1239
- const ctx = this.currentScope.getContext();
1240
- if (ctx !== 'client') {
1241
- this.error(`'store' can only be used inside a client block`, node.loc);
1242
- }
1243
- try {
1244
- this.currentScope.define(node.name,
1245
- new Symbol(node.name, 'variable', null, false, node.loc));
1246
- } catch (e) {
1247
- this.error(e.message);
1621
+ // ─── Expression visitors ──────────────────────────────────
1622
+
1623
+ visitIdentifier(node) {
1624
+ if (node.name === '_') return; // wildcard is always valid
1625
+ if (node.name === PIPE_TARGET) return; // pipe target placeholder from method pipe
1626
+
1627
+ // Common mistake: using `throw` (not a Tova keyword)
1628
+ if (node.name === 'throw') {
1629
+ this.warn("'throw' is not a Tova keyword — use Result for error handling, e.g. Err(\"message\")", node.loc, "try Err(value) instead of throw", { code: 'W206' });
1630
+ return;
1248
1631
  }
1249
1632
 
1250
- const prevScope = this.currentScope;
1251
- this.currentScope = this.currentScope.child('block');
1252
- try {
1253
- for (const child of node.body) {
1254
- this.visitNode(child);
1633
+ const sym = this.currentScope.lookup(node.name);
1634
+ if (!sym) {
1635
+ if (!this._isKnownGlobal(node.name)) {
1636
+ const suggestion = this._findClosestMatch(node.name);
1637
+ const hint = suggestion ? `did you mean '${suggestion}'?` : null;
1638
+ const fixOpts = { code: 'E200', length: node.name.length };
1639
+ if (suggestion) fixOpts.fix = { description: `Replace with '${suggestion}'`, replacement: suggestion };
1640
+ this.warn(`'${node.name}' is not defined`, node.loc, hint, fixOpts);
1255
1641
  }
1256
- } finally {
1257
- this.currentScope = prevScope;
1642
+ } else {
1643
+ sym.used = true;
1258
1644
  }
1259
1645
  }
1260
1646
 
1261
- visitRouteDeclaration(node) {
1262
- const ctx = this.currentScope.getContext();
1263
- if (ctx !== 'server') {
1264
- this.error(`'route' can only be used inside a server block`, node.loc);
1265
- }
1266
- this.visitExpression(node.handler);
1647
+ _isKnownGlobal(name) {
1648
+ // Tova stdlib (auto-synced from BUILTIN_FUNCTIONS in inline.js)
1649
+ if (BUILTIN_NAMES.has(name)) return true;
1267
1650
 
1268
- // Route param ↔ handler signature type safety
1269
- if (node.handler.type === 'Identifier') {
1270
- const handlerName = node.handler.name;
1271
- // Find the function declaration in the current server block scope
1272
- const fnSym = this.currentScope.lookup(handlerName);
1273
- if (fnSym && fnSym.kind === 'function' && fnSym._params) {
1274
- const pathParams = new Set();
1275
- const pathStr = node.path || '';
1276
- const paramMatches = pathStr.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
1277
- if (paramMatches) {
1278
- for (const m of paramMatches) pathParams.add(m.slice(1));
1279
- }
1280
- const handlerParams = fnSym._params.filter(p => p !== 'req');
1281
- for (const hp of handlerParams) {
1282
- if (pathParams.size > 0 && !pathParams.has(hp) && node.method.toUpperCase() === 'GET') {
1283
- // For GET routes, params not in path come from query — this is fine, just a warning
1284
- this.warn(`Handler '${handlerName}' param '${hp}' not in route path '${pathStr}' — will be extracted from query string`, node.loc);
1285
- }
1286
- }
1287
- }
1288
- }
1651
+ // Tova runtime names
1652
+ if (_TOVA_RUNTIME.has(name)) return true;
1653
+
1654
+ // JS globals / platform APIs
1655
+ return _JS_GLOBALS.has(name);
1289
1656
  }
1290
1657
 
1291
- visitMiddlewareDeclaration(node) {
1292
- const ctx = this.currentScope.getContext();
1293
- if (ctx !== 'server') {
1294
- this.error(`'middleware' can only be used inside a server block`, node.loc);
1658
+ _findClosestMatch(name) {
1659
+ const candidates = new Set();
1660
+ let scope = this.currentScope;
1661
+ while (scope) {
1662
+ for (const n of scope.symbols.keys()) candidates.add(n);
1663
+ scope = scope.parent;
1295
1664
  }
1296
- try {
1297
- this.currentScope.define(node.name,
1298
- new Symbol(node.name, 'function', null, false, node.loc));
1299
- } catch (e) {
1300
- this.error(e.message);
1301
- }
1302
- const prevScope = this.currentScope;
1303
- this.currentScope = this.currentScope.child('function');
1304
- for (const param of node.params) {
1305
- try {
1306
- this.currentScope.define(param.name,
1307
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1308
- } catch (e) {
1309
- this.error(e.message);
1310
- }
1311
- }
1312
- try {
1313
- this.visitNode(node.body);
1314
- } finally {
1315
- this.currentScope = prevScope;
1316
- }
1317
- }
1318
-
1319
- visitHealthCheckDeclaration(node) {
1320
- const ctx = this.currentScope.getContext();
1321
- if (ctx !== 'server') {
1322
- this.error(`'health' can only be used inside a server block`, node.loc);
1323
- }
1324
- }
1325
-
1326
- visitCorsDeclaration(node) {
1327
- const ctx = this.currentScope.getContext();
1328
- if (ctx !== 'server') {
1329
- this.error(`'cors' can only be used inside a server block`, node.loc);
1330
- }
1331
- for (const value of Object.values(node.config)) {
1332
- this.visitExpression(value);
1333
- }
1334
- }
1335
-
1336
- visitErrorHandlerDeclaration(node) {
1337
- const ctx = this.currentScope.getContext();
1338
- if (ctx !== 'server') {
1339
- this.error(`'on_error' can only be used inside a server block`, node.loc);
1340
- }
1341
- const prevScope = this.currentScope;
1342
- this.currentScope = this.currentScope.child('function');
1343
- for (const param of node.params) {
1344
- try {
1345
- this.currentScope.define(param.name,
1346
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1347
- } catch (e) {
1348
- this.error(e.message);
1349
- }
1350
- }
1351
- try {
1352
- this.visitNode(node.body);
1353
- } finally {
1354
- this.currentScope = prevScope;
1355
- }
1356
- }
1357
-
1358
- visitWebSocketDeclaration(node) {
1359
- const ctx = this.currentScope.getContext();
1360
- if (ctx !== 'server') {
1361
- this.error(`'ws' can only be used inside a server block`, node.loc);
1362
- }
1363
- for (const [, handler] of Object.entries(node.handlers)) {
1364
- if (!handler) continue;
1365
- const prevScope = this.currentScope;
1366
- this.currentScope = this.currentScope.child('function');
1367
- for (const param of handler.params) {
1368
- try {
1369
- this.currentScope.define(param.name,
1370
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1371
- } catch (e) {
1372
- this.error(e.message);
1373
- }
1374
- }
1375
- try {
1376
- this.visitNode(handler.body);
1377
- } finally {
1378
- this.currentScope = prevScope;
1379
- }
1380
- }
1381
- }
1382
-
1383
- visitStaticDeclaration(node) {
1384
- const ctx = this.currentScope.getContext();
1385
- if (ctx !== 'server') {
1386
- this.error(`'static' can only be used inside a server block`, node.loc);
1387
- }
1388
- }
1389
-
1390
- visitDiscoverDeclaration(node) {
1391
- const ctx = this.currentScope.getContext();
1392
- if (ctx !== 'server') {
1393
- this.error(`'discover' can only be used inside a server block`, node.loc);
1394
- }
1395
- this.visitExpression(node.urlExpression);
1396
- }
1397
-
1398
- visitAuthDeclaration(node) {
1399
- const ctx = this.currentScope.getContext();
1400
- if (ctx !== 'server') {
1401
- this.error(`'auth' can only be used inside a server block`, node.loc);
1402
- }
1403
- for (const value of Object.values(node.config)) {
1404
- this.visitExpression(value);
1405
- }
1406
- }
1407
-
1408
- visitMaxBodyDeclaration(node) {
1409
- const ctx = this.currentScope.getContext();
1410
- if (ctx !== 'server') {
1411
- this.error(`'max_body' can only be used inside a server block`, node.loc);
1412
- }
1413
- this.visitExpression(node.limit);
1414
- }
1415
-
1416
- visitRouteGroupDeclaration(node) {
1417
- const ctx = this.currentScope.getContext();
1418
- if (ctx !== 'server') {
1419
- this.error(`'routes' can only be used inside a server block`, node.loc);
1420
- }
1421
- for (const stmt of node.body) {
1422
- this.visitNode(stmt);
1423
- }
1424
- }
1425
-
1426
- visitRateLimitDeclaration(node) {
1427
- const ctx = this.currentScope.getContext();
1428
- if (ctx !== 'server') {
1429
- this.error(`'rate_limit' can only be used inside a server block`, node.loc);
1430
- }
1431
- for (const value of Object.values(node.config)) {
1432
- this.visitExpression(value);
1433
- }
1434
- }
1435
-
1436
- visitLifecycleHookDeclaration(node) {
1437
- const ctx = this.currentScope.getContext();
1438
- if (ctx !== 'server') {
1439
- this.error(`'on_${node.hook}' can only be used inside a server block`, node.loc);
1440
- }
1441
- const prevScope = this.currentScope;
1442
- this.currentScope = this.currentScope.child('function');
1443
- for (const param of node.params) {
1444
- try {
1445
- this.currentScope.define(param.name,
1446
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1447
- } catch (e) {
1448
- this.error(e.message);
1449
- }
1450
- }
1451
- try {
1452
- this.visitNode(node.body);
1453
- } finally {
1454
- this.currentScope = prevScope;
1455
- }
1456
- }
1457
-
1458
- visitSubscribeDeclaration(node) {
1459
- const ctx = this.currentScope.getContext();
1460
- if (ctx !== 'server') {
1461
- this.error(`'subscribe' can only be used inside a server block`, node.loc);
1462
- }
1463
- const prevScope = this.currentScope;
1464
- this.currentScope = this.currentScope.child('function');
1465
- for (const param of node.params) {
1466
- try {
1467
- this.currentScope.define(param.name,
1468
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1469
- } catch (e) {
1470
- this.error(e.message);
1471
- }
1472
- }
1473
- try {
1474
- this.visitNode(node.body);
1475
- } finally {
1476
- this.currentScope = prevScope;
1477
- }
1478
- }
1479
-
1480
- visitEnvDeclaration(node) {
1481
- const ctx = this.currentScope.getContext();
1482
- if (ctx !== 'server') {
1483
- this.error(`'env' can only be used inside a server block`, node.loc);
1484
- }
1485
- try {
1486
- this.currentScope.define(node.name,
1487
- new Symbol(node.name, 'variable', node.typeAnnotation, false, node.loc));
1488
- } catch (e) {
1489
- this.error(e.message);
1490
- }
1491
- if (node.defaultValue) {
1492
- this.visitExpression(node.defaultValue);
1493
- }
1494
- }
1495
-
1496
- visitScheduleDeclaration(node) {
1497
- const ctx = this.currentScope.getContext();
1498
- if (ctx !== 'server') {
1499
- this.error(`'schedule' can only be used inside a server block`, node.loc);
1500
- }
1501
- if (node.name) {
1502
- try {
1503
- this.currentScope.define(node.name,
1504
- new Symbol(node.name, 'function', null, false, node.loc));
1505
- } catch (e) {
1506
- this.error(e.message);
1507
- }
1508
- }
1509
- const prevScope = this.currentScope;
1510
- this.currentScope = this.currentScope.child('function');
1511
- for (const param of node.params) {
1512
- try {
1513
- this.currentScope.define(param.name,
1514
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1515
- } catch (e) {
1516
- this.error(e.message);
1517
- }
1518
- }
1519
- try {
1520
- this.visitNode(node.body);
1521
- } finally {
1522
- this.currentScope = prevScope;
1523
- }
1524
- }
1525
-
1526
- visitUploadDeclaration(node) {
1527
- const ctx = this.currentScope.getContext();
1528
- if (ctx !== 'server') {
1529
- this.error(`'upload' can only be used inside a server block`, node.loc);
1530
- }
1531
- for (const value of Object.values(node.config)) {
1532
- this.visitExpression(value);
1533
- }
1534
- }
1535
-
1536
- visitSessionDeclaration(node) {
1537
- const ctx = this.currentScope.getContext();
1538
- if (ctx !== 'server') {
1539
- this.error(`'session' can only be used inside a server block`, node.loc);
1540
- }
1541
- for (const value of Object.values(node.config)) {
1542
- this.visitExpression(value);
1543
- }
1544
- }
1545
-
1546
- visitDbDeclaration(node) {
1547
- const ctx = this.currentScope.getContext();
1548
- if (ctx !== 'server') {
1549
- this.error(`'db' can only be used inside a server block`, node.loc);
1550
- }
1551
- for (const value of Object.values(node.config)) {
1552
- this.visitExpression(value);
1553
- }
1554
- }
1555
-
1556
- visitTlsDeclaration(node) {
1557
- const ctx = this.currentScope.getContext();
1558
- if (ctx !== 'server') {
1559
- this.error(`'tls' can only be used inside a server block`, node.loc);
1560
- }
1561
- for (const value of Object.values(node.config)) {
1562
- this.visitExpression(value);
1563
- }
1564
- }
1565
-
1566
- visitCompressionDeclaration(node) {
1567
- const ctx = this.currentScope.getContext();
1568
- if (ctx !== 'server') {
1569
- this.error(`'compression' can only be used inside a server block`, node.loc);
1570
- }
1571
- for (const value of Object.values(node.config)) {
1572
- this.visitExpression(value);
1573
- }
1574
- }
1575
-
1576
- visitBackgroundJobDeclaration(node) {
1577
- const ctx = this.currentScope.getContext();
1578
- if (ctx !== 'server') {
1579
- this.error(`'background' can only be used inside a server block`, node.loc);
1580
- }
1581
- try {
1582
- this.currentScope.define(node.name,
1583
- new Symbol(node.name, 'function', null, false, node.loc));
1584
- } catch (e) {
1585
- this.error(e.message);
1586
- }
1587
- const prevScope = this.currentScope;
1588
- this.currentScope = this.currentScope.child('function');
1589
- for (const param of node.params) {
1590
- try {
1591
- this.currentScope.define(param.name,
1592
- new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
1593
- } catch (e) {
1594
- this.error(e.message);
1595
- }
1596
- }
1597
- try {
1598
- this.visitNode(node.body);
1599
- } finally {
1600
- this.currentScope = prevScope;
1601
- }
1602
- }
1603
-
1604
- visitCacheDeclaration(node) {
1605
- const ctx = this.currentScope.getContext();
1606
- if (ctx !== 'server') {
1607
- this.error(`'cache' can only be used inside a server block`, node.loc);
1608
- }
1609
- for (const value of Object.values(node.config)) {
1610
- this.visitExpression(value);
1611
- }
1612
- }
1613
-
1614
- visitSseDeclaration(node) {
1615
- const ctx = this.currentScope.getContext();
1616
- if (ctx !== 'server') {
1617
- this.error(`'sse' can only be used inside a server block`, node.loc);
1618
- }
1619
- const prevScope = this.currentScope;
1620
- this.currentScope = this.currentScope.child('block');
1621
- for (const p of node.params) {
1622
- this.currentScope.define(p.name, { kind: 'param' });
1623
- }
1624
- try {
1625
- for (const stmt of node.body.body || []) {
1626
- this.visitNode(stmt);
1627
- }
1628
- } finally {
1629
- this.currentScope = prevScope;
1630
- }
1631
- }
1632
-
1633
- visitModelDeclaration(node) {
1634
- const ctx = this.currentScope.getContext();
1635
- if (ctx !== 'server') {
1636
- this.error(`'model' can only be used inside a server block`, node.loc);
1637
- }
1638
- if (node.config) {
1639
- for (const value of Object.values(node.config)) {
1640
- this.visitExpression(value);
1641
- }
1642
- }
1643
- }
1644
-
1645
- visitTestBlock(node) {
1646
- const prevScope = this.currentScope;
1647
- this.currentScope = this.currentScope.child('block');
1648
- try {
1649
- for (const stmt of node.body) {
1650
- this.visitNode(stmt);
1651
- }
1652
- } finally {
1653
- this.currentScope = prevScope;
1654
- }
1655
- }
1656
-
1657
- // ─── Expression visitors ──────────────────────────────────
1658
-
1659
- visitIdentifier(node) {
1660
- if (node.name === '_') return; // wildcard is always valid
1661
- if (node.name === PIPE_TARGET) return; // pipe target placeholder from method pipe
1662
-
1663
- const sym = this.currentScope.lookup(node.name);
1664
- if (!sym) {
1665
- if (!this._isKnownGlobal(node.name)) {
1666
- this.warn(`'${node.name}' is not defined`, node.loc);
1667
- }
1668
- } else {
1669
- sym.used = true;
1670
- }
1671
- }
1672
-
1673
- _isKnownGlobal(name) {
1674
- // Tova stdlib (auto-synced from BUILTIN_FUNCTIONS in inline.js)
1675
- if (BUILTIN_NAMES.has(name)) return true;
1676
-
1677
- // Tova runtime names
1678
- if (_TOVA_RUNTIME.has(name)) return true;
1679
-
1680
- // JS globals / platform APIs
1681
- return _JS_GLOBALS.has(name);
1665
+ for (const n of BUILTIN_NAMES) candidates.add(n);
1666
+ for (const n of _JS_GLOBALS) candidates.add(n);
1667
+ for (const n of _TOVA_RUNTIME) candidates.add(n);
1668
+
1669
+ let best = null;
1670
+ let bestDist = Infinity;
1671
+ const maxDist = Math.max(2, Math.floor(name.length * 0.4));
1672
+ for (const c of candidates) {
1673
+ if (Math.abs(c.length - name.length) > maxDist) continue;
1674
+ const d = levenshtein(name.toLowerCase(), c.toLowerCase());
1675
+ if (d < bestDist && d <= maxDist && d > 0) {
1676
+ bestDist = d;
1677
+ best = c;
1678
+ }
1679
+ }
1680
+ return best;
1681
+ }
1682
+
1683
+ _conversionHint(expected, actual) {
1684
+ if (!expected || !actual) return null;
1685
+ const key = `${actual}->${expected}`;
1686
+ const hints = {
1687
+ 'Int->String': "try toString(value) to convert",
1688
+ 'Float->String': "try toString(value) to convert",
1689
+ 'Bool->String': "try toString(value) to convert",
1690
+ 'String->Int': "try toInt(value) to parse",
1691
+ 'String->Float': "try toFloat(value) to parse",
1692
+ 'Float->Int': "try floor(value) or round(value) to convert",
1693
+ };
1694
+ if (hints[key]) return hints[key];
1695
+ if (expected.startsWith('Result')) return "try Ok(value) to wrap in Result";
1696
+ if (expected.startsWith('Option')) return "try Some(value) to wrap in Option";
1697
+ return null;
1682
1698
  }
1683
1699
 
1684
1700
  visitLambda(node) {
@@ -1708,7 +1724,7 @@ export class Analyzer {
1708
1724
  this.visitNode(node.body);
1709
1725
  // Return path analysis for lambdas with block bodies and declared return types
1710
1726
  if (expectedReturn && !this._definitelyReturns(node.body)) {
1711
- this.warn(`Lambda declares return type ${expectedReturn} but not all code paths return a value`, node.loc);
1727
+ this.warn(`Lambda declares return type ${expectedReturn} but not all code paths return a value`, node.loc, null, { code: 'W205' });
1712
1728
  }
1713
1729
  } else {
1714
1730
  // Single-expression body — always returns implicitly
@@ -1723,7 +1739,14 @@ export class Analyzer {
1723
1739
 
1724
1740
  visitMatchExpression(node) {
1725
1741
  this.visitExpression(node.subject);
1742
+ let catchAllSeen = false;
1726
1743
  for (const arm of node.arms) {
1744
+ // Warn about unreachable arms after catch-all
1745
+ if (catchAllSeen) {
1746
+ this.warn("Unreachable match arm after catch-all pattern", arm.pattern.loc || arm.body.loc || node.loc, null, { code: 'W207' });
1747
+ continue; // Still visit remaining arms for completeness but skip analysis
1748
+ }
1749
+
1727
1750
  const prevScope = this.currentScope;
1728
1751
  this.currentScope = this.currentScope.child('block');
1729
1752
 
@@ -1739,6 +1762,11 @@ export class Analyzer {
1739
1762
  } finally {
1740
1763
  this.currentScope = prevScope;
1741
1764
  }
1765
+
1766
+ // Check if this arm is a catch-all (wildcard or unguarded binding)
1767
+ if ((arm.pattern.type === 'WildcardPattern' || arm.pattern.type === 'BindingPattern') && !arm.guard) {
1768
+ catchAllSeen = true;
1769
+ }
1742
1770
  }
1743
1771
 
1744
1772
  // Exhaustive match checking (#12)
@@ -1793,7 +1821,7 @@ export class Analyzer {
1793
1821
  const allVariants = subjectType.getVariantNames();
1794
1822
  for (const v of allVariants) {
1795
1823
  if (!coveredVariants.has(v)) {
1796
- this.warn(`Non-exhaustive match: missing '${v}' variant from type '${subjectType.name}'`, node.loc);
1824
+ this.warn(`Non-exhaustive match: missing '${v}' variant from type '${subjectType.name}'`, node.loc, `add a '${v}' arm or use '_ =>' as a catch-all`, { code: 'W200' });
1797
1825
  }
1798
1826
  }
1799
1827
  return; // Done — used precise ADT checking
@@ -1802,18 +1830,18 @@ export class Analyzer {
1802
1830
  // Check built-in Result/Option types
1803
1831
  if (coveredVariants.has('Ok') || coveredVariants.has('Err')) {
1804
1832
  if (!coveredVariants.has('Ok')) {
1805
- this.warn(`Non-exhaustive match: missing 'Ok' variant`, node.loc);
1833
+ this.warn(`Non-exhaustive match: missing 'Ok' variant`, node.loc, "add an 'Ok(value) =>' arm", { code: 'W200' });
1806
1834
  }
1807
1835
  if (!coveredVariants.has('Err')) {
1808
- this.warn(`Non-exhaustive match: missing 'Err' variant`, node.loc);
1836
+ this.warn(`Non-exhaustive match: missing 'Err' variant`, node.loc, "add an 'Err(e) =>' arm", { code: 'W200' });
1809
1837
  }
1810
1838
  }
1811
1839
  if (coveredVariants.has('Some') || coveredVariants.has('None')) {
1812
1840
  if (!coveredVariants.has('Some')) {
1813
- this.warn(`Non-exhaustive match: missing 'Some' variant`, node.loc);
1841
+ this.warn(`Non-exhaustive match: missing 'Some' variant`, node.loc, "add a 'Some(value) =>' arm", { code: 'W200' });
1814
1842
  }
1815
1843
  if (!coveredVariants.has('None')) {
1816
- this.warn(`Non-exhaustive match: missing 'None' variant`, node.loc);
1844
+ this.warn(`Non-exhaustive match: missing 'None' variant`, node.loc, "add a 'None =>' arm", { code: 'W200' });
1817
1845
  }
1818
1846
  }
1819
1847
 
@@ -1826,7 +1854,7 @@ export class Analyzer {
1826
1854
  const [typeName, typeVariants] = candidates[0];
1827
1855
  for (const v of typeVariants) {
1828
1856
  if (!coveredVariants.has(v)) {
1829
- this.warn(`Non-exhaustive match: missing '${v}' variant from type '${typeName}'`, node.loc);
1857
+ this.warn(`Non-exhaustive match: missing '${v}' variant from type '${typeName}'`, node.loc, `add a '${v}' arm or use '_ =>' as a catch-all`, { code: 'W200' });
1830
1858
  }
1831
1859
  }
1832
1860
  }
@@ -1939,77 +1967,23 @@ export class Analyzer {
1939
1967
  }
1940
1968
  }
1941
1969
 
1942
- visitJSXElement(node) {
1943
- for (const attr of node.attributes) {
1944
- if (attr.type === 'JSXSpreadAttribute') {
1945
- this.visitExpression(attr.expression);
1946
- } else {
1947
- this.visitExpression(attr.value);
1948
- }
1949
- }
1950
- for (const child of node.children) {
1951
- if (child.type === 'JSXElement') {
1952
- this.visitJSXElement(child);
1953
- } else if (child.type === 'JSXExpression') {
1954
- this.visitExpression(child.expression);
1955
- } else if (child.type === 'JSXFor') {
1956
- this.visitJSXFor(child);
1957
- } else if (child.type === 'JSXIf') {
1958
- this.visitJSXIf(child);
1959
- }
1960
- }
1961
- }
1962
-
1963
- visitJSXFor(node) {
1964
- const prevScope = this.currentScope;
1965
- this.currentScope = this.currentScope.child('block');
1966
- try {
1967
- this.visitExpression(node.iterable);
1968
- try {
1969
- this.currentScope.define(node.variable,
1970
- new Symbol(node.variable, 'variable', null, false, node.loc));
1971
- } catch (e) {
1972
- this.error(e.message);
1973
- }
1974
- for (const child of node.body) {
1975
- this.visitNode(child);
1976
- }
1977
- } finally {
1978
- this.currentScope = prevScope;
1979
- }
1980
- }
1981
-
1982
- visitJSXIf(node) {
1983
- this.visitExpression(node.condition);
1984
- for (const child of node.consequent) {
1985
- this.visitNode(child);
1986
- }
1987
- if (node.alternates) {
1988
- for (const alt of node.alternates) {
1989
- this.visitExpression(alt.condition);
1990
- for (const child of alt.body) {
1991
- this.visitNode(child);
1992
- }
1993
- }
1994
- }
1995
- if (node.alternate) {
1996
- for (const child of node.alternate) {
1997
- this.visitNode(child);
1998
- }
1999
- }
2000
- }
1970
+ // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in client-analyzer.js (lazy-loaded)
2001
1971
 
2002
1972
  // ─── New feature visitors ─────────────────────────────────
2003
1973
 
2004
1974
  visitBreakStatement(node) {
2005
1975
  if (!this._isInsideLoop()) {
2006
1976
  this.error("'break' can only be used inside a loop", node.loc);
1977
+ } else if (node.label && !this._isLabelInScope(node.label)) {
1978
+ this.error(`'break ${node.label}' references undefined label '${node.label}'`, node.loc);
2007
1979
  }
2008
1980
  }
2009
1981
 
2010
1982
  visitContinueStatement(node) {
2011
1983
  if (!this._isInsideLoop()) {
2012
1984
  this.error("'continue' can only be used inside a loop", node.loc);
1985
+ } else if (node.label && !this._isLabelInScope(node.label)) {
1986
+ this.error(`'continue ${node.label}' references undefined label '${node.label}'`, node.loc);
2013
1987
  }
2014
1988
  }
2015
1989
 
@@ -2090,20 +2064,127 @@ export class Analyzer {
2090
2064
  const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
2091
2065
  if (hasSpread) return;
2092
2066
 
2067
+ // Infer type parameter bindings from arguments for generic functions
2068
+ const typeParamBindings = new Map();
2069
+ if (fnSym._typeParams && fnSym._typeParams.length > 0) {
2070
+ for (let i = 0; i < node.arguments.length && i < fnSym._paramTypes.length; i++) {
2071
+ const arg = node.arguments[i];
2072
+ if (arg.type === 'NamedArgument' || arg.type === 'SpreadExpression') continue;
2073
+ const paramTypeAnn = fnSym._paramTypes[i];
2074
+ if (!paramTypeAnn) continue;
2075
+ const actualType = this._inferType(arg);
2076
+ if (actualType) {
2077
+ this._inferTypeParamBindings(paramTypeAnn, actualType, fnSym._typeParams, typeParamBindings);
2078
+ }
2079
+ }
2080
+ }
2081
+
2093
2082
  for (let i = 0; i < node.arguments.length && i < fnSym._paramTypes.length; i++) {
2094
2083
  const arg = node.arguments[i];
2095
2084
  if (arg.type === 'NamedArgument' || arg.type === 'SpreadExpression') continue;
2096
2085
  const paramTypeAnn = fnSym._paramTypes[i];
2097
2086
  if (!paramTypeAnn) continue;
2098
- const expectedType = this._typeAnnotationToString(paramTypeAnn);
2087
+ let expectedType = this._typeAnnotationToString(paramTypeAnn);
2088
+ // Substitute type parameters with inferred bindings
2089
+ if (typeParamBindings.size > 0) {
2090
+ expectedType = this._substituteTypeParams(expectedType, typeParamBindings);
2091
+ }
2092
+ // Skip check if expected type is still a bare type parameter (couldn't infer)
2093
+ if (fnSym._typeParams && fnSym._typeParams.includes(expectedType)) continue;
2099
2094
  const actualType = this._inferType(arg);
2100
2095
  if (!this._typesCompatible(expectedType, actualType)) {
2101
2096
  const paramName = fnSym._params ? fnSym._params[i] : `argument ${i + 1}`;
2102
- this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc);
2097
+ this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
2103
2098
  }
2104
2099
  }
2105
2100
  }
2106
2101
 
2102
+ /**
2103
+ * Infer type parameter bindings by matching a type annotation against an actual type string.
2104
+ * E.g., matching param annotation `T` against actual `Int` binds T=Int.
2105
+ * Matching `[T]` against `[Int]` binds T=Int.
2106
+ */
2107
+ _inferTypeParamBindings(ann, actualType, typeParams, bindings) {
2108
+ if (!ann || !actualType) return;
2109
+ const annStr = typeof ann === 'string' ? ann : (ann.type === 'TypeAnnotation' ? ann.name : null);
2110
+ if (!annStr) return;
2111
+
2112
+ // Direct type parameter match: T -> Int
2113
+ if (typeParams.includes(annStr) && !bindings.has(annStr)) {
2114
+ bindings.set(annStr, actualType);
2115
+ return;
2116
+ }
2117
+
2118
+ // Array type: [T] -> [Int]
2119
+ if (ann.type === 'ArrayTypeAnnotation' && actualType.startsWith('[') && actualType.endsWith(']')) {
2120
+ const innerActual = actualType.slice(1, -1);
2121
+ this._inferTypeParamBindings(ann.elementType, innerActual, typeParams, bindings);
2122
+ return;
2123
+ }
2124
+
2125
+ // Generic type: Result<T, E> -> Result<Int, String>
2126
+ if (ann.type === 'TypeAnnotation' && ann.typeParams && ann.typeParams.length > 0) {
2127
+ const parsed = this._parseGenericType(actualType);
2128
+ if (parsed.base === ann.name && parsed.params.length === ann.typeParams.length) {
2129
+ for (let i = 0; i < ann.typeParams.length; i++) {
2130
+ this._inferTypeParamBindings(ann.typeParams[i], parsed.params[i], typeParams, bindings);
2131
+ }
2132
+ }
2133
+ }
2134
+ }
2135
+
2136
+ /**
2137
+ * Substitute type parameter names in a type string with their inferred bindings.
2138
+ */
2139
+ _substituteTypeParams(typeStr, bindings) {
2140
+ if (!typeStr || bindings.size === 0) return typeStr;
2141
+ // Direct match
2142
+ if (bindings.has(typeStr)) return bindings.get(typeStr);
2143
+ // Array type
2144
+ if (typeStr.startsWith('[') && typeStr.endsWith(']')) {
2145
+ const inner = typeStr.slice(1, -1);
2146
+ return `[${this._substituteTypeParams(inner, bindings)}]`;
2147
+ }
2148
+ // Generic type
2149
+ const parsed = this._parseGenericType(typeStr);
2150
+ if (parsed.params.length > 0) {
2151
+ const substituted = parsed.params.map(p => this._substituteTypeParams(p, bindings));
2152
+ return `${parsed.base}<${substituted.join(', ')}>`;
2153
+ }
2154
+ return typeStr;
2155
+ }
2156
+
2157
+ /**
2158
+ * Resolve a type alias to its underlying type string.
2159
+ * E.g., if `type UserList = [User]`, resolves 'UserList' -> '[User]'.
2160
+ * For generic aliases like `type Callback<T> = fn(T) -> Result<T, String>`,
2161
+ * resolves 'Callback<Int>' by substituting T=Int.
2162
+ */
2163
+ _resolveTypeAlias(typeStr) {
2164
+ if (!typeStr) return typeStr;
2165
+ const parsed = this._parseGenericType(typeStr);
2166
+ const baseName = parsed.params.length > 0 ? parsed.base : typeStr;
2167
+
2168
+ // Look up the type in scope
2169
+ const sym = this.currentScope.lookup(baseName);
2170
+ if (!sym || sym.kind !== 'type' || !sym._typeAliasExpr) return typeStr;
2171
+
2172
+ // Resolve the alias
2173
+ let resolved = this._typeAnnotationToString(sym._typeAliasExpr);
2174
+ if (!resolved) return typeStr;
2175
+
2176
+ // Substitute type parameters if the alias is generic
2177
+ if (sym._typeParams && sym._typeParams.length > 0 && parsed.params.length > 0) {
2178
+ const bindings = new Map();
2179
+ for (let i = 0; i < sym._typeParams.length && i < parsed.params.length; i++) {
2180
+ bindings.set(sym._typeParams[i], parsed.params[i]);
2181
+ }
2182
+ resolved = this._substituteTypeParams(resolved, bindings);
2183
+ }
2184
+
2185
+ return resolved;
2186
+ }
2187
+
2107
2188
  _checkBinaryExprTypes(node) {
2108
2189
  const op = node.operator;
2109
2190
  const leftType = this._inferType(node.left);
@@ -2112,10 +2193,10 @@ export class Analyzer {
2112
2193
  if (op === '++') {
2113
2194
  // String concatenation: both sides should be String
2114
2195
  if (leftType && leftType !== 'String' && leftType !== 'Any') {
2115
- this.strictError(`Type mismatch: '++' expects String on left side, but got ${leftType}`, node.loc);
2196
+ this.strictError(`Type mismatch: '++' expects String on left side, but got ${leftType}`, node.loc, "try toString(value) to convert");
2116
2197
  }
2117
2198
  if (rightType && rightType !== 'String' && rightType !== 'Any') {
2118
- this.strictError(`Type mismatch: '++' expects String on right side, but got ${rightType}`, node.loc);
2199
+ this.strictError(`Type mismatch: '++' expects String on right side, but got ${rightType}`, node.loc, "try toString(value) to convert");
2119
2200
  }
2120
2201
  } else if (['-', '*', '/', '%', '**'].includes(op)) {
2121
2202
  // String literal * Int is valid (string repeat) — skip warning for that case
@@ -2127,19 +2208,23 @@ export class Analyzer {
2127
2208
  // Arithmetic: both sides must be numeric
2128
2209
  const numerics = new Set(['Int', 'Float']);
2129
2210
  if (leftType && !numerics.has(leftType) && leftType !== 'Any') {
2130
- this.strictError(`Type mismatch: '${op}' expects numeric type, but got ${leftType}`, node.loc);
2211
+ const hint = leftType === 'String' ? "try toInt(value) or toFloat(value) to parse" : null;
2212
+ this.strictError(`Type mismatch: '${op}' expects numeric type, but got ${leftType}`, node.loc, hint);
2131
2213
  }
2132
2214
  if (rightType && !numerics.has(rightType) && rightType !== 'Any') {
2133
- this.strictError(`Type mismatch: '${op}' expects numeric type, but got ${rightType}`, node.loc);
2215
+ const hint = rightType === 'String' ? "try toInt(value) or toFloat(value) to parse" : null;
2216
+ this.strictError(`Type mismatch: '${op}' expects numeric type, but got ${rightType}`, node.loc, hint);
2134
2217
  }
2135
2218
  } else if (op === '+') {
2136
2219
  // Addition: both sides must be numeric (Tova uses ++ for strings)
2137
2220
  const numerics = new Set(['Int', 'Float']);
2138
2221
  if (leftType && !numerics.has(leftType) && leftType !== 'Any') {
2139
- this.strictError(`Type mismatch: '+' expects numeric type, but got ${leftType}`, node.loc);
2222
+ const hint = leftType === 'String' ? "try toInt(value) or toFloat(value) to parse" : null;
2223
+ this.strictError(`Type mismatch: '+' expects numeric type, but got ${leftType}`, node.loc, hint);
2140
2224
  }
2141
2225
  if (rightType && !numerics.has(rightType) && rightType !== 'Any') {
2142
- this.strictError(`Type mismatch: '+' expects numeric type, but got ${rightType}`, node.loc);
2226
+ const hint = rightType === 'String' ? "try toInt(value) or toFloat(value) to parse" : null;
2227
+ this.strictError(`Type mismatch: '+' expects numeric type, but got ${rightType}`, node.loc, hint);
2143
2228
  }
2144
2229
  }
2145
2230
  }
@@ -2176,7 +2261,7 @@ export class Analyzer {
2176
2261
  }
2177
2262
  if (crossedBoundary) {
2178
2263
  const sym = scope.symbols.get(name);
2179
- if (sym) return true;
2264
+ if (sym && sym.kind !== 'builtin') return true;
2180
2265
  }
2181
2266
  scope = scope.parent;
2182
2267
  }
@@ -2203,9 +2288,36 @@ export class Analyzer {
2203
2288
  return false;
2204
2289
  }
2205
2290
 
2291
+ _isLabelInScope(label) {
2292
+ // Walk up scopes to find a matching loop label
2293
+ let scope = this.currentScope;
2294
+ while (scope) {
2295
+ if (scope._isLoop && scope._loopLabel === label) return true;
2296
+ if (scope.context === 'function') return false;
2297
+ scope = scope.parent;
2298
+ }
2299
+ return false;
2300
+ }
2301
+
2206
2302
  visitGuardStatement(node) {
2207
2303
  this.visitExpression(node.condition);
2208
2304
  this.visitNode(node.elseBody);
2305
+
2306
+ // Type narrowing after guard: guard x != nil else { return }
2307
+ // The condition being true means x is non-nil for the rest of the scope
2308
+ const narrowing = this._extractNarrowingInfo(node.condition);
2309
+ if (narrowing) {
2310
+ const sym = this.currentScope.lookup(narrowing.varName);
2311
+ if (sym) {
2312
+ // Narrow the variable in the current scope (not a new child scope)
2313
+ const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
2314
+ narrowedSym.inferredType = narrowing.narrowedType;
2315
+ narrowedSym._narrowed = true;
2316
+ narrowedSym.used = sym.used;
2317
+ // Replace in current scope so the rest of the function sees the narrowed type
2318
+ this.currentScope.symbols.set(narrowing.varName, narrowedSym);
2319
+ }
2320
+ }
2209
2321
  }
2210
2322
 
2211
2323
  visitInterfaceDeclaration(node) {
@@ -2259,16 +2371,16 @@ export class Analyzer {
2259
2371
  for (const required of traitSym._interfaceMethods) {
2260
2372
  const provided = providedMethods.get(required.name);
2261
2373
  if (!provided) {
2262
- this.warn(`Impl for '${typeName || 'type'}' missing required method '${required.name}' from trait '${node.traitName}'`, node.loc);
2374
+ this.warn(`Impl for '${typeName || 'type'}' missing required method '${required.name}' from trait '${node.traitName}'`, node.loc, null, { code: 'W300' });
2263
2375
  } else {
2264
2376
  // Check parameter count matches (excluding self)
2265
2377
  if (required.paramCount > 0 && provided.paramCount !== required.paramCount) {
2266
- this.warn(`Method '${required.name}' in impl for '${typeName}' has ${provided.paramCount} parameters, but trait '${node.traitName}' expects ${required.paramCount}`, node.loc);
2378
+ this.warn(`Method '${required.name}' in impl for '${typeName}' has ${provided.paramCount} parameters, but trait '${node.traitName}' expects ${required.paramCount}`, node.loc, null, { code: 'W301' });
2267
2379
  }
2268
2380
  // Check return type matches if both are annotated
2269
2381
  if (required.returnType && provided.returnType) {
2270
2382
  if (!provided.returnType.isAssignableTo(required.returnType)) {
2271
- this.warn(`Method '${required.name}' return type mismatch in impl for '${typeName}': expected ${required.returnType}, got ${provided.returnType}`, node.loc);
2383
+ this.warn(`Method '${required.name}' return type mismatch in impl for '${typeName}': expected ${required.returnType}, got ${provided.returnType}`, node.loc, null, { code: 'W302' });
2272
2384
  }
2273
2385
  }
2274
2386
  }
@@ -2342,8 +2454,18 @@ export class Analyzer {
2342
2454
 
2343
2455
  visitTypeAlias(node) {
2344
2456
  try {
2345
- this.currentScope.define(node.name,
2346
- new Symbol(node.name, 'type', null, false, node.loc));
2457
+ const typeSym = new Symbol(node.name, 'type', null, false, node.loc);
2458
+ // Store type alias info for resolution
2459
+ if (node.typeExpr) {
2460
+ typeSym._typeAliasExpr = node.typeExpr;
2461
+ typeSym._typeParams = node.typeParams || [];
2462
+ const resolved = typeAnnotationToType(node.typeExpr);
2463
+ if (resolved) {
2464
+ typeSym._typeStructure = resolved;
2465
+ this.typeRegistry.types.set(node.name, resolved);
2466
+ }
2467
+ }
2468
+ this.currentScope.define(node.name, typeSym);
2347
2469
  } catch (e) {
2348
2470
  this.error(e.message);
2349
2471
  }
@@ -2361,7 +2483,7 @@ export class Analyzer {
2361
2483
  scope = scope.parent;
2362
2484
  }
2363
2485
  if (!insideFunction) {
2364
- this.warn("'defer' used outside of a function", node.loc);
2486
+ this.warn("'defer' used outside of a function", node.loc, null, { code: 'W208' });
2365
2487
  }
2366
2488
  if (node.body) {
2367
2489
  if (node.body.type === 'BlockStatement') {