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.
@@ -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
+ }