tova 0.7.0 → 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 +1312 -139
- package/package.json +8 -1
- package/src/analyzer/analyzer.js +539 -11
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +1296 -23
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +87 -5
- package/src/codegen/deploy-codegen.js +49 -0
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/shared-codegen.js +5 -0
- package/src/codegen/theme-codegen.js +69 -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 +63 -0
- package/src/config/pkg-errors.js +62 -0
- package/src/config/resolve.js +26 -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 +61 -6
- package/src/deploy/deploy.js +217 -0
- package/src/deploy/infer.js +218 -0
- package/src/deploy/provision.js +315 -0
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/lsp/server.js +482 -0
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +39 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- 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/parser.js +42 -5
- package/src/parser/select-ast.js +39 -0
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +6 -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 +365 -10
- package/src/stdlib/runtime-bridge.js +152 -0
- 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') {
|
|
@@ -900,6 +962,19 @@ export class Analyzer {
|
|
|
900
962
|
}
|
|
901
963
|
this.visitExpression(node.argument);
|
|
902
964
|
return;
|
|
965
|
+
case 'SpawnExpression':
|
|
966
|
+
if (!this._concurrentDepth) {
|
|
967
|
+
this.warn("'spawn' should be used inside a 'concurrent' block", node.loc, null, {
|
|
968
|
+
code: 'W_SPAWN_OUTSIDE_CONCURRENT',
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
if (node.callee) this.visitExpression(node.callee);
|
|
972
|
+
if (node.arguments) {
|
|
973
|
+
for (const arg of node.arguments) {
|
|
974
|
+
this.visitExpression(arg);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return;
|
|
903
978
|
case 'YieldExpression':
|
|
904
979
|
if (node.argument) this.visitExpression(node.argument);
|
|
905
980
|
return;
|
|
@@ -950,6 +1025,210 @@ export class Analyzer {
|
|
|
950
1025
|
}
|
|
951
1026
|
}
|
|
952
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
|
+
|
|
953
1232
|
visitSecurityBlock(node) {
|
|
954
1233
|
// Per-block: only check for duplicate role names within this block
|
|
955
1234
|
const localRoles = new Set();
|
|
@@ -960,6 +1239,7 @@ export class Analyzer {
|
|
|
960
1239
|
message: `Duplicate role definition: '${stmt.name}'`,
|
|
961
1240
|
loc: stmt.loc,
|
|
962
1241
|
code: 'W_DUPLICATE_ROLE',
|
|
1242
|
+
category: 'security',
|
|
963
1243
|
});
|
|
964
1244
|
}
|
|
965
1245
|
localRoles.add(stmt.name);
|
|
@@ -1019,6 +1299,123 @@ export class Analyzer {
|
|
|
1019
1299
|
}
|
|
1020
1300
|
}
|
|
1021
1301
|
|
|
1302
|
+
visitConcurrentBlock(node) {
|
|
1303
|
+
// Validate mode
|
|
1304
|
+
const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
|
|
1305
|
+
if (!validModes.has(node.mode)) {
|
|
1306
|
+
this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
|
|
1307
|
+
code: 'W_UNKNOWN_CONCURRENT_MODE',
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Validate timeout
|
|
1312
|
+
if (node.mode === 'timeout' && !node.timeout) {
|
|
1313
|
+
this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
|
|
1314
|
+
code: 'W_MISSING_TIMEOUT',
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Warn on empty block
|
|
1319
|
+
if (node.body.length === 0) {
|
|
1320
|
+
this.warn("Empty concurrent block", node.loc, null, {
|
|
1321
|
+
code: 'W_EMPTY_CONCURRENT',
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Track concurrent depth for spawn validation
|
|
1326
|
+
this._concurrentDepth = (this._concurrentDepth || 0) + 1;
|
|
1327
|
+
|
|
1328
|
+
// Visit body statements (concurrent block does NOT create a new scope —
|
|
1329
|
+
// variables assigned inside should be visible after the block)
|
|
1330
|
+
for (const stmt of node.body) {
|
|
1331
|
+
this.visitNode(stmt);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
|
|
1335
|
+
let hasWasm = false;
|
|
1336
|
+
let hasNonWasm = false;
|
|
1337
|
+
for (const stmt of node.body) {
|
|
1338
|
+
const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
|
|
1339
|
+
? stmt.values[0]
|
|
1340
|
+
: (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
|
|
1341
|
+
? stmt.expression
|
|
1342
|
+
: null;
|
|
1343
|
+
if (!spawn) continue;
|
|
1344
|
+
const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
|
|
1345
|
+
if (calleeName) {
|
|
1346
|
+
const sym = this.currentScope.lookup(calleeName);
|
|
1347
|
+
if (sym && sym.isWasm) {
|
|
1348
|
+
hasWasm = true;
|
|
1349
|
+
} else {
|
|
1350
|
+
hasNonWasm = true;
|
|
1351
|
+
}
|
|
1352
|
+
} else {
|
|
1353
|
+
// Lambda or complex expression — always non-WASM
|
|
1354
|
+
hasNonWasm = true;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (hasWasm && hasNonWasm) {
|
|
1358
|
+
this.warn(
|
|
1359
|
+
"concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
|
|
1360
|
+
node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
this._concurrentDepth--;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
visitSelectStatement(node) {
|
|
1368
|
+
if (node.cases.length === 0) {
|
|
1369
|
+
this.warn("Empty select block", node.loc, null, {
|
|
1370
|
+
code: 'W_EMPTY_SELECT',
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
let defaultCount = 0;
|
|
1375
|
+
let timeoutCount = 0;
|
|
1376
|
+
for (const c of node.cases) {
|
|
1377
|
+
if (c.kind === 'default') defaultCount++;
|
|
1378
|
+
if (c.kind === 'timeout') timeoutCount++;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (defaultCount > 1) {
|
|
1382
|
+
this.warn("select block has multiple default cases", node.loc, null, {
|
|
1383
|
+
code: 'W_DUPLICATE_SELECT_DEFAULT',
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
if (timeoutCount > 1) {
|
|
1387
|
+
this.warn("select block has multiple timeout cases", node.loc, null, {
|
|
1388
|
+
code: 'W_DUPLICATE_SELECT_TIMEOUT',
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
if (defaultCount > 0 && timeoutCount > 0) {
|
|
1392
|
+
this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
|
|
1393
|
+
code: 'W_SELECT_DEFAULT_TIMEOUT',
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Visit each case's expressions and body
|
|
1398
|
+
for (const c of node.cases) {
|
|
1399
|
+
if (c.channel) this.visitNode(c.channel);
|
|
1400
|
+
if (c.value) this.visitNode(c.value);
|
|
1401
|
+
|
|
1402
|
+
if (c.kind === 'receive' && c.binding) {
|
|
1403
|
+
// Create scope for the binding variable
|
|
1404
|
+
this.pushScope('select-case');
|
|
1405
|
+
this.currentScope.define(c.binding,
|
|
1406
|
+
new Symbol(c.binding, 'variable', null, false, c.loc));
|
|
1407
|
+
for (const stmt of c.body) {
|
|
1408
|
+
this.visitNode(stmt);
|
|
1409
|
+
}
|
|
1410
|
+
this.popScope();
|
|
1411
|
+
} else {
|
|
1412
|
+
for (const stmt of c.body) {
|
|
1413
|
+
this.visitNode(stmt);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1022
1419
|
_validateCliCrossBlock() {
|
|
1023
1420
|
const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
|
|
1024
1421
|
if (cliBlocks.length === 0) return;
|
|
@@ -1258,6 +1655,19 @@ export class Analyzer {
|
|
|
1258
1655
|
}
|
|
1259
1656
|
|
|
1260
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
|
+
|
|
1261
1671
|
// Collect ALL security declarations across ALL security blocks in the AST
|
|
1262
1672
|
const allRoles = new Set();
|
|
1263
1673
|
const allProtects = [];
|
|
@@ -1308,6 +1718,7 @@ export class Analyzer {
|
|
|
1308
1718
|
message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
|
|
1309
1719
|
loc: decl.loc,
|
|
1310
1720
|
code: 'W_DUPLICATE_ROLE',
|
|
1721
|
+
category: 'security',
|
|
1311
1722
|
});
|
|
1312
1723
|
}
|
|
1313
1724
|
}
|
|
@@ -1322,6 +1733,7 @@ export class Analyzer {
|
|
|
1322
1733
|
message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
|
|
1323
1734
|
loc: authDecl.loc,
|
|
1324
1735
|
code: 'W_UNKNOWN_AUTH_TYPE',
|
|
1736
|
+
category: 'security',
|
|
1325
1737
|
});
|
|
1326
1738
|
}
|
|
1327
1739
|
}
|
|
@@ -1334,6 +1746,7 @@ export class Analyzer {
|
|
|
1334
1746
|
message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
|
|
1335
1747
|
loc: authDecl.loc,
|
|
1336
1748
|
code: 'W_HARDCODED_SECRET',
|
|
1749
|
+
category: 'security',
|
|
1337
1750
|
});
|
|
1338
1751
|
}
|
|
1339
1752
|
}
|
|
@@ -1348,6 +1761,7 @@ export class Analyzer {
|
|
|
1348
1761
|
message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
|
|
1349
1762
|
loc: corsDecl.loc,
|
|
1350
1763
|
code: 'W_CORS_WILDCARD',
|
|
1764
|
+
category: 'security',
|
|
1351
1765
|
});
|
|
1352
1766
|
break;
|
|
1353
1767
|
}
|
|
@@ -1370,6 +1784,7 @@ export class Analyzer {
|
|
|
1370
1784
|
message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
|
|
1371
1785
|
loc: rateLimitDecl.loc,
|
|
1372
1786
|
code: 'W_INVALID_RATE_LIMIT',
|
|
1787
|
+
category: 'security',
|
|
1373
1788
|
});
|
|
1374
1789
|
}
|
|
1375
1790
|
if (rlWindowVal !== null && rlWindowVal <= 0) {
|
|
@@ -1377,6 +1792,7 @@ export class Analyzer {
|
|
|
1377
1792
|
message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
|
|
1378
1793
|
loc: rateLimitDecl.loc,
|
|
1379
1794
|
code: 'W_INVALID_RATE_LIMIT',
|
|
1795
|
+
category: 'security',
|
|
1380
1796
|
});
|
|
1381
1797
|
}
|
|
1382
1798
|
}
|
|
@@ -1390,6 +1806,7 @@ export class Analyzer {
|
|
|
1390
1806
|
message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
|
|
1391
1807
|
loc: csrfDecl.loc,
|
|
1392
1808
|
code: 'W_CSRF_DISABLED',
|
|
1809
|
+
category: 'security',
|
|
1393
1810
|
});
|
|
1394
1811
|
}
|
|
1395
1812
|
}
|
|
@@ -1403,6 +1820,7 @@ export class Analyzer {
|
|
|
1403
1820
|
message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
|
|
1404
1821
|
loc: authDecl.loc,
|
|
1405
1822
|
code: 'W_LOCALSTORAGE_TOKEN',
|
|
1823
|
+
category: 'security',
|
|
1406
1824
|
});
|
|
1407
1825
|
}
|
|
1408
1826
|
}
|
|
@@ -1413,6 +1831,7 @@ export class Analyzer {
|
|
|
1413
1831
|
message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
|
|
1414
1832
|
loc: rateLimitDecl.loc,
|
|
1415
1833
|
code: 'W_INMEMORY_RATELIMIT',
|
|
1834
|
+
category: 'security',
|
|
1416
1835
|
});
|
|
1417
1836
|
}
|
|
1418
1837
|
|
|
@@ -1424,6 +1843,7 @@ export class Analyzer {
|
|
|
1424
1843
|
message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
|
|
1425
1844
|
loc: authDecl.loc,
|
|
1426
1845
|
code: 'W_NO_AUTH_RATELIMIT',
|
|
1846
|
+
category: 'security',
|
|
1427
1847
|
});
|
|
1428
1848
|
}
|
|
1429
1849
|
}
|
|
@@ -1436,6 +1856,7 @@ export class Analyzer {
|
|
|
1436
1856
|
message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
|
|
1437
1857
|
loc: s.loc,
|
|
1438
1858
|
code: 'W_HASH_NOT_ENFORCED',
|
|
1859
|
+
category: 'security',
|
|
1439
1860
|
});
|
|
1440
1861
|
}
|
|
1441
1862
|
}
|
|
@@ -1450,6 +1871,7 @@ export class Analyzer {
|
|
|
1450
1871
|
message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
|
|
1451
1872
|
loc: allProtects[0].loc,
|
|
1452
1873
|
code: 'W_PROTECT_WITHOUT_AUTH',
|
|
1874
|
+
category: 'security',
|
|
1453
1875
|
});
|
|
1454
1876
|
}
|
|
1455
1877
|
|
|
@@ -1462,6 +1884,7 @@ export class Analyzer {
|
|
|
1462
1884
|
message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
|
|
1463
1885
|
loc: protect.loc,
|
|
1464
1886
|
code: 'W_PROTECT_NO_REQUIRE',
|
|
1887
|
+
category: 'security',
|
|
1465
1888
|
});
|
|
1466
1889
|
continue;
|
|
1467
1890
|
}
|
|
@@ -1471,6 +1894,7 @@ export class Analyzer {
|
|
|
1471
1894
|
message: `Protect rule references undefined role '${requireExpr.name}'`,
|
|
1472
1895
|
loc: protect.loc,
|
|
1473
1896
|
code: 'W_UNDEFINED_ROLE',
|
|
1897
|
+
category: 'security',
|
|
1474
1898
|
});
|
|
1475
1899
|
}
|
|
1476
1900
|
}
|
|
@@ -1487,6 +1911,7 @@ export class Analyzer {
|
|
|
1487
1911
|
message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
|
|
1488
1912
|
loc: sensitive.loc,
|
|
1489
1913
|
code: 'W_UNDEFINED_ROLE',
|
|
1914
|
+
category: 'security',
|
|
1490
1915
|
});
|
|
1491
1916
|
}
|
|
1492
1917
|
}
|
|
@@ -1532,6 +1957,15 @@ export class Analyzer {
|
|
|
1532
1957
|
// Complex targets (e.g., arr[i] = val, obj.prop = val) — visit and skip declaration logic
|
|
1533
1958
|
if (typeof target !== 'string') {
|
|
1534
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
|
+
}
|
|
1535
1969
|
continue;
|
|
1536
1970
|
}
|
|
1537
1971
|
|
|
@@ -1614,9 +2048,10 @@ export class Analyzer {
|
|
|
1614
2048
|
} else if (node.pattern.type === 'ArrayPattern' || node.pattern.type === 'TuplePattern') {
|
|
1615
2049
|
for (const el of node.pattern.elements) {
|
|
1616
2050
|
if (el) {
|
|
2051
|
+
const varName = el.startsWith('...') ? el.slice(3) : el;
|
|
1617
2052
|
try {
|
|
1618
|
-
this.currentScope.define(
|
|
1619
|
-
new Symbol(
|
|
2053
|
+
this.currentScope.define(varName,
|
|
2054
|
+
new Symbol(varName, 'variable', null, false, node.loc));
|
|
1620
2055
|
} catch (e) {
|
|
1621
2056
|
this.error(e.message);
|
|
1622
2057
|
}
|
|
@@ -1634,6 +2069,7 @@ export class Analyzer {
|
|
|
1634
2069
|
sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
|
|
1635
2070
|
sym._typeParams = node.typeParams || [];
|
|
1636
2071
|
sym.isPublic = node.isPublic || false;
|
|
2072
|
+
sym.isWasm = !!(node.decorators && node.decorators.some(d => d.name === 'wasm'));
|
|
1637
2073
|
this.currentScope.define(node.name, sym);
|
|
1638
2074
|
} catch (e) {
|
|
1639
2075
|
this.error(e.message);
|
|
@@ -1785,6 +2221,19 @@ export class Analyzer {
|
|
|
1785
2221
|
}
|
|
1786
2222
|
}
|
|
1787
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
|
+
|
|
1788
2237
|
// Validate derive traits
|
|
1789
2238
|
if (node.derive) {
|
|
1790
2239
|
const builtinTraits = new Set(['Eq', 'Show', 'JSON']);
|
|
@@ -1878,7 +2327,7 @@ export class Analyzer {
|
|
|
1878
2327
|
const sym = this.currentScope.lookup(narrowing.varName);
|
|
1879
2328
|
if (sym) {
|
|
1880
2329
|
// Store narrowed type info in the scope
|
|
1881
|
-
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null,
|
|
2330
|
+
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
|
|
1882
2331
|
narrowedSym.inferredType = narrowing.narrowedType;
|
|
1883
2332
|
narrowedSym._narrowed = true;
|
|
1884
2333
|
try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
|
|
@@ -1903,7 +2352,7 @@ export class Analyzer {
|
|
|
1903
2352
|
this.currentScope = this.currentScope.child('block');
|
|
1904
2353
|
const sym = this.currentScope.lookup(narrowing.varName);
|
|
1905
2354
|
if (sym) {
|
|
1906
|
-
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null,
|
|
2355
|
+
const narrowedSym = new Symbol(narrowing.varName, sym.kind, null, sym.mutable, sym.loc);
|
|
1907
2356
|
narrowedSym.inferredType = narrowing.inverseType;
|
|
1908
2357
|
narrowedSym._narrowed = true;
|
|
1909
2358
|
try { this.currentScope.define(narrowing.varName, narrowedSym); } catch (e) { /* already defined */ }
|
|
@@ -2683,7 +3132,19 @@ export class Analyzer {
|
|
|
2683
3132
|
case 'BlockStatement':
|
|
2684
3133
|
if (node.body.length === 0) return false;
|
|
2685
3134
|
// Any statement that definitely returns makes the block definitely return
|
|
2686
|
-
|
|
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;
|
|
2687
3148
|
case 'IfStatement':
|
|
2688
3149
|
if (!node.elseBody) return false;
|
|
2689
3150
|
const consequentReturns = this._definitelyReturns(node.consequent);
|
|
@@ -2731,11 +3192,16 @@ export class Analyzer {
|
|
|
2731
3192
|
const hasSpread = node.arguments.some(a => a.type === 'SpreadExpression');
|
|
2732
3193
|
if (hasSpread) return;
|
|
2733
3194
|
|
|
2734
|
-
// 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
|
|
2735
3197
|
const hasNamedArgs = node.arguments.some(a => a.type === 'NamedArgument');
|
|
2736
3198
|
if (hasNamedArgs) {
|
|
2737
|
-
|
|
2738
|
-
|
|
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
|
+
}
|
|
2739
3205
|
} else {
|
|
2740
3206
|
var actualCount = node.arguments.length;
|
|
2741
3207
|
}
|
|
@@ -2789,6 +3255,68 @@ export class Analyzer {
|
|
|
2789
3255
|
this.error(`Type mismatch: '${paramName}' expects ${expectedType}, but got ${actualType}`, arg.loc || node.loc, this._conversionHint(expectedType, actualType));
|
|
2790
3256
|
}
|
|
2791
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
|
+
}
|
|
2792
3320
|
}
|
|
2793
3321
|
|
|
2794
3322
|
/**
|