tova 0.7.0 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/bin/tova.js +1312 -139
  2. package/package.json +8 -1
  3. package/src/analyzer/analyzer.js +539 -11
  4. package/src/analyzer/browser-analyzer.js +56 -8
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/scope.js +7 -0
  7. package/src/analyzer/server-analyzer.js +33 -1
  8. package/src/codegen/base-codegen.js +1296 -23
  9. package/src/codegen/browser-codegen.js +725 -20
  10. package/src/codegen/codegen.js +87 -5
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/server-codegen.js +54 -6
  13. package/src/codegen/shared-codegen.js +5 -0
  14. package/src/codegen/theme-codegen.js +69 -0
  15. package/src/codegen/wasm-codegen.js +6 -0
  16. package/src/config/edit-toml.js +6 -2
  17. package/src/config/git-resolver.js +128 -0
  18. package/src/config/lock-file.js +57 -0
  19. package/src/config/module-cache.js +58 -0
  20. package/src/config/module-entry.js +37 -0
  21. package/src/config/module-path.js +63 -0
  22. package/src/config/pkg-errors.js +62 -0
  23. package/src/config/resolve.js +26 -0
  24. package/src/config/resolver.js +139 -0
  25. package/src/config/search.js +28 -0
  26. package/src/config/semver.js +72 -0
  27. package/src/config/toml.js +61 -6
  28. package/src/deploy/deploy.js +217 -0
  29. package/src/deploy/infer.js +218 -0
  30. package/src/deploy/provision.js +315 -0
  31. package/src/diagnostics/security-scorecard.js +111 -0
  32. package/src/lexer/lexer.js +18 -3
  33. package/src/lsp/server.js +482 -0
  34. package/src/parser/animate-ast.js +45 -0
  35. package/src/parser/ast.js +39 -0
  36. package/src/parser/browser-ast.js +19 -1
  37. package/src/parser/browser-parser.js +221 -4
  38. package/src/parser/concurrency-ast.js +15 -0
  39. package/src/parser/concurrency-parser.js +236 -0
  40. package/src/parser/deploy-ast.js +37 -0
  41. package/src/parser/deploy-parser.js +132 -0
  42. package/src/parser/parser.js +42 -5
  43. package/src/parser/select-ast.js +39 -0
  44. package/src/parser/theme-ast.js +29 -0
  45. package/src/parser/theme-parser.js +70 -0
  46. package/src/registry/plugins/concurrency-plugin.js +32 -0
  47. package/src/registry/plugins/deploy-plugin.js +33 -0
  48. package/src/registry/plugins/theme-plugin.js +20 -0
  49. package/src/registry/register-all.js +6 -0
  50. package/src/runtime/charts.js +547 -0
  51. package/src/runtime/embedded.js +6 -2
  52. package/src/runtime/reactivity.js +60 -0
  53. package/src/runtime/router.js +703 -295
  54. package/src/runtime/table.js +606 -33
  55. package/src/stdlib/inline.js +365 -10
  56. package/src/stdlib/runtime-bridge.js +152 -0
  57. package/src/stdlib/string.js +84 -2
  58. package/src/stdlib/validation.js +1 -1
  59. package/src/version.js +1 -1
