tova 0.8.2 → 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.
@@ -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') {
@@ -963,6 +1025,210 @@ export class Analyzer {
963
1025
  }
964
1026
  }
965
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
+
966
1232
  visitSecurityBlock(node) {
967
1233
  // Per-block: only check for duplicate role names within this block
968
1234
  const localRoles = new Set();
@@ -973,6 +1239,7 @@ export class Analyzer {
973
1239
  message: `Duplicate role definition: '${stmt.name}'`,
974
1240
  loc: stmt.loc,
975
1241
  code: 'W_DUPLICATE_ROLE',
1242
+ category: 'security',
976
1243
  });
977
1244
  }
978
1245
  localRoles.add(stmt.name);
@@ -1388,6 +1655,19 @@ export class Analyzer {
1388
1655
  }
1389
1656
 
1390
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
+
1391
1671
  // Collect ALL security declarations across ALL security blocks in the AST
1392
1672
  const allRoles = new Set();
1393
1673
  const allProtects = [];
@@ -1438,6 +1718,7 @@ export class Analyzer {
1438
1718
  message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
1439
1719
  loc: decl.loc,
1440
1720
  code: 'W_DUPLICATE_ROLE',
1721
+ category: 'security',
1441
1722
  });
1442
1723
  }
1443
1724
  }
@@ -1452,6 +1733,7 @@ export class Analyzer {
1452
1733
  message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
1453
1734
  loc: authDecl.loc,
1454
1735
  code: 'W_UNKNOWN_AUTH_TYPE',
1736
+ category: 'security',
1455
1737
  });
1456
1738
  }
1457
1739
  }
@@ -1464,6 +1746,7 @@ export class Analyzer {
1464
1746
  message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
1465
1747
  loc: authDecl.loc,
1466
1748
  code: 'W_HARDCODED_SECRET',
1749
+ category: 'security',
1467
1750
  });
1468
1751
  }
1469
1752
  }
@@ -1478,6 +1761,7 @@ export class Analyzer {
1478
1761
  message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
1479
1762
  loc: corsDecl.loc,
1480
1763
  code: 'W_CORS_WILDCARD',
1764
+ category: 'security',
1481
1765
  });
1482
1766
  break;
1483
1767
  }
@@ -1500,6 +1784,7 @@ export class Analyzer {
1500
1784
  message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
1501
1785
  loc: rateLimitDecl.loc,
1502
1786
  code: 'W_INVALID_RATE_LIMIT',
1787
+ category: 'security',
1503
1788
  });
1504
1789
  }
1505
1790
  if (rlWindowVal !== null && rlWindowVal <= 0) {
@@ -1507,6 +1792,7 @@ export class Analyzer {
1507
1792
  message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
1508
1793
  loc: rateLimitDecl.loc,
1509
1794
  code: 'W_INVALID_RATE_LIMIT',
1795
+ category: 'security',
1510
1796
  });
1511
1797
  }
1512
1798
  }
@@ -1520,6 +1806,7 @@ export class Analyzer {
1520
1806
  message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
1521
1807
  loc: csrfDecl.loc,
1522
1808
  code: 'W_CSRF_DISABLED',
1809
+ category: 'security',
1523
1810
  });
1524
1811
  }
1525
1812
  }
@@ -1533,6 +1820,7 @@ export class Analyzer {
1533
1820
  message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
1534
1821
  loc: authDecl.loc,
1535
1822
  code: 'W_LOCALSTORAGE_TOKEN',
1823
+ category: 'security',
1536
1824
  });
1537
1825
  }
1538
1826
  }
@@ -1543,6 +1831,7 @@ export class Analyzer {
1543
1831
  message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
1544
1832
  loc: rateLimitDecl.loc,
1545
1833
  code: 'W_INMEMORY_RATELIMIT',
1834
+ category: 'security',
1546
1835
  });
1547
1836
  }
1548
1837
 
@@ -1554,6 +1843,7 @@ export class Analyzer {
1554
1843
  message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
1555
1844
  loc: authDecl.loc,
1556
1845
  code: 'W_NO_AUTH_RATELIMIT',
1846
+ category: 'security',
1557
1847
  });
