tova 0.8.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tova.js +1128 -137
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +405 -9
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +137 -13
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +67 -5
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/theme-codegen.js +69 -0
- package/src/config/module-path.js +34 -2
- package/src/config/resolve.js +9 -0
- package/src/config/toml.js +13 -1
- package/src/deploy/provision.js +6 -2
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +15 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/parser.js +21 -2
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +2 -0
- package/src/runtime/charts.js +547 -0
- package/src/runtime/embedded.js +6 -2
- package/src/runtime/reactivity.js +60 -0
- package/src/runtime/router.js +703 -295
- package/src/runtime/table.js +606 -33
- package/src/stdlib/inline.js +330 -7
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- package/src/version.js +1 -1
package/src/analyzer/analyzer.js
CHANGED
|
@@ -98,6 +98,11 @@ function levenshtein(a, b) {
|
|
|
98
98
|
const _TOVA_RUNTIME = new Set([
|
|
99
99
|
'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
|
|
100
100
|
'db', 'server', 'browser', 'client', 'shared',
|
|
101
|
+
// Router globals (injected by browser-codegen when routing is used)
|
|
102
|
+
'createRouter', 'navigate', 'getCurrentRoute', 'getParams', 'getPath',
|
|
103
|
+
'getQuery', 'getMeta', 'defineRoutes', 'onRouteChange',
|
|
104
|
+
'beforeNavigate', 'afterNavigate', 'getRouter', 'resetRouter',
|
|
105
|
+
'Router', 'Outlet', 'Link', 'Redirect', 'lazy',
|
|
101
106
|
]);
|
|
102
107
|
|
|
103
108
|
// Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
|
|
@@ -121,6 +126,7 @@ export class Analyzer {
|
|
|
121
126
|
this.warnings = [];
|
|
122
127
|
this.tolerant = options.tolerant || false;
|
|
123
128
|
this.strict = options.strict || false;
|
|
129
|
+
this.strictSecurity = options.strictSecurity || false;
|
|
124
130
|
this.globalScope = new Scope(null, 'module');
|
|
125
131
|
this.currentScope = this.globalScope;
|
|
126
132
|
this._allScopes = []; // Track all scopes for unused variable checking
|
|
@@ -173,6 +179,17 @@ export class Analyzer {
|
|
|
173
179
|
'table_union', 'table_drop_duplicates', 'table_rename',
|
|
174
180
|
// Table aggregation helpers
|
|
175
181
|
'agg_sum', 'agg_count', 'agg_mean', 'agg_median', 'agg_min', 'agg_max',
|
|
182
|
+
// Table window functions
|
|
183
|
+
'table_window',
|
|
184
|
+
'win_row_number', 'win_rank', 'win_dense_rank', 'win_percent_rank', 'win_ntile',
|
|
185
|
+
'win_lag', 'win_lead', 'win_first_value', 'win_last_value',
|
|
186
|
+
'win_running_sum', 'win_running_count', 'win_running_avg', 'win_running_min', 'win_running_max',
|
|
187
|
+
'win_moving_avg',
|
|
188
|
+
// Window function short names (used inside window() calls, codegen adds win_ prefix)
|
|
189
|
+
'row_number', 'rank', 'dense_rank', 'percent_rank', 'ntile',
|
|
190
|
+
'lag', 'lead', 'first_value', 'last_value',
|
|
191
|
+
'running_sum', 'running_count', 'running_avg', 'running_min', 'running_max',
|
|
192
|
+
'moving_avg',
|
|
176
193
|
// Data exploration
|
|
177
194
|
'peek', 'describe', 'schema_of',
|
|
178
195
|
// Data cleaning
|
|
@@ -184,7 +201,7 @@ export class Analyzer {
|
|
|
184
201
|
// Table operation aliases (short names)
|
|
185
202
|
'where', 'select', 'derive', 'agg', 'sort_by', 'limit',
|
|
186
203
|
'pivot', 'unpivot', 'explode', 'union', 'drop_duplicates', 'rename',
|
|
187
|
-
'mean', 'median',
|
|
204
|
+
'mean', 'median', 'window',
|
|
188
205
|
// Strings (new)
|
|
189
206
|
'index_of', 'last_index_of', 'count_of', 'reverse_str', 'substr',
|
|
190
207
|
'is_empty', 'kebab_case', 'center',
|
|
@@ -261,6 +278,7 @@ export class Analyzer {
|
|
|
261
278
|
if (opts.code) w.code = opts.code;
|
|
262
279
|
if (opts.length) w.length = opts.length;
|
|
263
280
|
if (opts.fix) w.fix = opts.fix;
|
|
281
|
+
if (opts.category) w.category = opts.category;
|
|
264
282
|
this.warnings.push(w);
|
|
265
283
|
}
|
|
266
284
|
|
|
@@ -338,6 +356,15 @@ export class Analyzer {
|
|
|
338
356
|
if (plugin.analyzer?.crossBlockValidate) plugin.analyzer.crossBlockValidate(this);
|
|
339
357
|
}
|
|
340
358
|
|
|
359
|
+
// --strict-security: promote security warnings to errors
|
|
360
|
+
if (this.strictSecurity) {
|
|
361
|
+
const securityWarnings = this.warnings.filter(w => w.category === 'security');
|
|
362
|
+
this.warnings = this.warnings.filter(w => w.category !== 'security');
|
|
363
|
+
for (const w of securityWarnings) {
|
|
364
|
+
this.errors.push(w);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
341
368
|
// Check for unused variables/imports (#9)
|
|
342
369
|
this._collectAllScopes(this.globalScope);
|
|
343
370
|
this._checkUnusedSymbols();
|
|
@@ -353,7 +380,7 @@ export class Analyzer {
|
|
|
353
380
|
throw err;
|
|
354
381
|
}
|
|
355
382
|
|
|
356
|
-
return { warnings: this.warnings, scope: this.globalScope, typeRegistry: this.typeRegistry };
|
|
383
|
+
return { warnings: this.warnings, errors: this.errors, scope: this.globalScope, typeRegistry: this.typeRegistry };
|
|
357
384
|
}
|
|
358
385
|
|
|
359
386
|
_checkUnusedSymbols() {
|
|
@@ -759,7 +786,11 @@ export class Analyzer {
|
|
|
759
786
|
case 'GuardStatement': return this.visitGuardStatement(node);
|
|
760
787
|
case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
|
|
761
788
|
case 'RefinementType': return;
|
|
762
|
-
case 'ComponentStyleBlock':
|
|
789
|
+
case 'ComponentStyleBlock':
|
|
790
|
+
this._validateStyleTokens(node.css, node.loc);
|
|
791
|
+
this._validateResponsiveBreakpoints(node.css, node.loc);
|
|
792
|
+
this._validateVariantProps(node.css, this._currentComponentProps, node.loc);
|
|
793
|
+
return;
|
|
763
794
|
case 'ImplDeclaration': return this.visitImplDeclaration(node);
|
|
764
795
|
case 'TraitDeclaration': return this.visitTraitDeclaration(node);
|
|
765
796
|
case 'TypeAlias': return this.visitTypeAlias(node);
|
|
@@ -818,6 +849,19 @@ export class Analyzer {
|
|
|
818
849
|
this.visitExpression(node.collection);
|
|
819
850
|
return;
|
|
820
851
|
case 'CallExpression':
|
|
852
|
+
// W_DANGEROUS_API: detect setTimeout/setInterval with string args
|
|
853
|
+
if (node.callee.type === 'Identifier') {
|
|
854
|
+
const calleeName = node.callee.name;
|
|
855
|
+
if ((calleeName === 'setTimeout' || calleeName === 'setInterval') &&
|
|
856
|
+
node.arguments.length > 0 && node.arguments[0].type === 'StringLiteral') {
|
|
857
|
+
this.warn(
|
|
858
|
+
`Passing strings to ${calleeName}() executes code dynamically — use a function instead`,
|
|
859
|
+
node.loc,
|
|
860
|
+
'Replace the string argument with a function',
|
|
861
|
+
{ code: 'W_DANGEROUS_API', category: 'security' }
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
821
865
|
// Validate inter-server RPC calls: peerName.functionName()
|
|
822
866
|
if (this._currentServerBlockName && node.callee.type === 'MemberExpression' &&
|
|
823
867
|
node.callee.object.type === 'Identifier' && !node.callee.computed) {
|
|
@@ -832,9 +876,27 @@ export class Analyzer {
|
|
|
832
876
|
}
|
|
833
877
|
}
|
|
834
878
|
}
|
|
879
|
+
// Validate named constructor args (before count/type checks)
|
|
880
|
+
this._checkNamedConstructorArgs(node);
|
|
835
881
|
// Argument count and type validation for known functions
|
|
836
882
|
this._checkCallArgCount(node);
|
|
837
883
|
this._checkCallArgTypes(node);
|
|
884
|
+
// W_UNSAFE_INTERPOLATION: detect template literals in database calls
|
|
885
|
+
if (node.callee.type === 'MemberExpression' && !node.callee.computed) {
|
|
886
|
+
const prop = node.callee.property;
|
|
887
|
+
const dbMethods = new Set(['query', 'run', 'exec', 'execute', 'prepare']);
|
|
888
|
+
if (dbMethods.has(prop) && node.arguments.length > 0) {
|
|
889
|
+
const firstArg = node.arguments[0];
|
|
890
|
+
if (firstArg.type === 'TemplateLiteral' && firstArg.parts && firstArg.parts.some(p => p.type === 'expr')) {
|
|
891
|
+
this.warn(
|
|
892
|
+
'Template literal with expressions in database query — use parameterized queries (?) to prevent SQL injection',
|
|
893
|
+
node.loc,
|
|
894
|
+
'Replace interpolated expressions with ? and pass values as parameters',
|
|
895
|
+
{ code: 'W_UNSAFE_INTERPOLATION', category: 'security' }
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
838
900
|
this.visitExpression(node.callee);
|
|
839
901
|
for (const arg of node.arguments) {
|
|
840
902
|
if (arg.type === 'NamedArgument') {
|
|
@@ -963,6 +1025,210 @@ export class Analyzer {
|
|
|
963
1025
|
}
|
|
964
1026
|
}
|
|
965
1027
|
|
|
1028
|
+
visitThemeBlock(node) {
|
|
1029
|
+
const VALID_THEME_SECTIONS = new Set([
|
|
1030
|
+
'colors', 'spacing', 'radius', 'shadow', 'font', 'breakpoints', 'transition'
|
|
1031
|
+
]);
|
|
1032
|
+
|
|
1033
|
+
// Check for multiple theme blocks
|
|
1034
|
+
if (!this._themeBlockSeen) {
|
|
1035
|
+
this._themeBlockSeen = true;
|
|
1036
|
+
} else {
|
|
1037
|
+
this.warnings.push({
|
|
1038
|
+
message: 'Multiple theme blocks found — only one theme block is allowed per project',
|
|
1039
|
+
loc: node.loc,
|
|
1040
|
+
code: 'W_MULTIPLE_THEME_BLOCKS',
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const CATEGORY_MAP = {
|
|
1045
|
+
colors: 'color', spacing: 'spacing', radius: 'radius',
|
|
1046
|
+
shadow: 'shadow', font: 'font', breakpoints: 'breakpoint', transition: 'transition'
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
// Store tokens for $token validation (used in Task 5)
|
|
1050
|
+
if (!this._themeTokens) this._themeTokens = new Map();
|
|
1051
|
+
this._hasThemeBlock = true;
|
|
1052
|
+
|
|
1053
|
+
const sectionNames = new Set();
|
|
1054
|
+
for (const section of node.sections) {
|
|
1055
|
+
if (!VALID_THEME_SECTIONS.has(section.name)) {
|
|
1056
|
+
this.warnings.push({
|
|
1057
|
+
message: `Unknown theme section '${section.name}' — valid sections are: ${[...VALID_THEME_SECTIONS].join(', ')}`,
|
|
1058
|
+
loc: section.loc,
|
|
1059
|
+
code: 'W_UNKNOWN_THEME_SECTION',
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (sectionNames.has(section.name)) {
|
|
1064
|
+
this.warnings.push({
|
|
1065
|
+
message: `Duplicate theme section '${section.name}'`,
|
|
1066
|
+
loc: section.loc,
|
|
1067
|
+
code: 'W_DUPLICATE_THEME_SECTION',
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
sectionNames.add(section.name);
|
|
1071
|
+
|
|
1072
|
+
// Store tokens for later $token validation
|
|
1073
|
+
const category = CATEGORY_MAP[section.name] || section.name;
|
|
1074
|
+
if (!this._themeTokens.has(category)) this._themeTokens.set(category, new Set());
|
|
1075
|
+
|
|
1076
|
+
const tokenNames = new Set();
|
|
1077
|
+
for (const token of section.tokens) {
|
|
1078
|
+
if (tokenNames.has(token.name)) {
|
|
1079
|
+
this.warnings.push({
|
|
1080
|
+
message: `Duplicate theme token '${token.name}' in section '${section.name}'`,
|
|
1081
|
+
loc: token.loc,
|
|
1082
|
+
code: 'W_DUPLICATE_THEME_TOKEN',
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
tokenNames.add(token.name);
|
|
1086
|
+
this._themeTokens.get(category).add(token.name);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Validate dark overrides
|
|
1091
|
+
for (const override of node.darkOverrides) {
|
|
1092
|
+
const dotIdx = override.name.indexOf('.');
|
|
1093
|
+
if (dotIdx === -1) continue;
|
|
1094
|
+
const sectionName = override.name.slice(0, dotIdx);
|
|
1095
|
+
if (!sectionNames.has(sectionName)) {
|
|
1096
|
+
this.warnings.push({
|
|
1097
|
+
message: `Dark override '${override.name}' references unknown section '${sectionName}'`,
|
|
1098
|
+
loc: override.loc,
|
|
1099
|
+
code: 'W_DARK_OVERRIDE_UNKNOWN_SECTION',
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
_validateStyleTokens(css, loc) {
|
|
1106
|
+
if (!this._hasThemeBlock) return;
|
|
1107
|
+
const VALID_CATEGORIES = new Set(['color', 'spacing', 'radius', 'shadow', 'font', 'transition', 'breakpoint']);
|
|
1108
|
+
const tokenRegex = /\$(\w+)\.([\w.]+)/g;
|
|
1109
|
+
let m;
|
|
1110
|
+
while ((m = tokenRegex.exec(css)) !== null) {
|
|
1111
|
+
const category = m[1];
|
|
1112
|
+
const name = m[2];
|
|
1113
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
1114
|
+
this.warnings.push({
|
|
1115
|
+
message: `Unknown theme category '$${category}' — valid categories are: ${[...VALID_CATEGORIES].join(', ')}`,
|
|
1116
|
+
loc,
|
|
1117
|
+
code: 'W_UNKNOWN_THEME_CATEGORY',
|
|
1118
|
+
});
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const tokenSet = this._themeTokens ? this._themeTokens.get(category) : null;
|
|
1122
|
+
if (!tokenSet || !tokenSet.has(name)) {
|
|
1123
|
+
const available = tokenSet ? [...tokenSet] : [];
|
|
1124
|
+
let suggestion = '';
|
|
1125
|
+
if (available.length > 0) {
|
|
1126
|
+
const closest = this._findClosestThemeMatch(name, available);
|
|
1127
|
+
if (closest) suggestion = ` — did you mean '$${category}.${closest}'?`;
|
|
1128
|
+
}
|
|
1129
|
+
this.warnings.push({
|
|
1130
|
+
message: `Unknown theme token '$${category}.${name}'${suggestion}`,
|
|
1131
|
+
loc,
|
|
1132
|
+
code: 'W_UNKNOWN_THEME_TOKEN',
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
_validateResponsiveBreakpoints(css, loc) {
|
|
1139
|
+
// Find responsive { ... } in raw CSS
|
|
1140
|
+
const responsiveMatch = css.match(/responsive\s*\{/);
|
|
1141
|
+
if (!responsiveMatch) return;
|
|
1142
|
+
|
|
1143
|
+
// Get available breakpoints
|
|
1144
|
+
let availableBreakpoints;
|
|
1145
|
+
if (this._hasThemeBlock && this._themeTokens && this._themeTokens.has('breakpoint')) {
|
|
1146
|
+
availableBreakpoints = this._themeTokens.get('breakpoint');
|
|
1147
|
+
} else if (this._hasThemeBlock) {
|
|
1148
|
+
// Theme exists but no breakpoints section — no defaults
|
|
1149
|
+
availableBreakpoints = new Set();
|
|
1150
|
+
} else {
|
|
1151
|
+
// No theme block — use defaults, skip validation
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Extract breakpoint names from responsive block
|
|
1156
|
+
const startIdx = responsiveMatch.index + responsiveMatch[0].length;
|
|
1157
|
+
let depth = 1;
|
|
1158
|
+
let i = startIdx;
|
|
1159
|
+
while (i < css.length && depth > 0) {
|
|
1160
|
+
if (css[i] === '{') depth++;
|
|
1161
|
+
else if (css[i] === '}') depth--;
|
|
1162
|
+
i++;
|
|
1163
|
+
}
|
|
1164
|
+
const content = css.slice(startIdx, i - 1);
|
|
1165
|
+
|
|
1166
|
+
// Parse top-level identifiers (breakpoint names) before { at depth 0
|
|
1167
|
+
const bpNames = [];
|
|
1168
|
+
let pos = 0;
|
|
1169
|
+
let bpDepth = 0;
|
|
1170
|
+
while (pos < content.length) {
|
|
1171
|
+
if (content[pos] === '{') {
|
|
1172
|
+
bpDepth++;
|
|
1173
|
+
pos++;
|
|
1174
|
+
} else if (content[pos] === '}') {
|
|
1175
|
+
bpDepth--;
|
|
1176
|
+
pos++;
|
|
1177
|
+
} else if (bpDepth === 0 && /[a-zA-Z_]/.test(content[pos])) {
|
|
1178
|
+
let name = '';
|
|
1179
|
+
while (pos < content.length && /\w/.test(content[pos])) {
|
|
1180
|
+
name += content[pos];
|
|
1181
|
+
pos++;
|
|
1182
|
+
}
|
|
1183
|
+
bpNames.push(name);
|
|
1184
|
+
} else {
|
|
1185
|
+
pos++;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
for (const name of bpNames) {
|
|
1190
|
+
if (!availableBreakpoints.has(name)) {
|
|
1191
|
+
this.warnings.push({
|
|
1192
|
+
message: `Unknown breakpoint '${name}' in responsive block — available breakpoints: ${[...availableBreakpoints].join(', ')}`,
|
|
1193
|
+
loc,
|
|
1194
|
+
code: 'W_UNKNOWN_BREAKPOINT',
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
_validateVariantProps(css, componentProps, loc) {
|
|
1201
|
+
if (!componentProps || componentProps.length === 0) return;
|
|
1202
|
+
const variantRegex = /variant\(([^)]+)\)/g;
|
|
1203
|
+
let m;
|
|
1204
|
+
while ((m = variantRegex.exec(css)) !== null) {
|
|
1205
|
+
const rawProps = m[1];
|
|
1206
|
+
// Split by + for compound variants
|
|
1207
|
+
const propNames = rawProps.split('+').map(s => s.trim());
|
|
1208
|
+
for (const propName of propNames) {
|
|
1209
|
+
if (!propName) continue;
|
|
1210
|
+
if (!componentProps.includes(propName)) {
|
|
1211
|
+
this.warn(
|
|
1212
|
+
`variant() references unknown prop '${propName}'. Component has props: [${componentProps.join(', ')}]`,
|
|
1213
|
+
loc,
|
|
1214
|
+
null,
|
|
1215
|
+
{ code: 'W_VARIANT_UNKNOWN_PROP' }
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
_findClosestThemeMatch(input, candidates) {
|
|
1223
|
+
let best = null;
|
|
1224
|
+
let bestDist = Infinity;
|
|
1225
|
+
for (const c of candidates) {
|
|
1226
|
+
const d = levenshtein(input, c);
|
|
1227
|
+
if (d < bestDist && d <= 3) { bestDist = d; best = c; }
|
|
1228
|
+
}
|
|
1229
|
+
return best;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
966
1232
|
visitSecurityBlock(node) {
|
|
967
1233
|
// Per-block: only check for duplicate role names within this block
|
|
968
1234
|
const localRoles = new Set();
|
|
@@ -973,6 +1239,7 @@ export class Analyzer {
|
|
|
973
1239
|
message: `Duplicate role definition: '${stmt.name}'`,
|
|
974
1240
|
loc: stmt.loc,
|
|
975
1241
|
code: 'W_DUPLICATE_ROLE',
|
|
1242
|
+
category: 'security',
|
|
976
1243
|
});
|
|
977
1244
|
}
|
|
978
1245
|
localRoles.add(stmt.name);
|
|
@@ -1388,6 +1655,19 @@ export class Analyzer {
|
|
|
1388
1655
|
}
|
|
1389
1656
|
|
|
1390
1657
|
_validateSecurityCrossBlock() {
|
|
1658
|
+
// W_NO_SECURITY_BLOCK: server/edge block without security block
|
|
1659
|
+
const hasServerOrEdge = this.ast.body.some(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
|
|
1660
|
+
const hasSecurityBlock = this.ast.body.some(n => n.type === 'SecurityBlock');
|
|
1661
|
+
if (hasServerOrEdge && !hasSecurityBlock) {
|
|
1662
|
+
const block = this.ast.body.find(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
|
|
1663
|
+
this.warnings.push({
|
|
1664
|
+
message: 'Server/edge block defined without a security block — consider adding security { ... } for auth, CORS, and CSRF protection',
|
|
1665
|
+
loc: block.loc,
|
|
1666
|
+
code: 'W_NO_SECURITY_BLOCK',
|
|
1667
|
+
category: 'security',
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1391
1671
|
// Collect ALL security declarations across ALL security blocks in the AST
|
|
1392
1672
|
const allRoles = new Set();
|
|
1393
1673
|
const allProtects = [];
|
|
@@ -1438,6 +1718,7 @@ export class Analyzer {
|
|
|
1438
1718
|
message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
|
|
1439
1719
|
loc: decl.loc,
|
|
1440
1720
|
code: 'W_DUPLICATE_ROLE',
|
|
1721
|
+
category: 'security',
|
|
1441
1722
|
});
|
|
1442
1723
|
}
|
|
1443
1724
|
}
|
|
@@ -1452,6 +1733,7 @@ export class Analyzer {
|
|
|
1452
1733
|
message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
|
|
1453
1734
|
loc: authDecl.loc,
|
|
1454
1735
|
code: 'W_UNKNOWN_AUTH_TYPE',
|
|
1736
|
+
category: 'security',
|
|
1455
1737
|
});
|
|
1456
1738
|
}
|
|
1457
1739
|
}
|
|
@@ -1464,6 +1746,7 @@ export class Analyzer {
|
|
|
1464
1746
|
message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
|
|
1465
1747
|
loc: authDecl.loc,
|
|
1466
1748
|
code: 'W_HARDCODED_SECRET',
|
|
1749
|
+
category: 'security',
|
|
1467
1750
|
});
|
|
1468
1751
|
}
|
|
1469
1752
|
}
|
|
@@ -1478,6 +1761,7 @@ export class Analyzer {
|
|
|
1478
1761
|
message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
|
|
1479
1762
|
loc: corsDecl.loc,
|
|
1480
1763
|
code: 'W_CORS_WILDCARD',
|
|
1764
|
+
category: 'security',
|
|
1481
1765
|
});
|
|
1482
1766
|
break;
|
|
1483
1767
|
}
|
|
@@ -1500,6 +1784,7 @@ export class Analyzer {
|
|
|
1500
1784
|
message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
|
|
1501
1785
|
loc: rateLimitDecl.loc,
|
|
1502
1786
|
code: 'W_INVALID_RATE_LIMIT',
|
|
1787
|
+
category: 'security',
|
|
1503
1788
|
});
|
|
1504
1789
|
}
|
|
1505
1790
|
if (rlWindowVal !== null && rlWindowVal <= 0) {
|
|
@@ -1507,6 +1792,7 @@ export class Analyzer {
|
|
|
1507
1792
|
message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
|
|
1508
1793
|
loc: rateLimitDecl.loc,
|
|
1509
1794
|
code: 'W_INVALID_RATE_LIMIT',
|
|
1795
|
+
category: 'security',
|
|
1510
1796
|
});
|
|
1511
1797
|
}
|
|
1512
1798
|
}
|
|
@@ -1520,6 +1806,7 @@ export class Analyzer {
|
|
|
1520
1806
|
message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
|
|
1521
1807
|
loc: csrfDecl.loc,
|
|
1522
1808
|
code: 'W_CSRF_DISABLED',
|
|
1809
|
+
category: 'security',
|
|
1523
1810
|
});
|
|
1524
1811
|
}
|
|
1525
1812
|
}
|
|
@@ -1533,6 +1820,7 @@ export class Analyzer {
|
|
|
1533
1820
|
message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
|
|
1534
1821
|
loc: authDecl.loc,
|
|
1535
1822
|
code: 'W_LOCALSTORAGE_TOKEN',
|
|
1823
|
+
category: 'security',
|
|
1536
1824
|
});
|
|
1537
1825
|
}
|
|
1538
1826
|
}
|
|
@@ -1543,6 +1831,7 @@ export class Analyzer {
|
|
|
1543
1831
|
message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
|
|
1544
1832
|
loc: rateLimitDecl.loc,
|
|
1545
1833
|
code: 'W_INMEMORY_RATELIMIT',
|
|
1834
|
+
category: 'security',
|
|
1546
1835
|
});
|
|
1547
1836
|
}
|
|
1548
1837
|
|
|
@@ -1554,6 +1843,7 @@ export class Analyzer {
|
|
|
1554
1843
|
message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
|
|
1555
1844
|
loc: authDecl.loc,
|
|
1556
1845
|
code: 'W_NO_AUTH_RATELIMIT',
|
|
1846
|
+
category: 'security',
|
|
1557
1847
|
});
|
|
1558
1848
|
}
|
|
1559
1849
|
}
|
|
@@ -1566,6 +1856,7 @@ export class Analyzer {
|
|
|
1566
1856
|
message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
|
|
1567
1857
|
loc: s.loc,
|
|
1568
1858
|
code: 'W_HASH_NOT_ENFORCED',
|
|
1859
|
+
category: 'security',
|
|
1569
1860
|
});
|
|
1570
1861
|
}
|
|
1571
1862
|
}
|
|
@@ -1580,6 +1871,7 @@ export class Analyzer {
|
|
|
1580
1871
|
message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
|
|
1581
1872
|
loc: allProtects[0].loc,
|
|
1582
1873
|
code: 'W_PROTECT_WITHOUT_AUTH',
|
|
1874
|
+
category: 'security',
|
|
1583
1875
|
});
|
|
1584
1876
|
}
|
|
1585
1877
|
|
|
@@ -1592,6 +1884,7 @@ export class Analyzer {
|
|
|
1592
1884
|
message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
|
|
1593
1885
|
loc: protect.loc,
|
|
1594
1886
|
code: 'W_PROTECT_NO_REQUIRE',
|
|
1887
|
+
category: 'security',
|
|
1595
1888
|
});
|
|
1596
1889
|
continue;
|
|
1597
1890
|
}
|
|
@@ -1601,6 +1894,7 @@ export class Analyzer {
|
|
|
1601
1894
|
message: `Protect rule references undefined role '${requireExpr.name}'`,
|
|
1602
1895
|
loc: protect.loc,
|
|
1603
1896
|
code: 'W_UNDEFINED_ROLE',
|
|
1897
|
+
category: 'security',
|
|
1604
1898
|
});
|
|
1605
1899
|
}
|
|
1606
1900
|
}
|
|
@@ -1617,6 +1911,7 @@ export class Analyzer {
|
|
|
1617
1911
|
message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
|
|
1618
1912
|
loc: sensitive.loc,
|
|
1619
1913
|
code: 'W_UNDEFINED_ROLE',
|
|
1914
|
+
category: 'security',
|
|
1620
1915
|
});
|
|
1621
1916
|
}
|
|
1622
1917
|
}
|
|
@@ -1662,6 +1957,15 @@ export class Analyzer {
|
|
|
1662
1957
|
// Complex targets (e.g., arr[i] = val, obj.prop = val) — visit and skip declaration logic
|
|
1663
1958
|
if (typeof target !== 'string') {
|
|
1664
1959
|
this.visitExpression(target);
|
|
1960
|
+
// W_DANGEROUS_API: detect innerHTML assignment
|
|
1961
|
+
if (target.type === 'MemberExpression' && !target.computed && target.property === 'innerHTML') {
|
|
1962
|
+
this.warn(
|
|
1963
|
+
'Direct innerHTML assignment is an XSS risk — use textContent or escape_html()',
|
|
1964
|
+
target.loc || node.loc,
|
|
1965
|
+
'Use el.textContent for plain text, or escape_html(value) for safe HTML rendering',
|
|
1966
|
+
{ code: 'W_DANGEROUS_API', category: 'security' }
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1665
1969
|
continue;
|
|
1666
1970
|
}
|
|
1667
1971
|
|
|
@@ -1917,6 +2221,19 @@ export class Analyzer {
|
|
|
1917
2221
|
}
|
|
1918
2222
|
}
|
|
1919
2223
|
|
|
2224
|
+
// Register struct type metadata (non-variant types)
|
|
2225
|
+
const hasVariants = node.variants.some(v => v.type === 'TypeVariant');
|
|
2226
|
+
if (!hasVariants && node.variants.length > 0) {
|
|
2227
|
+
const typeSym = this.currentScope.lookup(node.name);
|
|
2228
|
+
if (typeSym) {
|
|
2229
|
+
typeSym._params = node.variants.map(f => f.name);
|
|
2230
|
+
typeSym._totalParamCount = node.variants.length;
|
|
2231
|
+
typeSym._requiredParamCount = node.variants.length;
|
|
2232
|
+
typeSym._paramTypes = node.variants.map(f => f.typeAnnotation || null);
|
|
2233
|
+
typeSym._isStructConstructor = true;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
1920
2237
|
// Validate derive traits
|
|
1921
2238
|
if (node.derive) {
|
|
1922
2239
|
const builtinTraits = new Set(['Eq', 'Show', 'JSON']);
|
|
@@ -2010,7 +2327,7 @@ export class Analyzer {
|
|
|
2010
2327
|
const sym = this.currentScope.lookup(narrowing.varName);
|
|
2011
2328
|
if (sym) {
|
|
2012
2329
|
// Store narrowed type info in the scope
|
|
2013
|
-
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null,
|
|
2330
|
+
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
|
|
2014
2331
|
narrowedSym.inferredType = narrowing.narrowedType;
|
|
2015
2332
|
narrowedSym._narrowed = true;
|
|
2016
2333
|
try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
|
|
@@ -2035,7 +2352,7 @@ export class Analyzer {
|
|
|
2035
2352
|
this.currentScope = this.currentScope.child('block');
|
|
2036
2353
|
const sym = this.currentScope.lookup(narrowing.varName);
|
|
2037
2354
|
if (sym) {
|
|
2038
|
-
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null,
|
|
2355
|
+
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
|
|
2039
2356
|
narrowedSym.inferredType = narrowing.inverseType;
|
|
2040
2357
|
narrowedSym._narrowed = true;
|
|
2041
2358
|
try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
|
|
@@ -2815,7 +3132,19 @@ export class Analyzer {
|
|
|
2815
3132
|
case 'BlockStatement':
|
|
2816
3133
|
if (node.body.length === 0) return false;
|
|
2817
3134
|
// Any statement that definitely returns makes the block definitely return
|
|
2818
|
-
|
|
3135
|
+
if (node.body.some(stmt => this._definitelyReturns(stmt))) return true;
|
|
3136
|
+
// Last value-producing expression in a block is an implicit return (codegen adds `return`).
|
|
3137
|
+
// Only treat simple value expressions as implicit returns — calls, match, and if
|
|
3138
|
+
// require deeper analysis and are excluded to avoid false negatives.
|
|
3139
|
+
const lastStmt = node.body[node.body.length - 1];
|
|
3140
|
+
if (lastStmt && lastStmt.type === 'ExpressionStatement') {
|
|
3141
|
+
const exprType = lastStmt.expression.type;
|
|
3142
|
+
if (exprType !== 'CallExpression' && exprType !== 'MatchExpression' &&
|
|
3143
|
+
exprType !== 'IfExpression' && exprType !== 'IfStatement') {
|
|
3144
|
+
return true;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
return false;
|
|
2819
3148
|
case 'IfStatement':
|
|
2820
3149
|
if (!node.elseBody) return false;
|
|
2821
3150
|
const consequentReturns = this._definitelyReturns(node.consequent);
|
|
@@ -2863,11 +3192,16 @@ export class Analyzer {
|
|
|
2863
3192
|
const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
|
|
2864
3193
|
if (hasSpread) return;
|
|
2865
3194
|
|
|
2866
|
-
// Named arguments
|
|
3195
|
+
// Named arguments: for type constructors, each named arg = one field;
|
|
3196
|
+
// for regular functions, named args are collapsed into a single object
|
|
2867
3197
|
const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
|
|
2868
3198
|
if (hasNamedArgs) {
|
|
2869
|
-
|
|
2870
|
-
|
|
3199
|
+
if (fnSym._variantOf || fnSym._isStructConstructor) {
|
|
3200
|
+
var actualCount = node.arguments.length; // each arg = one field
|
|
3201
|
+
} else {
|
|
3202
|
+
const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
|
|
3203
|
+
var actualCount = positionalCount + 1; // named args become one object
|
|
3204
|
+
}
|
|
2871
3205
|
} else {
|
|
2872
3206
|
var actualCount = node.arguments.length;
|
|
2873
3207
|
}
|
|
@@ -2921,6 +3255,68 @@ export class Analyzer {
|
|
|
2921
3255
|
this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
|
|
2922
3256
|
}
|
|
2923
3257
|
}
|
|
3258
|
+
|
|
3259
|
+
// Type-check named args for type/variant constructors
|
|
3260
|
+
if ((fnSym._variantOf || fnSym._isStructConstructor) && fnSym._params && fnSym._paramTypes) {
|
|
3261
|
+
for (const arg of node.arguments) {
|
|
3262
|
+
if (arg.type !== 'NamedArgument') continue;
|
|
3263
|
+
const idx = fnSym._params.indexOf(arg.name);
|
|
3264
|
+
if (idx === -1 || !fnSym._paramTypes[idx]) continue;
|
|
3265
|
+
let expectedType = this._typeAnnotationToString(fnSym._paramTypes[idx]);
|
|
3266
|
+
if (typeParamBindings.size > 0) {
|
|
3267
|
+
expectedType = this._substituteTypeParams(expectedType, typeParamBindings);
|
|
3268
|
+
}
|
|
3269
|
+
if (fnSym._typeParams && fnSym._typeParams.includes(expectedType)) continue;
|
|
3270
|
+
const actualType = this._inferType(arg.value);
|
|
3271
|
+
if (!this._typesCompatible(expectedType, actualType)) {
|
|
3272
|
+
this.error(`Type mismatch: '${arg.name}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
_checkNamedConstructorArgs(node) {
|
|
3279
|
+
if (node.callee.type !== 'Identifier') return;
|
|
3280
|
+
const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
|
|
3281
|
+
if (!hasNamedArgs) return;
|
|
3282
|
+
|
|
3283
|
+
const fnSym = this.currentScope.lookup(node.callee.name);
|
|
3284
|
+
if (!fnSym) return;
|
|
3285
|
+
if (!fnSym._variantOf && !fnSym._isStructConstructor) return;
|
|
3286
|
+
if (!fnSym._params) return;
|
|
3287
|
+
|
|
3288
|
+
// Check for duplicate field names in the type definition (would make named args ambiguous)
|
|
3289
|
+
const uniqueParams = new Set(fnSym._params);
|
|
3290
|
+
if (uniqueParams.size !== fnSym._params.length) {
|
|
3291
|
+
this.error(`Cannot use named arguments with ${node.callee.name}: duplicate field names`, node.loc);
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
const positionalCount = node.arguments.filter(a => a.type !== 'NamedArgument').length;
|
|
3296
|
+
const seenNames = new Set();
|
|
3297
|
+
|
|
3298
|
+
for (const arg of node.arguments) {
|
|
3299
|
+
if (arg.type !== 'NamedArgument') continue;
|
|
3300
|
+
|
|
3301
|
+
// Unknown field
|
|
3302
|
+
if (!fnSym._params.includes(arg.name)) {
|
|
3303
|
+
this.error(`Unknown field '${arg.name}' in ${node.callee.name} constructor`, arg.loc || node.loc);
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
// Duplicate named arg
|
|
3308
|
+
if (seenNames.has(arg.name)) {
|
|
3309
|
+
this.error(`Duplicate named argument '${arg.name}'`, arg.loc || node.loc);
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
seenNames.add(arg.name);
|
|
3313
|
+
|
|
3314
|
+
// Overlaps with positional slot
|
|
3315
|
+
const fieldIdx = fnSym._params.indexOf(arg.name);
|
|
3316
|
+
if (fieldIdx < positionalCount) {
|
|
3317
|
+
this.error(`Field '${arg.name}' already provided positionally`, arg.loc || node.loc);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
2924
3320
|
}
|
|
2925
3321
|
|
|
2926
3322
|
/**
|