tova 0.4.7 → 0.5.1

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 CHANGED
@@ -36,9 +36,9 @@ const color = {
36
36
  };
37
37
 
38
38
  const HELP = `
39
- ╦ ╦═╗ ╦
40
- ║ ╠╣
41
- ╩═╝╚═╝╩═╝╩ v${VERSION}
39
+ ╔╦╗╔═╗╦ ╦╔═╗
40
+ ║ ║ ║╚╗╔╝╠═╣
41
+ ╩ ╚═╝ ╚╝ ╩ ╩ v${VERSION}
42
42
 
43
43
  Created by Enoch Kujem Abassey
44
44
  A modern full-stack language that transpiles to JavaScript
@@ -601,6 +601,18 @@ async function runFile(filePath, options = {}) {
601
601
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
602
602
  const stdlib = getRunStdlib();
603
603
 
604
+ // CLI mode: execute the cli code directly
605
+ if (output.isCli) {
606
+ let code = stdlib + '\n' + output.cli;
607
+ code = code.replace(/^export /gm, '');
608
+ // Override process.argv for cli dispatch
609
+ const scriptArgs = options.scriptArgs || [];
610
+ code = `process.argv = ["node", ${JSON.stringify(resolved)}, ...${JSON.stringify(scriptArgs)}];\n` + code;
611
+ const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
612
+ await fn(scriptArgs, resolved, dirname(resolved));
613
+ return;
614
+ }
615
+
604
616
  // Compile .tova dependencies and inline them
605
617
  let depCode = '';
606
618
  if (hasTovaImports) {
@@ -766,8 +778,29 @@ async function buildProject(args) {
766
778
  const outSubDir = dirname(join(outDir, outBaseName));
767
779
  if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
768
780
 
781
+ // CLI files: write single executable <name>.js with shebang
782
+ if (output.isCli) {
783
+ if (output.cli && output.cli.trim()) {
784
+ const cliPath = join(outDir, `${outBaseName}.js`);
785
+ const shebang = '#!/usr/bin/env node\n';
786
+ writeFileSync(cliPath, shebang + output.cli);
787
+ try { chmodSync(cliPath, 0o755); } catch (e) { /* ignore on Windows */ }
788
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', cliPath)} [cli]${timing}`);
789
+ }
790
+ if (!noCache) {
791
+ const outputPaths = {};
792
+ if (output.cli && output.cli.trim()) outputPaths.cli = join(outDir, `${outBaseName}.js`);
793
+ if (single) {
794
+ const absFile = files[0];
795
+ const sourceContent = readFileSync(absFile, 'utf-8');
796
+ buildCache.set(absFile, sourceContent, outputPaths);
797
+ } else {
798
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
799
+ }
800
+ }
801
+ }
769
802
  // Module files: write single <name>.js (not .shared.js)