@@ -98,6 +98,11 @@ function levenshtein(a, b) {
98
98
  const _TOVA_RUNTIME = new Set([
99
99
  'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
100
100
  'db', 'server', 'browser', 'client', 'shared',
101
+ // Router globals (injected by browser-codegen when routing is used)
102
+ 'createRouter', 'navigate', 'getCurrentRoute', 'getParams', 'getPath',
103
+ 'getQuery', 'getMeta', 'defineRoutes', 'onRouteChange',
104
+ 'beforeNavigate', 'afterNavigate', 'getRouter', 'resetRouter',
105
+ 'Router', 'Outlet', 'Link', 'Redirect', 'lazy',
101
106
  ]);
102
107
 
103
108
  // Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
@@ -121,6 +126,7 @@ export class Analyzer {
121
126
  this.warnings = [];
122
127
  this.tolerant = options.tolerant || false;
123
128
  this.strict = options.strict || false;
129
+ this.strictSecurity = options.strictSecurity || false;
124
130
  this.globalScope = new Scope(null, 'module');
125
131
  this.currentScope = this.globalScope;
126
132
  this._allScopes = []; // Track all scopes for unused variable checking
@@ -173,6 +179,17 @@ export class Analyzer {
173
179
  'table_union', 'table_drop_duplicates', 'table_rename',
174
180
  // Table aggregation helpers
175
181
  'agg_sum', 'agg_count', 'agg_mean', 'agg_median', 'agg_min', 'agg_max',
182
+ // Table window functions
183
+ 'table_window',
184
+ 'win_row_number', 'win_rank', 'win_dense_rank', 'win_percent_rank', 'win_ntile',
185
+ 'win_lag', 'win_lead', 'win_first_value', 'win_last_value',
186
+ 'win_running_sum', 'win_running_count', 'win_running_avg', 'win_running_min', 'win_running_max',
187
+ 'win_moving_avg',
188
+ // Window function short names (used inside window() calls, codegen adds win_ prefix)
189
+ 'row_number', 'rank', 'dense_rank', 'percent_rank', 'ntile',
190
+ 'lag', 'lead', 'first_value', 'last_value',
191
+ 'running_sum', 'running_count', 'running_avg', 'running_min', 'running_max',
192
+ 'moving_avg',
176
193
  // Data exploration
177
194
  'peek', 'describe', 'schema_of',
178
195
  // Data cleaning
@@ -184,7 +201,7 @@ export class Analyzer {
184
201
  // Table operation aliases (short names)
185
202
  'where', 'select', 'derive', 'agg', 'sort_by', 'limit',
186
203
  'pivot', 'unpivot', 'explode', 'union', 'drop_duplicates', 'rename',
187
- 'mean', 'median',
204
+ 'mean', 'median', 'window',
188
205
  // Strings (new)
189
206
  'index_of', 'last_index_of', 'count_of', 'reverse_str', 'substr',
190
207
  'is_empty', 'kebab_case', 'center',
@@ -261,6 +278,7 @@ export class Analyzer {
261
278
  if (opts.code) w.code = opts.code;
262
279
  if (opts.length) w.length = opts.length;
263
280
  if (opts.fix) w.fix = opts.fix;
281
+ if (opts.category) w.category = opts.category;
264
282
  this.warnings.push(w);
265
283
  }
266
284
 
@@ -338,6 +356,15 @@ export class Analyzer {
338
356
  if (plugin.analyzer?.crossBlockValidate) plugin.analyzer.crossBlockValidate(this);
339
357
  }
340
358
 
359
+ // --strict-security: promote security warnings to errors
360
+ if (this.strictSecurity) {
361
+ const securityWarnings = this.warnings.filter(w => w.category === 'security');
362
+ this.warnings = this.warnings.filter(w => w.category !== 'security');
363
+ for (const w of securityWarnings) {
364
+ this.errors.push(w);
365
+ }
366
+ }
367
+
341
368
  // Check for unused variables/imports (#9)
342
369
  this._collectAllScopes(this.globalScope);
343
370
  this._checkUnusedSymbols();
@@ -353,7 +380,7 @@ export class Analyzer {
353
380
  throw err;
354
381
  }
355
382
 
356
- return { warnings: this.warnings, scope: this.globalScope, typeRegistry: this.typeRegistry };
383
+ return { warnings: this.warnings, errors: this.errors, scope: this.globalScope, typeRegistry: this.typeRegistry };
357
384
  }
358
385
 
359
386
  _checkUnusedSymbols() {
@@ -759,7 +786,11 @@ export class Analyzer {
759
786
  case 'GuardStatement': return this.visitGuardStatement(node);
760
787
  case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
761
788
  case 'RefinementType': return;
762
- case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
789
+ case 'ComponentStyleBlock':
790
+ this._validateStyleTokens(node.css, node.loc);
791
+ this._validateResponsiveBreakpoints(node.css, node.loc);
792
+ this._validateVariantProps(node.css, this._currentComponentProps, node.loc);
793
+ return;
763
794
  case 'ImplDeclaration': return this.visitImplDeclaration(node);
764
795
  case 'TraitDeclaration': return this.visitTraitDeclaration(node);
765
796
  case 'TypeAlias': return this.visitTypeAlias(node);
@@ -818,6 +849,19 @@ export class Analyzer {
818
849
  this.visitExpression(node.collection);
819
850
  return;
820
851
  case 'CallExpression':
852
+ // W_DANGEROUS_API: detect setTimeout/setInterval with string args
853
+ if (node.callee.type === 'Identifier') {
854
+ const calleeName = node.callee.name;
855
+ if ((calleeName === 'setTimeout' || calleeName === 'setInterval') &&
856
+ node.arguments.length > 0 && node.arguments[0].type === 'StringLiteral') {
857
+ this.warn(
858
+ `Passing strings to ${calleeName}() executes code dynamically — use a function instead`,
859
+ node.loc,
860
+ 'Replace the string argument with a function',
861
+ { code: 'W_DANGEROUS_API', category: 'security' }
862
+ );
863
+ }
864
+ }
821
865
  // Validate inter-server RPC calls: peerName.functionName()
822
866
  if (this._currentServerBlockName && node.callee.type === 'MemberExpression' &&
823
867
  node.callee.object.type === 'Identifier' && !node.callee.computed) {
@@ -832,9 +876,27 @@ export class Analyzer {
832
876
  }
833
877
  }
834
878
  }
879
+ // Validate named constructor args (before count/type checks)
880
+ this._checkNamedConstructorArgs(node);
835
881
  // Argument count and type validation for known functions
836
882
  this._checkCallArgCount(node);
837
883
  this._checkCallArgTypes(node);
884
+ // W_UNSAFE_INTERPOLATION: detect template literals in database calls
885
+ if (node.callee.type === 'MemberExpression' && !node.callee.computed) {
886
+ const prop = node.callee.property;
887
+ const dbMethods = new Set(['query', 'run', 'exec', 'execute', 'prepare']);
888
+ if (dbMethods.has(prop) && node.arguments.length > 0) {
889
+ const firstArg = node.arguments[0];
890
+ if (firstArg.type === 'TemplateLiteral' && firstArg.parts && firstArg.parts.some(p => p.type === 'expr')) {
891
+ this.warn(
892
+ 'Template literal with expressions in database query — use parameterized queries (?) to prevent SQL injection',
893
+ node.loc,
894
+ 'Replace interpolated expressions with ? and pass values as parameters',
895
+ { code: 'W_UNSAFE_INTERPOLATION', category: 'security' }
896
+ );
897
+ }
898
+ }
899
+ }
838
900
  this.visitExpression(node.callee);
839
901
  for (const arg of node.arguments) {
840
902
  if (arg.type === 'NamedArgument') {
@@ -900,6 +962,19 @@ export class Analyzer {
900
962
  }
901
963
  this.visitExpression(node.argument);
902
964
  return;
965
+ case 'SpawnExpression':
966
+ if (!this._concurrentDepth) {
967
+ this.warn("'spawn' should be used inside a 'concurrent' block", node.loc, null, {
968
+ code: 'W_SPAWN_OUTSIDE_CONCURRENT',
969
+ });
970
+ }
971
+ if (node.callee) this.visitExpression(node.callee);
972
+ if (node.arguments) {
973
+ for (const arg of node.arguments) {
974
+ this.visitExpression(arg);
975
+ }
976
+ }
977
+ return;
903
978
  case 'YieldExpression':
904
979
  if (node.argument) this.visitExpression(node.argument);
905
980
  return;
@@ -950,6 +1025,210 @@ export class Analyzer {
950
1025
  }
951
1026
  }
952
1027
 
1028
+ visitThemeBlock(node) {
1029
+ const VALID_THEME_SECTIONS = new Set([
1030
+ 'colors', 'spacing', 'radius', 'shadow', 'font', 'breakpoints', 'transition'
1031
+ ]);
1032
+
1033
+ // Check for multiple theme blocks
1034
+ if (!this._themeBlockSeen) {
1035
+ this._themeBlockSeen = true;
1036
+ } else {
1037
+ this.warnings.push({
1038
+ message: 'Multiple theme blocks found — only one theme block is allowed per project',
1039
+ loc: node.loc,
1040
+ code: 'W_MULTIPLE_THEME_BLOCKS',
1041
+ });
1042
+ }
1043
+
1044
+ const CATEGORY_MAP = {
1045
+ colors: 'color', spacing: 'spacing', radius: 'radius',
1046
+ shadow: 'shadow', font: 'font', breakpoints: 'breakpoint', transition: 'transition'
1047
+ };
1048
+
1049
+ // Store tokens for $token validation (used in Task 5)
1050
+ if (!this._themeTokens) this._themeTokens = new Map();
1051
+ this._hasThemeBlock = true;
1052
+
1053
+ const sectionNames = new Set();
1054
+ for (const section of node.sections) {
1055
+ if (!VALID_THEME_SECTIONS.has(section.name)) {
1056
+ this.warnings.push({
1057
+ message: `Unknown theme section '${section.name}' — valid sections are: ${[...VALID_THEME_SECTIONS].join(', ')}`,
1058
+ loc: section.loc,
1059
+ code: 'W_UNKNOWN_THEME_SECTION',
1060
+ });
1061
+ }
1062
+
1063
+ if (sectionNames.has(section.name)) {
1064
+ this.warnings.push({
1065
+ message: `Duplicate theme section '${section.name}'`,
1066
+ loc: section.loc,
1067
+ code: 'W_DUPLICATE_THEME_SECTION',
1068
+ });
1069
+ }
1070
+ sectionNames.add(section.name);
1071
+
1072
+ // Store tokens for later $token validation
1073
+ const category = CATEGORY_MAP[section.name] || section.name;
1074
+ if (!this._themeTokens.has(category)) this._themeTokens.set(category, new Set());
1075
+
1076
+ const tokenNames = new Set();
1077
+ for (const token of section.tokens) {
1078
+ if (tokenNames.has(token.name)) {
1079
+ this.warnings.push({
1080
+ message: `Duplicate theme token '${token.name}' in section '${section.name}'`,
1081
+ loc: token.loc,
1082
+ code: 'W_DUPLICATE_THEME_TOKEN',
1083
+ });
1084
+ }
1085
+ tokenNames.add(token.name);
1086
+ this._themeTokens.get(category).add(token.name);
1087
+ }
1088
+ }
1089
+
1090
+ // Validate dark overrides
1091
+ for (const override of node.darkOverrides) {
1092
+ const dotIdx = override.name.indexOf('.');
1093
+ if (dotIdx === -1) continue;
1094
+ const sectionName = override.name.slice(0, dotIdx);
1095
+ if (!sectionNames.has(sectionName)) {
1096
+ this.warnings.push({
1097
+ message: `Dark override '${override.name}' references unknown section '${sectionName}'`,
1098
+ loc: override.loc,
1099
+ code: 'W_DARK_OVERRIDE_UNKNOWN_SECTION',
1100
+ });
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ _validateStyleTokens(css, loc) {
1106
+ if (!this._hasThemeBlock) return;
1107
+ const VALID_CATEGORIES = new Set(['color', 'spacing', 'radius', 'shadow', 'font', 'transition', 'breakpoint']);
1108
+ const tokenRegex = /\$(\w+)\.([\w.]+)/g;
1109
+ let m;
1110
+ while ((m = tokenRegex.exec(css)) !== null) {
1111
+ const category = m[1];
1112
+ const name = m[2];
1113
+ if (!VALID_CATEGORIES.has(category)) {
1114
+ this.warnings.push({
1115
+ message: `Unknown theme category '$${category}' — valid categories are: ${[...VALID_CATEGORIES].join(', ')}`,
1116
+ loc,
1117
+ code: 'W_UNKNOWN_THEME_CATEGORY',
1118
+ });
1119
+ continue;
1120
+ }
1121
+ const tokenSet = this._themeTokens ? this._themeTokens.get(category) : null;
1122
+ if (!tokenSet || !tokenSet.has(name)) {
1123
+ const available = tokenSet ? [...tokenSet] : [];
1124
+ let suggestion = '';
1125
+ if (available.length > 0) {
1126
+ const closest = this._findClosestThemeMatch(name, available);
1127
+ if (closest) suggestion = ` — did you mean '$${category}.${closest}'?`;
1128
+ }
1129
+ this.warnings.push({
1130
+ message: `Unknown theme token '$${category}.${name}'${suggestion}`,
1131
+ loc,
1132
+ code: 'W_UNKNOWN_THEME_TOKEN',
1133
+ });
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ _validateResponsiveBreakpoints(css, loc) {
1139
+ // Find responsive { ... } in raw CSS
1140
+ const responsiveMatch = css.match(/responsive\s*\{/);
1141
+ if (!responsiveMatch) return;
1142
+
1143
+ // Get available breakpoints
1144
+ let availableBreakpoints;
1145
+ if (this._hasThemeBlock && this._themeTokens && this._themeTokens.has('breakpoint')) {
1146
+ availableBreakpoints = this._themeTokens.get('breakpoint');
1147
+ } else if (this._hasThemeBlock) {
1148
+ // Theme exists but no breakpoints section — no defaults
1149
+ availableBreakpoints = new Set();
1150
+ } else {
1151
+ // No theme block — use defaults, skip validation
1152
+ return;
1153
+ }
1154
+
1155
+ // Extract breakpoint names from responsive block
1156
+ const startIdx = responsiveMatch.index + responsiveMatch[0].length;
1157
+ let depth = 1;
1158
+ let i = startIdx;
1159
+ while (i < css.length && depth > 0) {
1160
+ if (css[i] === '{') depth++;
1161
+ else if (css[i] === '}') depth--;
1162
+ i++;
1163
+ }
1164
+ const content = css.slice(startIdx, i - 1);
1165
+
1166
+ // Parse top-level identifiers (breakpoint names) before { at depth 0
1167
+ const bpNames = [];
1168
+ let pos = 0;
1169
+ let bpDepth = 0;
1170
+ while (pos < content.length) {
1171
+ if (content[pos] === '{') {
1172
+ bpDepth++;
1173
+ pos++;
1174
+ } else if (content[pos] === '}') {
1175
+ bpDepth--;
1176
+ pos++;
1177
+ } else if (bpDepth === 0 && /[a-zA-Z_]/.test(content[pos])) {
1178
+ let name = '';
1179
+ while (pos < content.length && /\w/.test(content[pos])) {
1180
+ name += content[pos];
1181
+ pos++;
1182
+ }
1183
+ bpNames.push(name);
1184
+ } else {
1185
+ pos++;
1186
+ }
1187
+ }
1188
+
1189
+ for (const name of bpNames) {
1190
+ if (!availableBreakpoints.has(name)) {
1191
+ this.warnings.push({
1192
+ message: `Unknown breakpoint '${name}' in responsive block — available breakpoints: ${[...availableBreakpoints].join(', ')}`,
1193
+ loc,
1194
+ code: 'W_UNKNOWN_BREAKPOINT',
1195
+ });
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ _validateVariantProps(css, componentProps, loc) {
1201
+ if (!componentProps || componentProps.length === 0) return;
1202
+ const variantRegex = /variant\(([^)]+)\)/g;
1203
+ let m;
1204
+ while ((m = variantRegex.exec(css)) !== null) {
1205
+ const rawProps = m[1];
1206
+ // Split by + for compound variants
1207
+ const propNames = rawProps.split('+').map(s => s.trim());
1208
+ for (const propName of propNames) {
1209
+ if (!propName) continue;
1210
+ if (!componentProps.includes(propName)) {
1211
+ this.warn(
1212
+ `variant() references unknown prop '${propName}'. Component has props: [${componentProps.join(', ')}]`,
1213
+ loc,
1214
+ null,
1215
+ { code: 'W_VARIANT_UNKNOWN_PROP' }
1216
+ );
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ _findClosestThemeMatch(input, candidates) {
1223
+ let best = null;
1224
+ let bestDist = Infinity;
1225
+ for (const c of candidates) {
1226
+ const d = levenshtein(input, c);
1227
+ if (d < bestDist && d <= 3) { bestDist = d; best = c; }
1228
+ }
1229
+ return best;
1230
+ }
1231
+
953
1232
  visitSecurityBlock(node) {
954
1233
  // Per-block: only check for duplicate role names within this block
955
1234
  const localRoles = new Set();
@@ -960,6 +1239,7 @@ export class Analyzer {
960
1239
  message: `Duplicate role definition: '${stmt.name}'`,
961
1240
  loc: stmt.loc,
962
1241
  code: 'W_DUPLICATE_ROLE',
1242
+ category: 'security',
963
1243
  });
964
1244
  }
965
1245
  localRoles.add(stmt.name);
@@ -1019,6 +1299,123 @@ export class Analyzer {
1019
1299
  }
1020
1300
  }
1021
1301
 
1302
+ visitConcurrentBlock(node) {
1303
+ // Validate mode
1304
+ const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
1305
+ if (!validModes.has(node.mode)) {
1306
+ this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
1307
+ code: 'W_UNKNOWN_CONCURRENT_MODE',
1308
+ });
1309
+ }
1310
+
1311
+ // Validate timeout
1312
+ if (node.mode === 'timeout' && !node.timeout) {
1313
+ this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
1314
+ code: 'W_MISSING_TIMEOUT',
1315
+ });
1316
+ }
1317
+
1318
+ // Warn on empty block
1319
+ if (node.body.length === 0) {
1320
+ this.warn("Empty concurrent block", node.loc, null, {
1321
+ code: 'W_EMPTY_CONCURRENT',
1322
+ });
1323
+ }
1324
+
1325
+ // Track concurrent depth for spawn validation
1326
+ this._concurrentDepth = (this._concurrentDepth || 0) + 1;
1327
+
1328
+ // Visit body statements (concurrent block does NOT create a new scope —
1329
+ // variables assigned inside should be visible after the block)
1330
+ for (const stmt of node.body) {
1331
+ this.visitNode(stmt);
1332
+ }
1333
+
1334
+ // Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
1335
+ let hasWasm = false;
1336
+ let hasNonWasm = false;
1337
+ for (const stmt of node.body) {
1338
+ const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
1339
+ ? stmt.values[0]
1340
+ : (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
1341
+ ? stmt.expression
1342
+ : null;
1343
+ if (!spawn) continue;
1344
+ const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
1345
+ if (calleeName) {
1346
+ const sym = this.currentScope.lookup(calleeName);
1347
+ if (sym && sym.isWasm) {
1348
+ hasWasm = true;
1349
+ } else {
1350
+ hasNonWasm = true;
1351
+ }
1352
+ } else {
1353
+ // Lambda or complex expression — always non-WASM
1354
+ hasNonWasm = true;
1355
+ }
1356
+ }
1357
+ if (hasWasm && hasNonWasm) {
1358
+ this.warn(
1359
+ "concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
1360
+ node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
1361
+ );
1362
+ }
1363
+
1364
+ this._concurrentDepth--;
1365
+ }
1366
+
1367
+ visitSelectStatement(node) {
1368
+ if (node.cases.length === 0) {
1369
+ this.warn("Empty select block", node.loc, null, {
1370
+ code: 'W_EMPTY_SELECT',
1371
+ });
1372
+ }
1373
+
1374
+ let defaultCount = 0;
1375
+ let timeoutCount = 0;
1376
+ for (const c of node.cases) {
1377
+ if (c.kind === 'default') defaultCount++;
1378
+ if (c.kind === 'timeout') timeoutCount++;
1379
+ }
1380
+
1381
+ if (defaultCount > 1) {
1382
+ this.warn("select block has multiple default cases", node.loc, null, {
1383
+ code: 'W_DUPLICATE_SELECT_DEFAULT',
1384
+ });
1385
+ }
1386
+ if (timeoutCount > 1) {
1387
+ this.warn("select block has multiple timeout cases", node.loc, null, {
1388
+ code: 'W_DUPLICATE_SELECT_TIMEOUT',
1389
+ });
1390
+ }
1391
+ if (defaultCount > 0 && timeoutCount > 0) {
1392
+ this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
1393
+ code: 'W_SELECT_DEFAULT_TIMEOUT',
1394
+ });
1395
+ }
1396
+
1397
+ // Visit each case's expressions and body
1398
+ for (const c of node.cases) {
1399
+ if (c.channel) this.visitNode(c.channel);
1400
+ if (c.value) this.visitNode(c.value);
1401
+
1402
+ if (c.kind === 'receive' && c.binding) {
1403
+ // Create scope for the binding variable
1404
+ this.pushScope('select-case');
1405
+ this.currentScope.define(c.binding,
1406
+ new Symbol(c.binding, 'variable', null, false, c.loc));
1407
+ for (const stmt of c.body) {
1408
+ this.visitNode(stmt);
1409
+ }
1410
+ this.popScope();
1411
+ } else {
1412
+ for (const stmt of c.body) {
1413
+ this.visitNode(stmt);
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+
1022
1419
  _validateCliCrossBlock() {
1023
1420
  const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
1024
1421
  if (cliBlocks.length === 0) return;
@@ -1258,6 +1655,19 @@ export class Analyzer {
1258
1655
  }
1259
1656
 
1260
1657
  _validateSecurityCrossBlock() {
1658
+ // W_NO_SECURITY_BLOCK: server/edge block without security block
1659
+ const hasServerOrEdge = this.ast.body.some(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
1660
+ const hasSecurityBlock = this.ast.body.some(n => n.type === 'SecurityBlock');
1661
+ if (hasServerOrEdge && !hasSecurityBlock) {
1662
+ const block = this.ast.body.find(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
1663
+ this.warnings.push({
1664
+ message: 'Server/edge block defined without a security block — consider adding security { ... } for auth, CORS, and CSRF protection',
1665
+ loc: block.loc,
1666
+ code: 'W_NO_SECURITY_BLOCK',
1667
+ category: 'security',
1668
+ });
1669
+ }
1670
+
1261
1671
  // Collect ALL security declarations across ALL security blocks in the AST
1262
1672
  const allRoles = new Set();
1263
1673
  const allProtects = [];
@@ -1308,6 +1718,7 @@ export class Analyzer {
1308
1718
  message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
1309
1719
  loc: decl.loc,
1310
1720
  code: 'W_DUPLICATE_ROLE',
1721
+ category: 'security',
1311
1722
  });
1312
1723
  }
1313
1724
  }
@@ -1322,6 +1733,7 @@ export class Analyzer {
1322
1733
  message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
1323
1734
  loc: authDecl.loc,
1324
1735
  code: 'W_UNKNOWN_AUTH_TYPE',
1736
+ category: 'security',
1325
1737
  });
1326
1738
  }
1327
1739
  }
@@ -1334,6 +1746,7 @@ export class Analyzer {
1334
1746
  message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
1335
1747
  loc: authDecl.loc,
1336
1748
  code: 'W_HARDCODED_SECRET',
1749
+ category: 'security',
1337
1750
  });
1338
1751
  }
1339
1752
  }
@@ -1348,6 +1761,7 @@ export class Analyzer {
1348
1761
  message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
1349
1762
  loc: corsDecl.loc,
1350
1763
  code: 'W_CORS_WILDCARD',
1764
+ category: 'security',
1351
1765
  });
1352
1766
  break;
1353
1767
  }
@@ -1370,6 +1784,7 @@ export class Analyzer {
1370
1784
  message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
1371
1785
  loc: rateLimitDecl.loc,
1372
1786
  code: 'W_INVALID_RATE_LIMIT',
1787
+ category: 'security',
1373
1788
  });
1374
1789
  }
1375
1790
  if (rlWindowVal !== null && rlWindowVal <= 0) {
@@ -1377,6 +1792,7 @@ export class Analyzer {
1377
1792
  message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
1378
1793
  loc: rateLimitDecl.loc,
1379
1794
  code: 'W_INVALID_RATE_LIMIT',
1795
+ category: 'security',
1380
1796
  });
1381
1797
  }
1382
1798
  }
@@ -1390,6 +1806,7 @@ export class Analyzer {
1390
1806
  message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
1391
1807
  loc: csrfDecl.loc,
1392
1808
  code: 'W_CSRF_DISABLED',
1809
+ category: 'security',
1393
1810
  });
1394
1811
  }
1395
1812
  }
@@ -1403,6 +1820,7 @@ export class Analyzer {
1403
1820
  message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
1404
1821
  loc: authDecl.loc,
1405
1822
  code: 'W_LOCALSTORAGE_TOKEN',
1823
+ category: 'security',
1406
1824
  });
1407
1825
  }
