diff --git a/Cargo.lock b/Cargo.lock index 09f216c..f6d45df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cashmere" -version = "0.4.0" +version = "0.5.0" dependencies = [ "assert_cmd", "clap", @@ -149,6 +149,7 @@ dependencies = [ "oxc_allocator", "oxc_ast", "oxc_parser", + "oxc_semantic", "oxc_span", "serde", "serde_json", @@ -275,6 +276,12 @@ version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "errno" version = "0.3.14" @@ -524,6 +531,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -707,6 +723,18 @@ dependencies = [ "syn", ] +[[package]] +name = "oxc_ast_visit" +version = "0.108.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561ace6525ddc90b36103764a959eb261ff7f92a76172a34ac2d24d579f1260d" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + [[package]] name = "oxc_data_structures" version = "0.108.0" @@ -794,6 +822,28 @@ dependencies = [ "unicode-id-start", ] +[[package]] +name = "oxc_semantic" +version = "0.108.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef9534d21d00ac38ca4eab91e7b7f4fa0f1c7f0279d07865074c05357366d5c" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + [[package]] name = "oxc_span" version = "0.108.0" @@ -1052,6 +1102,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "seq-macro" version = "0.3.6" @@ -1139,6 +1195,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" diff --git a/Cargo.toml b/Cargo.toml index 3d112a8..2e66701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ oxc_parser = "0.108" oxc_ast = "0.108" oxc_span = "0.108" oxc_allocator = "0.108" +oxc_semantic = "0.108" walkdir = "2" clap = { version = "4", features = ["derive"] } tower-lsp = "0.20" diff --git a/src/linter.rs b/src/linter.rs index 348a861..9f1a2ad 100644 --- a/src/linter.rs +++ b/src/linter.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use oxc_allocator::Allocator; use oxc_ast::ast::*; use oxc_parser::{Parser as OxcParser, ParserReturn}; +use oxc_semantic::{Semantic, SemanticBuilder, SymbolId}; use oxc_span::{GetSpan, SourceType, Span}; #[derive(Debug, Clone)] @@ -44,6 +45,12 @@ fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { (line, col) } +/// Context for tracking when we're inside a step.do callback +#[derive(Debug, Clone)] +struct StepCallbackContext { + method_name: String, // e.g., "step.do" +} + /// Tracks step promise calls within a function scope #[derive(Debug, Default)] struct StepPromiseTracker { @@ -108,21 +115,366 @@ impl StepPromiseTracker { } } -pub struct Linter<'a> { +/// Tracks which symbols are known to be WorkflowStep instances +#[derive(Debug, Default)] +struct WorkflowStepSymbols { + /// SymbolIds of parameters explicitly typed as WorkflowStep (TypeScript only) + typed_step_symbols: HashSet, + /// SymbolIds of 2nd params of run() methods in classes extending WorkflowEntrypoint + /// (JavaScript heuristic - also applies to TypeScript) + inferred_step_symbols: HashSet, +} + +impl WorkflowStepSymbols { + fn contains(&self, symbol_id: SymbolId) -> bool { + self.typed_step_symbols.contains(&symbol_id) + || self.inferred_step_symbols.contains(&symbol_id) + } +} + +/// Tracks imports from "cloudflare:workers" +/// Used only for JavaScript heuristic (detecting classes that extend WorkflowEntrypoint) +#[derive(Debug, Default)] +struct CloudflareImports { + /// Maps local name -> imported name for imports from "cloudflare:workers" + /// e.g., if `import { WorkflowEntrypoint as WE } from "cloudflare:workers"`, + /// then local_to_imported["WE"] = "WorkflowEntrypoint" + local_to_imported: HashMap, + /// SymbolIds of imported items from "cloudflare:workers" + symbol_ids: HashMap, +} + +impl CloudflareImports { + fn is_workflow_entrypoint(&self, local_name: &str) -> bool { + self.local_to_imported + .get(local_name) + .map(|imported| imported == "WorkflowEntrypoint") + .unwrap_or(false) + } + + fn get_workflow_entrypoint_symbol(&self) -> Option { + for (local_name, imported_name) in &self.local_to_imported { + if imported_name == "WorkflowEntrypoint" { + return self.symbol_ids.get(local_name).copied(); + } + } + None + } +} + +/// Find all imports from "cloudflare:workers" +/// Used for JavaScript heuristic (detecting WorkflowEntrypoint class) +fn find_cloudflare_imports(program: &Program) -> CloudflareImports { + let mut imports = CloudflareImports::default(); + + for stmt in &program.body { + if let Statement::ImportDeclaration(import_decl) = stmt { + let source = import_decl.source.value.as_str(); + if source == "cloudflare:workers" { + if let Some(specifiers) = &import_decl.specifiers { + for specifier in specifiers { + if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { + let local_name = spec.local.name.as_str().to_string(); + let imported_name = spec.imported.name().as_str().to_string(); + imports + .local_to_imported + .insert(local_name.clone(), imported_name); + + // Get the SymbolId for this import + if let Some(symbol_id) = spec.local.symbol_id.get() { + imports.symbol_ids.insert(local_name, symbol_id); + } + } + } + } + } + } + } + + imports +} + +/// Check if a type annotation refers to WorkflowStep +/// For TypeScript, we simply check if the type name is "WorkflowStep" +/// (the type comes from @cloudflare/workers-types ambient declarations) +fn is_workflow_step_type(type_ann: &TSTypeAnnotation) -> bool { + if let TSType::TSTypeReference(type_ref) = &type_ann.type_annotation { + if let TSTypeName::IdentifierReference(id) = &type_ref.type_name { + return id.name.as_str() == "WorkflowStep"; + } + } + false +} + +/// Find all parameters typed as WorkflowStep (TypeScript only) +/// Simply looks for any parameter with type annotation "WorkflowStep" +fn find_typed_workflow_step_symbols(program: &Program) -> HashSet { + let mut symbols = HashSet::new(); + + // Helper to process function parameters + fn process_params(params: &FormalParameters, symbols: &mut HashSet) { + for param in ¶ms.items { + // type_annotation is directly on FormalParameter + if let Some(type_ann) = ¶m.type_annotation { + if is_workflow_step_type(type_ann) { + // Get the symbol id from the binding pattern + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = param.pattern.get_binding_identifier() { + if let Some(symbol_id) = id.symbol_id.get() { + symbols.insert(symbol_id); + } + } + } + } + } + } + + // Walk the AST to find all functions with WorkflowStep typed parameters + for stmt in &program.body { + match stmt { + Statement::FunctionDeclaration(func) => { + process_params(&func.params, &mut symbols); + } + Statement::ExportDefaultDeclaration(export) => { + if let ExportDefaultDeclarationKind::FunctionDeclaration(func) = &export.declaration + { + process_params(&func.params, &mut symbols); + } + if let ExportDefaultDeclarationKind::ClassDeclaration(class) = &export.declaration { + process_class_methods(class, &mut symbols); + } + } + Statement::ExportNamedDeclaration(export) => { + if let Some(Declaration::FunctionDeclaration(func)) = &export.declaration { + process_params(&func.params, &mut symbols); + } + if let Some(Declaration::ClassDeclaration(class)) = &export.declaration { + process_class_methods(class, &mut symbols); + } + } + Statement::ClassDeclaration(class) => { + process_class_methods(class, &mut symbols); + } + Statement::VariableDeclaration(var_decl) => { + for declarator in &var_decl.declarations { + if let Some(init) = &declarator.init { + process_expression_functions(init, &mut symbols); + } + } + } + Statement::ExpressionStatement(expr_stmt) => { + process_expression_functions(&expr_stmt.expression, &mut symbols); + } + _ => {} + } + } + + symbols +} + +/// Process class methods to find WorkflowStep typed parameters +fn process_class_methods(class: &Class, symbols: &mut HashSet) { + for element in &class.body.body { + if let ClassElement::MethodDefinition(method) = element { + for param in &method.value.params.items { + // type_annotation is directly on FormalParameter + if let Some(type_ann) = ¶m.type_annotation { + if is_workflow_step_type(type_ann) { + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = param.pattern.get_binding_identifier() { + if let Some(symbol_id) = id.symbol_id.get() { + symbols.insert(symbol_id); + } + } + } + } + } + } + } +} + +/// Process function expressions (arrow functions, function expressions) for WorkflowStep params +fn process_expression_functions(expr: &Expression, symbols: &mut HashSet) { + match expr { + Expression::ArrowFunctionExpression(arrow) => { + for param in &arrow.params.items { + // type_annotation is directly on FormalParameter + if let Some(type_ann) = ¶m.type_annotation { + if is_workflow_step_type(type_ann) { + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = param.pattern.get_binding_identifier() { + if let Some(symbol_id) = id.symbol_id.get() { + symbols.insert(symbol_id); + } + } + } + } + } + } + Expression::FunctionExpression(func) => { + for param in &func.params.items { + // type_annotation is directly on FormalParameter + if let Some(type_ann) = ¶m.type_annotation { + if is_workflow_step_type(type_ann) { + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = param.pattern.get_binding_identifier() { + if let Some(symbol_id) = id.symbol_id.get() { + symbols.insert(symbol_id); + } + } + } + } + } + } + _ => {} + } +} + +/// Find inferred WorkflowStep symbols (2nd param of run() in classes extending WorkflowEntrypoint) +/// This works for both JavaScript and TypeScript +fn find_inferred_workflow_step_symbols( + program: &Program, + semantic: &Semantic, + cloudflare_imports: &CloudflareImports, +) -> HashSet { + let mut symbols = HashSet::new(); + + // Get the SymbolId for WorkflowEntrypoint import (if any) + let workflow_entrypoint_symbol = cloudflare_imports.get_workflow_entrypoint_symbol(); + + // Helper to check if a class extends WorkflowEntrypoint + fn extends_workflow_entrypoint( + class: &Class, + semantic: &Semantic, + cloudflare_imports: &CloudflareImports, + workflow_entrypoint_symbol: Option, + ) -> bool { + if let Some(super_class) = &class.super_class { + if let Expression::Identifier(id) = super_class { + // Check by name first + if cloudflare_imports.is_workflow_entrypoint(id.name.as_str()) { + return true; + } + + // Also check by symbol resolution + if let Some(expected_symbol) = workflow_entrypoint_symbol { + if let Some(reference_id) = id.reference_id.get() { + let reference = semantic.scoping().get_reference(reference_id); + if let Some(symbol_id) = reference.symbol_id() { + return symbol_id == expected_symbol; + } + } + } + } + } + false + } + + // Helper to get the 2nd parameter's SymbolId from a run() method + fn get_run_method_step_param(class: &Class) -> Option { + for element in &class.body.body { + if let ClassElement::MethodDefinition(method) = element { + // Check if this is the "run" method + if let PropertyKey::StaticIdentifier(id) = &method.key { + if id.name.as_str() == "run" { + // Get the 2nd parameter (index 1) + if let Some(param) = method.value.params.items.get(1) { + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = param.pattern.get_binding_identifier() { + return id.symbol_id.get(); + } + } + } + } + } + } + None + } + + // Walk the AST to find classes extending WorkflowEntrypoint + for stmt in &program.body { + let class = match stmt { + Statement::ClassDeclaration(class) => Some(class.as_ref()), + Statement::ExportDefaultDeclaration(export) => { + if let ExportDefaultDeclarationKind::ClassDeclaration(class) = &export.declaration { + Some(class.as_ref()) + } else { + None + } + } + Statement::ExportNamedDeclaration(export) => { + if let Some(Declaration::ClassDeclaration(class)) = &export.declaration { + Some(class.as_ref()) + } else { + None + } + } + _ => None, + }; + + if let Some(class) = class { + if extends_workflow_entrypoint( + class, + semantic, + cloudflare_imports, + workflow_entrypoint_symbol, + ) { + if let Some(symbol_id) = get_run_method_step_param(class) { + symbols.insert(symbol_id); + } + } + } + } + + symbols +} + +/// Build the complete WorkflowStepSymbols from the program +fn build_workflow_step_symbols(program: &Program, semantic: &Semantic) -> WorkflowStepSymbols { + // For TypeScript: find parameters typed as WorkflowStep (no import needed) + let typed_step_symbols = find_typed_workflow_step_symbols(program); + + // For JavaScript: find 2nd param of run() in classes extending WorkflowEntrypoint + // This requires tracking imports from "cloudflare:workers" + let cloudflare_imports = find_cloudflare_imports(program); + let inferred_step_symbols = + find_inferred_workflow_step_symbols(program, semantic, &cloudflare_imports); + + WorkflowStepSymbols { + typed_step_symbols, + inferred_step_symbols, + } +} + +struct Linter<'a> { source: &'a str, file_path: &'a str, diagnostics: Vec, /// Stack of trackers for nested function scopes tracker_stack: Vec, + /// Stack for tracking when we're inside a step.do callback (for nested-step rule) + step_callback_stack: Vec, + /// Semantic model for symbol resolution + semantic: &'a Semantic<'a>, + /// Known WorkflowStep symbols + workflow_step_symbols: WorkflowStepSymbols, } impl<'a> Linter<'a> { - pub fn new(source: &'a str, file_path: &'a str) -> Self { + fn new( + source: &'a str, + file_path: &'a str, + semantic: &'a Semantic<'a>, + workflow_step_symbols: WorkflowStepSymbols, + ) -> Self { Self { source, file_path, diagnostics: Vec::new(), tracker_stack: Vec::new(), + step_callback_stack: Vec::new(), + semantic, + workflow_step_symbols, } } @@ -151,7 +503,7 @@ impl<'a> Linter<'a> { } } - pub fn lint_program(&mut self, program: &Program) { + fn lint_program(&mut self, program: &Program) { // Push a tracker for the top-level scope self.push_tracker(); for stmt in &program.body { @@ -277,7 +629,8 @@ impl<'a> Linter<'a> { if let Expression::CallExpression(call) = init { if self.is_step_method_call(call) { // Get the variable name being assigned to - if let BindingPattern::BindingIdentifier(id) = &declarator.id { + // BindingPattern is an enum - use get_binding_identifier() + if let Some(id) = declarator.id.get_binding_identifier() { let var_name = id.name.as_str(); let method_name = self.get_step_method_name(call); if let Some(tracker) = self.current_tracker() { @@ -404,6 +757,22 @@ impl<'a> Linter<'a> { // Check if this is a step.do or step.sleep call if self.is_step_method_call(call) { let method_name = self.get_step_method_name(call); + + // Check for nested step calls (nested-step rule) + if let Some(outer_ctx) = self.step_callback_stack.last() { + self.diagnostics.push(LintDiagnostic::new( + self.file_path, + self.source, + call.span(), + &format!( + "`{}` is nested inside `{}`. Nested steps are discouraged as they can cause unexpected behavior during workflow replay.", + method_name, + outer_ctx.method_name + ), + "nested-step", + )); + } + if is_awaited { // Immediately awaited - mark as awaited by span if let Some(tracker) = self.current_tracker() { @@ -413,11 +782,18 @@ impl<'a> Linter<'a> { // Not immediately awaited and not in a variable assignment // Record as unassigned unawaited step if let Some(tracker) = self.current_tracker() { - tracker.record_unassigned_unawaited_step(call.span(), method_name); + tracker + .record_unassigned_unawaited_step(call.span(), method_name.clone()); } } - // Still lint the call's arguments - self.lint_call_arguments(call); + + // Handle step.do callback specially for nested step detection + if self.is_step_do_call(call) { + self.lint_step_do_with_callback(call, &method_name); + } else { + // For non-step.do calls, just lint arguments normally + self.lint_call_arguments(call); + } return; } @@ -540,15 +916,20 @@ impl<'a> Linter<'a> { } } - /// Check if the call expression is a step.do() or step.sleep() call + /// Check if the call expression is a WorkflowStep method call (do, sleep, etc.) + /// Uses semantic analysis to verify the object is actually a WorkflowStep fn is_step_method_call(&self, call: &CallExpression) -> bool { if let Expression::StaticMemberExpression(member) = &call.callee { let method_name = member.property.name.as_str(); if matches!(method_name, "do" | "sleep" | "waitForEvent" | "sleepUntil") { - // Check if the object is named "step" (or ends with step-like pattern) if let Expression::Identifier(id) = &member.object { - let name = id.name.as_str().to_lowercase(); - return name == "step" || name.ends_with("step"); + // Use semantic resolution to check if this identifier refers to a WorkflowStep + if let Some(reference_id) = id.reference_id.get() { + let reference = self.semantic.scoping().get_reference(reference_id); + if let Some(symbol_id) = reference.symbol_id() { + return self.workflow_step_symbols.contains(symbol_id); + } + } } } } @@ -567,7 +948,49 @@ impl<'a> Linter<'a> { "step.do".to_string() } - pub fn into_diagnostics(self) -> Vec { + /// Check if this is specifically a step.do call (which has a callback) + fn is_step_do_call(&self, call: &CallExpression) -> bool { + if let Expression::StaticMemberExpression(member) = &call.callee { + let method_name = member.property.name.as_str(); + if method_name == "do" { + if let Expression::Identifier(id) = &member.object { + // Use semantic resolution + if let Some(reference_id) = id.reference_id.get() { + let reference = self.semantic.scoping().get_reference(reference_id); + if let Some(symbol_id) = reference.symbol_id() { + return self.workflow_step_symbols.contains(symbol_id); + } + } + } + } + } + false + } + + /// Lint a step.do call, handling the callback specially for nested step detection + fn lint_step_do_with_callback(&mut self, call: &CallExpression, method_name: &str) { + // step.do signature: step.do(name, callback, options?) + // The callback is the second argument (index 1) + for (i, arg) in call.arguments.iter().enumerate() { + if let Some(expr) = arg.as_expression() { + if i == 1 { + // This is the callback argument - push context before linting + self.step_callback_stack.push(StepCallbackContext { + method_name: method_name.to_string(), + }); + self.lint_expression(expr, false); + self.step_callback_stack.pop(); + } else { + // Other arguments (name, options) - lint normally + self.lint_expression(expr, false); + } + } else if let Argument::SpreadElement(spread) = arg { + self.lint_expression(&spread.argument, false); + } + } + } + + fn into_diagnostics(self) -> Vec { self.diagnostics } } @@ -577,7 +1000,14 @@ pub fn lint_source(source: &str, file_path: &str) -> Vec { let allocator = Allocator::default(); let ParserReturn { program, .. } = OxcParser::new(&allocator, source, source_type).parse(); - let mut linter = Linter::new(source, file_path); + // Build semantic model for symbol resolution + let semantic_ret = SemanticBuilder::new().build(&program); + let semantic = semantic_ret.semantic; + + // Find all WorkflowStep symbols (typed for TS, inferred for JS) + let workflow_step_symbols = build_workflow_step_symbols(&program, &semantic); + + let mut linter = Linter::new(source, file_path, &semantic, workflow_step_symbols); linter.lint_program(&program); linter.into_diagnostics() } diff --git a/tests/cli.rs b/tests/cli.rs index a8018f9..1f2eeff 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5,6 +5,7 @@ use tempfile::NamedTempFile; #[test] fn test_unawaited_step_do_is_flagged() { // TypeScript code with unawaited step.do() call + // No import needed - WorkflowStep comes from @cloudflare/workers-types ambient declarations let typescript_code = r#" export class MyWorkflow { async run(step: WorkflowStep) { @@ -421,3 +422,496 @@ async function workflow(step: WorkflowStep) { println!("=== Actual Output ==="); println!("{}", stdout); } + +// ============================================================================ +// nested-step rule tests +// ============================================================================ + +#[test] +fn test_nested_step_do_in_step_do_is_flagged() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + await step.do('inner', async () => { + return { done: true }; + }); + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("nested inside"), + "Expected nested step error\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_nested_step_sleep_in_step_do_is_flagged() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + await step.sleep('wait', '1 second'); + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("`step.sleep` is nested inside `step.do`"), + "Expected nested step.sleep error\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_nested_step_wait_for_event_in_step_do_is_flagged() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + await step.waitForEvent('event', { timeout: '1 minute' }); + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("`step.waitForEvent` is nested inside `step.do`"), + "Expected nested step.waitForEvent error\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_nested_step_in_conditional_is_flagged() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + if (someCondition) { + await step.do('inner', async () => {}); + } + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("nested inside"), + "Expected nested step error even in conditional\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_nested_step_in_inline_function_is_flagged() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + const helper = async () => { + await step.do('inner', async () => {}); + }; + await helper(); + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("nested inside"), + "Expected nested step error in inline function\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_deeply_nested_step_callbacks_flagged() { + // Triple nesting: outer -> middle -> inner + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('outer', async () => { + await step.do('middle', async () => { + await step.do('inner', async () => {}); + }); + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Should have at least 2 nested-step errors (middle nested in outer, inner nested in middle) + assert!( + stdout.contains("[nested-step]"), + "Expected [nested-step] rule name\nActual output:\n{}", + stdout + ); + // Count occurrences of "nested inside" + let nested_count = stdout.matches("nested inside").count(); + assert!( + nested_count >= 2, + "Expected at least 2 nested step errors, found {}\nActual output:\n{}", + nested_count, + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_sequential_steps_pass() { + // Steps at the same level should not be flagged + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.do('first', async () => { + return { done: true }; + }); + await step.sleep('wait', '1 second'); + await step.do('second', async () => { + return { done: true }; + }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("No issues found"), + "Expected no issues for sequential steps\nActual output:\n{}", + stdout + ); + assert!(output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_step_sleep_outside_callback_passes() { + let typescript_code = r#" +async function workflow(step: WorkflowStep) { + await step.sleep('wait', '1 second'); + await step.waitForEvent('event', { timeout: '1 minute' }); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("No issues found"), + "Expected no issues for steps outside callbacks\nActual output:\n{}", + stdout + ); + assert!(output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +// ============================================================================ +// Semantic-based detection tests (false positive prevention) +// ============================================================================ + +#[test] +fn test_unrelated_step_object_not_flagged() { + // This should NOT be flagged - "step" is just a regular object, not a WorkflowStep + let typescript_code = r#" +const step = { + do: async (name: string, fn: () => void) => { fn(); }, + sleep: async (name: string, duration: string) => {} +}; + +async function someFunction() { + // These should NOT trigger errors - step is not a WorkflowStep + step.do('task', async () => {}); + step.sleep('wait', '1 second'); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("No issues found"), + "Expected no issues for unrelated 'step' object\nActual output:\n{}", + stdout + ); + assert!(output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_javascript_workflow_entrypoint_class_detected() { + // JavaScript file (no type annotations) with class extending WorkflowEntrypoint + // The 2nd parameter of run() should be inferred as WorkflowStep + let javascript_code = r#" +import { WorkflowEntrypoint } from "cloudflare:workers"; + +export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + // This should be flagged - step.do() is not awaited + step.do('task', async () => {}); + } +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".js").unwrap(); + temp_file.write_all(javascript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("`step.do` must be awaited."), + "Expected error for unawaited step.do() in JS workflow\nActual output:\n{}", + stdout + ); + assert!( + stdout.contains("[await-step]"), + "Expected [await-step] rule name\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input JavaScript ==="); + println!("{}", javascript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_javascript_workflow_entrypoint_awaited_passes() { + // JavaScript file where step calls are properly awaited + let javascript_code = r#" +import { WorkflowEntrypoint } from "cloudflare:workers"; + +export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + await step.do('task', async () => {}); + await step.sleep('wait', '1 second'); + } +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".js").unwrap(); + temp_file.write_all(javascript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("No issues found"), + "Expected no issues for properly awaited JS workflow\nActual output:\n{}", + stdout + ); + assert!(output.status.success()); + + println!("=== Input JavaScript ==="); + println!("{}", javascript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_different_type_name_not_flagged() { + // If the type annotation is not literally "WorkflowStep", it should NOT be flagged + // This tests that we only detect the specific type name + let typescript_code = r#" +type WS = { + do(name: string, fn: () => void): Promise; + sleep(name: string, duration: string): Promise; +}; + +async function workflow(step: WS) { + // This should NOT be flagged - type is "WS", not "WorkflowStep" + step.do('task', async () => {}); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("No issues found"), + "Expected no issues when type is not 'WorkflowStep'\nActual output:\n{}", + stdout + ); + assert!(output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +} + +#[test] +fn test_workflow_step_type_always_detected() { + // Any parameter typed as "WorkflowStep" should be detected, regardless of where the type is defined + // The linter relies on the type name, assuming it comes from @cloudflare/workers-types + let typescript_code = r#" +// Even with a local interface, if it's named WorkflowStep, it will be detected +// This is intentional - we trust that "WorkflowStep" means the Cloudflare Workflows type +interface WorkflowStep { + do(name: string, fn: () => void): Promise; + sleep(name: string, duration: string): Promise; +} + +async function workflow(step: WorkflowStep) { + // This WILL be flagged because the type is named "WorkflowStep" + step.do('task', async () => {}); +} +"#; + + let mut temp_file = NamedTempFile::with_suffix(".ts").unwrap(); + temp_file.write_all(typescript_code.as_bytes()).unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let mut cmd = Command::cargo_bin("cashmere").unwrap(); + let output = cmd.arg(temp_path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("`step.do` must be awaited."), + "Expected error for unawaited step.do() with WorkflowStep type\nActual output:\n{}", + stdout + ); + assert!(!output.status.success()); + + println!("=== Input TypeScript ==="); + println!("{}", typescript_code); + println!("=== Actual Output ==="); + println!("{}", stdout); +}