770
- if (output.isModule) {
803
+ else if (output.isModule) {
771
804
  if (output.shared && output.shared.trim()) {
772
805
  const modulePath = join(outDir, `${outBaseName}.js`);
773
806
  writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
@@ -4655,9 +4688,9 @@ async function infoCommand() {
4655
4688
  const config = resolveConfig(process.cwd());
4656
4689
  const hasTOML = config._source === 'tova.toml';
4657
4690
 
4658
- console.log(`\n ╦ ╦═╗ ╦`);
4659
- console.log(` ║ ╠╣`);
4660
- console.log(` ╩═╝╚═╝╩═╝╩ v${VERSION}\n`);
4691
+ console.log(`\n ╔╦╗╔═╗╦ ╦╔═╗`);
4692
+ console.log(` ║ ║ ║╚╗╔╝╠═╣`);
4693
+ console.log(` ╩ ╚═╝ ╚╝ ╩ ╩ v${VERSION}\n`);
4661
4694
 
4662
4695
  // Bun version
4663
4696
  let bunVersion = 'not found';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tova",
3
- "version": "0.4.7",
3
+ "version": "0.5.1",
4
4
  "description": "Tova — a modern programming language that transpiles to JavaScript, unifying frontend and backend",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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,
@@ -327,17 +326,18 @@ 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
 
336
+ // Post-visit cross-block validation
337
+ for (const plugin of BlockRegistry.all()) {
338
+ if (plugin.analyzer?.crossBlockValidate) plugin.analyzer.crossBlockValidate(this);
339
+ }
340
+
341
341
  // Check for unused variables/imports (#9)
342
342
  this._collectAllScopes(this.globalScope);
343
343
  this._checkUnusedSymbols();
@@ -729,43 +729,14 @@ export class Analyzer {
729
729
  visitNode(node) {
730
730
  if (!node) return;
731
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
+
732
739
  switch (node.type) {
733
- case 'ServerBlock':
734
- case 'RouteDeclaration':
735
- case 'MiddlewareDeclaration':
736
- case 'HealthCheckDeclaration':
737
- case 'CorsDeclaration':
738
- case 'ErrorHandlerDeclaration':
739
- case 'WebSocketDeclaration':
740
- case 'StaticDeclaration':
741
- case 'DiscoverDeclaration':
742
- case 'AuthDeclaration':
743
- case 'MaxBodyDeclaration':
744
- case 'RouteGroupDeclaration':
745
- case 'RateLimitDeclaration':
746
- case 'LifecycleHookDeclaration':
747
- case 'SubscribeDeclaration':
748
- case 'EnvDeclaration':
749
- case 'ScheduleDeclaration':
750
- case 'UploadDeclaration':
751
- case 'SessionDeclaration':
752
- case 'DbDeclaration':
753
- case 'TlsDeclaration':
754
- case 'CompressionDeclaration':
755
- case 'BackgroundJobDeclaration':
756
- case 'CacheDeclaration':
757
- case 'SseDeclaration':
758
- case 'ModelDeclaration':
759
- return this._visitServerNode(node);
760
- case 'AiConfigDeclaration': return; // handled at block level
761
- case 'ClientBlock':
762
- case 'StateDeclaration':
763
- case 'ComputedDeclaration':
764
- case 'EffectDeclaration':
765
- case 'ComponentDeclaration':
766
- case 'StoreDeclaration':
767
- return this._visitClientNode(node);
768
- case 'SharedBlock': return this.visitSharedBlock(node);
769
740
  case 'Assignment': return this.visitAssignment(node);
770
741
  case 'VarDeclaration': return this.visitVarDeclaration(node);
771
742
  case 'LetDestructure': return this.visitLetDestructure(node);
@@ -787,14 +758,7 @@ export class Analyzer {
787
758
  case 'ContinueStatement': return this.visitContinueStatement(node);
788
759
  case 'GuardStatement': return this.visitGuardStatement(node);
789
760
  case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
790
- case 'DataBlock': return this.visitDataBlock(node);
791
- case 'SourceDeclaration': return;
792
- case 'PipelineDeclaration': return;
793
- case 'ValidateBlock': return;
794
- case 'RefreshPolicy': return;
795
761
  case 'RefinementType': return;
796
- case 'TestBlock': return this.visitTestBlock(node);
797
- case 'BenchBlock': return this.visitTestBlock(node);
798
762
  case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
799
763
  case 'ImplDeclaration': return this.visitImplDeclaration(node);
800
764
  case 'TraitDeclaration': return this.visitTraitDeclaration(node);
@@ -808,19 +772,14 @@ export class Analyzer {
808
772
  }
809
773
 
810
774
  _visitServerNode(node) {
811
- if (!Analyzer.prototype._serverAnalyzerInstalled) {
812
- installServerAnalyzer(Analyzer);
813
- }
814
775
  const methodName = 'visit' + node.type;
815
776
  return this[methodName](node);
816
777
  }
817
778
 
818
779
  _visitClientNode(node) {
819
- if (!Analyzer.prototype._clientAnalyzerInstalled) {
820
- installClientAnalyzer(Analyzer);
821
- }
822
- const methodName = 'visit' + node.type;
823
- return this[methodName](node);
780
+ // Ensure client analyzer is installed (may be called from visitExpression for JSX)
781
+ const plugin = BlockRegistry.get('client');
782
+ return plugin.analyzer.visit(this, node);
824
783
  }
825
784
 
826
785
  visitExpression(node) {
@@ -991,6 +950,343 @@ export class Analyzer {
991
950
  }
992
951
  }
993
952
 
953
+ visitSecurityBlock(node) {
954
+ // Per-block: only check for duplicate role names within this block
955
+ const localRoles = new Set();
956
+ for (const stmt of node.body) {
957
+ if (stmt.type === 'SecurityRoleDeclaration') {
958
+ if (localRoles.has(stmt.name)) {
959
+ this.warnings.push({
960
+ message: `Duplicate role definition: '${stmt.name}'`,
961
+ loc: stmt.loc,
962
+ code: 'W_DUPLICATE_ROLE',
963
+ });
964
+ }
965
+ localRoles.add(stmt.name);
966
+ }
967
+ }
968
+ }
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
+ _validateSecurityCrossBlock() {
1053
+ // Collect ALL security declarations across ALL security blocks in the AST
1054
+ const allRoles = new Set();
1055
+ const allProtects = [];
1056
+ const allSensitives = [];
1057
+ let hasAuth = false;
1058
+ let hasProtect = false;
1059
+ let authDecl = null;
1060
+ let corsDecl = null;
1061
+ let rateLimitDecl = null;
1062
+ let csrfDecl = null;
1063
+
1064
+ const roleDecls = []; // track all role declarations for cross-block duplicate detection
1065
+ for (const node of this.ast.body) {
1066
+ if (node.type !== 'SecurityBlock') continue;
1067
+ for (const stmt of node.body) {
1068
+ if (stmt.type === 'SecurityRoleDeclaration') {
1069
+ roleDecls.push(stmt);
1070
+ allRoles.add(stmt.name);
1071
+ } else if (stmt.type === 'SecurityProtectDeclaration') {
1072
+ allProtects.push(stmt);
1073
+ hasProtect = true;
1074
+ } else if (stmt.type === 'SecuritySensitiveDeclaration') {
1075
+ allSensitives.push(stmt);
1076
+ } else if (stmt.type === 'SecurityAuthDeclaration') {
1077
+ hasAuth = true;
1078
+ authDecl = stmt;
1079
+ } else if (stmt.type === 'SecurityCorsDeclaration') {
1080
+ corsDecl = stmt;
1081
+ } else if (stmt.type === 'SecurityRateLimitDeclaration') {
1082
+ rateLimitDecl = stmt;
1083
+ } else if (stmt.type === 'SecurityCsrfDeclaration') {
1084
+ csrfDecl = stmt;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ // W_DUPLICATE_ROLE across blocks: detect roles with same name in different security blocks
1090
+ const seenRoleNames = new Map(); // name -> first declaration
1091
+ for (const decl of roleDecls) {
1092
+ const prev = seenRoleNames.get(decl.name);
1093
+ if (prev && prev.loc !== decl.loc) {
1094
+ // Only warn if this is from a different block (same-block dupes handled by visitSecurityBlock)
1095
+ const prevInSameBlock = this.ast.body.some(b =>
1096
+ b.type === 'SecurityBlock' && b.body.includes(prev) && b.body.includes(decl)
1097
+ );
1098
+ if (!prevInSameBlock) {
1099
+ this.warnings.push({
1100
+ message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
1101
+ loc: decl.loc,
1102
+ code: 'W_DUPLICATE_ROLE',
1103
+ });
1104
+ }
1105
+ }
1106
+ seenRoleNames.set(decl.name, decl);
1107
+ }
1108
+
1109
+ // W_UNKNOWN_AUTH_TYPE — validate auth type is a known value
1110
+ if (authDecl && authDecl.authType) {
1111
+ const validAuthTypes = ['jwt', 'api_key'];
1112
+ if (!validAuthTypes.includes(authDecl.authType)) {
1113
+ this.warnings.push({
1114
+ message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
1115
+ loc: authDecl.loc,
1116
+ code: 'W_UNKNOWN_AUTH_TYPE',
1117
+ });
1118
+ }
1119
+ }
1120
+
1121
+ // Fix 2: W_HARDCODED_SECRET — warn if auth secret is a string literal
1122
+ if (authDecl && authDecl.config.secret) {
1123
+ const secretNode = authDecl.config.secret;
1124
+ if (secretNode.type === 'StringLiteral') {
1125
+ this.warnings.push({
1126
+ message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
1127
+ loc: authDecl.loc,
1128
+ code: 'W_HARDCODED_SECRET',
1129
+ });
1130
+ }
1131
+ }
1132
+
1133
+ // Fix 7: W_CORS_WILDCARD — warn if cors origins contains "*"
1134
+ if (corsDecl && corsDecl.config.origins) {
1135
+ const originsNode = corsDecl.config.origins;
1136
+ if (originsNode.elements) {
1137
+ for (const elem of originsNode.elements) {
1138
+ if (elem.type === 'StringLiteral' && elem.value === '*') {
1139
+ this.warnings.push({
1140
+ message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
1141
+ loc: corsDecl.loc,
1142
+ code: 'W_CORS_WILDCARD',
1143
+ });
1144
+ break;
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // W_INVALID_RATE_LIMIT — validate rate limit max/window are positive numbers
1151
+ const _rlNumericValue = (node) => {
1152
+ if (!node) return null;
1153
+ if (node.type === 'NumberLiteral') return node.value;
1154
+ if (node.type === 'UnaryExpression' && node.operator === '-' && node.operand && node.operand.type === 'NumberLiteral') return -node.operand.value;
1155
+ return null;
1156
+ };
1157
+ if (rateLimitDecl && rateLimitDecl.config) {
1158
+ const rlMaxVal = _rlNumericValue(rateLimitDecl.config.max);
1159
+ const rlWindowVal = _rlNumericValue(rateLimitDecl.config.window);
1160
+ if (rlMaxVal !== null && rlMaxVal <= 0) {
1161
+ this.warnings.push({
1162
+ message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
1163
+ loc: rateLimitDecl.loc,
1164
+ code: 'W_INVALID_RATE_LIMIT',
1165
+ });
1166
+ }
1167
+ if (rlWindowVal !== null && rlWindowVal <= 0) {
1168
+ this.warnings.push({
1169
+ message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
1170
+ loc: rateLimitDecl.loc,
1171
+ code: 'W_INVALID_RATE_LIMIT',
1172
+ });
1173
+ }
1174
+ }
1175
+
1176
+ // W_CSRF_DISABLED — warn when CSRF is explicitly disabled
1177
+ if (csrfDecl && csrfDecl.config && csrfDecl.config.enabled) {
1178
+ const enabledNode = csrfDecl.config.enabled;
1179
+ if ((enabledNode.type === 'BooleanLiteral' && enabledNode.value === false) ||
1180
+ (enabledNode.type === 'Identifier' && enabledNode.name === 'false')) {
1181
+ this.warnings.push({
1182
+ message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
1183
+ loc: csrfDecl.loc,
1184
+ code: 'W_CSRF_DISABLED',
1185
+ });
1186
+ }
1187
+ }
1188
+
1189
+ // W_LOCALSTORAGE_TOKEN — warn when auth uses default localStorage storage (XSS-vulnerable)
1190
+ if (authDecl && authDecl.authType === 'jwt') {
1191
+ const storageNode = authDecl.config.storage;
1192
+ const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
1193
+ if (!isCookieAuth) {
1194
+ this.warnings.push({
1195
+ message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
1196
+ loc: authDecl.loc,
1197
+ code: 'W_LOCALSTORAGE_TOKEN',
1198
+ });
1199
+ }
1200
+ }
1201
+
1202
+ // Fix 5: W_INMEMORY_RATELIMIT — warn that rate limiting is in-memory only
1203
+ if (rateLimitDecl) {
1204
+ this.warnings.push({
1205
+ message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
1206
+ loc: rateLimitDecl.loc,
1207
+ code: 'W_INMEMORY_RATELIMIT',
1208
+ });
1209
+ }
1210
+
1211
+ // Fix 6: W_NO_AUTH_RATELIMIT — warn when auth exists but no rate limiting protects against brute-force
1212
+ if (hasAuth && !rateLimitDecl) {
1213
+ const hasAuthRateLimit = allProtects.some(p => p.config && p.config.rate_limit);
1214
+ if (!hasAuthRateLimit) {
1215
+ this.warnings.push({
1216
+ message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
1217
+ loc: authDecl.loc,
1218
+ code: 'W_NO_AUTH_RATELIMIT',
1219
+ });
1220
+ }
1221
+ }
1222
+
1223
+ // Fix 7: W_HASH_NOT_ENFORCED — warn when sensitive declares hash but it's not auto-enforced
1224
+ for (const s of allSensitives) {
1225
+ if (s.config && s.config.hash) {
1226
+ const hashVal = s.config.hash.value || s.config.hash;
1227
+ this.warnings.push({
1228
+ message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
1229
+ loc: s.loc,
1230
+ code: 'W_HASH_NOT_ENFORCED',
1231
+ });
1232
+ }
1233
+ }
1234
+
1235
+ // No security blocks → nothing to validate (but allow auth/cors checks above)
1236
+ if (!hasProtect && allSensitives.length === 0) return;
1237
+
1238
+ // W_PROTECT_WITHOUT_AUTH: protect rules exist but no auth configured
1239
+ if (hasProtect && !hasAuth) {
1240
+ // Find first protect for location
1241
+ this.warnings.push({
1242
+ message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
1243
+ loc: allProtects[0].loc,
1244
+ code: 'W_PROTECT_WITHOUT_AUTH',
1245
+ });
1246
+ }
1247
+
1248
+ // W_UNDEFINED_ROLE: protect rules reference roles not defined anywhere
1249
+ for (const protect of allProtects) {
1250
+ const requireExpr = protect.config.require;
1251
+ if (!requireExpr) {
1252
+ // W_PROTECT_NO_REQUIRE: protect rule has no require key
1253
+ this.warnings.push({
1254
+ message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
1255
+ loc: protect.loc,
1256
+ code: 'W_PROTECT_NO_REQUIRE',
1257
+ });
1258
+ continue;
1259
+ }
1260
+ if (requireExpr.type === 'Identifier' && requireExpr.name !== 'authenticated') {
1261
+ if (!allRoles.has(requireExpr.name)) {
1262
+ this.warnings.push({
1263
+ message: `Protect rule references undefined role '${requireExpr.name}'`,
1264
+ loc: protect.loc,
1265
+ code: 'W_UNDEFINED_ROLE',
1266
+ });
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ // W_UNDEFINED_ROLE: sensitive visible_to references roles not defined anywhere
1272
+ for (const sensitive of allSensitives) {
1273
+ const visibleTo = sensitive.config.visible_to;
1274
+ if (visibleTo && (visibleTo.type === 'ArrayExpression' || visibleTo.type === 'ArrayLiteral')) {
1275
+ for (const elem of visibleTo.elements) {
1276
+ if (elem.type === 'Identifier' && elem.name !== 'self') {
1277
+ if (!allRoles.has(elem.name)) {
1278
+ this.warnings.push({
1279
+ message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
1280
+ loc: sensitive.loc,
1281
+ code: 'W_UNDEFINED_ROLE',
1282
+ });
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+ }
1289
+
994
1290
  // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
995
1291
 
996
1292
  visitSharedBlock(node) {