tova 0.5.0 → 0.7.0

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 (43) hide show
  1. package/bin/tova.js +109 -57
  2. package/package.json +7 -2
  3. package/src/analyzer/analyzer.js +315 -79
  4. package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
  5. package/src/analyzer/form-analyzer.js +113 -0
  6. package/src/analyzer/scope.js +2 -2
  7. package/src/codegen/base-codegen.js +1 -0
  8. package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
  9. package/src/codegen/cli-codegen.js +386 -0
  10. package/src/codegen/codegen.js +163 -45
  11. package/src/codegen/edge-codegen.js +1351 -0
  12. package/src/codegen/form-codegen.js +553 -0
  13. package/src/codegen/security-codegen.js +5 -5
  14. package/src/codegen/server-codegen.js +88 -7
  15. package/src/diagnostics/error-codes.js +1 -1
  16. package/src/docs/generator.js +1 -1
  17. package/src/formatter/formatter.js +4 -4
  18. package/src/lexer/tokens.js +12 -2
  19. package/src/lsp/server.js +1 -1
  20. package/src/parser/ast.js +45 -5
  21. package/src/parser/{client-ast.js → browser-ast.js} +3 -3
  22. package/src/parser/{client-parser.js → browser-parser.js} +42 -15
  23. package/src/parser/cli-ast.js +35 -0
  24. package/src/parser/cli-parser.js +140 -0
  25. package/src/parser/edge-ast.js +83 -0
  26. package/src/parser/edge-parser.js +262 -0
  27. package/src/parser/form-ast.js +80 -0
  28. package/src/parser/form-parser.js +206 -0
  29. package/src/parser/parser.js +86 -53
  30. package/src/registry/block-registry.js +56 -0
  31. package/src/registry/plugins/bench-plugin.js +23 -0
  32. package/src/registry/plugins/browser-plugin.js +30 -0
  33. package/src/registry/plugins/cli-plugin.js +24 -0
  34. package/src/registry/plugins/data-plugin.js +21 -0
  35. package/src/registry/plugins/edge-plugin.js +32 -0
  36. package/src/registry/plugins/security-plugin.js +27 -0
  37. package/src/registry/plugins/server-plugin.js +46 -0
  38. package/src/registry/plugins/shared-plugin.js +17 -0
  39. package/src/registry/plugins/test-plugin.js +23 -0
  40. package/src/registry/register-all.js +25 -0
  41. package/src/runtime/ssr.js +2 -2
  42. package/src/stdlib/inline.js +178 -3
  43. package/src/version.js +1 -1
@@ -1,8 +1,7 @@
1
1
  import { Scope, Symbol } from './scope.js';
2
2
  import { PIPE_TARGET } from '../parser/ast.js';
3
3
  import { BUILTIN_NAMES } from '../stdlib/inline.js';
