tova 0.2.9 → 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.
- package/bin/tova.js +1404 -114
- package/package.json +3 -1
- package/src/analyzer/analyzer.js +882 -695
- package/src/analyzer/client-analyzer.js +191 -0
- package/src/analyzer/server-analyzer.js +467 -0
- package/src/analyzer/types.js +20 -4
- package/src/codegen/base-codegen.js +473 -111
- package/src/codegen/client-codegen.js +109 -46
- package/src/codegen/codegen.js +65 -5
- package/src/codegen/server-codegen.js +297 -38
- package/src/diagnostics/error-codes.js +255 -0
- package/src/diagnostics/formatter.js +150 -28
- package/src/docs/generator.js +390 -0
- package/src/lexer/lexer.js +306 -64
- package/src/lexer/tokens.js +19 -0
- package/src/lsp/server.js +935 -53
- package/src/parser/ast.js +81 -368
- package/src/parser/client-ast.js +138 -0
- package/src/parser/client-parser.js +504 -0
- package/src/parser/parser.js +492 -1056
- package/src/parser/server-ast.js +240 -0
- package/src/parser/server-parser.js +602 -0
- package/src/runtime/array-proto.js +32 -0
- package/src/runtime/embedded.js +1 -1
- package/src/runtime/reactivity.js +239 -42
- package/src/stdlib/advanced-collections.js +81 -0
- package/src/stdlib/inline.js +556 -13
- package/src/version.js +1 -1
package/src/analyzer/analyzer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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)
|
|
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;
|
|
@@ -298,7 +435,9 @@ export class Analyzer {
|
|
|
298
435
|
if (['+', '-', '*', '/', '%', '**'].includes(expr.operator)) {
|
|
299
436
|
const lt = this._inferType(expr.left);
|
|
300
437
|
const rt = this._inferType(expr.right);
|
|
438
|
+
if (!lt && !rt) return null;
|
|
301
439
|
if (lt === 'Float' || rt === 'Float') return 'Float';
|
|
440
|
+
if (lt === 'String' || rt === 'String') return 'String';
|
|
302
441
|
return 'Int';
|
|
303
442
|
}
|
|
304
443
|
if (['==', '!=', '<', '>', '<=', '>='].includes(expr.operator)) return 'Bool';
|
|
@@ -309,11 +448,96 @@ export class Analyzer {
|
|
|
309
448
|
return null;
|
|
310
449
|
case 'LogicalExpression':
|
|
311
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;
|
|
312
457
|
default:
|
|
313
458
|
return null;
|
|
314
459
|
}
|
|
315
460
|
}
|
|
316
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
|
+
|
|
317
541
|
_typeAnnotationToString(ann) {
|
|
318
542
|
if (!ann) return null;
|
|
319
543
|
if (typeof ann === 'string') return ann;
|
|
@@ -330,6 +554,8 @@ export class Analyzer {
|
|
|
330
554
|
return `(${ann.elementTypes.map(t => this._typeAnnotationToString(t) || 'Any').join(', ')})`;
|
|
331
555
|
case 'FunctionTypeAnnotation':
|
|
332
556
|
return 'Function';
|
|
557
|
+
case 'UnionTypeAnnotation':
|
|
558
|
+
return ann.members.map(m => this._typeAnnotationToString(m) || 'Any').join(' | ');
|
|
333
559
|
default:
|
|
334
560
|
return null;
|
|
335
561
|
}
|
|
@@ -362,14 +588,29 @@ export class Analyzer {
|
|
|
362
588
|
if (!expected || !actual) return true;
|
|
363
589
|
if (expected === 'Any' || actual === 'Any') return true;
|
|
364
590
|
if (expected === '_' || actual === '_') return true;
|
|
591
|
+
// Resolve type aliases before comparison
|
|
592
|
+
expected = this._resolveTypeAlias(expected);
|
|
593
|
+
actual = this._resolveTypeAlias(actual);
|
|
365
594
|
// Exact match
|
|
366
595
|
if (expected === actual) return true;
|
|
367
|
-
// Numeric compatibility: Int
|
|
368
|
-
|
|
369
|
-
if (
|
|
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
|
|
370
599
|
// Nil is compatible with Option
|
|
371
600
|
if (actual === 'Nil' && (expected === 'Option' || expected.startsWith('Option'))) return true;
|
|
372
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;
|
|
373
614
|
// Array compatibility: check element types
|
|
374
615
|
if (expected.startsWith('[') && actual.startsWith('[')) {
|
|
375
616
|
const expEl = expected.slice(1, -1);
|
|
@@ -414,8 +655,41 @@ export class Analyzer {
|
|
|
414
655
|
if (!node) return;
|
|
415
656
|
|
|
416
657
|
switch (node.type) {
|
|
417
|
-
case 'ServerBlock':
|
|
418
|
-
case '
|
|
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);
|
|
419
693
|
case 'SharedBlock': return this.visitSharedBlock(node);
|
|
420
694
|
case 'Assignment': return this.visitAssignment(node);
|
|
421
695
|
case 'VarDeclaration': return this.visitVarDeclaration(node);
|
|
@@ -428,6 +702,7 @@ export class Analyzer {
|
|
|
428
702
|
case 'IfStatement': return this.visitIfStatement(node);
|
|
429
703
|
case 'ForStatement': return this.visitForStatement(node);
|
|
430
704
|
case 'WhileStatement': return this.visitWhileStatement(node);
|
|
705
|
+
case 'LoopStatement': return this.visitLoopStatement(node);
|
|
431
706
|
case 'TryCatchStatement': return this.visitTryCatchStatement(node);
|
|
432
707
|
case 'ReturnStatement': return this.visitReturnStatement(node);
|
|
433
708
|
case 'ExpressionStatement': return this.visitExpression(node.expression);
|
|
@@ -437,37 +712,6 @@ export class Analyzer {
|
|
|
437
712
|
case 'ContinueStatement': return this.visitContinueStatement(node);
|
|
438
713
|
case 'GuardStatement': return this.visitGuardStatement(node);
|
|
439
714
|
case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
|
|
440
|
-
case 'StateDeclaration': return this.visitStateDeclaration(node);
|
|
441
|
-
case 'ComputedDeclaration': return this.visitComputedDeclaration(node);
|
|
442
|
-
case 'EffectDeclaration': return this.visitEffectDeclaration(node);
|
|
443
|
-
case 'ComponentDeclaration': return this.visitComponentDeclaration(node);
|
|
444
|
-
case 'StoreDeclaration': return this.visitStoreDeclaration(node);
|
|
445
|
-
case 'RouteDeclaration': return this.visitRouteDeclaration(node);
|
|
446
|
-
case 'MiddlewareDeclaration': return this.visitMiddlewareDeclaration(node);
|
|
447
|
-
case 'HealthCheckDeclaration': return this.visitHealthCheckDeclaration(node);
|
|
448
|
-
case 'CorsDeclaration': return this.visitCorsDeclaration(node);
|
|
449
|
-
case 'ErrorHandlerDeclaration': return this.visitErrorHandlerDeclaration(node);
|
|
450
|
-
case 'WebSocketDeclaration': return this.visitWebSocketDeclaration(node);
|
|
451
|
-
case 'StaticDeclaration': return this.visitStaticDeclaration(node);
|
|
452
|
-
case 'DiscoverDeclaration': return this.visitDiscoverDeclaration(node);
|
|
453
|
-
case 'AuthDeclaration': return this.visitAuthDeclaration(node);
|
|
454
|
-
case 'MaxBodyDeclaration': return this.visitMaxBodyDeclaration(node);
|
|
455
|
-
case 'RouteGroupDeclaration': return this.visitRouteGroupDeclaration(node);
|
|
456
|
-
case 'RateLimitDeclaration': return this.visitRateLimitDeclaration(node);
|
|
457
|
-
case 'LifecycleHookDeclaration': return this.visitLifecycleHookDeclaration(node);
|
|
458
|
-
case 'SubscribeDeclaration': return this.visitSubscribeDeclaration(node);
|
|
459
|
-
case 'EnvDeclaration': return this.visitEnvDeclaration(node);
|
|
460
|
-
case 'ScheduleDeclaration': return this.visitScheduleDeclaration(node);
|
|
461
|
-
case 'UploadDeclaration': return this.visitUploadDeclaration(node);
|
|
462
|
-
case 'SessionDeclaration': return this.visitSessionDeclaration(node);
|
|
463
|
-
case 'DbDeclaration': return this.visitDbDeclaration(node);
|
|
464
|
-
case 'TlsDeclaration': return this.visitTlsDeclaration(node);
|
|
465
|
-
case 'CompressionDeclaration': return this.visitCompressionDeclaration(node);
|
|
466
|
-
case 'BackgroundJobDeclaration': return this.visitBackgroundJobDeclaration(node);
|
|
467
|
-
case 'CacheDeclaration': return this.visitCacheDeclaration(node);
|
|
468
|
-
case 'SseDeclaration': return this.visitSseDeclaration(node);
|
|
469
|
-
case 'ModelDeclaration': return this.visitModelDeclaration(node);
|
|
470
|
-
case 'AiConfigDeclaration': return; // handled at block level
|
|
471
715
|
case 'DataBlock': return this.visitDataBlock(node);
|
|
472
716
|
case 'SourceDeclaration': return;
|
|
473
717
|
case 'PipelineDeclaration': return;
|
|
@@ -475,6 +719,7 @@ export class Analyzer {
|
|
|
475
719
|
case 'RefreshPolicy': return;
|
|
476
720
|
case 'RefinementType': return;
|
|
477
721
|
case 'TestBlock': return this.visitTestBlock(node);
|
|
722
|
+
case 'BenchBlock': return this.visitTestBlock(node);
|
|
478
723
|
case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
|
|
479
724
|
case 'ImplDeclaration': return this.visitImplDeclaration(node);
|
|
480
725
|
case 'TraitDeclaration': return this.visitTraitDeclaration(node);
|
|
@@ -487,6 +732,24 @@ export class Analyzer {
|
|
|
487
732
|
}
|
|
488
733
|
}
|
|
489
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
|
+
|
|
490
753
|
visitExpression(node) {
|
|
491
754
|
if (!node) return;
|
|
492
755
|
|
|
@@ -567,8 +830,16 @@ export class Analyzer {
|
|
|
567
830
|
return;
|
|
568
831
|
case 'ObjectLiteral':
|
|
569
832
|
for (const prop of node.properties) {
|
|
570
|
-
|
|
571
|
-
|
|
833
|
+
if (prop.spread) {
|
|
834
|
+
// Spread property: {...expr}
|
|
835
|
+
this.visitExpression(prop.argument);
|
|
836
|
+
} else if (prop.shorthand) {
|
|
837
|
+
// Shorthand: {name} — key IS the variable reference
|
|
838
|
+
this.visitExpression(prop.key);
|
|
839
|
+
} else {
|
|
840
|
+
// Non-shorthand: {key: value} — only visit value, key is a label
|
|
841
|
+
this.visitExpression(prop.value);
|
|
842
|
+
}
|
|
572
843
|
}
|
|
573
844
|
return;
|
|
574
845
|
case 'ListComprehension':
|
|
@@ -593,7 +864,7 @@ export class Analyzer {
|
|
|
593
864
|
return;
|
|
594
865
|
case 'AwaitExpression':
|
|
595
866
|
if (this._asyncDepth === 0) {
|
|
596
|
-
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' });
|
|
597
868
|
}
|
|
598
869
|
this.visitExpression(node.argument);
|
|
599
870
|
return;
|
|
@@ -613,7 +884,8 @@ export class Analyzer {
|
|
|
613
884
|
this.visitNode(node.elseBody);
|
|
614
885
|
return;
|
|
615
886
|
case 'JSXElement':
|
|
616
|
-
|
|
887
|
+
case 'JSXFragment':
|
|
888
|
+
return this._visitClientNode(node);
|
|
617
889
|
// Column expressions (for table operations) — no semantic analysis needed
|
|
618
890
|
case 'ColumnExpression':
|
|
619
891
|
return;
|
|
@@ -627,49 +899,6 @@ export class Analyzer {
|
|
|
627
899
|
|
|
628
900
|
// ─── Block visitors ───────────────────────────────────────
|
|
629
901
|
|
|
630
|
-
visitServerBlock(node) {
|
|
631
|
-
const prevScope = this.currentScope;
|
|
632
|
-
const prevServerBlockName = this._currentServerBlockName;
|
|
633
|
-
this._currentServerBlockName = node.name || null;
|
|
634
|
-
this.currentScope = this.currentScope.child('server');
|
|
635
|
-
|
|
636
|
-
try {
|
|
637
|
-
// Register peer server block names as valid identifiers in this scope
|
|
638
|
-
if (node.name && this.serverBlockFunctions.size > 0) {
|
|
639
|
-
for (const [peerName] of this.serverBlockFunctions) {
|
|
640
|
-
if (peerName !== node.name) {
|
|
641
|
-
try {
|
|
642
|
-
this.currentScope.define(peerName,
|
|
643
|
-
new Symbol(peerName, 'builtin', null, false, { line: 0, column: 0, file: '<peer-server>' }));
|
|
644
|
-
} catch (e) {
|
|
645
|
-
// Ignore if already defined
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Register AI provider names as variables (named: claude, gpt, etc.; default: ai)
|
|
652
|
-
for (const stmt of node.body) {
|
|
653
|
-
if (stmt.type === 'AiConfigDeclaration') {
|
|
654
|
-
const aiName = stmt.name || 'ai';
|
|
655
|
-
try {
|
|
656
|
-
this.currentScope.define(aiName,
|
|
657
|
-
new Symbol(aiName, 'builtin', null, false, stmt.loc));
|
|
658
|
-
} catch (e) {
|
|
659
|
-
// Ignore if already defined
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
for (const stmt of node.body) {
|
|
665
|
-
this.visitNode(stmt);
|
|
666
|
-
}
|
|
667
|
-
} finally {
|
|
668
|
-
this.currentScope = prevScope;
|
|
669
|
-
this._currentServerBlockName = prevServerBlockName;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
902
|
visitDataBlock(node) {
|
|
674
903
|
// Register source and pipeline names in global scope
|
|
675
904
|
for (const stmt of node.body) {
|
|
@@ -689,17 +918,7 @@ export class Analyzer {
|
|
|
689
918
|
}
|
|
690
919
|
}
|
|
691
920
|
|
|
692
|
-
visitClientBlock(
|
|
693
|
-
const prevScope = this.currentScope;
|
|
694
|
-
this.currentScope = this.currentScope.child('client');
|
|
695
|
-
try {
|
|
696
|
-
for (const stmt of node.body) {
|
|
697
|
-
this.visitNode(stmt);
|
|
698
|
-
}
|
|
699
|
-
} finally {
|
|
700
|
-
this.currentScope = prevScope;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
921
|
+
// visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
|
|
703
922
|
|
|
704
923
|
visitSharedBlock(node) {
|
|
705
924
|
const prevScope = this.currentScope;
|
|
@@ -712,10 +931,10 @@ export class Analyzer {
|
|
|
712
931
|
} finally {
|
|
713
932
|
this.currentScope = prevScope;
|
|
714
933
|
}
|
|
715
|
-
// Promote shared
|
|
716
|
-
// so server/client blocks can reference them
|
|
934
|
+
// Promote shared types and functions to parent scope
|
|
935
|
+
// so server/client blocks can reference them (but not variables)
|
|
717
936
|
for (const [name, sym] of sharedScope.symbols) {
|
|
718
|
-
if (!prevScope.symbols.has(name)) {
|
|
937
|
+
if (!prevScope.symbols.has(name) && (sym.kind === 'type' || sym.kind === 'function')) {
|
|
719
938
|
prevScope.symbols.set(name, sym);
|
|
720
939
|
}
|
|
721
940
|
}
|
|
@@ -735,17 +954,21 @@ export class Analyzer {
|
|
|
735
954
|
const existing = this._lookupAssignTarget(target);
|
|
736
955
|
if (existing) {
|
|
737
956
|
if (!existing.mutable) {
|
|
738
|
-
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
|
+
});
|
|
739
962
|
}
|
|
740
963
|
// Type check reassignment
|
|
741
964
|
if (existing.inferredType && i < node.values.length) {
|
|
742
965
|
const newType = this._inferType(node.values[i]);
|
|
743
966
|
if (!this._typesCompatible(existing.inferredType, newType)) {
|
|
744
|
-
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' });
|
|
745
968
|
}
|
|
746
969
|
// Float narrowing warning in strict mode
|
|
747
970
|
if (this.strict && newType === 'Float' && existing.inferredType === 'Int') {
|
|
748
|
-
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' });
|
|
749
972
|
}
|
|
750
973
|
}
|
|
751
974
|
existing.used = true;
|
|
@@ -754,7 +977,7 @@ export class Analyzer {
|
|
|
754
977
|
const inferredType = i < node.values.length ? this._inferType(node.values[i]) : null;
|
|
755
978
|
// Warn if this shadows a variable from an outer function scope
|
|
756
979
|
if (this._existsInOuterScope(target)) {
|
|
757
|
-
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 });
|
|
758
981
|
}
|
|
759
982
|
try {
|
|
760
983
|
const sym = new Symbol(target, 'variable', null, false, node.loc);
|
|
@@ -763,6 +986,7 @@ export class Analyzer {
|
|
|
763
986
|
} catch (e) {
|
|
764
987
|
this.error(e.message);
|
|
765
988
|
}
|
|
989
|
+
this._checkNamingConvention(target, 'variable', node.loc);
|
|
766
990
|
}
|
|
767
991
|
}
|
|
768
992
|
}
|
|
@@ -782,6 +1006,7 @@ export class Analyzer {
|
|
|
782
1006
|
} catch (e) {
|
|
783
1007
|
this.error(e.message);
|
|
784
1008
|
}
|
|
1009
|
+
this._checkNamingConvention(target, 'variable', node.loc);
|
|
785
1010
|
}
|
|
786
1011
|
}
|
|
787
1012
|
|
|
@@ -818,11 +1043,16 @@ export class Analyzer {
|
|
|
818
1043
|
sym._totalParamCount = node.params.length;
|
|
819
1044
|
sym._requiredParamCount = node.params.filter(p => !p.defaultValue).length;
|
|
820
1045
|
sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
|
|
1046
|
+
sym._typeParams = node.typeParams || [];
|
|
1047
|
+
sym.isPublic = node.isPublic || false;
|
|
821
1048
|
this.currentScope.define(node.name, sym);
|
|
822
1049
|
} catch (e) {
|
|
823
1050
|
this.error(e.message);
|
|
824
1051
|
}
|
|
825
1052
|
|
|
1053
|
+
// Naming convention check (skip variant constructors — handled in visitTypeDeclaration)
|
|
1054
|
+
this._checkNamingConvention(node.name, 'function', node.loc);
|
|
1055
|
+
|
|
826
1056
|
const prevScope = this.currentScope;
|
|
827
1057
|
this.currentScope = this.currentScope.child('function');
|
|
828
1058
|
if (node.loc) {
|
|
@@ -832,7 +1062,12 @@ export class Analyzer {
|
|
|
832
1062
|
// Push expected return type for return-statement checking
|
|
833
1063
|
const expectedReturn = node.returnType ? this._typeAnnotationToString(node.returnType) : null;
|
|
834
1064
|
this._functionReturnTypeStack.push(expectedReturn);
|
|
835
|
-
|
|
1065
|
+
const prevAsyncDepth = this._asyncDepth;
|
|
1066
|
+
if (node.isAsync) {
|
|
1067
|
+
this._asyncDepth++;
|
|
1068
|
+
} else {
|
|
1069
|
+
this._asyncDepth = 0; // Non-async function resets async context
|
|
1070
|
+
}
|
|
836
1071
|
|
|
837
1072
|
try {
|
|
838
1073
|
for (const param of node.params) {
|
|
@@ -846,6 +1081,7 @@ export class Analyzer {
|
|
|
846
1081
|
} catch (e) {
|
|
847
1082
|
this.error(e.message);
|
|
848
1083
|
}
|
|
1084
|
+
this._checkNamingConvention(param.name, 'parameter', param.loc);
|
|
849
1085
|
}
|
|
850
1086
|
if (param.defaultValue) {
|
|
851
1087
|
this.visitExpression(param.defaultValue);
|
|
@@ -857,11 +1093,11 @@ export class Analyzer {
|
|
|
857
1093
|
// Return path analysis: check that all paths return a value
|
|
858
1094
|
if (expectedReturn && node.body.type === 'BlockStatement') {
|
|
859
1095
|
if (!this._definitelyReturns(node.body)) {
|
|
860
|
-
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' });
|
|
861
1097
|
}
|
|
862
1098
|
}
|
|
863
1099
|
} finally {
|
|
864
|
-
|
|
1100
|
+
this._asyncDepth = prevAsyncDepth;
|
|
865
1101
|
this._functionReturnTypeStack.pop();
|
|
866
1102
|
this.currentScope = prevScope;
|
|
867
1103
|
}
|
|
@@ -913,6 +1149,8 @@ export class Analyzer {
|
|
|
913
1149
|
}
|
|
914
1150
|
|
|
915
1151
|
visitTypeDeclaration(node) {
|
|
1152
|
+
this._checkNamingConvention(node.name, 'type', node.loc);
|
|
1153
|
+
|
|
916
1154
|
// Build ADT type structure
|
|
917
1155
|
const variants = new Map();
|
|
918
1156
|
for (const variant of node.variants) {
|
|
@@ -955,6 +1193,19 @@ export class Analyzer {
|
|
|
955
1193
|
}
|
|
956
1194
|
}
|
|
957
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
|
+
}
|
|
958
1209
|
}
|
|
959
1210
|
|
|
960
1211
|
visitImportDeclaration(node) {
|
|
@@ -995,8 +1246,16 @@ export class Analyzer {
|
|
|
995
1246
|
this.currentScope.startLoc = { line: node.loc.line, column: node.loc.column };
|
|
996
1247
|
}
|
|
997
1248
|
try {
|
|
1249
|
+
let terminated = false;
|
|
998
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
|
+
}
|
|
999
1255
|
this.visitNode(stmt);
|
|
1256
|
+
if (stmt.type === 'ReturnStatement' || stmt.type === 'BreakStatement' || stmt.type === 'ContinueStatement') {
|
|
1257
|
+
terminated = true;
|
|
1258
|
+
}
|
|
1000
1259
|
}
|
|
1001
1260
|
} finally {
|
|
1002
1261
|
if (node.loc) {
|
|
@@ -1007,21 +1266,193 @@ export class Analyzer {
|
|
|
1007
1266
|
}
|
|
1008
1267
|
|
|
1009
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
|
+
|
|
1010
1278
|
this.visitExpression(node.condition);
|
|
1011
|
-
|
|
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
|
+
|
|
1012
1303
|
for (const alt of node.alternates) {
|
|
1013
1304
|
this.visitExpression(alt.condition);
|
|
1014
1305
|
this.visitNode(alt.body);
|
|
1015
1306
|
}
|
|
1307
|
+
|
|
1308
|
+
// Visit else body with inverse narrowing
|
|
1016
1309
|
if (node.elseBody) {
|
|
1017
|
-
|
|
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
|
+
}
|
|
1018
1327
|
}
|
|
1019
1328
|
}
|
|
1020
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 };
|
|
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;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1021
1451
|
visitForStatement(node) {
|
|
1022
1452
|
const prevScope = this.currentScope;
|
|
1023
1453
|
this.currentScope = this.currentScope.child('block');
|
|
1024
1454
|
this.currentScope._isLoop = true;
|
|
1455
|
+
if (node.label) this.currentScope._loopLabel = node.label;
|
|
1025
1456
|
|
|
1026
1457
|
try {
|
|
1027
1458
|
this.visitExpression(node.iterable);
|
|
@@ -1037,6 +1468,10 @@ export class Analyzer {
|
|
|
1037
1468
|
}
|
|
1038
1469
|
}
|
|
1039
1470
|
|
|
1471
|
+
if (node.guard) {
|
|
1472
|
+
this.visitExpression(node.guard);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1040
1475
|
this.visitNode(node.body);
|
|
1041
1476
|
} finally {
|
|
1042
1477
|
this.currentScope = prevScope;
|
|
@@ -1048,10 +1483,28 @@ export class Analyzer {
|
|
|
1048
1483
|
}
|
|
1049
1484
|
|
|
1050
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
|
+
|
|
1051
1491
|
this.visitExpression(node.condition);
|
|
1052
1492
|
const prevScope = this.currentScope;
|
|
1053
1493
|
this.currentScope = this.currentScope.child('block');
|
|
1054
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;
|
|
1055
1508
|
try {
|
|
1056
1509
|
this.visitNode(node.body);
|
|
1057
1510
|
} finally {
|
|
@@ -1097,7 +1550,7 @@ export class Analyzer {
|
|
|
1097
1550
|
}
|
|
1098
1551
|
// Return must be inside a function
|
|
1099
1552
|
if (this._functionReturnTypeStack.length === 0) {
|
|
1100
|
-
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' });
|
|
1101
1554
|
return;
|
|
1102
1555
|
}
|
|
1103
1556
|
// Check return type against declared function return type
|
|
@@ -1106,7 +1559,7 @@ export class Analyzer {
|
|
|
1106
1559
|
if (expectedReturn) {
|
|
1107
1560
|
const actualType = node.value ? this._inferType(node.value) : 'Nil';
|
|
1108
1561
|
if (!this._typesCompatible(expectedReturn, actualType)) {
|
|
1109
|
-
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' });
|
|
1110
1563
|
}
|
|
1111
1564
|
}
|
|
1112
1565
|
}
|
|
@@ -1117,7 +1570,7 @@ export class Analyzer {
|
|
|
1117
1570
|
if (node.target.type === 'Identifier') {
|
|
1118
1571
|
const sym = this.currentScope.lookup(node.target.name);
|
|
1119
1572
|
if (sym && !sym.mutable && sym.kind !== 'builtin') {
|
|
1120
|
-
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' });
|
|
1121
1574
|
}
|
|
1122
1575
|
// Type check compound assignment
|
|
1123
1576
|
if (sym && sym.inferredType) {
|
|
@@ -1151,471 +1604,40 @@ export class Analyzer {
|
|
|
1151
1604
|
this.visitExpression(node.value);
|
|
1152
1605
|
}
|
|
1153
1606
|
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
visitStateDeclaration(node) {
|
|
1157
|
-
const ctx = this.currentScope.getContext();
|
|
1158
|
-
if (ctx !== 'client') {
|
|
1159
|
-
this.error(`'state' can only be used inside a client block`, node.loc);
|
|
1160
|
-
}
|
|
1161
|
-
try {
|
|
1162
|
-
this.currentScope.define(node.name,
|
|
1163
|
-
new Symbol(node.name, 'state', node.typeAnnotation, true, node.loc));
|
|
1164
|
-
} catch (e) {
|
|
1165
|
-
this.error(e.message);
|
|
1166
|
-
}
|
|
1167
|
-
this.visitExpression(node.initialValue);
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
visitComputedDeclaration(node) {
|
|
1171
|
-
const ctx = this.currentScope.getContext();
|
|
1172
|
-
if (ctx !== 'client') {
|
|
1173
|
-
this.error(`'computed' can only be used inside a client block`, node.loc);
|
|
1174
|
-
}
|
|
1175
|
-
try {
|
|
1176
|
-
this.currentScope.define(node.name,
|
|
1177
|
-
new Symbol(node.name, 'computed', null, false, node.loc));
|
|
1178
|
-
} catch (e) {
|
|
1179
|
-
this.error(e.message);
|
|
1180
|
-
}
|
|
1181
|
-
this.visitExpression(node.expression);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
visitEffectDeclaration(node) {
|
|
1185
|
-
const ctx = this.currentScope.getContext();
|
|
1186
|
-
if (ctx !== 'client') {
|
|
1187
|
-
this.error(`'effect' can only be used inside a client block`, node.loc);
|
|
1188
|
-
}
|
|
1189
|
-
this.visitNode(node.body);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
visitComponentDeclaration(node) {
|
|
1193
|
-
const ctx = this.currentScope.getContext();
|
|
1194
|
-
if (ctx !== 'client') {
|
|
1195
|
-
this.error(`'component' can only be used inside a client block`, node.loc);
|
|
1196
|
-
}
|
|
1197
|
-
try {
|
|
1198
|
-
this.currentScope.define(node.name,
|
|
1199
|
-
new Symbol(node.name, 'component', null, false, node.loc));
|
|
1200
|
-
} catch (e) {
|
|
1201
|
-
this.error(e.message);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
const prevScope = this.currentScope;
|
|
1205
|
-
this.currentScope = this.currentScope.child('function');
|
|
1206
|
-
for (const param of node.params) {
|
|
1207
|
-
try {
|
|
1208
|
-
this.currentScope.define(param.name,
|
|
1209
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1210
|
-
} catch (e) {
|
|
1211
|
-
this.error(e.message);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
for (const child of node.body) {
|
|
1215
|
-
this.visitNode(child);
|
|
1216
|
-
}
|
|
1217
|
-
this.currentScope = prevScope;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
visitStoreDeclaration(node) {
|
|
1221
|
-
const ctx = this.currentScope.getContext();
|
|
1222
|
-
if (ctx !== 'client') {
|
|
1223
|
-
this.error(`'store' can only be used inside a client block`, node.loc);
|
|
1224
|
-
}
|
|
1225
|
-
try {
|
|
1226
|
-
this.currentScope.define(node.name,
|
|
1227
|
-
new Symbol(node.name, 'variable', null, false, node.loc));
|
|
1228
|
-
} catch (e) {
|
|
1229
|
-
this.error(e.message);
|
|
1230
|
-
}
|
|
1607
|
+
// Client-specific visitors (visitState, visitComputed, etc.) are in client-analyzer.js (lazy-loaded)
|
|
1231
1608
|
|
|
1609
|
+
visitTestBlock(node) {
|
|
1232
1610
|
const prevScope = this.currentScope;
|
|
1233
1611
|
this.currentScope = this.currentScope.child('block');
|
|
1234
|
-
for (const child of node.body) {
|
|
1235
|
-
this.visitNode(child);
|
|
1236
|
-
}
|
|
1237
|
-
this.currentScope = prevScope;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
visitRouteDeclaration(node) {
|
|
1241
|
-
const ctx = this.currentScope.getContext();
|
|
1242
|
-
if (ctx !== 'server') {
|
|
1243
|
-
this.error(`'route' can only be used inside a server block`, node.loc);
|
|
1244
|
-
}
|
|
1245
|
-
this.visitExpression(node.handler);
|
|
1246
|
-
|
|
1247
|
-
// Route param ↔ handler signature type safety
|
|
1248
|
-
if (node.handler.type === 'Identifier') {
|
|
1249
|
-
const handlerName = node.handler.name;
|
|
1250
|
-
// Find the function declaration in the current server block scope
|
|
1251
|
-
const fnSym = this.currentScope.lookup(handlerName);
|
|
1252
|
-
if (fnSym && fnSym.kind === 'function' && fnSym._params) {
|
|
1253
|
-
const pathParams = new Set();
|
|
1254
|
-
const pathStr = node.path || '';
|
|
1255
|
-
const paramMatches = pathStr.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
|
|
1256
|
-
if (paramMatches) {
|
|
1257
|
-
for (const m of paramMatches) pathParams.add(m.slice(1));
|
|
1258
|
-
}
|
|
1259
|
-
const handlerParams = fnSym._params.filter(p => p !== 'req');
|
|
1260
|
-
for (const hp of handlerParams) {
|
|
1261
|
-
if (pathParams.size > 0 && !pathParams.has(hp) && node.method.toUpperCase() === 'GET') {
|
|
1262
|
-
// For GET routes, params not in path come from query — this is fine, just a warning
|
|
1263
|
-
this.warn(`Handler '${handlerName}' param '${hp}' not in route path '${pathStr}' — will be extracted from query string`, node.loc);
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
visitMiddlewareDeclaration(node) {
|
|
1271
|
-
const ctx = this.currentScope.getContext();
|
|
1272
|
-
if (ctx !== 'server') {
|
|
1273
|
-
this.error(`'middleware' can only be used inside a server block`, node.loc);
|
|
1274
|
-
}
|
|
1275
1612
|
try {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
} catch (e) {
|
|
1279
|
-
this.error(e.message);
|
|
1280
|
-
}
|
|
1281
|
-
const prevScope = this.currentScope;
|
|
1282
|
-
this.currentScope = this.currentScope.child('function');
|
|
1283
|
-
for (const param of node.params) {
|
|
1284
|
-
try {
|
|
1285
|
-
this.currentScope.define(param.name,
|
|
1286
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1287
|
-
} catch (e) {
|
|
1288
|
-
this.error(e.message);
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
this.visitNode(node.body);
|
|
1292
|
-
this.currentScope = prevScope;
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
visitHealthCheckDeclaration(node) {
|
|
1296
|
-
const ctx = this.currentScope.getContext();
|
|
1297
|
-
if (ctx !== 'server') {
|
|
1298
|
-
this.error(`'health' can only be used inside a server block`, node.loc);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
visitCorsDeclaration(node) {
|
|
1303
|
-
const ctx = this.currentScope.getContext();
|
|
1304
|
-
if (ctx !== 'server') {
|
|
1305
|
-
this.error(`'cors' can only be used inside a server block`, node.loc);
|
|
1306
|
-
}
|
|
1307
|
-
for (const value of Object.values(node.config)) {
|
|
1308
|
-
this.visitExpression(value);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
visitErrorHandlerDeclaration(node) {
|
|
1313
|
-
const ctx = this.currentScope.getContext();
|
|
1314
|
-
if (ctx !== 'server') {
|
|
1315
|
-
this.error(`'on_error' can only be used inside a server block`, node.loc);
|
|
1316
|
-
}
|
|
1317
|
-
const prevScope = this.currentScope;
|
|
1318
|
-
this.currentScope = this.currentScope.child('function');
|
|
1319
|
-
for (const param of node.params) {
|
|
1320
|
-
try {
|
|
1321
|
-
this.currentScope.define(param.name,
|
|
1322
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1323
|
-
} catch (e) {
|
|
1324
|
-
this.error(e.message);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
this.visitNode(node.body);
|
|
1328
|
-
this.currentScope = prevScope;
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
visitWebSocketDeclaration(node) {
|
|
1332
|
-
const ctx = this.currentScope.getContext();
|
|
1333
|
-
if (ctx !== 'server') {
|
|
1334
|
-
this.error(`'ws' can only be used inside a server block`, node.loc);
|
|
1335
|
-
}
|
|
1336
|
-
for (const [, handler] of Object.entries(node.handlers)) {
|
|
1337
|
-
if (!handler) continue;
|
|
1338
|
-
const prevScope = this.currentScope;
|
|
1339
|
-
this.currentScope = this.currentScope.child('function');
|
|
1340
|
-
for (const param of handler.params) {
|
|
1341
|
-
try {
|
|
1342
|
-
this.currentScope.define(param.name,
|
|
1343
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1344
|
-
} catch (e) {
|
|
1345
|
-
this.error(e.message);
|
|
1346
|
-
}
|
|
1613
|
+
for (const stmt of node.body) {
|
|
1614
|
+
this.visitNode(stmt);
|
|
1347
1615
|
}
|
|
1348
|
-
|
|
1616
|
+
} finally {
|
|
1349
1617
|
this.currentScope = prevScope;
|
|
1350
1618
|
}
|
|
1351
1619
|
}
|
|
1352
1620
|
|
|
1353
|
-
visitStaticDeclaration(node) {
|
|
1354
|
-
const ctx = this.currentScope.getContext();
|
|
1355
|
-
if (ctx !== 'server') {
|
|
1356
|
-
this.error(`'static' can only be used inside a server block`, node.loc);
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
visitDiscoverDeclaration(node) {
|
|
1361
|
-
const ctx = this.currentScope.getContext();
|
|
1362
|
-
if (ctx !== 'server') {
|
|
1363
|
-
this.error(`'discover' can only be used inside a server block`, node.loc);
|
|
1364
|
-
}
|
|
1365
|
-
this.visitExpression(node.urlExpression);
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
visitAuthDeclaration(node) {
|
|
1369
|
-
const ctx = this.currentScope.getContext();
|
|
1370
|
-
if (ctx !== 'server') {
|
|
1371
|
-
this.error(`'auth' can only be used inside a server block`, node.loc);
|
|
1372
|
-
}
|
|
1373
|
-
for (const value of Object.values(node.config)) {
|
|
1374
|
-
this.visitExpression(value);
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
visitMaxBodyDeclaration(node) {
|
|
1379
|
-
const ctx = this.currentScope.getContext();
|
|
1380
|
-
if (ctx !== 'server') {
|
|
1381
|
-
this.error(`'max_body' can only be used inside a server block`, node.loc);
|
|
1382
|
-
}
|
|
1383
|
-
this.visitExpression(node.limit);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
visitRouteGroupDeclaration(node) {
|
|
1387
|
-
const ctx = this.currentScope.getContext();
|
|
1388
|
-
if (ctx !== 'server') {
|
|
1389
|
-
this.error(`'routes' can only be used inside a server block`, node.loc);
|
|
1390
|
-
}
|
|
1391
|
-
for (const stmt of node.body) {
|
|
1392
|
-
this.visitNode(stmt);
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
visitRateLimitDeclaration(node) {
|
|
1397
|
-
const ctx = this.currentScope.getContext();
|
|
1398
|
-
if (ctx !== 'server') {
|
|
1399
|
-
this.error(`'rate_limit' can only be used inside a server block`, node.loc);
|
|
1400
|
-
}
|
|
1401
|
-
for (const value of Object.values(node.config)) {
|
|
1402
|
-
this.visitExpression(value);
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
visitLifecycleHookDeclaration(node) {
|
|
1407
|
-
const ctx = this.currentScope.getContext();
|
|
1408
|
-
if (ctx !== 'server') {
|
|
1409
|
-
this.error(`'on_${node.hook}' can only be used inside a server block`, node.loc);
|
|
1410
|
-
}
|
|
1411
|
-
const prevScope = this.currentScope;
|
|
1412
|
-
this.currentScope = this.currentScope.child('function');
|
|
1413
|
-
for (const param of node.params) {
|
|
1414
|
-
try {
|
|
1415
|
-
this.currentScope.define(param.name,
|
|
1416
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1417
|
-
} catch (e) {
|
|
1418
|
-
this.error(e.message);
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
this.visitNode(node.body);
|
|
1422
|
-
this.currentScope = prevScope;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
visitSubscribeDeclaration(node) {
|
|
1426
|
-
const ctx = this.currentScope.getContext();
|
|
1427
|
-
if (ctx !== 'server') {
|
|
1428
|
-
this.error(`'subscribe' can only be used inside a server block`, node.loc);
|
|
1429
|
-
}
|
|
1430
|
-
const prevScope = this.currentScope;
|
|
1431
|
-
this.currentScope = this.currentScope.child('function');
|
|
1432
|
-
for (const param of node.params) {
|
|
1433
|
-
try {
|
|
1434
|
-
this.currentScope.define(param.name,
|
|
1435
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1436
|
-
} catch (e) {
|
|
1437
|
-
this.error(e.message);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
this.visitNode(node.body);
|
|
1441
|
-
this.currentScope = prevScope;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
visitEnvDeclaration(node) {
|
|
1445
|
-
const ctx = this.currentScope.getContext();
|
|
1446
|
-
if (ctx !== 'server') {
|
|
1447
|
-
this.error(`'env' can only be used inside a server block`, node.loc);
|
|
1448
|
-
}
|
|
1449
|
-
try {
|
|
1450
|
-
this.currentScope.define(node.name,
|
|
1451
|
-
new Symbol(node.name, 'variable', node.typeAnnotation, false, node.loc));
|
|
1452
|
-
} catch (e) {
|
|
1453
|
-
this.error(e.message);
|
|
1454
|
-
}
|
|
1455
|
-
if (node.defaultValue) {
|
|
1456
|
-
this.visitExpression(node.defaultValue);
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
visitScheduleDeclaration(node) {
|
|
1461
|
-
const ctx = this.currentScope.getContext();
|
|
1462
|
-
if (ctx !== 'server') {
|
|
1463
|
-
this.error(`'schedule' can only be used inside a server block`, node.loc);
|
|
1464
|
-
}
|
|
1465
|
-
if (node.name) {
|
|
1466
|
-
try {
|
|
1467
|
-
this.currentScope.define(node.name,
|
|
1468
|
-
new Symbol(node.name, 'function', null, false, node.loc));
|
|
1469
|
-
} catch (e) {
|
|
1470
|
-
this.error(e.message);
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
const prevScope = this.currentScope;
|
|
1474
|
-
this.currentScope = this.currentScope.child('function');
|
|
1475
|
-
for (const param of node.params) {
|
|
1476
|
-
try {
|
|
1477
|
-
this.currentScope.define(param.name,
|
|
1478
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1479
|
-
} catch (e) {
|
|
1480
|
-
this.error(e.message);
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
this.visitNode(node.body);
|
|
1484
|
-
this.currentScope = prevScope;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
visitUploadDeclaration(node) {
|
|
1488
|
-
const ctx = this.currentScope.getContext();
|
|
1489
|
-
if (ctx !== 'server') {
|
|
1490
|
-
this.error(`'upload' can only be used inside a server block`, node.loc);
|
|
1491
|
-
}
|
|
1492
|
-
for (const value of Object.values(node.config)) {
|
|
1493
|
-
this.visitExpression(value);
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
visitSessionDeclaration(node) {
|
|
1498
|
-
const ctx = this.currentScope.getContext();
|
|
1499
|
-
if (ctx !== 'server') {
|
|
1500
|
-
this.error(`'session' can only be used inside a server block`, node.loc);
|
|
1501
|
-
}
|
|
1502
|
-
for (const value of Object.values(node.config)) {
|
|
1503
|
-
this.visitExpression(value);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
visitDbDeclaration(node) {
|
|
1508
|
-
const ctx = this.currentScope.getContext();
|
|
1509
|
-
if (ctx !== 'server') {
|
|
1510
|
-
this.error(`'db' can only be used inside a server block`, node.loc);
|
|
1511
|
-
}
|
|
1512
|
-
for (const value of Object.values(node.config)) {
|
|
1513
|
-
this.visitExpression(value);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
visitTlsDeclaration(node) {
|
|
1518
|
-
const ctx = this.currentScope.getContext();
|
|
1519
|
-
if (ctx !== 'server') {
|
|
1520
|
-
this.error(`'tls' can only be used inside a server block`, node.loc);
|
|
1521
|
-
}
|
|
1522
|
-
for (const value of Object.values(node.config)) {
|
|
1523
|
-
this.visitExpression(value);
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
visitCompressionDeclaration(node) {
|
|
1528
|
-
const ctx = this.currentScope.getContext();
|
|
1529
|
-
if (ctx !== 'server') {
|
|
1530
|
-
this.error(`'compression' can only be used inside a server block`, node.loc);
|
|
1531
|
-
}
|
|
1532
|
-
for (const value of Object.values(node.config)) {
|
|
1533
|
-
this.visitExpression(value);
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
visitBackgroundJobDeclaration(node) {
|
|
1538
|
-
const ctx = this.currentScope.getContext();
|
|
1539
|
-
if (ctx !== 'server') {
|
|
1540
|
-
this.error(`'background' can only be used inside a server block`, node.loc);
|
|
1541
|
-
}
|
|
1542
|
-
try {
|
|
1543
|
-
this.currentScope.define(node.name,
|
|
1544
|
-
new Symbol(node.name, 'function', null, false, node.loc));
|
|
1545
|
-
} catch (e) {
|
|
1546
|
-
this.error(e.message);
|
|
1547
|
-
}
|
|
1548
|
-
const prevScope = this.currentScope;
|
|
1549
|
-
this.currentScope = this.currentScope.child('function');
|
|
1550
|
-
for (const param of node.params) {
|
|
1551
|
-
try {
|
|
1552
|
-
this.currentScope.define(param.name,
|
|
1553
|
-
new Symbol(param.name, 'parameter', param.typeAnnotation, false, param.loc));
|
|
1554
|
-
} catch (e) {
|
|
1555
|
-
this.error(e.message);
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
this.visitNode(node.body);
|
|
1559
|
-
this.currentScope = prevScope;
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
visitCacheDeclaration(node) {
|
|
1563
|
-
const ctx = this.currentScope.getContext();
|
|
1564
|
-
if (ctx !== 'server') {
|
|
1565
|
-
this.error(`'cache' can only be used inside a server block`, node.loc);
|
|
1566
|
-
}
|
|
1567
|
-
for (const value of Object.values(node.config)) {
|
|
1568
|
-
this.visitExpression(value);
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
visitSseDeclaration(node) {
|
|
1573
|
-
const ctx = this.currentScope.getContext();
|
|
1574
|
-
if (ctx !== 'server') {
|
|
1575
|
-
this.error(`'sse' can only be used inside a server block`, node.loc);
|
|
1576
|
-
}
|
|
1577
|
-
const prevScope = this.currentScope;
|
|
1578
|
-
this.currentScope = this.currentScope.child('block');
|
|
1579
|
-
for (const p of node.params) {
|
|
1580
|
-
this.currentScope.define(p.name, { kind: 'param' });
|
|
1581
|
-
}
|
|
1582
|
-
for (const stmt of node.body.body || []) {
|
|
1583
|
-
this.visitNode(stmt);
|
|
1584
|
-
}
|
|
1585
|
-
this.currentScope = prevScope;
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
visitModelDeclaration(node) {
|
|
1589
|
-
const ctx = this.currentScope.getContext();
|
|
1590
|
-
if (ctx !== 'server') {
|
|
1591
|
-
this.error(`'model' can only be used inside a server block`, node.loc);
|
|
1592
|
-
}
|
|
1593
|
-
if (node.config) {
|
|
1594
|
-
for (const value of Object.values(node.config)) {
|
|
1595
|
-
this.visitExpression(value);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
visitTestBlock(node) {
|
|
1601
|
-
const prevScope = this.currentScope;
|
|
1602
|
-
this.currentScope = this.currentScope.child('block');
|
|
1603
|
-
for (const stmt of node.body) {
|
|
1604
|
-
this.visitNode(stmt);
|
|
1605
|
-
}
|
|
1606
|
-
this.currentScope = prevScope;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
1621
|
// ─── Expression visitors ──────────────────────────────────
|
|
1610
1622
|
|
|
1611
1623
|
visitIdentifier(node) {
|
|
1612
1624
|
if (node.name === '_') return; // wildcard is always valid
|
|
1613
1625
|
if (node.name === PIPE_TARGET) return; // pipe target placeholder from method pipe
|
|
1614
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;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1615
1633
|
const sym = this.currentScope.lookup(node.name);
|
|
1616
1634
|
if (!sym) {
|
|
1617
1635
|
if (!this._isKnownGlobal(node.name)) {
|
|
1618
|
-
this.
|
|
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);
|
|
1619
1641
|
}
|
|
1620
1642
|
} else {
|
|
1621
1643
|
sym.used = true;
|
|
@@ -1633,13 +1655,60 @@ export class Analyzer {
|
|
|
1633
1655
|
return _JS_GLOBALS.has(name);
|
|
1634
1656
|
}
|
|
1635
1657
|
|
|
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;
|
|
1664
|
+
}
|
|
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;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1636
1700
|
visitLambda(node) {
|
|
1637
1701
|
const prevScope = this.currentScope;
|
|
1638
1702
|
this.currentScope = this.currentScope.child('function');
|
|
1639
1703
|
|
|
1640
1704
|
const expectedReturn = node.returnType ? this._typeAnnotationToString(node.returnType) : null;
|
|
1641
1705
|
this._functionReturnTypeStack.push(expectedReturn);
|
|
1642
|
-
|
|
1706
|
+
const prevAsyncDepth = this._asyncDepth;
|
|
1707
|
+
if (node.isAsync) {
|
|
1708
|
+
this._asyncDepth++;
|
|
1709
|
+
} else {
|
|
1710
|
+
this._asyncDepth = 0; // Non-async lambda resets async context
|
|
1711
|
+
}
|
|
1643
1712
|
|
|
1644
1713
|
try {
|
|
1645
1714
|
for (const param of node.params) {
|
|
@@ -1655,14 +1724,14 @@ export class Analyzer {
|
|
|
1655
1724
|
this.visitNode(node.body);
|
|
1656
1725
|
// Return path analysis for lambdas with block bodies and declared return types
|
|
1657
1726
|
if (expectedReturn && !this._definitelyReturns(node.body)) {
|
|
1658
|
-
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' });
|
|
1659
1728
|
}
|
|
1660
1729
|
} else {
|
|
1661
1730
|
// Single-expression body — always returns implicitly
|
|
1662
1731
|
this.visitExpression(node.body);
|
|
1663
1732
|
}
|
|
1664
1733
|
} finally {
|
|
1665
|
-
|
|
1734
|
+
this._asyncDepth = prevAsyncDepth;
|
|
1666
1735
|
this._functionReturnTypeStack.pop();
|
|
1667
1736
|
this.currentScope = prevScope;
|
|
1668
1737
|
}
|
|
@@ -1670,7 +1739,14 @@ export class Analyzer {
|
|
|
1670
1739
|
|
|
1671
1740
|
visitMatchExpression(node) {
|
|
1672
1741
|
this.visitExpression(node.subject);
|
|
1742
|
+
let catchAllSeen = false;
|
|
1673
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
|
+
|
|
1674
1750
|
const prevScope = this.currentScope;
|
|
1675
1751
|
this.currentScope = this.currentScope.child('block');
|
|
1676
1752
|
|
|
@@ -1686,6 +1762,11 @@ export class Analyzer {
|
|
|
1686
1762
|
} finally {
|
|
1687
1763
|
this.currentScope = prevScope;
|
|
1688
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
|
+
}
|
|
1689
1770
|
}
|
|
1690
1771
|
|
|
1691
1772
|
// Exhaustive match checking (#12)
|
|
@@ -1740,7 +1821,7 @@ export class Analyzer {
|
|
|
1740
1821
|
const allVariants = subjectType.getVariantNames();
|
|
1741
1822
|
for (const v of allVariants) {
|
|
1742
1823
|
if (!coveredVariants.has(v)) {
|
|
1743
|
-
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' });
|
|
1744
1825
|
}
|
|
1745
1826
|
}
|
|
1746
1827
|
return; // Done — used precise ADT checking
|
|
@@ -1749,46 +1830,50 @@ export class Analyzer {
|
|
|
1749
1830
|
// Check built-in Result/Option types
|
|
1750
1831
|
if (coveredVariants.has('Ok') || coveredVariants.has('Err')) {
|
|
1751
1832
|
if (!coveredVariants.has('Ok')) {
|
|
1752
|
-
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' });
|
|
1753
1834
|
}
|
|
1754
1835
|
if (!coveredVariants.has('Err')) {
|
|
1755
|
-
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' });
|
|
1756
1837
|
}
|
|
1757
1838
|
}
|
|
1758
1839
|
if (coveredVariants.has('Some') || coveredVariants.has('None')) {
|
|
1759
1840
|
if (!coveredVariants.has('Some')) {
|
|
1760
|
-
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' });
|
|
1761
1842
|
}
|
|
1762
1843
|
if (!coveredVariants.has('None')) {
|
|
1763
|
-
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' });
|
|
1764
1845
|
}
|
|
1765
1846
|
}
|
|
1766
1847
|
|
|
1767
|
-
// Check user-defined types —
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
_collectTypeVariants(node, allVariants, coveredVariants, matchLoc) {
|
|
1776
|
-
if (node.type === 'TypeDeclaration') {
|
|
1777
|
-
const typeVariants = node.variants.filter(v => v.type === 'TypeVariant').map(v => v.name);
|
|
1778
|
-
// If any of the match arms reference a variant from this type, check all
|
|
1779
|
-
const relevantVariants = typeVariants.filter(v => coveredVariants.has(v));
|
|
1780
|
-
if (relevantVariants.length > 0) {
|
|
1848
|
+
// Check user-defined types — find the single best-matching type whose variants
|
|
1849
|
+
// contain ALL covered variant names (avoids false positives with shared names)
|
|
1850
|
+
const candidates = [];
|
|
1851
|
+
this._collectTypeCandidates(this.ast.body, coveredVariants, candidates);
|
|
1852
|
+
// Only warn if exactly one type contains all covered variants
|
|
1853
|
+
if (candidates.length === 1) {
|
|
1854
|
+
const [typeName, typeVariants] = candidates[0];
|
|
1781
1855
|
for (const v of typeVariants) {
|
|
1782
1856
|
if (!coveredVariants.has(v)) {
|
|
1783
|
-
this.warn(`Non-exhaustive match: missing '${v}' variant from type '${node.
|
|
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' });
|
|
1784
1858
|
}
|
|
1785
1859
|
}
|
|
1786
1860
|
}
|
|
1787
1861
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
_collectTypeCandidates(nodes, coveredVariants, candidates) {
|
|
1865
|
+
for (const node of nodes) {
|
|
1866
|
+
if (node.type === 'TypeDeclaration') {
|
|
1867
|
+
const typeVariants = node.variants.filter(v => v.type === 'TypeVariant').map(v => v.name);
|
|
1868
|
+
if (typeVariants.length === 0) continue;
|
|
1869
|
+
// All covered variants must be contained in this type's variants
|
|
1870
|
+
const allCovered = [...coveredVariants].every(v => typeVariants.includes(v));
|
|
1871
|
+
if (allCovered) {
|
|
1872
|
+
candidates.push([node.name, typeVariants]);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
|
|
1876
|
+
this._collectTypeCandidates(node.body, coveredVariants, candidates);
|
|
1792
1877
|
}
|
|
1793
1878
|
}
|
|
1794
1879
|
}
|
|
@@ -1882,77 +1967,23 @@ export class Analyzer {
|
|
|
1882
1967
|
}
|
|
1883
1968
|
}
|
|
1884
1969
|
|
|
1885
|
-
visitJSXElement(
|
|
1886
|
-
for (const attr of node.attributes) {
|
|
1887
|
-
if (attr.type === 'JSXSpreadAttribute') {
|
|
1888
|
-
this.visitExpression(attr.expression);
|
|
1889
|
-
} else {
|
|
1890
|
-
this.visitExpression(attr.value);
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
for (const child of node.children) {
|
|
1894
|
-
if (child.type === 'JSXElement') {
|
|
1895
|
-
this.visitJSXElement(child);
|
|
1896
|
-
} else if (child.type === 'JSXExpression') {
|
|
1897
|
-
this.visitExpression(child.expression);
|
|
1898
|
-
} else if (child.type === 'JSXFor') {
|
|
1899
|
-
this.visitJSXFor(child);
|
|
1900
|
-
} else if (child.type === 'JSXIf') {
|
|
1901
|
-
this.visitJSXIf(child);
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
visitJSXFor(node) {
|
|
1907
|
-
const prevScope = this.currentScope;
|
|
1908
|
-
this.currentScope = this.currentScope.child('block');
|
|
1909
|
-
try {
|
|
1910
|
-
this.visitExpression(node.iterable);
|
|
1911
|
-
try {
|
|
1912
|
-
this.currentScope.define(node.variable,
|
|
1913
|
-
new Symbol(node.variable, 'variable', null, false, node.loc));
|
|
1914
|
-
} catch (e) {
|
|
1915
|
-
this.error(e.message);
|
|
1916
|
-
}
|
|
1917
|
-
for (const child of node.body) {
|
|
1918
|
-
this.visitNode(child);
|
|
1919
|
-
}
|
|
1920
|
-
} finally {
|
|
1921
|
-
this.currentScope = prevScope;
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
visitJSXIf(node) {
|
|
1926
|
-
this.visitExpression(node.condition);
|
|
1927
|
-
for (const child of node.consequent) {
|
|
1928
|
-
this.visitNode(child);
|
|
1929
|
-
}
|
|
1930
|
-
if (node.alternates) {
|
|
1931
|
-
for (const alt of node.alternates) {
|
|
1932
|
-
this.visitExpression(alt.condition);
|
|
1933
|
-
for (const child of alt.body) {
|
|
1934
|
-
this.visitNode(child);
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
if (node.alternate) {
|
|
1939
|
-
for (const child of node.alternate) {
|
|
1940
|
-
this.visitNode(child);
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1970
|
+
// visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in client-analyzer.js (lazy-loaded)
|
|
1944
1971
|
|
|
1945
1972
|
// ─── New feature visitors ─────────────────────────────────
|
|
1946
1973
|
|
|
1947
1974
|
visitBreakStatement(node) {
|
|
1948
1975
|
if (!this._isInsideLoop()) {
|
|
1949
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);
|
|
1950
1979
|
}
|
|
1951
1980
|
}
|
|
1952
1981
|
|
|
1953
1982
|
visitContinueStatement(node) {
|
|
1954
1983
|
if (!this._isInsideLoop()) {
|
|
1955
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);
|
|
1956
1987
|
}
|
|
1957
1988
|
}
|
|
1958
1989
|
|
|
@@ -1963,7 +1994,8 @@ export class Analyzer {
|
|
|
1963
1994
|
return true;
|
|
1964
1995
|
case 'BlockStatement':
|
|
1965
1996
|
if (node.body.length === 0) return false;
|
|
1966
|
-
|
|
1997
|
+
// Any statement that definitely returns makes the block definitely return
|
|
1998
|
+
return node.body.some(stmt => this._definitelyReturns(stmt));
|
|
1967
1999
|
case 'IfStatement':
|
|
1968
2000
|
if (!node.elseBody) return false;
|
|
1969
2001
|
const consequentReturns = this._definitelyReturns(node.consequent);
|
|
@@ -1971,8 +2003,9 @@ export class Analyzer {
|
|
|
1971
2003
|
const allAlternatesReturn = (node.alternates || []).every(alt => this._definitelyReturns(alt.body));
|
|
1972
2004
|
return consequentReturns && elseReturns && allAlternatesReturn;
|
|
1973
2005
|
case 'GuardStatement':
|
|
1974
|
-
// Guard
|
|
1975
|
-
return
|
|
2006
|
+
// Guard only handles the failure case — when condition is true, execution falls through
|
|
2007
|
+
// A guard alone never guarantees return on ALL paths
|
|
2008
|
+
return false;
|
|
1976
2009
|
case 'MatchExpression': {
|
|
1977
2010
|
const hasWildcard = node.arms.some(arm =>
|
|
1978
2011
|
arm.pattern.type === 'WildcardPattern' ||
|
|
@@ -1983,15 +2016,15 @@ export class Analyzer {
|
|
|
1983
2016
|
}
|
|
1984
2017
|
case 'TryCatchStatement': {
|
|
1985
2018
|
const tryReturns = node.tryBody.length > 0 &&
|
|
1986
|
-
|
|
2019
|
+
node.tryBody.some(s => this._definitelyReturns(s));
|
|
1987
2020
|
const catchReturns = !node.catchBody || (node.catchBody.length > 0 &&
|
|
1988
|
-
|
|
2021
|
+
node.catchBody.some(s => this._definitelyReturns(s)));
|
|
1989
2022
|
return tryReturns && catchReturns;
|
|
1990
2023
|
}
|
|
1991
2024
|
case 'ExpressionStatement':
|
|
1992
2025
|
return this._definitelyReturns(node.expression);
|
|
1993
2026
|
case 'CallExpression':
|
|
1994
|
-
return
|
|
2027
|
+
return false;
|
|
1995
2028
|
default:
|
|
1996
2029
|
return false;
|
|
1997
2030
|
}
|
|
@@ -2031,18 +2064,125 @@ export class Analyzer {
|
|
|
2031
2064
|
const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
|
|
2032
2065
|
if (hasSpread) return;
|
|
2033
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
|
+
|
|
2034
2082
|
for (let i = 0; i < node.arguments.length && i < fnSym._paramTypes.length; i++) {
|
|
2035
2083
|
const arg = node.arguments[i];
|
|
2036
2084
|
if (arg.type === 'NamedArgument' || arg.type === 'SpreadExpression') continue;
|
|
2037
2085
|
const paramTypeAnn = fnSym._paramTypes[i];
|
|
2038
2086
|
if (!paramTypeAnn) continue;
|
|
2039
|
-
|
|
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;
|
|
2040
2094
|
const actualType = this._inferType(arg);
|
|
2041
2095
|
if (!this._typesCompatible(expectedType, actualType)) {
|
|
2042
2096
|
const paramName = fnSym._params ? fnSym._params[i] : `argument ${i + 1}`;
|
|
2043
|
-
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));
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
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]);
|
|
2044
2181
|
}
|
|
2182
|
+
resolved = this._substituteTypeParams(resolved, bindings);
|
|
2045
2183
|
}
|
|
2184
|
+
|
|
2185
|
+
return resolved;
|
|
2046
2186
|
}
|
|
2047
2187
|
|
|
2048
2188
|
_checkBinaryExprTypes(node) {
|
|
@@ -2053,28 +2193,38 @@ export class Analyzer {
|
|
|
2053
2193
|
if (op === '++') {
|
|
2054
2194
|
// String concatenation: both sides should be String
|
|
2055
2195
|
if (leftType && leftType !== 'String' && leftType !== 'Any') {
|
|
2056
|
-
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");
|
|
2057
2197
|
}
|
|
2058
2198
|
if (rightType && rightType !== 'String' && rightType !== 'Any') {
|
|
2059
|
-
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");
|
|
2060
2200
|
}
|
|
2061
2201
|
} else if (['-', '*', '/', '%', '**'].includes(op)) {
|
|
2202
|
+
// String literal * Int is valid (string repeat) — skip warning for that case
|
|
2203
|
+
if (op === '*') {
|
|
2204
|
+
const leftIsStr = node.left.type === 'StringLiteral' || node.left.type === 'TemplateLiteral';
|
|
2205
|
+
const rightIsStr = node.right.type === 'StringLiteral' || node.right.type === 'TemplateLiteral';
|
|
2206
|
+
if (leftIsStr || rightIsStr) return;
|
|
2207
|
+
}
|
|
2062
2208
|
// Arithmetic: both sides must be numeric
|
|
2063
2209
|
const numerics = new Set(['Int', 'Float']);
|
|
2064
2210
|
if (leftType && !numerics.has(leftType) && leftType !== 'Any') {
|
|
2065
|
-
|
|
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);
|
|
2066
2213
|
}
|
|
2067
2214
|
if (rightType && !numerics.has(rightType) && rightType !== 'Any') {
|
|
2068
|
-
|
|
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);
|
|
2069
2217
|
}
|
|
2070
2218
|
} else if (op === '+') {
|
|
2071
2219
|
// Addition: both sides must be numeric (Tova uses ++ for strings)
|
|
2072
2220
|
const numerics = new Set(['Int', 'Float']);
|
|
2073
2221
|
if (leftType && !numerics.has(leftType) && leftType !== 'Any') {
|
|
2074
|
-
|
|
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);
|
|
2075
2224
|
}
|
|
2076
2225
|
if (rightType && !numerics.has(rightType) && rightType !== 'Any') {
|
|
2077
|
-
|
|
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);
|
|
2078
2228
|
}
|
|
2079
2229
|
}
|
|
2080
2230
|
}
|
|
@@ -2111,7 +2261,7 @@ export class Analyzer {
|
|
|
2111
2261
|
}
|
|
2112
2262
|
if (crossedBoundary) {
|
|
2113
2263
|
const sym = scope.symbols.get(name);
|
|
2114
|
-
if (sym) return true;
|
|
2264
|
+
if (sym && sym.kind !== 'builtin') return true;
|
|
2115
2265
|
}
|
|
2116
2266
|
scope = scope.parent;
|
|
2117
2267
|
}
|
|
@@ -2138,9 +2288,36 @@ export class Analyzer {
|
|
|
2138
2288
|
return false;
|
|
2139
2289
|
}
|
|
2140
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
|
+
|
|
2141
2302
|
visitGuardStatement(node) {
|
|
2142
2303
|
this.visitExpression(node.condition);
|
|
2143
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
|
+
}
|
|
2144
2321
|
}
|
|
2145
2322
|
|
|
2146
2323
|
visitInterfaceDeclaration(node) {
|
|
@@ -2194,16 +2371,16 @@ export class Analyzer {
|
|
|
2194
2371
|
for (const required of traitSym._interfaceMethods) {
|
|
2195
2372
|
const provided = providedMethods.get(required.name);
|
|
2196
2373
|
if (!provided) {
|
|
2197
|
-
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' });
|
|
2198
2375
|
} else {
|
|
2199
2376
|
// Check parameter count matches (excluding self)
|
|
2200
2377
|
if (required.paramCount > 0 && provided.paramCount !== required.paramCount) {
|
|
2201
|
-
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' });
|
|
2202
2379
|
}
|
|
2203
2380
|
// Check return type matches if both are annotated
|
|
2204
2381
|
if (required.returnType && provided.returnType) {
|
|
2205
2382
|
if (!provided.returnType.isAssignableTo(required.returnType)) {
|
|
2206
|
-
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' });
|
|
2207
2384
|
}
|
|
2208
2385
|
}
|
|
2209
2386
|
}
|
|
@@ -2277,8 +2454,18 @@ export class Analyzer {
|
|
|
2277
2454
|
|
|
2278
2455
|
visitTypeAlias(node) {
|
|
2279
2456
|
try {
|
|
2280
|
-
|
|
2281
|
-
|
|
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);
|
|
2282
2469
|
} catch (e) {
|
|
2283
2470
|
this.error(e.message);
|
|
2284
2471
|
}
|
|
@@ -2296,7 +2483,7 @@ export class Analyzer {
|
|
|
2296
2483
|
scope = scope.parent;
|
|
2297
2484
|
}
|
|
2298
2485
|
if (!insideFunction) {
|
|
2299
|
-
this.warn("'defer' used outside of a function", node.loc);
|
|
2486
|
+
this.warn("'defer' used outside of a function", node.loc, null, { code: 'W208' });
|
|
2300
2487
|
}
|
|
2301
2488
|
if (node.body) {
|
|
2302
2489
|
if (node.body.type === 'BlockStatement') {
|