1408
1826
  }
@@ -1413,6 +1831,7 @@ export class Analyzer {
1413
1831
  message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
1414
1832
  loc: rateLimitDecl.loc,
1415
1833
  code: 'W_INMEMORY_RATELIMIT',
1834
+ category: 'security',
1416
1835
  });
1417
1836
  }
1418
1837
 
@@ -1424,6 +1843,7 @@ export class Analyzer {
1424
1843
  message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
1425
1844
  loc: authDecl.loc,
1426
1845
  code: 'W_NO_AUTH_RATELIMIT',
1846
+ category: 'security',
1427
1847
  });
1428
1848
  }
1429
1849
  }
@@ -1436,6 +1856,7 @@ export class Analyzer {
1436
1856
  message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
1437
1857
  loc: s.loc,
1438
1858
  code: 'W_HASH_NOT_ENFORCED',
1859
+ category: 'security',
1439
1860
  });
1440
1861
  }
1441
1862
  }
@@ -1450,6 +1871,7 @@ export class Analyzer {
1450
1871
  message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
1451
1872
  loc: allProtects[0].loc,
1452
1873
  code: 'W_PROTECT_WITHOUT_AUTH',
1874
+ category: 'security',
1453
1875
  });
1454
1876
  }
1455
1877
 
@@ -1462,6 +1884,7 @@ export class Analyzer {
1462
1884
  message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
1463
1885
  loc: protect.loc,
1464
1886
  code: 'W_PROTECT_NO_REQUIRE',
1887
+ category: 'security',
1465
1888
  });
