flarelint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1082 -0
- package/Cargo.toml +32 -0
- package/LICENSE-APACHE +176 -0
- package/LICENSE-MIT +21 -0
- package/README.md +165 -0
- package/bin/flarelint.js +78 -0
- package/package.json +52 -0
- package/scripts/postinstall.mjs +83 -0
- package/src/config.rs +197 -0
- package/src/diagnostics.rs +146 -0
- package/src/formatter.rs +129 -0
- package/src/lib.rs +9 -0
- package/src/main.rs +178 -0
- package/src/parser.rs +215 -0
- package/src/rules/do_storage.rs +400 -0
- package/src/rules/mod.rs +120 -0
- package/src/rules/node_compat.rs +501 -0
- package/src/rules/routes.rs +268 -0
- package/src/rules/waituntil.rs +400 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::collections::HashSet;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use crate::diagnostics::{Diagnostic, SourceLocation};
|
|
6
|
+
|
|
7
|
+
const MAX_PAGES_ROUTES_LIMIT: usize = 100;
|
|
8
|
+
|
|
9
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
10
|
+
pub struct RoutesConfig {
|
|
11
|
+
pub version: u32,
|
|
12
|
+
#[serde(default)]
|
|
13
|
+
pub include: Vec<String>,
|
|
14
|
+
#[serde(default)]
|
|
15
|
+
pub exclude: Vec<String>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pub struct RoutesLinter<'a> {
|
|
19
|
+
pub file_path: &'a Path,
|
|
20
|
+
pub full_source: &'a str,
|
|
21
|
+
pub diagnostics: Vec<Diagnostic>,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl<'a> RoutesLinter<'a> {
|
|
25
|
+
pub fn new(file_path: &'a Path, full_source: &'a str) -> Self {
|
|
26
|
+
Self {
|
|
27
|
+
file_path,
|
|
28
|
+
full_source,
|
|
29
|
+
diagnostics: Vec::new(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn lint(&mut self) {
|
|
34
|
+
let parsed: Result<RoutesConfig, _> = serde_json::from_str(self.full_source);
|
|
35
|
+
let config = match parsed {
|
|
36
|
+
Ok(c) => c,
|
|
37
|
+
Err(e) => {
|
|
38
|
+
let diag = Diagnostic::error(
|
|
39
|
+
"routes/invalid-json",
|
|
40
|
+
format!("Malformed _routes.json: {}", e),
|
|
41
|
+
self.file_path,
|
|
42
|
+
)
|
|
43
|
+
.with_location(SourceLocation::new(1, 1, 0, 0));
|
|
44
|
+
self.diagnostics.push(diag);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if config.version != 1 {
|
|
50
|
+
let loc = self.find_line_offset("\"version\"");
|
|
51
|
+
let diag = Diagnostic::error(
|
|
52
|
+
"routes/invalid-version",
|
|
53
|
+
format!(
|
|
54
|
+
"Invalid version {}. Cloudflare Pages requires version: 1",
|
|
55
|
+
config.version
|
|
56
|
+
),
|
|
57
|
+
self.file_path,
|
|
58
|
+
)
|
|
59
|
+
.with_location(loc)
|
|
60
|
+
.with_suggestion("Set \"version\": 1", Some("\"version\": 1".to_string()));
|
|
61
|
+
self.diagnostics.push(diag);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let total_rules = config.include.len() + config.exclude.len();
|
|
65
|
+
if total_rules > MAX_PAGES_ROUTES_LIMIT {
|
|
66
|
+
let diag = Diagnostic::error(
|
|
67
|
+
"routes/exceeds-limit",
|
|
68
|
+
format!(
|
|
69
|
+
"Total route count ({}) exceeds Cloudflare Pages limit of {} rules (include: {}, exclude: {}).",
|
|
70
|
+
total_rules, MAX_PAGES_ROUTES_LIMIT, config.include.len(), config.exclude.len()
|
|
71
|
+
),
|
|
72
|
+
self.file_path,
|
|
73
|
+
)
|
|
74
|
+
.with_location(SourceLocation::new(1, 1, 0, 0))
|
|
75
|
+
.with_suggestion(
|
|
76
|
+
format!("Reduce rules to <= {} by consolidating paths with wildcards (e.g. '/api/*').", MAX_PAGES_ROUTES_LIMIT),
|
|
77
|
+
None,
|
|
78
|
+
);
|
|
79
|
+
self.diagnostics.push(diag);
|
|
80
|
+
} else if total_rules >= 90 {
|
|
81
|
+
let diag = Diagnostic::warning(
|
|
82
|
+
"routes/approaching-limit",
|
|
83
|
+
format!(
|
|
84
|
+
"Total route count ({}) is approaching the Cloudflare Pages limit of {} rules.",
|
|
85
|
+
total_rules, MAX_PAGES_ROUTES_LIMIT
|
|
86
|
+
),
|
|
87
|
+
self.file_path,
|
|
88
|
+
)
|
|
89
|
+
.with_location(SourceLocation::new(1, 1, 0, 0));
|
|
90
|
+
self.diagnostics.push(diag);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if config.include.is_empty() {
|
|
94
|
+
let diag = Diagnostic::error(
|
|
95
|
+
"routes/missing-include",
|
|
96
|
+
"Cloudflare Pages _routes.json requires at least 1 'include' rule (e.g. '/*').",
|
|
97
|
+
self.file_path,
|
|
98
|
+
)
|
|
99
|
+
.with_location(SourceLocation::new(1, 1, 0, 0))
|
|
100
|
+
.with_suggestion(
|
|
101
|
+
"Add '\"include\": [\"/*\"]'",
|
|
102
|
+
Some("\"include\": [\"/*\"]".to_string()),
|
|
103
|
+
);
|
|
104
|
+
self.diagnostics.push(diag);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let mut seen_includes = HashSet::new();
|
|
108
|
+
for rule in &config.include {
|
|
109
|
+
self.validate_pattern(rule);
|
|
110
|
+
if !seen_includes.insert(rule) {
|
|
111
|
+
let loc = self.find_line_offset(rule);
|
|
112
|
+
let diag = Diagnostic::warning(
|
|
113
|
+
"routes/duplicate-rule",
|
|
114
|
+
format!("Duplicate include rule '{}'", rule),
|
|
115
|
+
self.file_path,
|
|
116
|
+
)
|
|
117
|
+
.with_location(loc)
|
|
118
|
+
.with_suggestion(format!("Remove duplicate rule '{}'", rule), None);
|
|
119
|
+
self.diagnostics.push(diag);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let mut seen_excludes = HashSet::new();
|
|
124
|
+
for rule in &config.exclude {
|
|
125
|
+
self.validate_pattern(rule);
|
|
126
|
+
if !seen_excludes.insert(rule) {
|
|
127
|
+
let loc = self.find_line_offset(rule);
|
|
128
|
+
let diag = Diagnostic::warning(
|
|
129
|
+
"routes/duplicate-rule",
|
|
130
|
+
format!("Duplicate exclude rule '{}'", rule),
|
|
131
|
+
self.file_path,
|
|
132
|
+
)
|
|
133
|
+
.with_location(loc)
|
|
134
|
+
.with_suggestion(format!("Remove duplicate rule '{}'", rule), None);
|
|
135
|
+
self.diagnostics.push(diag);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (i, r1) in config.include.iter().enumerate() {
|
|
140
|
+
for (j, r2) in config.include.iter().enumerate() {
|
|
141
|
+
if i != j && self.pattern_shadows(r1, r2) {
|
|
142
|
+
let loc = self.find_line_offset(r2);
|
|
143
|
+
let diag = Diagnostic::warning(
|
|
144
|
+
"routes/shadowed-rule",
|
|
145
|
+
format!(
|
|
146
|
+
"Include rule '{}' is redundant because it is already matched by '{}'.",
|
|
147
|
+
r2, r1
|
|
148
|
+
),
|
|
149
|
+
self.file_path,
|
|
150
|
+
)
|
|
151
|
+
.with_location(loc)
|
|
152
|
+
.with_suggestion(format!("Remove shadowed rule '{}'", r2), None);
|
|
153
|
+
self.diagnostics.push(diag);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for exc in &config.exclude {
|
|
159
|
+
let matched_by_any = config
|
|
160
|
+
.include
|
|
161
|
+
.iter()
|
|
162
|
+
.any(|inc| self.pattern_matches_or_overlaps(inc, exc));
|
|
163
|
+
if !matched_by_any && !config.include.is_empty() {
|
|
164
|
+
let loc = self.find_line_offset(exc);
|
|
165
|
+
let diag = Diagnostic::warning(
|
|
166
|
+
"routes/unmatched-exclude",
|
|
167
|
+
format!("Exclude rule '{}' is unnecessary because no include rule matches its path prefix.", exc),
|
|
168
|
+
self.file_path,
|
|
169
|
+
)
|
|
170
|
+
.with_location(loc)
|
|
171
|
+
.with_suggestion(format!("Remove unneeded exclude rule '{}' to save quota", exc), None);
|
|
172
|
+
self.diagnostics.push(diag);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn validate_pattern(&mut self, pattern: &str) {
|
|
178
|
+
let loc = self.find_line_offset(pattern);
|
|
179
|
+
|
|
180
|
+
if !pattern.starts_with('/') {
|
|
181
|
+
let diag = Diagnostic::error(
|
|
182
|
+
"routes/invalid-path",
|
|
183
|
+
format!("Route pattern '{}' must start with '/'", pattern),
|
|
184
|
+
self.file_path,
|
|
185
|
+
)
|
|
186
|
+
.with_location(loc)
|
|
187
|
+
.with_suggestion(
|
|
188
|
+
format!("Prepend '/' to '/{}'", pattern.trim_start_matches('/')),
|
|
189
|
+
Some(format!("/{}", pattern)),
|
|
190
|
+
);
|
|
191
|
+
self.diagnostics.push(diag);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if let Some(star_pos) = pattern.find('*')
|
|
196
|
+
&& star_pos != pattern.len() - 1
|
|
197
|
+
{
|
|
198
|
+
let diag = Diagnostic::error(
|
|
199
|
+
"routes/invalid-glob",
|
|
200
|
+
format!(
|
|
201
|
+
"Cloudflare Pages wildcards ('*') can only appear at the end of a route pattern (e.g. '/api/*'). In '{}', wildcard is at position {}.",
|
|
202
|
+
pattern, star_pos
|
|
203
|
+
),
|
|
204
|
+
self.file_path,
|
|
205
|
+
)
|
|
206
|
+
.with_location(loc)
|
|
207
|
+
.with_suggestion(
|
|
208
|
+
"Move wildcard to the end of the prefix or use exact matching.",
|
|
209
|
+
None,
|
|
210
|
+
);
|
|
211
|
+
self.diagnostics.push(diag);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if pattern.contains("//") {
|
|
215
|
+
let diag = Diagnostic::warning(
|
|
216
|
+
"routes/double-slash",
|
|
217
|
+
format!(
|
|
218
|
+
"Route pattern '{}' contains consecutive slashes '//'",
|
|
219
|
+
pattern
|
|
220
|
+
),
|
|
221
|
+
self.file_path,
|
|
222
|
+
)
|
|
223
|
+
.with_location(loc);
|
|
224
|
+
self.diagnostics.push(diag);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn pattern_shadows(&self, broader: &str, narrower: &str) -> bool {
|
|
229
|
+
if broader == "/*" && narrower != "/*" {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
if let Some(prefix) = broader.strip_suffix("/*")
|
|
233
|
+
&& narrower.starts_with(prefix)
|
|
234
|
+
&& narrower != broader
|
|
235
|
+
{
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn pattern_matches_or_overlaps(&self, include: &str, exclude: &str) -> bool {
|
|
242
|
+
if include == "/*" {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if let Some(prefix) = include.strip_suffix("/*")
|
|
246
|
+
&& exclude.starts_with(prefix)
|
|
247
|
+
{
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if include == exclude {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn find_line_offset(&self, query: &str) -> SourceLocation {
|
|
257
|
+
let mut offset = 0;
|
|
258
|
+
|
|
259
|
+
for (line_num, line) in (1..).zip(self.full_source.lines()) {
|
|
260
|
+
if let Some(col) = line.find(query) {
|
|
261
|
+
return SourceLocation::new(line_num, col + 1, offset + col, query.len());
|
|
262
|
+
}
|
|
263
|
+
offset += line.len() + 1;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
SourceLocation::new(1, 1, 0, query.len())
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
use oxc_ast::ast::*;
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
|
|
4
|
+
use crate::diagnostics::Diagnostic;
|
|
5
|
+
use crate::parser::{AstUnit, offset_to_location};
|
|
6
|
+
|
|
7
|
+
pub struct WaitUntilLinter<'a> {
|
|
8
|
+
pub file_path: &'a Path,
|
|
9
|
+
pub full_source: &'a str,
|
|
10
|
+
pub diagnostics: Vec<Diagnostic>,
|
|
11
|
+
in_handler_scope: bool,
|
|
12
|
+
handler_ctx_name: Option<String>,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
impl<'a> WaitUntilLinter<'a> {
|
|
16
|
+
pub fn new(file_path: &'a Path, full_source: &'a str) -> Self {
|
|
17
|
+
Self {
|
|
18
|
+
file_path,
|
|
19
|
+
full_source,
|
|
20
|
+
diagnostics: Vec::new(),
|
|
21
|
+
in_handler_scope: false,
|
|
22
|
+
handler_ctx_name: None,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn lint_ast(&mut self, ast: &AstUnit<'_>) {
|
|
27
|
+
for stmt in &ast.program.body {
|
|
28
|
+
self.visit_top_level_statement(stmt, ast);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn extract_snippet(&self, target_line: usize) -> Option<String> {
|
|
33
|
+
let lines: Vec<&str> = self.full_source.lines().collect();
|
|
34
|
+
if target_line == 0 || target_line > lines.len() {
|
|
35
|
+
return None;
|
|
36
|
+
}
|
|
37
|
+
Some(lines[target_line - 1].trim().to_string())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fn visit_top_level_statement(&mut self, stmt: &Statement<'_>, ast: &AstUnit<'_>) {
|
|
41
|
+
match stmt {
|
|
42
|
+
Statement::ExportDefaultDeclaration(export_default) => {
|
|
43
|
+
match &export_default.declaration {
|
|
44
|
+
ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
|
|
45
|
+
self.lint_function_handler(func, "default", ast);
|
|
46
|
+
}
|
|
47
|
+
decl => {
|
|
48
|
+
if let Some(Expression::ObjectExpression(obj)) = decl.as_expression() {
|
|
49
|
+
for prop in &obj.properties {
|
|
50
|
+
if let ObjectPropertyKind::ObjectProperty(p) = prop {
|
|
51
|
+
let key_name = self.get_property_key_name(&p.key);
|
|
52
|
+
if matches!(
|
|
53
|
+
key_name.as_deref(),
|
|
54
|
+
Some(
|
|
55
|
+
"fetch"
|
|
56
|
+
| "scheduled"
|
|
57
|
+
| "queue"
|
|
58
|
+
| "email"
|
|
59
|
+
| "tail"
|
|
60
|
+
| "trace"
|
|
61
|
+
)
|
|
62
|
+
) {
|
|
63
|
+
self.lint_handler_property(
|
|
64
|
+
p,
|
|
65
|
+
key_name.as_deref().unwrap(),
|
|
66
|
+
ast,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
Statement::ExportNamedDeclaration(export_named) => {
|
|
76
|
+
if let Some(Declaration::FunctionDeclaration(func)) = &export_named.declaration
|
|
77
|
+
&& let Some(ident) = &func.id
|
|
78
|
+
&& matches!(
|
|
79
|
+
ident.name.as_str(),
|
|
80
|
+
"GET"
|
|
81
|
+
| "POST"
|
|
82
|
+
| "PUT"
|
|
83
|
+
| "DELETE"
|
|
84
|
+
| "PATCH"
|
|
85
|
+
| "ALL"
|
|
86
|
+
| "onRequest"
|
|
87
|
+
| "onRequestGet"
|
|
88
|
+
| "onRequestPost"
|
|
89
|
+
)
|
|
90
|
+
{
|
|
91
|
+
self.lint_function_handler(func, ident.name.as_str(), ast);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
Statement::ExpressionStatement(expr_stmt) => {
|
|
95
|
+
if let Expression::CallExpression(call) = &expr_stmt.expression
|
|
96
|
+
&& self.is_addeventlistener_fetch(call)
|
|
97
|
+
{
|
|
98
|
+
self.lint_event_listener_call(call, ast);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
_ => {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn get_property_key_name(&self, key: &PropertyKey<'_>) -> Option<String> {
|
|
106
|
+
match key {
|
|
107
|
+
PropertyKey::StaticIdentifier(ident) => Some(ident.name.to_string()),
|
|
108
|
+
PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()),
|
|
109
|
+
_ => None,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn is_addeventlistener_fetch(&self, call: &CallExpression<'_>) -> bool {
|
|
114
|
+
let is_listener = if let Expression::Identifier(ident) = &call.callee {
|
|
115
|
+
ident.name == "addEventListener"
|
|
116
|
+
} else if let Some(mem) = call.callee.as_member_expression() {
|
|
117
|
+
if let Expression::Identifier(ident) = mem.object() {
|
|
118
|
+
(ident.name == "self" || ident.name == "globalThis")
|
|
119
|
+
&& mem.static_property_name() == Some("addEventListener")
|
|
120
|
+
} else {
|
|
121
|
+
false
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
false
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if is_listener
|
|
128
|
+
&& !call.arguments.is_empty()
|
|
129
|
+
&& let Some(first_arg) = call.arguments.first()
|
|
130
|
+
&& let Some(lit) = first_arg.as_expression().and_then(|e| match e {
|
|
131
|
+
Expression::StringLiteral(s) => Some(s),
|
|
132
|
+
_ => None,
|
|
133
|
+
})
|
|
134
|
+
{
|
|
135
|
+
return lit.value == "fetch" || lit.value == "scheduled" || lit.value == "queue";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn lint_handler_property(
|
|
142
|
+
&mut self,
|
|
143
|
+
prop: &ObjectProperty<'_>,
|
|
144
|
+
handler_name: &str,
|
|
145
|
+
ast: &AstUnit<'_>,
|
|
146
|
+
) {
|
|
147
|
+
match &prop.value {
|
|
148
|
+
Expression::FunctionExpression(func) => {
|
|
149
|
+
self.lint_function_handler(func, handler_name, ast);
|
|
150
|
+
}
|
|
151
|
+
Expression::ArrowFunctionExpression(arrow) => {
|
|
152
|
+
let ctx_name = if arrow.params.items.len() >= 3 {
|
|
153
|
+
arrow
|
|
154
|
+
.params
|
|
155
|
+
.items
|
|
156
|
+
.get(2)
|
|
157
|
+
.and_then(|p| self.get_param_name(&p.pattern))
|
|
158
|
+
} else if arrow.params.items.len() == 1 {
|
|
159
|
+
arrow
|
|
160
|
+
.params
|
|
161
|
+
.items
|
|
162
|
+
.first()
|
|
163
|
+
.and_then(|p| self.get_param_name(&p.pattern))
|
|
164
|
+
} else {
|
|
165
|
+
None
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let prev_in_scope = self.in_handler_scope;
|
|
169
|
+
let prev_ctx = self.handler_ctx_name.clone();
|
|
170
|
+
self.in_handler_scope = true;
|
|
171
|
+
self.handler_ctx_name = ctx_name;
|
|
172
|
+
|
|
173
|
+
for s in &arrow.body.statements {
|
|
174
|
+
self.visit_handler_statement(s, ast);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
self.in_handler_scope = prev_in_scope;
|
|
178
|
+
self.handler_ctx_name = prev_ctx;
|
|
179
|
+
}
|
|
180
|
+
_ => {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fn get_param_name(&self, pattern: &BindingPattern<'_>) -> Option<String> {
|
|
185
|
+
match &pattern.kind {
|
|
186
|
+
BindingPatternKind::BindingIdentifier(ident) => Some(ident.name.to_string()),
|
|
187
|
+
_ => None,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn lint_function_handler(
|
|
192
|
+
&mut self,
|
|
193
|
+
func: &Function<'_>,
|
|
194
|
+
_handler_name: &str,
|
|
195
|
+
ast: &AstUnit<'_>,
|
|
196
|
+
) {
|
|
197
|
+
let ctx_name = if func.params.items.len() >= 3 {
|
|
198
|
+
func.params
|
|
199
|
+
.items
|
|
200
|
+
.get(2)
|
|
201
|
+
.and_then(|p| self.get_param_name(&p.pattern))
|
|
202
|
+
} else if func.params.items.len() == 1 {
|
|
203
|
+
func.params
|
|
204
|
+
.items
|
|
205
|
+
.first()
|
|
206
|
+
.and_then(|p| self.get_param_name(&p.pattern))
|
|
207
|
+
} else {
|
|
208
|
+
None
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let prev_in_scope = self.in_handler_scope;
|
|
212
|
+
let prev_ctx = self.handler_ctx_name.clone();
|
|
213
|
+
self.in_handler_scope = true;
|
|
214
|
+
self.handler_ctx_name = ctx_name;
|
|
215
|
+
|
|
216
|
+
if let Some(body) = &func.body {
|
|
217
|
+
for s in &body.statements {
|
|
218
|
+
self.visit_handler_statement(s, ast);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
self.in_handler_scope = prev_in_scope;
|
|
223
|
+
self.handler_ctx_name = prev_ctx;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn lint_event_listener_call(&mut self, call: &CallExpression<'_>, ast: &AstUnit<'_>) {
|
|
227
|
+
if call.arguments.len() >= 2
|
|
228
|
+
&& let Some(second_arg) = call.arguments.get(1)
|
|
229
|
+
&& let Some(expr) = second_arg.as_expression()
|
|
230
|
+
{
|
|
231
|
+
match expr {
|
|
232
|
+
Expression::FunctionExpression(func) => {
|
|
233
|
+
self.lint_function_handler(func, "eventListener", ast);
|
|
234
|
+
}
|
|
235
|
+
Expression::ArrowFunctionExpression(arrow) => {
|
|
236
|
+
let event_name = arrow
|
|
237
|
+
.params
|
|
238
|
+
.items
|
|
239
|
+
.first()
|
|
240
|
+
.and_then(|p| self.get_param_name(&p.pattern));
|
|
241
|
+
let prev_in_scope = self.in_handler_scope;
|
|
242
|
+
let prev_ctx = self.handler_ctx_name.clone();
|
|
243
|
+
self.in_handler_scope = true;
|
|
244
|
+
self.handler_ctx_name = event_name;
|
|
245
|
+
|
|
246
|
+
for s in &arrow.body.statements {
|
|
247
|
+
self.visit_handler_statement(s, ast);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
self.in_handler_scope = prev_in_scope;
|
|
251
|
+
self.handler_ctx_name = prev_ctx;
|
|
252
|
+
}
|
|
253
|
+
_ => {}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn is_wait_until_call(&self, call: &CallExpression<'_>) -> bool {
|
|
259
|
+
if let Some(mem) = call.callee.as_member_expression()
|
|
260
|
+
&& let Some(prop_name) = mem.static_property_name()
|
|
261
|
+
{
|
|
262
|
+
return prop_name == "waitUntil";
|
|
263
|
+
}
|
|
264
|
+
false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn visit_handler_statement(&mut self, stmt: &Statement<'_>, ast: &AstUnit<'_>) {
|
|
268
|
+
match stmt {
|
|
269
|
+
Statement::ExpressionStatement(expr_stmt) => {
|
|
270
|
+
self.check_handler_expression(
|
|
271
|
+
&expr_stmt.expression,
|
|
272
|
+
expr_stmt.span.start,
|
|
273
|
+
expr_stmt.span.end,
|
|
274
|
+
ast,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
Statement::BlockStatement(block) => {
|
|
278
|
+
for s in &block.body {
|
|
279
|
+
self.visit_handler_statement(s, ast);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
Statement::IfStatement(if_stmt) => {
|
|
283
|
+
self.visit_handler_statement(&if_stmt.consequent, ast);
|
|
284
|
+
if let Some(alt) = &if_stmt.alternate {
|
|
285
|
+
self.visit_handler_statement(alt, ast);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
Statement::TryStatement(try_stmt) => {
|
|
289
|
+
for s in &try_stmt.block.body {
|
|
290
|
+
self.visit_handler_statement(s, ast);
|
|
291
|
+
}
|
|
292
|
+
if let Some(h) = &try_stmt.handler {
|
|
293
|
+
for s in &h.body.body {
|
|
294
|
+
self.visit_handler_statement(s, ast);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if let Some(f) = &try_stmt.finalizer {
|
|
298
|
+
for s in &f.body {
|
|
299
|
+
self.visit_handler_statement(s, ast);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
Statement::ForStatement(for_stmt) => {
|
|
304
|
+
self.visit_handler_statement(&for_stmt.body, ast);
|
|
305
|
+
}
|
|
306
|
+
Statement::ForInStatement(for_in) => {
|
|
307
|
+
self.visit_handler_statement(&for_in.body, ast);
|
|
308
|
+
}
|
|
309
|
+
Statement::ForOfStatement(for_of) => {
|
|
310
|
+
self.visit_handler_statement(&for_of.body, ast);
|
|
311
|
+
}
|
|
312
|
+
Statement::WhileStatement(while_stmt) => {
|
|
313
|
+
self.visit_handler_statement(&while_stmt.body, ast);
|
|
314
|
+
}
|
|
315
|
+
_ => {}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
fn is_suspect_floating_promise(&self, call: &CallExpression<'_>) -> Option<String> {
|
|
320
|
+
if self.is_wait_until_call(call) {
|
|
321
|
+
return None;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
match &call.callee {
|
|
325
|
+
Expression::Identifier(ident) => {
|
|
326
|
+
let name = ident.name.as_str();
|
|
327
|
+
if name == "fetch"
|
|
328
|
+
|| name.starts_with("send")
|
|
329
|
+
|| name.starts_with("log")
|
|
330
|
+
|| name.starts_with("save")
|
|
331
|
+
|| name.starts_with("track")
|
|
332
|
+
|| name.ends_with("Async")
|
|
333
|
+
{
|
|
334
|
+
return Some(name.to_string());
|
|
335
|
+
}
|
|
336
|
+
if name != "console" && !name.starts_with("assert") {
|
|
337
|
+
return Some(format!("{}()", name));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
_ => {
|
|
341
|
+
if let Some(mem) = call.callee.as_member_expression()
|
|
342
|
+
&& let Some(prop) = mem.static_property_name()
|
|
343
|
+
&& matches!(
|
|
344
|
+
prop,
|
|
345
|
+
"put"
|
|
346
|
+
| "get"
|
|
347
|
+
| "delete"
|
|
348
|
+
| "list"
|
|
349
|
+
| "post"
|
|
350
|
+
| "send"
|
|
351
|
+
| "track"
|
|
352
|
+
| "query"
|
|
353
|
+
| "exec"
|
|
354
|
+
| "run"
|
|
355
|
+
| "fetch"
|
|
356
|
+
| "write"
|
|
357
|
+
)
|
|
358
|
+
{
|
|
359
|
+
return Some(format!(".{}()", prop));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
None
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fn check_handler_expression(
|
|
368
|
+
&mut self,
|
|
369
|
+
expr: &Expression<'_>,
|
|
370
|
+
span_start: u32,
|
|
371
|
+
span_end: u32,
|
|
372
|
+
ast: &AstUnit<'_>,
|
|
373
|
+
) {
|
|
374
|
+
if let Expression::CallExpression(call) = expr
|
|
375
|
+
&& let Some(call_desc) = self.is_suspect_floating_promise(call)
|
|
376
|
+
{
|
|
377
|
+
let loc = offset_to_location(self.full_source, ast.byte_offset, span_start, span_end);
|
|
378
|
+
|
|
379
|
+
let ctx_var = self.handler_ctx_name.as_deref().unwrap_or("ctx");
|
|
380
|
+
let mut diag = Diagnostic::error(
|
|
381
|
+
"waituntil/unawaited-async",
|
|
382
|
+
format!(
|
|
383
|
+
"Un-awaited asynchronous operation '{}' in request handler will be terminated early when the response completes.",
|
|
384
|
+
call_desc
|
|
385
|
+
),
|
|
386
|
+
self.file_path,
|
|
387
|
+
)
|
|
388
|
+
.with_location(loc)
|
|
389
|
+
.with_suggestion(
|
|
390
|
+
format!(
|
|
391
|
+
"Pass promise to '{}.waitUntil(...)', or use 'await'.",
|
|
392
|
+
ctx_var
|
|
393
|
+
),
|
|
394
|
+
Some(format!("{}.waitUntil(...)", ctx_var)),
|
|
395
|
+
);
|
|
396
|
+
diag.code_snippet = self.extract_snippet(loc.line);
|
|
397
|
+
self.diagnostics.push(diag);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|