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.
- package/bin/tova.js +109 -57
- package/package.json +7 -2
- package/src/analyzer/analyzer.js +315 -79
- package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
- package/src/analyzer/form-analyzer.js +113 -0
- package/src/analyzer/scope.js +2 -2
- package/src/codegen/base-codegen.js +1 -0
- package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
- package/src/codegen/cli-codegen.js +386 -0
- package/src/codegen/codegen.js +163 -45
- package/src/codegen/edge-codegen.js +1351 -0
- package/src/codegen/form-codegen.js +553 -0
- package/src/codegen/security-codegen.js +5 -5
- package/src/codegen/server-codegen.js +88 -7
- package/src/diagnostics/error-codes.js +1 -1
- package/src/docs/generator.js +1 -1
- package/src/formatter/formatter.js +4 -4
- package/src/lexer/tokens.js +12 -2
- package/src/lsp/server.js +1 -1
- package/src/parser/ast.js +45 -5
- package/src/parser/{client-ast.js → browser-ast.js} +3 -3
- package/src/parser/{client-parser.js → browser-parser.js} +42 -15
- package/src/parser/cli-ast.js +35 -0
- package/src/parser/cli-parser.js +140 -0
- package/src/parser/edge-ast.js +83 -0
- package/src/parser/edge-parser.js +262 -0
- package/src/parser/form-ast.js +80 -0
- package/src/parser/form-parser.js +206 -0
- package/src/parser/parser.js +86 -53
- package/src/registry/block-registry.js +56 -0
- package/src/registry/plugins/bench-plugin.js +23 -0
- package/src/registry/plugins/browser-plugin.js +30 -0
- package/src/registry/plugins/cli-plugin.js +24 -0
- package/src/registry/plugins/data-plugin.js +21 -0
- package/src/registry/plugins/edge-plugin.js +32 -0
- package/src/registry/plugins/security-plugin.js +27 -0
- package/src/registry/plugins/server-plugin.js +46 -0
- package/src/registry/plugins/shared-plugin.js +17 -0
- package/src/registry/plugins/test-plugin.js +23 -0
- package/src/registry/register-all.js +25 -0
- package/src/runtime/ssr.js +2 -2
- package/src/stdlib/inline.js +178 -3
- package/src/version.js +1 -1
package/src/analyzer/analyzer.js
CHANGED
|
@@ -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 {
|
|
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
|
|
331
|
-
const
|
|
332
|
-
|
|
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
|
-
//
|
|
342
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 === '
|
|
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
|
|
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
|
-
//
|
|
2
|
-
// Extracted from analyzer.js for lazy loading — only loaded when
|
|
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
|
|
7
|
-
if (AnalyzerClass.prototype.
|
|
8
|
-
AnalyzerClass.prototype.
|
|
7
|
+
export function installBrowserAnalyzer(AnalyzerClass) {
|
|
8
|
+
if (AnalyzerClass.prototype._browserAnalyzerInstalled) return;
|
|
9
|
+
AnalyzerClass.prototype._browserAnalyzerInstalled = true;
|
|
9
10
|
|
|
10
|
-
AnalyzerClass
|
|
11
|
+
installFormAnalyzer(AnalyzerClass);
|
|
12
|
+
|
|
13
|
+
AnalyzerClass.prototype.visitBrowserBlock = function(node) {
|
|
11
14
|
const prevScope = this.currentScope;
|
|
12
|
-
this.currentScope = this.currentScope.child('
|
|
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 !== '
|
|
25
|
-
this.error(`'state' can only be used inside a
|
|
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 !== '
|
|
39
|
-
this.error(`'computed' can only be used inside a
|
|
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 !== '
|
|
53
|
-
this.error(`'effect' can only be used inside a
|
|
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 !== '
|
|
61
|
-
this.error(`'component' can only be used inside a
|
|
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 !== '
|
|
93
|
-
this.error(`'store' can only be used inside a
|
|
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
|
+
}
|