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/src/parser.rs ADDED
@@ -0,0 +1,215 @@
1
+ use oxc_allocator::Allocator;
2
+ use oxc_ast::ast::Program;
3
+ use oxc_parser::Parser;
4
+ use oxc_span::SourceType;
5
+ use std::fs;
6
+ use std::path::{Path, PathBuf};
7
+ use walkdir::WalkDir;
8
+
9
+ use crate::diagnostics::SourceLocation;
10
+
11
+ #[derive(Debug, Clone)]
12
+ pub struct SourceScript<'a> {
13
+ pub content: &'a str,
14
+ pub source_type: SourceType,
15
+ pub byte_offset: usize,
16
+ pub is_astro_frontmatter: bool,
17
+ }
18
+
19
+ #[derive(Debug)]
20
+ pub struct ParsedFile<'a> {
21
+ pub path: PathBuf,
22
+ pub full_source: String,
23
+ pub scripts: Vec<SourceScript<'a>>,
24
+ }
25
+
26
+ pub struct AstUnit<'a> {
27
+ pub program: Program<'a>,
28
+ pub byte_offset: usize,
29
+ pub script_source: &'a str,
30
+ }
31
+
32
+ pub fn is_supported_source_file(path: &Path) -> bool {
33
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
34
+ matches!(
35
+ ext,
36
+ "js" | "mjs" | "cjs" | "ts" | "mts" | "cts" | "jsx" | "tsx" | "astro"
37
+ )
38
+ }
39
+
40
+ pub fn is_routes_json_file(path: &Path) -> bool {
41
+ path.file_name()
42
+ .and_then(|n| n.to_str())
43
+ .is_some_and(|name| name == "_routes.json")
44
+ }
45
+
46
+ pub fn discover_files(root: &Path) -> Vec<PathBuf> {
47
+ if root.is_file() {
48
+ return vec![root.to_path_buf()];
49
+ }
50
+
51
+ let mut files = Vec::new();
52
+ for entry in WalkDir::new(root)
53
+ .into_iter()
54
+ .filter_entry(|e| !should_ignore_entry(e.path()))
55
+ .filter_map(Result::ok)
56
+ {
57
+ let path = entry.path();
58
+ if path.is_file() && (is_supported_source_file(path) || is_routes_json_file(path)) {
59
+ files.push(path.to_path_buf());
60
+ }
61
+ }
62
+ files.sort();
63
+ files
64
+ }
65
+
66
+ pub fn should_ignore_entry(path: &Path) -> bool {
67
+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
68
+ matches!(
69
+ name,
70
+ "node_modules"
71
+ | "dist"
72
+ | ".wrangler"
73
+ | ".astro"
74
+ | "build"
75
+ | "target"
76
+ | "coverage"
77
+ | ".git"
78
+ | ".turbo"
79
+ | ".next"
80
+ )
81
+ } else {
82
+ false
83
+ }
84
+ }
85
+
86
+ pub fn extract_scripts<'a>(path: &Path, content: &'a str) -> Vec<SourceScript<'a>> {
87
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
88
+ if ext == "astro" {
89
+ extract_astro_scripts(content)
90
+ } else {
91
+ let source_type = SourceType::from_path(path).unwrap_or_else(|_| {
92
+ SourceType::default()
93
+ .with_module(true)
94
+ .with_typescript(true)
95
+ .with_jsx(true)
96
+ });
97
+ vec![SourceScript {
98
+ content,
99
+ source_type,
100
+ byte_offset: 0,
101
+ is_astro_frontmatter: false,
102
+ }]
103
+ }
104
+ }
105
+
106
+ fn extract_astro_scripts(content: &str) -> Vec<SourceScript<'_>> {
107
+ let mut scripts = Vec::new();
108
+ let trimmed = content.trim_start();
109
+ let leading_whitespace_len = content.len() - trimmed.len();
110
+
111
+ if let Some(after_first) = trimmed.strip_prefix("---")
112
+ && let Some(end_idx) = after_first.find("---")
113
+ {
114
+ let frontmatter = &after_first[..end_idx];
115
+ let byte_offset = leading_whitespace_len + 3;
116
+ scripts.push(SourceScript {
117
+ content: frontmatter,
118
+ source_type: SourceType::default()
119
+ .with_module(true)
120
+ .with_typescript(true)
121
+ .with_jsx(true),
122
+ byte_offset,
123
+ is_astro_frontmatter: true,
124
+ });
125
+ }
126
+
127
+ let mut search_pos = 0;
128
+ while let Some(start_tag) = content[search_pos..].find("<script") {
129
+ let actual_start = search_pos + start_tag;
130
+ if let Some(tag_end) = content[actual_start..].find('>') {
131
+ let body_start = actual_start + tag_end + 1;
132
+ if let Some(close_tag) = content[body_start..].find("</script>") {
133
+ let script_body = &content[body_start..body_start + close_tag];
134
+ scripts.push(SourceScript {
135
+ content: script_body,
136
+ source_type: SourceType::default()
137
+ .with_module(true)
138
+ .with_typescript(true)
139
+ .with_jsx(true),
140
+ byte_offset: body_start,
141
+ is_astro_frontmatter: false,
142
+ });
143
+ search_pos = body_start + close_tag + 9;
144
+ } else {
145
+ break;
146
+ }
147
+ } else {
148
+ break;
149
+ }
150
+ }
151
+
152
+ if scripts.is_empty() {
153
+ scripts.push(SourceScript {
154
+ content,
155
+ source_type: SourceType::default()
156
+ .with_module(true)
157
+ .with_typescript(true)
158
+ .with_jsx(true),
159
+ byte_offset: 0,
160
+ is_astro_frontmatter: false,
161
+ });
162
+ }
163
+
164
+ scripts
165
+ }
166
+
167
+ pub fn parse_ast<'a>(
168
+ allocator: &'a Allocator,
169
+ script: &SourceScript<'a>,
170
+ ) -> Result<AstUnit<'a>, String> {
171
+ let ret = Parser::new(allocator, script.content, script.source_type).parse();
172
+
173
+ if !ret.errors.is_empty() {
174
+ let first_err = &ret.errors[0];
175
+ return Err(format!("Parse error: {}", first_err));
176
+ }
177
+
178
+ Ok(AstUnit {
179
+ program: ret.program,
180
+ byte_offset: script.byte_offset,
181
+ script_source: script.content,
182
+ })
183
+ }
184
+
185
+ pub fn offset_to_location(
186
+ full_source: &str,
187
+ script_byte_offset: usize,
188
+ span_start: u32,
189
+ span_end: u32,
190
+ ) -> SourceLocation {
191
+ let absolute_start = script_byte_offset + span_start as usize;
192
+ let absolute_end = script_byte_offset + span_end as usize;
193
+ let length = absolute_end.saturating_sub(absolute_start);
194
+
195
+ let mut line = 1;
196
+ let mut col = 1;
197
+
198
+ let target_offset = absolute_start.min(full_source.len());
199
+ let sub = &full_source[..target_offset];
200
+
201
+ for c in sub.chars() {
202
+ if c == '\n' {
203
+ line += 1;
204
+ col = 1;
205
+ } else {
206
+ col += 1;
207
+ }
208
+ }
209
+
210
+ SourceLocation::new(line, col, target_offset, length)
211
+ }
212
+
213
+ pub fn read_file_string(path: &Path) -> Result<String, String> {
214
+ fs::read_to_string(path).map_err(|e| format!("Failed to read file {}: {}", path.display(), e))
215
+ }
@@ -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 DoStorageLinter<'a> {
8
+ pub file_path: &'a Path,
9
+ pub full_source: &'a str,
10
+ pub diagnostics: Vec<Diagnostic>,
11
+ in_transaction_depth: usize,
12
+ }
13
+
14
+ impl<'a> DoStorageLinter<'a> {
15
+ pub fn new(file_path: &'a Path, full_source: &'a str) -> Self {
16
+ Self {
17
+ file_path,
18
+ full_source,
19
+ diagnostics: Vec::new(),
20
+ in_transaction_depth: 0,
21
+ }
22
+ }
23
+
24
+ pub fn lint_ast(&mut self, ast: &AstUnit<'_>) {
25
+ for stmt in &ast.program.body {
26
+ self.visit_statement(stmt, ast);
27
+ }
28
+ }
29
+
30
+ fn extract_snippet(&self, target_line: usize) -> Option<String> {
31
+ let lines: Vec<&str> = self.full_source.lines().collect();
32
+ if target_line == 0 || target_line > lines.len() {
33
+ return None;
34
+ }
35
+ Some(lines[target_line - 1].trim().to_string())
36
+ }
37
+
38
+ fn is_storage_member_call<'b>(
39
+ &self,
40
+ call: &'b CallExpression<'_>,
41
+ ) -> Option<(&'b str, String)> {
42
+ if let Some(mem) = call.callee.as_member_expression()
43
+ && let Some(prop_name) = mem.static_property_name()
44
+ && matches!(
45
+ prop_name,
46
+ "put"
47
+ | "get"
48
+ | "delete"
49
+ | "deleteAll"
50
+ | "list"
51
+ | "getAlarm"
52
+ | "setAlarm"
53
+ | "deleteAlarm"
54
+ | "sync"
55
+ | "transaction"
56
+ | "sql"
57
+ )
58
+ {
59
+ let obj = mem.object();
60
+ if self.is_storage_object(obj) {
61
+ return Some((prop_name, self.format_member_expr(mem)));
62
+ }
63
+ }
64
+ None
65
+ }
66
+
67
+ fn format_member_expr(&self, mem: &MemberExpression<'_>) -> String {
68
+ let prop = mem.static_property_name().unwrap_or("method");
69
+ if let Some(sub_mem) = mem.object().as_member_expression() {
70
+ let sub_prop = sub_mem.static_property_name().unwrap_or("storage");
71
+ format!("{}.{}", sub_prop, prop)
72
+ } else {
73
+ match mem.object() {
74
+ Expression::Identifier(ident) => {
75
+ format!("{}.{}", ident.name, prop)
76
+ }
77
+ Expression::ThisExpression(_) => {
78
+ format!("this.{}", prop)
79
+ }
80
+ _ => format!("storage.{}", prop),
81
+ }
82
+ }
83
+ }
84
+
85
+ fn is_storage_object(&self, expr: &Expression<'_>) -> bool {
86
+ if let Some(mem) = expr.as_member_expression() {
87
+ if let Some(prop) = mem.static_property_name()
88
+ && prop == "storage"
89
+ {
90
+ return true;
91
+ }
92
+ if let Expression::ThisExpression(_) = mem.object() {
93
+ return mem.static_property_name() == Some("storage")
94
+ || mem.static_property_name() == Some("ctx")
95
+ || mem.static_property_name() == Some("state");
96
+ }
97
+ if let Some(inner) = mem.object().as_member_expression() {
98
+ return inner.static_property_name() == Some("storage");
99
+ }
100
+ return false;
101
+ }
102
+
103
+ match expr {
104
+ Expression::Identifier(ident) => {
105
+ ident.name == "storage" || ident.name == "state" || ident.name == "ctx"
106
+ }
107
+ _ => false,
108
+ }
109
+ }
110
+
111
+ fn visit_statement(&mut self, stmt: &Statement<'_>, ast: &AstUnit<'_>) {
112
+ match stmt {
113
+ Statement::ExportNamedDeclaration(export_named) => {
114
+ if let Some(decl) = &export_named.declaration {
115
+ self.visit_declaration(decl, ast);
116
+ }
117
+ }
118
+ Statement::ExportDefaultDeclaration(export_default) => {
119
+ match &export_default.declaration {
120
+ ExportDefaultDeclarationKind::ClassDeclaration(cls) => {
121
+ self.visit_class(cls, ast);
122
+ }
123
+ ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
124
+ self.visit_function(func, ast);
125
+ }
126
+ decl => {
127
+ if let Some(expr) = decl.as_expression() {
128
+ self.visit_expression(expr, ast);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ Statement::ExpressionStatement(expr_stmt) => {
134
+ if let Expression::CallExpression(call) = &expr_stmt.expression
135
+ && let Some((method, display_name)) = self.is_storage_member_call(call)
136
+ {
137
+ let loc = offset_to_location(
138
+ self.full_source,
139
+ ast.byte_offset,
140
+ expr_stmt.span.start,
141
+ expr_stmt.span.end,
142
+ );
143
+
144
+ let mut diag = Diagnostic::error(
145
+ "do-storage/unawaited-storage-op",
146
+ format!(
147
+ "Durable Object storage operation '{display_name}()' must be awaited to guarantee persistence and avoid concurrency hazards."
148
+ ),
149
+ self.file_path,
150
+ )
151
+ .with_location(loc)
152
+ .with_suggestion(
153
+ format!("Add 'await' before '{display_name}(...)'."),
154
+ Some(format!("await {display_name}(...)")),
155
+ );
156
+ diag.code_snippet = self.extract_snippet(loc.line);
157
+ self.diagnostics.push(diag);
158
+
159
+ if method == "transaction" {
160
+ self.in_transaction_depth += 1;
161
+ for arg in &call.arguments {
162
+ if let Some(expr) = arg.as_expression() {
163
+ self.visit_expression(expr, ast);
164
+ }
165
+ }
166
+ self.in_transaction_depth -= 1;
167
+ return;
168
+ }
169
+ }
170
+ self.visit_expression(&expr_stmt.expression, ast);
171
+ }
172
+ Statement::BlockStatement(block) => {
173
+ for s in &block.body {
174
+ self.visit_statement(s, ast);
175
+ }
176
+ }
177
+ Statement::IfStatement(if_stmt) => {
178
+ self.visit_expression(&if_stmt.test, ast);
179
+ self.visit_statement(&if_stmt.consequent, ast);
180
+ if let Some(alt) = &if_stmt.alternate {
181
+ self.visit_statement(alt, ast);
182
+ }
183
+ }
184
+ Statement::VariableDeclaration(var_decl) => {
185
+ for decl in &var_decl.declarations {
186
+ if let Some(init) = &decl.init {
187
+ self.visit_expression(init, ast);
188
+ }
189
+ }
190
+ }
191
+ Statement::FunctionDeclaration(func) => {
192
+ self.visit_function(func, ast);
193
+ }
194
+ Statement::ClassDeclaration(cls) => {
195
+ self.visit_class(cls, ast);
196
+ }
197
+ Statement::ReturnStatement(ret) => {
198
+ if let Some(arg) = &ret.argument {
199
+ self.visit_expression(arg, ast);
200
+ }
201
+ }
202
+ Statement::TryStatement(try_stmt) => {
203
+ for s in &try_stmt.block.body {
204
+ self.visit_statement(s, ast);
205
+ }
206
+ if let Some(h) = &try_stmt.handler {
207
+ for s in &h.body.body {
208
+ self.visit_statement(s, ast);
209
+ }
210
+ }
211
+ if let Some(f) = &try_stmt.finalizer {
212
+ for s in &f.body {
213
+ self.visit_statement(s, ast);
214
+ }
215
+ }
216
+ }
217
+ _ => {}
218
+ }
219
+ }
220
+
221
+ fn visit_declaration(&mut self, decl: &Declaration<'_>, ast: &AstUnit<'_>) {
222
+ match decl {
223
+ Declaration::ClassDeclaration(cls) => self.visit_class(cls, ast),
224
+ Declaration::FunctionDeclaration(func) => self.visit_function(func, ast),
225
+ Declaration::VariableDeclaration(var_decl) => {
226
+ for d in &var_decl.declarations {
227
+ if let Some(init) = &d.init {
228
+ self.visit_expression(init, ast);
229
+ }
230
+ }
231
+ }
232
+ _ => {}
233
+ }
234
+ }
235
+
236
+ fn visit_function(&mut self, func: &Function<'_>, ast: &AstUnit<'_>) {
237
+ if let Some(body) = &func.body {
238
+ for s in &body.statements {
239
+ self.visit_statement(s, ast);
240
+ }
241
+ }
242
+ }
243
+
244
+ fn visit_class(&mut self, cls: &Class<'_>, ast: &AstUnit<'_>) {
245
+ for elem in &cls.body.body {
246
+ if let ClassElement::MethodDefinition(m) = elem
247
+ && let Some(body) = &m.value.body
248
+ {
249
+ for s in &body.statements {
250
+ self.visit_statement(s, ast);
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ fn visit_expression(&mut self, expr: &Expression<'_>, ast: &AstUnit<'_>) {
257
+ match expr {
258
+ Expression::CallExpression(call) => {
259
+ if self.is_promise_all_hazard(call) {
260
+ let loc = offset_to_location(
261
+ self.full_source,
262
+ ast.byte_offset,
263
+ call.span.start,
264
+ call.span.end,
265
+ );
266
+
267
+ let mut diag = Diagnostic::warning(
268
+ "do-storage/concurrent-write-hazard",
269
+ "Concurrent writes in Promise.all risk non-atomic state mutations and write collisions in Durable Objects.",
270
+ self.file_path,
271
+ )
272
+ .with_location(loc)
273
+ .with_suggestion(
274
+ "Use 'storage.transaction(async (txn) => { ... })' for atomic batch writes.",
275
+ None,
276
+ );
277
+ diag.code_snippet = self.extract_snippet(loc.line);
278
+ self.diagnostics.push(diag);
279
+ }
280
+
281
+ if let Some((method, display_name)) = self.is_storage_member_call(call) {
282
+ if method == "transaction" {
283
+ if self.in_transaction_depth > 0 {
284
+ let loc = offset_to_location(
285
+ self.full_source,
286
+ ast.byte_offset,
287
+ call.span.start,
288
+ call.span.end,
289
+ );
290
+ let mut diag = Diagnostic::error(
291
+ "do-storage/nested-transaction",
292
+ "Nested Durable Object transactions are not supported by the runtime.",
293
+ self.file_path,
294
+ )
295
+ .with_location(loc);
296
+ diag.code_snippet = self.extract_snippet(loc.line);
297
+ self.diagnostics.push(diag);
298
+ }
299
+
300
+ self.in_transaction_depth += 1;
301
+ for arg in &call.arguments {
302
+ if let Some(arg_expr) = arg.as_expression() {
303
+ self.visit_expression(arg_expr, ast);
304
+ }
305
+ }
306
+ self.in_transaction_depth -= 1;
307
+ return;
308
+ }
309
+
310
+ if self.in_transaction_depth > 0
311
+ && (display_name.starts_with("this.") || display_name.starts_with("state."))
312
+ {
313
+ let loc = offset_to_location(
314
+ self.full_source,
315
+ ast.byte_offset,
316
+ call.span.start,
317
+ call.span.end,
318
+ );
319
+ let mut diag = Diagnostic::warning(
320
+ "do-storage/transaction-escape",
321
+ format!(
322
+ "Calling '{display_name}()' inside a transaction bypasses the atomic transaction instance."
323
+ ),
324
+ self.file_path,
325
+ )
326
+ .with_location(loc)
327
+ .with_suggestion(
328
+ "Use the transaction handle (e.g. 'txn.put()') provided to the transaction callback.",
329
+ None,
330
+ );
331
+ diag.code_snippet = self.extract_snippet(loc.line);
332
+ self.diagnostics.push(diag);
333
+ }
334
+ }
335
+
336
+ for arg in &call.arguments {
337
+ if let Some(arg_expr) = arg.as_expression() {
338
+ self.visit_expression(arg_expr, ast);
339
+ }
340
+ }
341
+ }
342
+ Expression::AwaitExpression(aw) => {
343
+ self.visit_expression(&aw.argument, ast);
344
+ }
345
+ Expression::ArrowFunctionExpression(arrow) => {
346
+ for s in &arrow.body.statements {
347
+ self.visit_statement(s, ast);
348
+ }
349
+ }
350
+ Expression::FunctionExpression(func) => {
351
+ if let Some(body) = &func.body {
352
+ for s in &body.statements {
353
+ self.visit_statement(s, ast);
354
+ }
355
+ }
356
+ }
357
+ Expression::ArrayExpression(arr) => {
358
+ for el in &arr.elements {
359
+ if let Some(e) = el.as_expression() {
360
+ self.visit_expression(e, ast);
361
+ }
362
+ }
363
+ }
364
+ Expression::ObjectExpression(obj) => {
365
+ for prop in &obj.properties {
366
+ if let ObjectPropertyKind::ObjectProperty(p) = prop {
367
+ self.visit_expression(&p.value, ast);
368
+ }
369
+ }
370
+ }
371
+ _ => {}
372
+ }
373
+ }
374
+
375
+ fn is_promise_all_hazard(&self, call: &CallExpression<'_>) -> bool {
376
+ if let Some(mem) = call.callee.as_member_expression()
377
+ && let Expression::Identifier(ident) = mem.object()
378
+ && ident.name == "Promise"
379
+ && mem.static_property_name() == Some("all")
380
+ && let Some(first_arg) = call.arguments.first()
381
+ && let Some(Expression::ArrayExpression(arr)) = first_arg.as_expression()
382
+ {
383
+ let write_ops = arr
384
+ .elements
385
+ .iter()
386
+ .filter_map(|el| el.as_expression())
387
+ .filter(|e| {
388
+ if let Expression::CallExpression(c) = e
389
+ && let Some((method, _)) = self.is_storage_member_call(c)
390
+ {
391
+ return matches!(method, "put" | "delete" | "deleteAll");
392
+ }
393
+ false
394
+ })
395
+ .count();
396
+ return write_ops > 1;
397
+ }
398
+ false
399
+ }
400
+ }