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