1466
1889
  continue;
1467
1890
  }
@@ -1471,6 +1894,7 @@ export class Analyzer {
1471
1894
  message: `Protect rule references undefined role '${requireExpr.name}'`,
1472
1895
  loc: protect.loc,
1473
1896
  code: 'W_UNDEFINED_ROLE',
1897
+ category: 'security',
1474
1898
  });
1475
1899
  }
1476
1900
  }
@@ -1487,6 +1911,7 @@ export class Analyzer {
1487
1911
  message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
1488
1912
  loc: sensitive.loc,
1489
1913
  code: 'W_UNDEFINED_ROLE',
1914
+ category: 'security',
1490
1915
  });
1491
1916
  }
1492
1917
  }
@@ -1532,6 +1957,15 @@ export class Analyzer {
1532
1957
  // Complex targets (e.g., arr[i] = val, obj.prop = val) — visit and skip declaration logic
1533
1958
  if (typeof target !== 'string') {
1534
1959
  this.visitExpression(target);
1960
+ // W_DANGEROUS_API: detect innerHTML assignment
1961
+ if (target.type === 'MemberExpression' && !target.computed && target.property === 'innerHTML') {
1962
+ this.warn(
1963
+ 'Direct innerHTML assignment is an XSS risk — use textContent or escape_html()',
1964
+ target.loc || node.loc,
1965
+ 'Use el.textContent for plain text, or escape_html(value) for safe HTML rendering',
1966
+ { code: 'W_DANGEROUS_API', category: 'security' }
1967
+ );
1968
+ }
1535
1969
  continue;
1536
1970
  }
1537
1971
 
@@ -1614,9 +2048,10 @@ export class Analyzer {
1614
2048
  } else if (node.pattern.type === 'ArrayPattern' || node.pattern.type === 'TuplePattern') {
1615
2049
  for (const el of node.pattern.elements) {
1616
2050
  if (el) {
2051
+ const varName = el.startsWith('...') ? el.slice(3) : el;
1617
2052
  try {
1618
- this.currentScope.define(el,
1619
- new Symbol(el, 'variable', null, false, node.loc));
2053
+ this.currentScope.define(varName,
2054
+ new Symbol(varName, 'variable', null, false, node.loc));
1620
2055
  } catch (e) {
1621
2056
  this.error(e.message);
1622
2057
  }
@@ -1634,6 +2069,7 @@ export class Analyzer {
1634
2069
  sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
1635
2070
  sym._typeParams = node.typeParams || [];
1636
2071
  sym.isPublic = node.isPublic || false;
2072
+ sym.isWasm = !!(node.decorators && node.decorators.some(d => d.name === 'wasm'));
1637
2073
  this.currentScope.define(node.name, sym);
1638
2074
  } catch (e) {
1639
2075
  this.error(e.message);
@@ -1785,6 +2221,19 @@ export class Analyzer {
1785
2221
  }
1786
2222
  }
1787
2223
 
2224
+ // Register struct type metadata (non-variant types)
2225
+ const hasVariants = node.variants.some(v => v.type === 'TypeVariant');
2226
+ if (!hasVariants && node.variants.length > 0) {
2227
+ const typeSym = this.currentScope.lookup(node.name);
2228
+ if (typeSym) {
2229
+ typeSym._params = node.variants.map(f => f.name);
2230
+ typeSym._totalParamCount = node.variants.length;
2231
+ typeSym._requiredParamCount = node.variants.length;
2232
+ typeSym._paramTypes = node.variants.map(f => f.typeAnnotation || null);
2233
+ typeSym._isStructConstructor = true;
2234
+ }
2235
+ }
2236
+
1788
2237
  // Validate derive traits
1789
2238
  if (node.derive) {
1790
2239
  const builtinTraits = new Set(['Eq', 'Show', 'JSON']);
@@ -1878,7 +2327,7 @@ export class Analyzer {
1878
2327
  const sym = this.currentScope.lookup(narrowing.varName);
1879
2328
  if (sym) {
1880
2329
  // Store narrowed type info in the scope
1881
- const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, false, sym.loc);
2330
+ const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
1882
2331
  narrowedSym.inferredType = narrowing.narrowedType;
1883
2332
  narrowedSym._narrowed = true;
1884
2333
  try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
@@ -1903,7 +2352,7 @@ export class Analyzer {
1903
2352
  this.currentScope = this.currentScope.child('block');
1904
2353
  const sym = this.currentScope.lookup(narrowing.varName);
1905
2354
  if (sym) {
1906
- const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, false, sym.loc);
2355
+ const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
1907
2356
  narrowedSym.inferredType = narrowing.inverseType;
1908
2357
  narrowedSym._narrowed = true;
1909
2358
  try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
@@ -2683,7 +3132,19 @@ export class Analyzer {
2683
3132
  case 'BlockStatement':
2684
3133
  if (node.body.length === 0) return false;
2685
3134
  // Any statement that definitely returns makes the block definitely return
2686
- return node.body.some(stmt => this._definitelyReturns(stmt));
3135
+ if (node.body.some(stmt => this._definitelyReturns(stmt))) return true;
3136
+ // Last value-producing expression in a block is an implicit return (codegen adds `return`).
3137
+ // Only treat simple value expressions as implicit returns — calls, match, and if
3138
+ // require deeper analysis and are excluded to avoid false negatives.
3139
+ const lastStmt = node.body[node.body.length - 1];
3140
+ if (lastStmt && lastStmt.type === 'ExpressionStatement') {
3141
+ const exprType = lastStmt.expression.type;
3142
+ if (exprType !== 'CallExpression' && exprType !== 'MatchExpression' &&
3143
+ exprType !== 'IfExpression' && exprType !== 'IfStatement') {
3144
+ return true;
3145
+ }
3146
+ }
3147
+ return false;
2687
3148
  case 'IfStatement':
2688
3149
  if (!node.elseBody) return false;
2689
3150
  const consequentReturns = this._definitelyReturns(node.consequent);
@@ -2731,11 +3192,16 @@ export class Analyzer {
2731
3192
  const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
2732
3193
  if (hasSpread) return;
2733
3194
 
2734
- // Named arguments are collapsed into a single object at codegen
3195
+ // Named arguments: for type constructors, each named arg = one field;
3196
+ // for regular functions, named args are collapsed into a single object
2735
3197
  const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
2736
3198
  if (hasNamedArgs) {
2737
- const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
2738
- var actualCount = positionalCount + 1; // named args become one object
3199
+ if (fnSym._variantOf || fnSym._isStructConstructor) {
3200
+ var actualCount = node.arguments.length; // each arg = one field
3201
+ } else {
3202
+ const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
3203
+ var actualCount = positionalCount + 1; // named args become one object
3204
+ }
2739
3205
  } else {
2740
3206
  var actualCount = node.arguments.length;
2741
3207
  }
@@ -2789,6 +3255,68 @@ export class Analyzer {
2789
3255
  this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
2790
3256
  }
2791
3257
  }
3258
+
3259
+ // Type-check named args for type/variant constructors
3260
+ if ((fnSym._variantOf || fnSym._isStructConstructor) && fnSym._params && fnSym._paramTypes) {
3261
+ for (const arg of node.arguments) {
3262
+ if (arg.type !== 'NamedArgument') continue;
3263
+ const idx = fnSym._params.indexOf(arg.name);
3264
+ if (idx === -1 || !fnSym._paramTypes[idx]) continue;
3265
+ let expectedType = this._typeAnnotationToString(fnSym._paramTypes[idx]);
3266
+ if (typeParamBindings.size > 0) {
3267
+ expectedType = this._substituteTypeParams(expectedType, typeParamBindings);
3268
+ }
3269
+ if (fnSym._typeParams && fnSym._typeParams.includes(expectedType)) continue;
3270
+ const actualType = this._inferType(arg.value);
3271
+ if (!this._typesCompatible(expectedType, actualType)) {
3272
+ this.error(`Type mismatch: '${arg.name}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
3273
+ }
3274
+ }
3275
+ }
3276
+ }
3277
+
3278
+ _checkNamedConstructorArgs(node) {
3279
+ if (node.callee.type !== 'Identifier') return;
3280
+ const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
3281
+ if (!hasNamedArgs) return;
3282
+
3283
+ const fnSym = this.currentScope.lookup(node.callee.name);
3284
+ if (!fnSym) return;
3285
+ if (!fnSym._variantOf && !fnSym._isStructConstructor) return;
3286
+ if (!fnSym._params) return;
3287
+
3288
+ // Check for duplicate field names in the type definition (would make named args ambiguous)
3289
+ const uniqueParams = new Set(fnSym._params);
3290
+ if (uniqueParams.size !== fnSym._params.length) {
3291
+ this.error(`Cannot use named arguments with ${node.callee.name}: duplicate field names`, node.loc);
3292
+ return;
3293
+ }
3294
+
3295
+ const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
3296
+ const seenNames = new Set();
3297
+
3298
+ for (const arg of node.arguments) {
3299
+ if (arg.type !== 'NamedArgument') continue;
3300
+
3301
+ // Unknown field
3302
+ if (!fnSym._params.includes(arg.name)) {
3303
+ this.error(`Unknown field '${arg.name}' in ${node.callee.name} constructor`, arg.loc || node.loc);
3304
+ continue;
3305
+ }
3306
+
3307
+ // Duplicate named arg
3308
+ if (seenNames.has(arg.name)) {
3309
+ this.error(`Duplicate named argument '${arg.name}'`, arg.loc || node.loc);
3310
+ continue;
3311
+ }
3312
+ seenNames.add(arg.name);
3313
+
3314
+ // Overlaps with positional slot
3315
+ const fieldIdx = fnSym._params.indexOf(arg.name);
3316
+ if (fieldIdx < positionalCount) {
3317
+ this.error(`Field '${arg.name}' already provided positionally`, arg.loc || node.loc);
3318
+ }
3319
+ }
2792
3320
  }
2793
3321
 
2794
3322
  /**