tova 0.5.1 → 0.8.2
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 +261 -60
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +351 -11
- package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/analyzer/form-analyzer.js +113 -0
- package/src/analyzer/scope.js +2 -2
- package/src/codegen/base-codegen.js +1160 -10
- package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
- package/src/codegen/codegen.js +119 -28
- package/src/codegen/deploy-codegen.js +49 -0
- 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/codegen/shared-codegen.js +5 -0
- package/src/codegen/wasm-codegen.js +6 -0
- package/src/config/edit-toml.js +6 -2
- package/src/config/git-resolver.js +128 -0
- package/src/config/lock-file.js +57 -0
- package/src/config/module-cache.js +58 -0
- package/src/config/module-entry.js +37 -0
- package/src/config/module-path.js +31 -0
- package/src/config/pkg-errors.js +62 -0
- package/src/config/resolve.js +17 -0
- package/src/config/resolver.js +139 -0
- package/src/config/search.js +28 -0
- package/src/config/semver.js +72 -0
- package/src/config/toml.js +48 -5
- package/src/deploy/deploy.js +217 -0
- package/src/deploy/infer.js +218 -0
- package/src/deploy/provision.js +311 -0
- 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 +483 -1
- package/src/parser/ast.js +60 -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/concurrency-ast.js +15 -0
- package/src/parser/concurrency-parser.js +236 -0
- package/src/parser/deploy-ast.js +37 -0
- package/src/parser/deploy-parser.js +132 -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 +82 -14
- package/src/parser/select-ast.js +39 -0
- package/src/registry/plugins/browser-plugin.js +30 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/plugins/edge-plugin.js +32 -0
- package/src/registry/register-all.js +8 -2
- package/src/runtime/ssr.js +2 -2
- package/src/stdlib/inline.js +38 -6
- package/src/stdlib/runtime-bridge.js +152 -0
- package/src/version.js +1 -1
- package/src/registry/plugins/client-plugin.js +0 -30
package/src/analyzer/analyzer.js
CHANGED
|
@@ -97,7 +97,7 @@ function levenshtein(a, b) {
|
|
|
97
97
|
|
|
98
98
|
const _TOVA_RUNTIME = new Set([
|
|
99
99
|
'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
|
|
100
|
-
'db', 'server', 'client', 'shared',
|
|
100
|
+
'db', 'server', 'browser', 'client', 'shared',
|
|
101
101
|
]);
|
|
102
102
|
|
|
103
103
|
// Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
|
|
@@ -776,9 +776,9 @@ export class Analyzer {
|
|
|
776
776
|
return this[methodName](node);
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
-
|
|
780
|
-
// Ensure
|
|
781
|
-
const plugin = BlockRegistry.get('
|
|
779
|
+
_visitBrowserNode(node) {
|
|
780
|
+
// Ensure browser analyzer is installed (may be called from visitExpression for JSX)
|
|
781
|
+
const plugin = BlockRegistry.get('browser');
|
|
782
782
|
return plugin.analyzer.visit(this, node);
|
|
783
783
|
}
|
|
784
784
|
|
|
@@ -900,6 +900,19 @@ export class Analyzer {
|
|
|
900
900
|
}
|
|
901
901
|
this.visitExpression(node.argument);
|
|
902
902
|
return;
|
|
903
|
+
case 'SpawnExpression':
|
|
904
|
+
if (!this._concurrentDepth) {
|
|
905
|
+
this.warn("'spawn' should be used inside a 'concurrent' block", node.loc, null, {
|
|
906
|
+
code: 'W_SPAWN_OUTSIDE_CONCURRENT',
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (node.callee) this.visitExpression(node.callee);
|
|
910
|
+
if (node.arguments) {
|
|
911
|
+
for (const arg of node.arguments) {
|
|
912
|
+
this.visitExpression(arg);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
903
916
|
case 'YieldExpression':
|
|
904
917
|
if (node.argument) this.visitExpression(node.argument);
|
|
905
918
|
return;
|
|
@@ -917,7 +930,7 @@ export class Analyzer {
|
|
|
917
930
|
return;
|
|
918
931
|
case 'JSXElement':
|
|
919
932
|
case 'JSXFragment':
|
|
920
|
-
return this.
|
|
933
|
+
return this._visitBrowserNode(node);
|
|
921
934
|
// Column expressions (for table operations) — no semantic analysis needed
|
|
922
935
|
case 'ColumnExpression':
|
|
923
936
|
return;
|
|
@@ -1019,6 +1032,123 @@ export class Analyzer {
|
|
|
1019
1032
|
}
|
|
1020
1033
|
}
|
|
1021
1034
|
|
|
1035
|
+
visitConcurrentBlock(node) {
|
|
1036
|
+
// Validate mode
|
|
1037
|
+
const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
|
|
1038
|
+
if (!validModes.has(node.mode)) {
|
|
1039
|
+
this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
|
|
1040
|
+
code: 'W_UNKNOWN_CONCURRENT_MODE',
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Validate timeout
|
|
1045
|
+
if (node.mode === 'timeout' && !node.timeout) {
|
|
1046
|
+
this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
|
|
1047
|
+
code: 'W_MISSING_TIMEOUT',
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Warn on empty block
|
|
1052
|
+
if (node.body.length === 0) {
|
|
1053
|
+
this.warn("Empty concurrent block", node.loc, null, {
|
|
1054
|
+
code: 'W_EMPTY_CONCURRENT',
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Track concurrent depth for spawn validation
|
|
1059
|
+
this._concurrentDepth = (this._concurrentDepth || 0) + 1;
|
|
1060
|
+
|
|
1061
|
+
// Visit body statements (concurrent block does NOT create a new scope —
|
|
1062
|
+
// variables assigned inside should be visible after the block)
|
|
1063
|
+
for (const stmt of node.body) {
|
|
1064
|
+
this.visitNode(stmt);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
|
|
1068
|
+
let hasWasm = false;
|
|
1069
|
+
let hasNonWasm = false;
|
|
1070
|
+
for (const stmt of node.body) {
|
|
1071
|
+
const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
|
|
1072
|
+
? stmt.values[0]
|
|
1073
|
+
: (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
|
|
1074
|
+
? stmt.expression
|
|
1075
|
+
: null;
|
|
1076
|
+
if (!spawn) continue;
|
|
1077
|
+
const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
|
|
1078
|
+
if (calleeName) {
|
|
1079
|
+
const sym = this.currentScope.lookup(calleeName);
|
|
1080
|
+
if (sym && sym.isWasm) {
|
|
1081
|
+
hasWasm = true;
|
|
1082
|
+
} else {
|
|
1083
|
+
hasNonWasm = true;
|
|
1084
|
+
}
|
|
1085
|
+
} else {
|
|
1086
|
+
// Lambda or complex expression — always non-WASM
|
|
1087
|
+
hasNonWasm = true;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (hasWasm && hasNonWasm) {
|
|
1091
|
+
this.warn(
|
|
1092
|
+
"concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
|
|
1093
|
+
node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
this._concurrentDepth--;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
visitSelectStatement(node) {
|
|
1101
|
+
if (node.cases.length === 0) {
|
|
1102
|
+
this.warn("Empty select block", node.loc, null, {
|
|
1103
|
+
code: 'W_EMPTY_SELECT',
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
let defaultCount = 0;
|
|
1108
|
+
let timeoutCount = 0;
|
|
1109
|
+
for (const c of node.cases) {
|
|
1110
|
+
if (c.kind === 'default') defaultCount++;
|
|
1111
|
+
if (c.kind === 'timeout') timeoutCount++;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (defaultCount > 1) {
|
|
1115
|
+
this.warn("select block has multiple default cases", node.loc, null, {
|
|
1116
|
+
code: 'W_DUPLICATE_SELECT_DEFAULT',
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
if (timeoutCount > 1) {
|
|
1120
|
+
this.warn("select block has multiple timeout cases", node.loc, null, {
|
|
1121
|
+
code: 'W_DUPLICATE_SELECT_TIMEOUT',
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
if (defaultCount > 0 && timeoutCount > 0) {
|
|
1125
|
+
this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
|
|
1126
|
+
code: 'W_SELECT_DEFAULT_TIMEOUT',
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Visit each case's expressions and body
|
|
1131
|
+
for (const c of node.cases) {
|
|
1132
|
+
if (c.channel) this.visitNode(c.channel);
|
|
1133
|
+
if (c.value) this.visitNode(c.value);
|
|
1134
|
+
|
|
1135
|
+
if (c.kind === 'receive' && c.binding) {
|
|
1136
|
+
// Create scope for the binding variable
|
|
1137
|
+
this.pushScope('select-case');
|
|
1138
|
+
this.currentScope.define(c.binding,
|
|
1139
|
+
new Symbol(c.binding, 'variable', null, false, c.loc));
|
|
1140
|
+
for (const stmt of c.body) {
|
|
1141
|
+
this.visitNode(stmt);
|
|
1142
|
+
}
|
|
1143
|
+
this.popScope();
|
|
1144
|
+
} else {
|
|
1145
|
+
for (const stmt of c.body) {
|
|
1146
|
+
this.visitNode(stmt);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1022
1152
|
_validateCliCrossBlock() {
|
|
1023
1153
|
const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
|
|
1024
1154
|
if (cliBlocks.length === 0) return;
|
|
@@ -1049,6 +1179,214 @@ export class Analyzer {
|
|
|
1049
1179
|
}
|
|
1050
1180
|
}
|
|
1051
1181
|
|
|
1182
|
+
visitEdgeBlock(node) {
|
|
1183
|
+
const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
|
|
1184
|
+
const validConfigKeys = new Set(['target']);
|
|
1185
|
+
const bindingNames = new Set();
|
|
1186
|
+
|
|
1187
|
+
// Binding support matrix per target
|
|
1188
|
+
const BINDING_SUPPORT = {
|
|
1189
|
+
cloudflare: { kv: true, sql: true, storage: true, queue: true },
|
|
1190
|
+
deno: { kv: true, sql: false, storage: false, queue: false },
|
|
1191
|
+
vercel: { kv: false, sql: false, storage: false, queue: false },
|
|
1192
|
+
lambda: { kv: false, sql: false, storage: false, queue: false },
|
|
1193
|
+
bun: { kv: false, sql: true, storage: false, queue: false },
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// Targets that support schedule/consume/middleware
|
|
1197
|
+
const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
|
|
1198
|
+
const CONSUME_TARGETS = new Set(['cloudflare']);
|
|
1199
|
+
|
|
1200
|
+
// Determine target from config fields
|
|
1201
|
+
let target = 'cloudflare';
|
|
1202
|
+
for (const stmt of node.body) {
|
|
1203
|
+
if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
|
|
1204
|
+
target = stmt.value.value;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
this.pushScope('edge');
|
|
1209
|
+
|
|
1210
|
+
let kvCount = 0;
|
|
1211
|
+
const queueNames = new Set();
|
|
1212
|
+
const consumers = [];
|
|
1213
|
+
|
|
1214
|
+
for (const stmt of node.body) {
|
|
1215
|
+
// Validate config fields
|
|
1216
|
+
if (stmt.type === 'EdgeConfigField') {
|
|
1217
|
+
if (!validConfigKeys.has(stmt.key)) {
|
|
1218
|
+
this.warnings.push({
|
|
1219
|
+
message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
|
|
1220
|
+
loc: stmt.loc,
|
|
1221
|
+
code: 'W_UNKNOWN_EDGE_CONFIG',
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
|
|
1225
|
+
if (!validTargets.has(stmt.value.value)) {
|
|
1226
|
+
this.warnings.push({
|
|
1227
|
+
message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
|
|
1228
|
+
loc: stmt.loc,
|
|
1229
|
+
code: 'W_UNKNOWN_EDGE_TARGET',
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Check for duplicate binding names
|
|
1237
|
+
if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
|
|
1238
|
+
stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
|
|
1239
|
+
if (bindingNames.has(stmt.name)) {
|
|
1240
|
+
this.warnings.push({
|
|
1241
|
+
message: `Duplicate edge binding '${stmt.name}'`,
|
|
1242
|
+
loc: stmt.loc,
|
|
1243
|
+
code: 'W_DUPLICATE_EDGE_BINDING',
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
bindingNames.add(stmt.name);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Track queue names for consume validation
|
|
1250
|
+
if (stmt.type === 'EdgeQueueDeclaration') {
|
|
1251
|
+
queueNames.add(stmt.name);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Check for duplicate env/secret names
|
|
1255
|
+
if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
|
|
1256
|
+
if (bindingNames.has(stmt.name)) {
|
|
1257
|
+
this.warnings.push({
|
|
1258
|
+
message: `Duplicate edge binding '${stmt.name}'`,
|
|
1259
|
+
loc: stmt.loc,
|
|
1260
|
+
code: 'W_DUPLICATE_EDGE_BINDING',
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
bindingNames.add(stmt.name);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Unsupported binding warnings (per target)
|
|
1267
|
+
const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
|
|
1268
|
+
if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
|
|
1269
|
+
this.warnings.push({
|
|
1270
|
+
message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1271
|
+
loc: stmt.loc,
|
|
1272
|
+
code: 'W_UNSUPPORTED_KV',
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
|
|
1276
|
+
this.warnings.push({
|
|
1277
|
+
message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1278
|
+
loc: stmt.loc,
|
|
1279
|
+
code: 'W_UNSUPPORTED_SQL',
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
|
|
1283
|
+
this.warnings.push({
|
|
1284
|
+
message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1285
|
+
loc: stmt.loc,
|
|
1286
|
+
code: 'W_UNSUPPORTED_STORAGE',
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
|
|
1290
|
+
this.warnings.push({
|
|
1291
|
+
message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1292
|
+
loc: stmt.loc,
|
|
1293
|
+
code: 'W_UNSUPPORTED_QUEUE',
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Deno multi-KV warning
|
|
1298
|
+
if (stmt.type === 'EdgeKVDeclaration') {
|
|
1299
|
+
kvCount++;
|
|
1300
|
+
if (kvCount > 1 && target === 'deno') {
|
|
1301
|
+
this.warnings.push({
|
|
1302
|
+
message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
|
|
1303
|
+
loc: stmt.loc,
|
|
1304
|
+
code: 'W_DENO_MULTI_KV',
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Validate schedule cron expressions + target support
|
|
1310
|
+
if (stmt.type === 'EdgeScheduleDeclaration') {
|
|
1311
|
+
const parts = stmt.cron.split(/\s+/);
|
|
1312
|
+
if (parts.length < 5 || parts.length > 6) {
|
|
1313
|
+
this.warnings.push({
|
|
1314
|
+
message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
|
|
1315
|
+
loc: stmt.loc,
|
|
1316
|
+
code: 'W_INVALID_CRON',
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
if (!SCHEDULE_TARGETS.has(target)) {
|
|
1320
|
+
this.warnings.push({
|
|
1321
|
+
message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
|
|
1322
|
+
loc: stmt.loc,
|
|
1323
|
+
code: 'W_UNSUPPORTED_SCHEDULE',
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Collect consume declarations for post-loop validation
|
|
1329
|
+
if (stmt.type === 'EdgeConsumeDeclaration') {
|
|
1330
|
+
consumers.push(stmt);
|
|
1331
|
+
if (!CONSUME_TARGETS.has(target)) {
|
|
1332
|
+
this.warnings.push({
|
|
1333
|
+
message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
|
|
1334
|
+
loc: stmt.loc,
|
|
1335
|
+
code: 'W_UNSUPPORTED_CONSUME',
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Visit child nodes — edge-specific types are noop in the registry,
|
|
1341
|
+
// so explicitly visit bodies that contain statements
|
|
1342
|
+
if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
|
|
1343
|
+
for (const s of stmt.body.body || []) this.visitNode(s);
|
|
1344
|
+
} else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
|
|
1345
|
+
this.visitNode(stmt);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Post-loop: validate consume references a declared queue
|
|
1350
|
+
for (const consumer of consumers) {
|
|
1351
|
+
if (!queueNames.has(consumer.queue)) {
|
|
1352
|
+
this.warnings.push({
|
|
1353
|
+
message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
|
|
1354
|
+
loc: consumer.loc,
|
|
1355
|
+
code: 'W_CONSUME_UNKNOWN_QUEUE',
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Warn if edge block has no route or schedule handlers
|
|
1361
|
+
const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
|
|
1362
|
+
const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
|
|
1363
|
+
const hasConsumers = consumers.length > 0;
|
|
1364
|
+
if (!hasRoutes && !hasSchedules && !hasConsumers) {
|
|
1365
|
+
this.warnings.push({
|
|
1366
|
+
message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
|
|
1367
|
+
loc: node.loc,
|
|
1368
|
+
code: 'W_EDGE_NO_HANDLERS',
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
this.popScope();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
_validateEdgeCrossBlock() {
|
|
1376
|
+
const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
|
|
1377
|
+
if (edgeBlocks.length === 0) return;
|
|
1378
|
+
|
|
1379
|
+
// Warn if edge + cli coexist (cli takes over with earlyReturn)
|
|
1380
|
+
const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
|
|
1381
|
+
if (hasCli) {
|
|
1382
|
+
this.warnings.push({
|
|
1383
|
+
message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
|
|
1384
|
+
loc: edgeBlocks[0].loc,
|
|
1385
|
+
code: 'W_EDGE_WITH_CLI',
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1052
1390
|
_validateSecurityCrossBlock() {
|
|
1053
1391
|
// Collect ALL security declarations across ALL security blocks in the AST
|
|
1054
1392
|
const allRoles = new Set();
|
|
@@ -1287,7 +1625,7 @@ export class Analyzer {
|
|
|
1287
1625
|
}
|
|
1288
1626
|
}
|
|
1289
1627
|
|
|
1290
|
-
//
|
|
1628
|
+
// visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
|
|
1291
1629
|
|
|
1292
1630
|
visitSharedBlock(node) {
|
|
1293
1631
|
const prevScope = this.currentScope;
|
|
@@ -1406,9 +1744,10 @@ export class Analyzer {
|
|
|
1406
1744
|
} else if (node.pattern.type === 'ArrayPattern' || node.pattern.type === 'TuplePattern') {
|
|
1407
1745
|
for (const el of node.pattern.elements) {
|
|
1408
1746
|
if (el) {
|
|
1747
|
+
const varName = el.startsWith('...') ? el.slice(3) : el;
|
|
1409
1748
|
try {
|
|
1410
|
-
this.currentScope.define(
|
|
1411
|
-
new Symbol(
|
|
1749
|
+
this.currentScope.define(varName,
|
|
1750
|
+
new Symbol(varName, 'variable', null, false, node.loc));
|
|
1412
1751
|
} catch (e) {
|
|
1413
1752
|
this.error(e.message);
|
|
1414
1753
|
}
|
|
@@ -1426,6 +1765,7 @@ export class Analyzer {
|
|
|
1426
1765
|
sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
|
|
1427
1766
|
sym._typeParams = node.typeParams || [];
|
|
1428
1767
|
sym.isPublic = node.isPublic || false;
|
|
1768
|
+
sym.isWasm = !!(node.decorators && node.decorators.some(d => d.name === 'wasm'));
|
|
1429
1769
|
this.currentScope.define(node.name, sym);
|
|
1430
1770
|
} catch (e) {
|
|
1431
1771
|
this.error(e.message);
|
|
@@ -1987,7 +2327,7 @@ export class Analyzer {
|
|
|
1987
2327
|
this.visitExpression(node.value);
|
|
1988
2328
|
}
|
|
1989
2329
|
|
|
1990
|
-
//
|
|
2330
|
+
// Browser-specific visitors (visitState, visitComputed, etc.) are in browser-analyzer.js (lazy-loaded)
|
|
1991
2331
|
|
|
1992
2332
|
visitTestBlock(node) {
|
|
1993
2333
|
const prevScope = this.currentScope;
|
|
@@ -2352,7 +2692,7 @@ export class Analyzer {
|
|
|
2352
2692
|
candidates.push([node.name, typeVariants]);
|
|
2353
2693
|
}
|
|
2354
2694
|
}
|
|
2355
|
-
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === '
|
|
2695
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
|
|
2356
2696
|
this._collectTypeCandidates(node.body, coveredVariants, candidates);
|
|
2357
2697
|
}
|
|
2358
2698
|
}
|
|
@@ -2447,7 +2787,7 @@ export class Analyzer {
|
|
|
2447
2787
|
}
|
|
2448
2788
|
}
|
|
2449
2789
|
|
|
2450
|
-
// visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in
|
|
2790
|
+
// visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in browser-analyzer.js (lazy-loaded)
|
|
2451
2791
|
|
|
2452
2792
|
// ─── New feature visitors ─────────────────────────────────
|
|
2453
2793
|
|
|
@@ -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,44 @@
|
|
|
1
|
+
// Deploy-specific analyzer methods for the Tova language
|
|
2
|
+
// Extracted from analyzer.js for lazy loading — only loaded when deploy { } blocks are encountered.
|
|
3
|
+
|
|
4
|
+
const KNOWN_DEPLOY_FIELDS = new Set([
|
|
5
|
+
'server', 'domain', 'instances', 'memory', 'branch',
|
|
6
|
+
'health', 'health_interval', 'health_timeout',
|
|
7
|
+
'restart_on_failure', 'keep_releases',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const REQUIRED_DEPLOY_FIELDS = ['server', 'domain'];
|
|
11
|
+
|
|
12
|
+
export function installDeployAnalyzer(AnalyzerClass) {
|
|
13
|
+
if (AnalyzerClass.prototype._deployAnalyzerInstalled) return;
|
|
14
|
+
AnalyzerClass.prototype._deployAnalyzerInstalled = true;
|
|
15
|
+
|
|
16
|
+
AnalyzerClass.prototype.visitDeployBlock = function(node) {
|
|
17
|
+
// Collect config field keys present in the deploy block body
|
|
18
|
+
const presentFields = new Set();
|
|
19
|
+
for (const stmt of node.body) {
|
|
20
|
+
if (stmt.type === 'DeployConfigField') {
|
|
21
|
+
// Validate unknown fields
|
|
22
|
+
if (!KNOWN_DEPLOY_FIELDS.has(stmt.key)) {
|
|
23
|
+
this.error(
|
|
24
|
+
`Unknown deploy config field "${stmt.key}"`,
|
|
25
|
+
stmt.loc,
|
|
26
|
+
`Known fields: ${[...KNOWN_DEPLOY_FIELDS].join(', ')}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
presentFields.add(stmt.key);
|
|
30
|
+
}
|
|
31
|
+
// DeployEnvBlock and DeployDbBlock are valid sub-blocks — no additional validation needed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Validate required fields
|
|
35
|
+
for (const required of REQUIRED_DEPLOY_FIELDS) {
|
|
36
|
+
if (!presentFields.has(required)) {
|
|
37
|
+
this.error(
|
|
38
|
+
`Deploy block "${node.name}" is missing required field "${required}"`,
|
|
39
|
+
node.loc
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|