4
- import { collectServerBlockFunctions, installServerAnalyzer } from './server-analyzer.js';
5
- import { installClientAnalyzer } from './client-analyzer.js';
4
+ import { BlockRegistry } from '../registry/register-all.js';
6
5
  import {
7
6
  Type, PrimitiveType, NilType, AnyType, UnknownType,
8
7
  ArrayType, TupleType, FunctionType, RecordType, ADTType,
@@ -98,7 +97,7 @@ function levenshtein(a, b) {
98
97
 
99
98
  const _TOVA_RUNTIME = new Set([
100
99
  'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
101
- 'db', 'server', 'client', 'shared',
100
+ 'db', 'server', 'browser', 'client', 'shared',
102
101
  ]);
103
102
 
104
103
  // Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
@@ -327,19 +326,17 @@ export class Analyzer {
327
326
  }
328
327
 
329
328
  analyze() {
330
- // Pre-pass: collect named server block functions for inter-server RPC validation
331
- const hasServerBlocks = this.ast.body.some(n => n.type === 'ServerBlock');
332
- if (hasServerBlocks) {
333
- installServerAnalyzer(Analyzer);
334
- this.serverBlockFunctions = collectServerBlockFunctions(this.ast);
335
- } else {
336
- this.serverBlockFunctions = new Map();
329
+ // Pre-pass hooks (e.g., server block function collection for RPC validation)
330
+ for (const plugin of BlockRegistry.all()) {
331
+ if (plugin.analyzer?.prePass) plugin.analyzer.prePass(this);
337
332
  }
338
333
 
339
334
  this.visitProgram(this.ast);
340
335
 
341
- // Cross-block security validation (after all blocks are visited)
342
- this._validateSecurityCrossBlock();
336
+ // Post-visit cross-block validation
337
+ for (const plugin of BlockRegistry.all()) {
338
+ if (plugin.analyzer?.crossBlockValidate) plugin.analyzer.crossBlockValidate(this);
339
+ }
343
340
 
344
341
  // Check for unused variables/imports (#9)
345
342
  this._collectAllScopes(this.globalScope);
@@ -732,43 +729,14 @@ export class Analyzer {
732
729
  visitNode(node) {
733
730
  if (!node) return;
734
731
 
732
+ // Single registry lookup: returns plugin, NOOP sentinel, or null
733
+ const entry = BlockRegistry.getByAstType(node.type);
734
+ if (entry) {
735
+ if (entry === BlockRegistry.NOOP) return;
736
+ if (entry.analyzer?.visit) return entry.analyzer.visit(this, node);
737
+ }
738
+
735
739
  switch (node.type) {
736
- case 'ServerBlock':
737
- case 'RouteDeclaration':
738
- case 'MiddlewareDeclaration':
739
- case 'HealthCheckDeclaration':
740
- case 'CorsDeclaration':
741
- case 'ErrorHandlerDeclaration':
742
- case 'WebSocketDeclaration':
743
- case 'StaticDeclaration':
744
- case 'DiscoverDeclaration':
745
- case 'AuthDeclaration':
746
- case 'MaxBodyDeclaration':
747
- case 'RouteGroupDeclaration':
748
- case 'RateLimitDeclaration':
749
- case 'LifecycleHookDeclaration':
750
- case 'SubscribeDeclaration':
751
- case 'EnvDeclaration':
752
- case 'ScheduleDeclaration':
753
- case 'UploadDeclaration':
754
- case 'SessionDeclaration':
755
- case 'DbDeclaration':
756
- case 'TlsDeclaration':
757
- case 'CompressionDeclaration':
758
- case 'BackgroundJobDeclaration':
759
- case 'CacheDeclaration':
760
- case 'SseDeclaration':
761
- case 'ModelDeclaration':
762
- return this._visitServerNode(node);
763
- case 'AiConfigDeclaration': return; // handled at block level
764
- case 'ClientBlock':
765
- case 'StateDeclaration':
766
- case 'ComputedDeclaration':
767
- case 'EffectDeclaration':
768
- case 'ComponentDeclaration':
769
- case 'StoreDeclaration':
770
- return this._visitClientNode(node);
771
- case 'SharedBlock': return this.visitSharedBlock(node);
772
740
  case 'Assignment': return this.visitAssignment(node);
773
741
  case 'VarDeclaration': return this.visitVarDeclaration(node);
774
742
  case 'LetDestructure': return this.visitLetDestructure(node);
@@ -790,24 +758,7 @@ export class Analyzer {
790
758
  case 'ContinueStatement': return this.visitContinueStatement(node);
791
759
  case 'GuardStatement': return this.visitGuardStatement(node);
792
760
  case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
793
- case 'DataBlock': return this.visitDataBlock(node);
794
- case 'SecurityBlock': return this.visitSecurityBlock(node);
795
- case 'SecurityAuthDeclaration': return;
796
- case 'SecurityRoleDeclaration': return;
797
- case 'SecurityProtectDeclaration': return;
798
- case 'SecuritySensitiveDeclaration': return;
799
- case 'SecurityCorsDeclaration': return;
800
- case 'SecurityCspDeclaration': return;
801
- case 'SecurityRateLimitDeclaration': return;
802
- case 'SecurityCsrfDeclaration': return;
803
- case 'SecurityAuditDeclaration': return;
804
- case 'SourceDeclaration': return;
805
- case 'PipelineDeclaration': return;
806
- case 'ValidateBlock': return;
807
- case 'RefreshPolicy': return;
808
761
  case 'RefinementType': return;
809
- case 'TestBlock': return this.visitTestBlock(node);
810
- case 'BenchBlock': return this.visitTestBlock(node);
811
762
  case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
812
763
  case 'ImplDeclaration': return this.visitImplDeclaration(node);
813
764
  case 'TraitDeclaration': return this.visitTraitDeclaration(node);
@@ -821,19 +772,14 @@ export class Analyzer {
821
772
  }
822
773
 
823
774
  _visitServerNode(node) {
824
- if (!Analyzer.prototype._serverAnalyzerInstalled) {
825
- installServerAnalyzer(Analyzer);
826
- }
827
775
  const methodName = 'visit' + node.type;
828
776
  return this[methodName](node);
829
777
  }
830
778
 
831
- _visitClientNode(node) {
832
- if (!Analyzer.prototype._clientAnalyzerInstalled) {
833
- installClientAnalyzer(Analyzer);
834
- }
835
- const methodName = 'visit' + node.type;
836
- return this[methodName](node);
779
+ _visitBrowserNode(node) {
780
+ // Ensure browser analyzer is installed (may be called from visitExpression for JSX)
781
+ const plugin = BlockRegistry.get('browser');
782
+ return plugin.analyzer.visit(this, node);
837
783
  }
838
784
 
839
785
  visitExpression(node) {
@@ -971,7 +917,7 @@ export class Analyzer {
971
917
  return;
972
918
  case 'JSXElement':
973
919
  case 'JSXFragment':
974
- return this._visitClientNode(node);
920
+ return this._visitBrowserNode(node);
975
921
  // Column expressions (for table operations) — no semantic analysis needed
976
922
  case 'ColumnExpression':
977
923
  return;
@@ -1021,6 +967,296 @@ export class Analyzer {
1021
967
  }
1022
968
  }
1023
969
 
970
+ visitCliBlock(node) {
971
+ const validKeys = new Set(['name', 'version', 'description']);
972
+
973
+ // Validate config keys
974
+ for (const field of node.config) {
975
+ if (!validKeys.has(field.key)) {
976
+ this.warnings.push({
977
+ message: `Unknown cli config key '${field.key}' — valid keys are: ${[...validKeys].join(', ')}`,
978
+ loc: field.loc,
979
+ code: 'W_UNKNOWN_CLI_CONFIG',
980
+ });
981
+ }
982
+ }
983
+
984
+ // Validate commands
985
+ const commandNames = new Set();
986
+ for (const cmd of node.commands) {
987
+ // Duplicate command names
988
+ if (commandNames.has(cmd.name)) {
989
+ this.warnings.push({
990
+ message: `Duplicate cli command '${cmd.name}'`,
991
+ loc: cmd.loc,
992
+ code: 'W_DUPLICATE_CLI_COMMAND',
993
+ });
994
+ }
995
+ commandNames.add(cmd.name);
996
+
997
+ // Check for positional args after flags
998
+ let seenFlag = false;
999
+ for (const param of cmd.params) {
1000
+ if (param.isFlag) {
1001
+ seenFlag = true;
1002
+ } else if (seenFlag) {
1003
+ this.warnings.push({
1004
+ message: `Positional argument '${param.name}' after flag in command '${cmd.name}' — positionals should come before flags`,
1005
+ loc: param.loc,
1006
+ code: 'W_POSITIONAL_AFTER_FLAG',
1007
+ });
1008
+ }
1009
+ }
1010
+
1011
+ // Visit command body with params in scope
1012
+ this.pushScope('function');
1013
+ for (const param of cmd.params) {
1014
+ this.currentScope.define(param.name,
1015
+ new Symbol(param.name, 'parameter', null, false, param.loc));
1016
+ }
1017
+ this.visitNode(cmd.body);
1018
+ this.popScope();
1019
+ }
1020
+ }
1021
+
1022
+ _validateCliCrossBlock() {
1023
+ const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
1024
+ if (cliBlocks.length === 0) return;
1025
+
1026
+ // Warn if cli + server coexist
1027
+ const hasServer = this.ast.body.some(n => n.type === 'ServerBlock');
1028
+ if (hasServer) {
1029
+ this.warnings.push({
1030
+ message: 'cli {} and server {} blocks in the same file — cli produces a standalone executable, not a web server',
1031
+ loc: cliBlocks[0].loc,
1032
+ code: 'W_CLI_WITH_SERVER',
1033
+ });
1034
+ }
1035
+
1036
+ // Check for missing name across all cli blocks
1037
+ let hasName = false;
1038
+ for (const block of cliBlocks) {
1039
+ for (const field of block.config) {
1040
+ if (field.key === 'name') hasName = true;
1041
+ }
1042
+ }
1043
+ if (!hasName) {
1044
+ this.warnings.push({
1045
+ message: 'cli block has no name: field — consider adding name: "your-tool"',
1046
+ loc: cliBlocks[0].loc,
1047
+ code: 'W_CLI_MISSING_NAME',
1048
+ });
1049
+ }
1050
+ }
1051
+
1052
+ visitEdgeBlock(node) {
1053
+ const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
1054
+ const validConfigKeys = new Set(['target']);
1055
+ const bindingNames = new Set();
1056
+
1057
+ // Binding support matrix per target
1058
+ const BINDING_SUPPORT = {
1059
+ cloudflare: { kv: true, sql: true, storage: true, queue: true },
1060
+ deno: { kv: true, sql: false, storage: false, queue: false },
1061
+ vercel: { kv: false, sql: false, storage: false, queue: false },
1062
+ lambda: { kv: false, sql: false, storage: false, queue: false },
1063
+ bun: { kv: false, sql: true, storage: false, queue: false },
1064
+ };
1065
+
1066
+ // Targets that support schedule/consume/middleware
1067
+ const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
1068
+ const CONSUME_TARGETS = new Set(['cloudflare']);
1069
+
1070
+ // Determine target from config fields
1071
+ let target = 'cloudflare';
1072
+ for (const stmt of node.body) {
1073
+ if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1074
+ target = stmt.value.value;
1075
+ }
1076
+ }
1077
+
1078
+ this.pushScope('edge');
1079
+
1080
+ let kvCount = 0;
1081
+ const queueNames = new Set();
1082
+ const consumers = [];
1083
+
1084
+ for (const stmt of node.body) {
1085
+ // Validate config fields
1086
+ if (stmt.type === 'EdgeConfigField') {
1087
+ if (!validConfigKeys.has(stmt.key)) {
1088
+ this.warnings.push({
1089
+ message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
1090
+ loc: stmt.loc,
1091
+ code: 'W_UNKNOWN_EDGE_CONFIG',
1092
+ });
1093
+ }
1094
+ if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1095
+ if (!validTargets.has(stmt.value.value)) {
1096
+ this.warnings.push({
1097
+ message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
1098
+ loc: stmt.loc,
1099
+ code: 'W_UNKNOWN_EDGE_TARGET',
1100
+ });
1101
+ }
1102
+ }
1103
+ continue;
1104
+ }
1105
+
1106
+ // Check for duplicate binding names
1107
+ if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
1108
+ stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
1109
+ if (bindingNames.has(stmt.name)) {
1110
+ this.warnings.push({
1111
+ message: `Duplicate edge binding '${stmt.name}'`,
1112
+ loc: stmt.loc,
1113
+ code: 'W_DUPLICATE_EDGE_BINDING',
1114
+ });
1115
+ }
1116
+ bindingNames.add(stmt.name);
1117
+ }
1118
+
1119
+ // Track queue names for consume validation
1120
+ if (stmt.type === 'EdgeQueueDeclaration') {
1121
+ queueNames.add(stmt.name);
1122
+ }
1123
+
1124
+ // Check for duplicate env/secret names
1125
+ if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
1126
+ if (bindingNames.has(stmt.name)) {
1127
+ this.warnings.push({
1128
+ message: `Duplicate edge binding '${stmt.name}'`,
1129
+ loc: stmt.loc,
1130
+ code: 'W_DUPLICATE_EDGE_BINDING',
1131
+ });
1132
+ }
1133
+ bindingNames.add(stmt.name);
1134
+ }
1135
+
1136
+ // Unsupported binding warnings (per target)
1137
+ const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
1138
+ if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
1139
+ this.warnings.push({
1140
+ message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1141
+ loc: stmt.loc,
1142
+ code: 'W_UNSUPPORTED_KV',
1143
+ });
1144
+ }
1145
+ if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
1146
+ this.warnings.push({
1147
+ message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1148
+ loc: stmt.loc,
1149
+ code: 'W_UNSUPPORTED_SQL',
1150
+ });
1151
+ }
1152
+ if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
1153
+ this.warnings.push({
1154
+ message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1155
+ loc: stmt.loc,
1156
+ code: 'W_UNSUPPORTED_STORAGE',
1157
+ });
1158
+ }
1159
+ if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
1160
+ this.warnings.push({
1161
+ message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1162
+ loc: stmt.loc,
1163
+ code: 'W_UNSUPPORTED_QUEUE',
1164
+ });
1165
+ }
1166
+
1167
+ // Deno multi-KV warning
1168
+ if (stmt.type === 'EdgeKVDeclaration') {
1169
+ kvCount++;
1170
+ if (kvCount > 1 && target === 'deno') {
1171
+ this.warnings.push({
1172
+ message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
1173
+ loc: stmt.loc,
1174
+ code: 'W_DENO_MULTI_KV',
1175
+ });
1176
+ }
1177
+ }
1178
+
1179
+ // Validate schedule cron expressions + target support
1180
+ if (stmt.type === 'EdgeScheduleDeclaration') {
1181
+ const parts = stmt.cron.split(/\s+/);
1182
+ if (parts.length < 5 || parts.length > 6) {
1183
+ this.warnings.push({
1184
+ message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
1185
+ loc: stmt.loc,
1186
+ code: 'W_INVALID_CRON',
1187
+ });
1188
+ }
1189
+ if (!SCHEDULE_TARGETS.has(target)) {
1190
+ this.warnings.push({
1191
+ message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
1192
+ loc: stmt.loc,
1193
+ code: 'W_UNSUPPORTED_SCHEDULE',
1194
+ });
1195
+ }
1196
+ }
1197
+
1198
+ // Collect consume declarations for post-loop validation
1199
+ if (stmt.type === 'EdgeConsumeDeclaration') {
1200
+ consumers.push(stmt);
1201
+ if (!CONSUME_TARGETS.has(target)) {
1202
+ this.warnings.push({
1203
+ message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
1204
+ loc: stmt.loc,
1205
+ code: 'W_UNSUPPORTED_CONSUME',
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ // Visit child nodes — edge-specific types are noop in the registry,
1211
+ // so explicitly visit bodies that contain statements
1212
+ if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
1213
+ for (const s of stmt.body.body || []) this.visitNode(s);
1214
+ } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
1215
+ this.visitNode(stmt);
1216
+ }
1217
+ }
1218
+
1219
+ // Post-loop: validate consume references a declared queue
1220
+ for (const consumer of consumers) {
1221
+ if (!queueNames.has(consumer.queue)) {
1222
+ this.warnings.push({
1223
+ message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
1224
+ loc: consumer.loc,
1225
+ code: 'W_CONSUME_UNKNOWN_QUEUE',
1226
+ });
1227
+ }
1228
+ }
1229
+
1230
+ // Warn if edge block has no route or schedule handlers
1231
+ const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
1232
+ const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
1233
+ const hasConsumers = consumers.length > 0;
1234
+ if (!hasRoutes && !hasSchedules && !hasConsumers) {
1235
+ this.warnings.push({
1236
+ message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
1237
+ loc: node.loc,
1238
+ code: 'W_EDGE_NO_HANDLERS',
1239
+ });
1240
+ }
1241
+
1242
+ this.popScope();
1243
+ }
1244
+
1245
+ _validateEdgeCrossBlock() {
1246
+ const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
1247
+ if (edgeBlocks.length === 0) return;
1248
+
1249
+ // Warn if edge + cli coexist (cli takes over with earlyReturn)
1250
+ const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
1251
+ if (hasCli) {
1252
+ this.warnings.push({
1253
+ message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
1254
+ loc: edgeBlocks[0].loc,
1255
+ code: 'W_EDGE_WITH_CLI',
1256
+ });
1257
+ }
1258
+ }
1259
+
1024
1260
  _validateSecurityCrossBlock() {
1025
1261
  // Collect ALL security declarations across ALL security blocks in the AST
1026
1262
  const allRoles = new Set();
@@ -1259,7 +1495,7 @@ export class Analyzer {
1259
1495
  }
1260
1496
  }
1261
1497
 
1262
- // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
1498
+ // visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
1263
1499
 
1264
1500
  visitSharedBlock(node) {
1265
1501
  const prevScope = this.currentScope;
@@ -1959,7 +2195,7 @@ export class Analyzer {
1959
2195
  this.visitExpression(node.value);
1960
2196
  }
1961
2197
 
1962
- // Client-specific visitors (visitState, visitComputed, etc.) are in client-analyzer.js (lazy-loaded)
2198
+ // Browser-specific visitors (visitState, visitComputed, etc.) are in browser-analyzer.js (lazy-loaded)
1963
2199
 
1964
2200
  visitTestBlock(node) {
1965
2201
  const prevScope = this.currentScope;
@@ -2324,7 +2560,7 @@ export class Analyzer {
2324
2560
  candidates.push([node.name, typeVariants]);
2325
2561
  }
2326
2562
  }
2327
- if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
2563
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
2328
2564
  this._collectTypeCandidates(node.body, coveredVariants, candidates);
2329
2565
  }
2330
2566
  }
@@ -2419,7 +2655,7 @@ export class Analyzer {
2419
2655
  }
2420
2656
  }
2421
2657
 
2422
- // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in client-analyzer.js (lazy-loaded)
2658
+ // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in browser-analyzer.js (lazy-loaded)
2423
2659
 
2424
2660
  // ─── New feature visitors ─────────────────────────────────
2425
2661
 
@@ -1,15 +1,18 @@
1
- // Client-specific analyzer methods for the Tova language
2
- // Extracted from analyzer.js for lazy loading — only loaded when client { } blocks are encountered.
1
+ // Browser-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when browser { } blocks are encountered.
3
3
 
4
4
  import { Symbol } from './scope.js';
5
+ import { installFormAnalyzer } from './form-analyzer.js';
5
6
 
6
- export function installClientAnalyzer(AnalyzerClass) {
7
- if (AnalyzerClass.prototype._clientAnalyzerInstalled) return;
8
- AnalyzerClass.prototype._clientAnalyzerInstalled = true;
7
+ export function installBrowserAnalyzer(AnalyzerClass) {
8
+ if (AnalyzerClass.prototype._browserAnalyzerInstalled) return;
9
+ AnalyzerClass.prototype._browserAnalyzerInstalled = true;
9
10
 
10
- AnalyzerClass.prototype.visitClientBlock = function(node) {
11
+ installFormAnalyzer(AnalyzerClass);
12
+
13
+ AnalyzerClass.prototype.visitBrowserBlock = function(node) {
11
14
  const prevScope = this.currentScope;
12
- this.currentScope = this.currentScope.child('client');
15
+ this.currentScope = this.currentScope.child('browser');
13
16
  try {
14
17
  for (const stmt of node.body) {
15
18
  this.visitNode(stmt);
@@ -21,8 +24,8 @@ export function installClientAnalyzer(AnalyzerClass) {
21
24
 
22
25
  AnalyzerClass.prototype.visitStateDeclaration = function(node) {
23
26
  const ctx = this.currentScope.getContext();
24
- if (ctx !== 'client') {
25
- this.error(`'state' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
27
+ if (ctx !== 'browser') {
28
+ this.error(`'state' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
26
29
  }
27
30
  try {
28
31
  this.currentScope.define(node.name,
@@ -35,8 +38,8 @@ export function installClientAnalyzer(AnalyzerClass) {
35
38
 
36
39
  AnalyzerClass.prototype.visitComputedDeclaration = function(node) {
37
40
  const ctx = this.currentScope.getContext();
38
- if (ctx !== 'client') {
39
- this.error(`'computed' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
41
+ if (ctx !== 'browser') {
42
+ this.error(`'computed' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
40
43
  }
41
44
  try {
42
45
  this.currentScope.define(node.name,
@@ -49,16 +52,16 @@ export function installClientAnalyzer(AnalyzerClass) {
49
52
 
50
53
  AnalyzerClass.prototype.visitEffectDeclaration = function(node) {
51
54
  const ctx = this.currentScope.getContext();
52
- if (ctx !== 'client') {
53
- this.error(`'effect' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
55
+ if (ctx !== 'browser') {
56
+ this.error(`'effect' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
54
57
  }
55
58
  this.visitNode(node.body);
56
59
  };
57
60
 
58
61
  AnalyzerClass.prototype.visitComponentDeclaration = function(node) {
59
62
  const ctx = this.currentScope.getContext();
60
- if (ctx !== 'client') {
61
- this.error(`'component' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
63
+ if (ctx !== 'browser') {
64
+ this.error(`'component' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
62
65
  }
63
66
  this._checkNamingConvention(node.name, 'component', node.loc);
64
67
  try {
@@ -89,8 +92,8 @@ export function installClientAnalyzer(AnalyzerClass) {
89
92
 
90
93
  AnalyzerClass.prototype.visitStoreDeclaration = function(node) {
91
94
  const ctx = this.currentScope.getContext();
92
- if (ctx !== 'client') {
93
- this.error(`'store' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
95
+ if (ctx !== 'browser') {
96
+ this.error(`'store' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
94
97
  }
95
98
  this._checkNamingConvention(node.name, 'store', node.loc);
96
99
  try {
@@ -0,0 +1,113 @@
1
+ // Form-specific analyzer methods for the Tova language
2
+ // Extracted for lazy loading — only loaded when form { } blocks are encountered.
3
+
4
+ import { Symbol } from './scope.js';
5
+
6
+ export function installFormAnalyzer(AnalyzerClass) {
7
+ if (AnalyzerClass.prototype._formAnalyzerInstalled) return;
8
+ AnalyzerClass.prototype._formAnalyzerInstalled = true;
9
+
10
+ const KNOWN_VALIDATORS = new Set([
11
+ 'required', 'minLength', 'maxLength', 'min', 'max',
12
+ 'pattern', 'email', 'matches', 'oneOf', 'validate',
13
+ ]);
14
+
15
+ AnalyzerClass.prototype.visitFormDeclaration = function(node) {
16
+ const ctx = this.currentScope.getContext();
17
+ if (ctx !== 'browser') {
18
+ this.error(`'form' can only be used inside a browser block or component`, node.loc,
19
+ "move this inside a browser { } block", { code: 'E310' });
20
+ }
21
+
22
+ try {
23
+ this.currentScope.define(node.name,
24
+ new Symbol(node.name, 'form', node.typeAnnotation, false, node.loc));
25
+ } catch (e) {
26
+ this.error(e.message);
27
+ }
28
+
29
+ const prevScope = this.currentScope;
30
+ this.currentScope = this.currentScope.child('form');
31
+ try {
32
+ for (const field of node.fields) { this._visitFormField(field); }
33
+ for (const group of node.groups) { this._visitFormGroup(group); }
34
+ for (const arr of node.arrays) { this._visitFormArray(arr); }
35
+ for (const comp of node.computeds) { this.visitNode(comp); }
36
+ if (node.steps) { this._visitFormSteps(node, node.steps); }
37
+ if (node.onSubmit) { this.visitNode(node.onSubmit); }
38
+ } finally {
39
+ this.currentScope = prevScope;
40
+ }
41
+ };
42
+
43
+ AnalyzerClass.prototype._visitFormField = function(node) {
44
+ try {
45
+ this.currentScope.define(node.name,
46
+ new Symbol(node.name, 'formField', node.typeAnnotation, false, node.loc));
47
+ } catch (e) {
48
+ this.error(e.message);
49
+ }
50
+ if (node.initialValue) {
51
+ this.visitExpression(node.initialValue);
52
+ }
53
+ for (const v of node.validators) {
54
+ if (!KNOWN_VALIDATORS.has(v.name)) {
55
+ this.warn(`Unknown validator '${v.name}'`, v.loc, null, { code: 'W_UNKNOWN_VALIDATOR' });
56
+ }
57
+ for (const arg of v.args) {
58
+ this.visitExpression(arg);
59
+ }
60
+ }
61
+ };
62
+
63
+ AnalyzerClass.prototype._visitFormGroup = function(node) {
64
+ try {
65
+ this.currentScope.define(node.name,
66
+ new Symbol(node.name, 'formGroup', null, false, node.loc));
67
+ } catch (e) {
68
+ this.error(e.message);
69
+ }
70
+ if (node.condition) {
71
+ this.visitExpression(node.condition);
72
+ }
73
+ const prevScope = this.currentScope;
74
+ this.currentScope = this.currentScope.child('block');
75
+ try {
76
+ for (const field of node.fields) { this._visitFormField(field); }
77
+ for (const group of node.groups) { this._visitFormGroup(group); }
78
+ } finally {
79
+ this.currentScope = prevScope;
80
+ }
81
+ };
82
+
83
+ AnalyzerClass.prototype._visitFormArray = function(node) {
84
+ try {
85
+ this.currentScope.define(node.name,
86
+ new Symbol(node.name, 'formArray', null, false, node.loc));
87
+ } catch (e) {
88
+ this.error(e.message);
89
+ }
90
+ const prevScope = this.currentScope;
91
+ this.currentScope = this.currentScope.child('block');
92
+ try {
93
+ for (const field of node.fields) { this._visitFormField(field); }
94
+ } finally {
95
+ this.currentScope = prevScope;
96
+ }
97
+ };
98
+
99
+ AnalyzerClass.prototype._visitFormSteps = function(formNode, stepsNode) {
100
+ const knownMembers = new Set();
101
+ for (const f of formNode.fields) knownMembers.add(f.name);
102
+ for (const g of formNode.groups) knownMembers.add(g.name);
103
+ for (const a of formNode.arrays) knownMembers.add(a.name);
104
+
105
+ for (const step of stepsNode.steps) {
106
+ for (const member of step.members) {
107
+ if (!knownMembers.has(member)) {
108
+ this.warn(`Step '${step.label}' references unknown member '${member}'`, step.loc, null, { code: 'W_STEP_UNKNOWN_MEMBER' });
109
+ }
110
+ }
111
+ }
112
+ };
113
+ }