1558
1848
  }
1559
1849
  }
@@ -1566,6 +1856,7 @@ export class Analyzer {
1566
1856
  message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
1567
1857
  loc: s.loc,
1568
1858
  code: 'W_HASH_NOT_ENFORCED',
1859
+ category: 'security',
1569
1860
  });
1570
1861
  }
1571
1862
  }
@@ -1580,6 +1871,7 @@ export class Analyzer {
1580
1871
  message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
1581
1872
  loc: allProtects[0].loc,
1582
1873
  code: 'W_PROTECT_WITHOUT_AUTH',
1874
+ category: 'security',
1583
1875
  });
1584
1876
  }
1585
1877
 
@@ -1592,6 +1884,7 @@ export class Analyzer {
1592
1884
  message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
1593
1885
  loc: protect.loc,
1594
1886
  code: 'W_PROTECT_NO_REQUIRE',
1887
+ category: 'security',
1595
1888
  });
1596
1889
  continue;
1597
1890
  }
@@ -1601,6 +1894,7 @@ export class Analyzer {
1601
1894
  message: `Protect rule references undefined role '${requireExpr.name}'`,
1602
1895
  loc: protect.loc,
1603
1896
  code: 'W_UNDEFINED_ROLE',
1897
+ category: 'security',
1604
1898
  });
1605
1899
  }
1606
1900
  }
@@ -1617,6 +1911,7 @@ export class Analyzer {
1617
1911
  message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
1618
1912
  loc: sensitive.loc,
1619
1913
  code: 'W_UNDEFINED_ROLE',
1914
+ category: 'security',
1620
1915
  });
1621
1916
  }
1622
1917
  }
@@ -1662,6 +1957,15 @@ export class Analyzer {
1662
1957
  // Complex targets (e.g., arr[i] = val, obj.prop = val) — visit and skip declaration logic
1663
1958
  if (typeof target !== 'string') {
1664
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
+ }
1665
1969
  continue;
1666
1970
  }
1667
1971
 
@@ -1917,6 +2221,19 @@ export class Analyzer {
1917
2221
  }
1918
2222
  }
1919
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
+
1920
2237
  // Validate derive traits
1921
2238
  if (node.derive) {
1922
2239
  const builtinTraits = new Set(['Eq', 'Show', 'JSON']);
@@ -2010,7 +2327,7 @@ export class Analyzer {
2010
2327
  const sym = this.currentScope.lookup(narrowing.varName);
2011
2328
  if (sym) {
2012
2329
  // Store narrowed type info in the scope
2013
- 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);
2014
2331
  narrowedSym.inferredType = narrowing.narrowedType;
2015
2332
  narrowedSym._narrowed = true;
2016
2333
  try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
@@ -2035,7 +2352,7 @@ export class Analyzer {
2035
2352
  this.currentScope = this.currentScope.child('block');
2036
2353
  const sym = this.currentScope.lookup(narrowing.varName);
2037
2354
  if (sym) {
2038
- 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);
2039
2356
  narrowedSym.inferredType = narrowing.inverseType;
2040
2357
  narrowedSym._narrowed = true;
2041
2358
  try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
@@ -2815,7 +3132,19 @@ export class Analyzer {
2815
3132
  case 'BlockStatement':
2816
3133
  if (node.body.length === 0) return false;
2817
3134
  // Any statement that definitely returns makes the block definitely return
2818
- 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;
2819
3148
  case 'IfStatement':
2820
3149
  if (!node.elseBody) return false;
2821
3150
  const consequentReturns = this._definitelyReturns(node.consequent);
@@ -2863,11 +3192,16 @@ export class Analyzer {
2863
3192
  const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
2864
3193
  if (hasSpread) return;
2865
3194
 
2866
- // 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
2867
3197
  const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
2868
3198
  if (hasNamedArgs) {
2869
- const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
2870
- 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
+ }
2871
3205
  } else {
2872
3206
  var actualCount = node.arguments.length;
2873
3207
  }
@@ -2921,6 +3255,68 @@ export class Analyzer {
2921
3255
  this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
2922
3256
  }
2923
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
+ }
2924
3320
  }
2925
3321
 
2926
3322
  /**