diff --git a/CHANGELOG.md b/CHANGELOG.md index b058a3c3..e71466ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,16 @@ after live mutation and before backup cleanup proves retry recovery and artifact cleanup. Promotion-scoped locking prevents healthy backups from being mistaken for abandoned crash recovery state by concurrent readers. +- Added parser-backed Fastify route extraction for direct verbs, including + `TRACE`, and static `route({ method, url, handler })` objects on + source-ordered, module-scope + receivers constructed from explicit ESM or CommonJS `fastify` bindings. + JavaScript, TypeScript, and TSX accept ordinary static strings and + substitution-free templates, invalidate ownership after reassignment or + shadowing, and avoid handler edges for wrapped or inline handlers. Dynamic + paths and methods, method arrays, escaped literals, nested builders, and + nested, injected, factory-returned, or unsupported receivers are excluded; + malformed-file recovery remains error-local structural evidence. - Added parser-backed Express route extraction for static module-scope registrations on source-ordered app and router bindings constructed from explicit `express` imports or `require("express")`. JavaScript, TypeScript, diff --git a/crates/codestory-indexer/src/framework_routes.rs b/crates/codestory-indexer/src/framework_routes.rs index 91f167f7..5f800e36 100644 --- a/crates/codestory-indexer/src/framework_routes.rs +++ b/crates/codestory-indexer/src/framework_routes.rs @@ -48,6 +48,14 @@ struct ExpressBindings { require_shadowed: bool, } +#[derive(Default)] +struct FastifyBindings { + constructors: HashSet, + modules: HashSet, + receivers: HashSet, + require_shadowed: bool, +} + #[derive(Default)] struct FastApiBindings { constructors: HashSet, @@ -232,6 +240,185 @@ pub(super) fn collect_javascript_express_routes( Ok(routes) } +pub(super) fn collect_javascript_fastify_routes( + language: &Language, + dialect: JavaScriptDialect, + tree: &Tree, + source: &str, +) -> Result> { + let cache = match dialect { + JavaScriptDialect::JavaScript => &JAVASCRIPT_EXPRESS_COMPILED_QUERY, + JavaScriptDialect::TypeScript => &TYPESCRIPT_EXPRESS_COMPILED_QUERY, + JavaScriptDialect::Tsx => &TSX_EXPRESS_COMPILED_QUERY, + }; + let query = cache + .get_or_init(|| { + Query::new(language, JAVASCRIPT_EXPRESS_QUERY).map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(|message| anyhow!(message.clone()))?; + let capture_names = query.capture_names(); + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes()); + let mut routes = Vec::new(); + + while { + matches.advance(); + matches.get().is_some() + } { + let Some(query_match) = matches.get() else { + continue; + }; + let mut receiver = None; + let mut method = None; + let mut arguments = None; + let mut route_node = None; + for capture in query_match.captures { + let name = capture_names + .get(capture.index as usize) + .copied() + .unwrap_or_default(); + match name { + "receiver" => receiver = node_text(capture.node, source), + "method" => method = node_text(capture.node, source), + "arguments" => arguments = Some(capture.node), + "route" => route_node = Some(capture.node), + _ => {} + } + } + let (Some(receiver), Some(method), Some(arguments), Some(route_node)) = + (receiver, method, arguments, route_node) + else { + continue; + }; + if route_node.has_error() + || !javascript_route_is_module_statement(route_node) + || !module_fastify_receiver_owned_at(tree, source, &receiver, route_node.start_byte()) + { + continue; + } + let mut argument_cursor = arguments.walk(); + let values = arguments + .named_children(&mut argument_cursor) + .filter(|node| node.kind() != "comment") + .collect::>(); + let parsed = if is_fastify_route_method(&method) { + let Some(path) = values + .first() + .copied() + .and_then(|node| javascript_static_string(node, source)) + else { + continue; + }; + if !matches!(values.len(), 2 | 3) || (values.len() == 3 && values[1].kind() != "object") + { + continue; + } + let handler = values + .last() + .copied() + .filter(|node| matches!(node.kind(), "identifier" | "member_expression")) + .and_then(|node| node_text(node, source)); + Some((method.to_ascii_uppercase(), path, handler)) + } else if method == "route" && values.len() == 1 { + parse_fastify_route_object(values[0], source) + } else { + None + }; + let Some((method, path, handler)) = parsed else { + continue; + }; + routes.push( + FrameworkRoute::new( + "fastify", + method, + path, + handler, + route_node.start_position().row as u32 + 1, + "heuristic", + ) + .with_claim_evidence("tree_sitter_query", "parser_backed"), + ); + } + + Ok(routes) +} + +fn is_fastify_route_method(method: &str) -> bool { + matches!( + method, + "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace" + ) +} + +fn parse_fastify_route_object( + object: Node<'_>, + source: &str, +) -> Option<(String, String, Option)> { + if object.kind() != "object" { + return None; + } + let mut method = None; + let mut path = None; + let mut path_seen = false; + let mut handler = None; + let mut handler_seen = false; + let mut cursor = object.walk(); + for property in object.named_children(&mut cursor) { + if property.kind() == "comment" { + continue; + } + if property.kind() == "shorthand_property_identifier" { + let name = node_text(property, source)?; + if name == "handler" { + if handler_seen { + return None; + } + handler_seen = true; + handler = Some(name); + } else if matches!(name.as_str(), "method" | "url") { + return None; + } + continue; + } + if property.kind() != "pair" { + return None; + } + let key_node = property.child_by_field_name("key")?; + let key = match key_node.kind() { + "property_identifier" | "identifier" => node_text(key_node, source), + "string" => javascript_static_string(key_node, source), + _ => return None, + }; + let value = property.child_by_field_name("value")?; + match key.as_deref() { + Some("method") if method.is_none() => { + let value = javascript_static_string(value, source)?; + if !is_fastify_route_method(&value.to_ascii_lowercase()) { + return None; + } + method = Some(value.to_ascii_uppercase()); + } + Some("url") if !path_seen => { + path_seen = true; + path = Some(javascript_static_string(value, source)?); + } + Some("handler") if !handler_seen => { + handler_seen = true; + handler = matches!(value.kind(), "identifier" | "member_expression") + .then_some(value) + .and_then(|node| node_text(node, source)); + } + Some("method" | "url" | "handler") => return None, + _ => {} + } + } + if !handler_seen { + return None; + } + Some((method?, path?, handler)) +} + pub(super) fn allow_javascript_express_lexical_fallback( tree: &Tree, source: &str, @@ -258,6 +445,277 @@ pub(super) fn allow_javascript_express_lexical_fallback( ) } +pub(super) fn allow_javascript_fastify_lexical_fallback( + tree: &Tree, + source: &str, + route: &FrameworkRoute, +) -> bool { + if !is_fastify_route_method(&route.method.to_ascii_lowercase()) { + return false; + } + let Some(line) = source.lines().nth(route.line.saturating_sub(1) as usize) else { + return false; + }; + let code_line = super::strip_c_style_comments(line) + .into_iter() + .next() + .unwrap_or_default(); + let receiver = if let Some((receiver, argument)) = + javascript_route_receiver_and_argument(&code_line, &route.method) + { + if !javascript_fallback_has_static_argument(argument, route) { + return false; + } + receiver + } else { + let Some((receiver, _)) = code_line.trim_start().split_once(".route(") else { + return false; + }; + if javascript_fallback_has_top_level_spread(&code_line) + || !javascript_fallback_has_direct_handler_property(&code_line) + || !javascript_fallback_has_static_method_property(&code_line, &route.method) + || !javascript_fallback_has_static_property(&code_line, "url", &route.raw_path) + { + return false; + } + if receiver.is_empty() + || !receiver + .chars() + .all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) + { + return false; + } + receiver + }; + !route.raw_path.contains('\\') + && syntax_error_near_line(tree.root_node(), route.line) + && javascript_line_is_module_scope(tree, line, route.line) + && javascript_byte_is_code(source, source_byte_at_line(source, route.line)) + && module_fastify_receiver_owned_at( + tree, + source, + receiver, + source_byte_at_line(source, route.line), + ) +} + +fn javascript_fallback_static_string_remainder<'a>( + value: &'a str, + expected: &str, +) -> Option<&'a str> { + let value = value.trim_start(); + let quote = value + .chars() + .next() + .filter(|quote| matches!(quote, '\'' | '"'))?; + let end = value[quote.len_utf8()..].find(quote)? + quote.len_utf8(); + let literal = &value[quote.len_utf8()..end]; + (literal == expected && !literal.contains('\\')) + .then(|| value[end + quote.len_utf8()..].trim_start()) +} + +fn javascript_fallback_has_static_argument(argument: &str, route: &FrameworkRoute) -> bool { + let Some(remainder) = javascript_fallback_static_string_remainder(argument, &route.raw_path) + else { + return false; + }; + if remainder.starts_with(',') { + return true; + } + let Some(handler) = route.handler.as_deref() else { + return false; + }; + let candidate = remainder + .split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '.'))) + .next() + .unwrap_or_default(); + !candidate.is_empty() && candidate.rsplit('.').next() == Some(handler) +} + +fn javascript_fallback_has_static_property(line: &str, key: &str, expected: &str) -> bool { + let Some(after) = javascript_fallback_single_top_level_property_tail(line, key) else { + return false; + }; + let Some(value) = after.trim_start().strip_prefix(':') else { + return false; + }; + javascript_fallback_static_string_remainder(value, expected).is_some_and(|remainder| { + remainder.is_empty() || remainder.starts_with(',') || remainder.starts_with('}') + }) +} + +fn javascript_fallback_has_static_method_property(line: &str, expected: &str) -> bool { + let Some(after) = javascript_fallback_single_top_level_property_tail(line, "method") else { + return false; + }; + let Some(value) = after.trim_start().strip_prefix(':') else { + return false; + }; + let value = value.trim_start(); + let Some(quote) = value + .chars() + .next() + .filter(|quote| matches!(quote, '\'' | '"')) + else { + return false; + }; + let Some(end) = value[quote.len_utf8()..] + .find(quote) + .map(|end| end + quote.len_utf8()) + else { + return false; + }; + let literal = &value[quote.len_utf8()..end]; + let remainder = value[end + quote.len_utf8()..].trim_start(); + literal.eq_ignore_ascii_case(expected) + && !literal.contains('\\') + && (remainder.is_empty() || remainder.starts_with(',') || remainder.starts_with('}')) +} + +fn javascript_fallback_has_direct_handler_property(line: &str) -> bool { + let Some(after) = javascript_fallback_single_top_level_property_tail(line, "handler") else { + return false; + }; + let after = after.trim_start(); + if after.is_empty() || after.starts_with(',') || after.starts_with('}') { + return true; + } + let Some(value) = after.strip_prefix(':').map(str::trim_start) else { + return false; + }; + let end = value + .find(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '.'))) + .unwrap_or(value.len()); + let candidate = &value[..end]; + let remainder = value[end..].trim_start(); + !candidate.is_empty() + && candidate + .split('.') + .all(|part| !part.is_empty() && is_javascript_identifier(part)) + && (remainder.is_empty() || remainder.starts_with(',') || remainder.starts_with('}')) +} + +fn javascript_fallback_single_top_level_property_tail<'a>( + line: &'a str, + key: &str, +) -> Option<&'a str> { + let tails = javascript_fallback_top_level_property_tails(line, key)?; + match tails.as_slice() { + [tail] => Some(*tail), + _ => None, + } +} + +fn javascript_fallback_top_level_property_tails<'a>( + line: &'a str, + key: &str, +) -> Option> { + let (_, arguments) = line.split_once(".route(")?; + let object = arguments.trim_start().strip_prefix('{')?; + let mut depth = 0usize; + let mut quote = None; + let mut quote_start = None; + let mut escaped = false; + let mut tails = Vec::new(); + for (index, ch) in object.char_indices() { + if let Some(active_quote) = quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == active_quote { + if active_quote != '`' + && depth == 0 + && quote_start.is_some_and(|start| &object[start..index] == key) + && object[index + ch.len_utf8()..] + .trim_start() + .starts_with(':') + { + tails.push(&object[index + ch.len_utf8()..]); + } + quote = None; + quote_start = None; + } + continue; + } + if matches!(ch, '\'' | '"' | '`') { + quote = Some(ch); + quote_start = Some(index + ch.len_utf8()); + continue; + } + match ch { + '{' | '[' | '(' => depth += 1, + '}' | ']' | ')' if depth == 0 => break, + '}' | ']' | ')' => depth -= 1, + _ => {} + } + if depth != 0 || !object[index..].starts_with(key) { + continue; + } + let before = &object[..index]; + let after = &object[index + key.len()..]; + if before + .chars() + .next_back() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + || after + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + { + continue; + } + tails.push(after); + } + Some(tails) +} + +fn javascript_fallback_has_top_level_spread(line: &str) -> bool { + let Some((_, arguments)) = line.split_once(".route(") else { + return true; + }; + let Some(object) = arguments.trim_start().strip_prefix('{') else { + return true; + }; + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (index, ch) in object.char_indices() { + if let Some(active_quote) = quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == active_quote { + quote = None; + } + continue; + } + if matches!(ch, '\'' | '"' | '`') { + quote = Some(ch); + continue; + } + match ch { + '{' | '[' | '(' => depth += 1, + '}' | ']' | ')' if depth == 0 => break, + '}' | ']' | ')' => depth -= 1, + _ => {} + } + if depth == 0 && object[index..].starts_with("...") { + return true; + } + } + false +} + +fn is_javascript_identifier(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) +} + fn javascript_byte_is_code(source: &str, target: usize) -> bool { let mut chars = source.char_indices().peekable(); let mut quote = None; @@ -336,9 +794,13 @@ fn javascript_line_is_module_scope(tree: &Tree, line: &str, line_number: u32) -> ) { return false; } - node = current.parent(); + let parent = current.parent(); + if parent.is_some_and(|parent| parent.kind() == "program") { + return matches!(current.kind(), "expression_statement" | "ERROR"); + } + node = parent; } - true + false } fn javascript_route_receiver_and_argument<'a>( @@ -417,13 +879,7 @@ fn apply_express_module_statement( return; } - if matches!( - statement.kind(), - "function_declaration" | "class_declaration" - ) && let Some(name) = statement - .child_by_field_name("name") - .and_then(|node| node_text(node, source)) - { + if let Some(name) = javascript_runtime_declaration_name(statement, source) { invalidate_express_binding(&name, bindings); return; } @@ -537,6 +993,7 @@ fn apply_express_declarator(node: Node<'_>, source: &str, bindings: &mut Express } fn expression_is_express_require(node: Node<'_>, source: &str, bindings: &ExpressBindings) -> bool { + let node = javascript_transparent_expression(node); if bindings.require_shadowed || node.kind() != "call_expression" { return false; } @@ -557,6 +1014,7 @@ fn expression_constructs_express_receiver( source: &str, bindings: &ExpressBindings, ) -> bool { + let node = javascript_transparent_expression(node); if node.kind() != "call_expression" { return false; } @@ -592,6 +1050,219 @@ fn invalidate_express_binding(name: &str, bindings: &mut ExpressBindings) { } } +fn module_fastify_receiver_owned_at( + tree: &Tree, + source: &str, + receiver: &str, + before_byte: usize, +) -> bool { + let mut bindings = FastifyBindings::default(); + let root = tree.root_node(); + let mut cursor = root.walk(); + for statement in root.named_children(&mut cursor) { + if statement.start_byte() >= before_byte { + break; + } + apply_fastify_module_statement(statement, source, &mut bindings); + } + bindings.receivers.contains(receiver) +} + +fn apply_fastify_module_statement( + statement: Node<'_>, + source: &str, + bindings: &mut FastifyBindings, +) { + if statement.kind() == "import_statement" { + apply_fastify_import(statement, source, bindings); + return; + } + + let mut declarators = Vec::new(); + collect_nodes_of_kind(statement, "variable_declarator", &mut declarators); + if !declarators.is_empty() { + for declarator in declarators { + apply_fastify_declarator(declarator, source, bindings); + if let Some(value) = declarator.child_by_field_name("value") { + let mut writes = HashSet::new(); + collect_javascript_written_names(value, source, &mut writes); + for name in writes { + invalidate_fastify_binding(&name, bindings); + } + } + } + return; + } + + if let Some(name) = javascript_runtime_declaration_name(statement, source) { + invalidate_fastify_binding(&name, bindings); + return; + } + + let mut names = HashSet::new(); + collect_javascript_written_names(statement, source, &mut names); + for name in names { + invalidate_fastify_binding(&name, bindings); + } +} + +fn apply_fastify_import(node: Node<'_>, source: &str, bindings: &mut FastifyBindings) { + let source_module = node + .child_by_field_name("source") + .and_then(|node| javascript_static_string(node, source)); + let Some(clause) = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "import_clause") + else { + return; + }; + if node_has_direct_anonymous_token(node, &["type", "typeof"]) + || node_has_direct_anonymous_token(clause, &["type", "typeof"]) + { + return; + } + let imported_bindings = javascript_import_local_bindings(clause, source); + for name in &imported_bindings { + invalidate_fastify_binding(name, bindings); + } + if source_module.as_deref() != Some("fastify") { + return; + } + + let mut cursor = clause.walk(); + for child in clause.named_children(&mut cursor) { + match child.kind() { + "identifier" => { + if let Some(name) = node_text(child, source) { + bindings.constructors.insert(name); + } + } + "namespace_import" => { + if let Some(name) = last_identifier(child, source) { + bindings.modules.insert(name); + } + } + "named_imports" => { + let mut import_cursor = child.walk(); + for specifier in child.named_children(&mut import_cursor) { + if specifier.kind() != "import_specifier" + || node_has_direct_anonymous_token(specifier, &["type", "typeof"]) + { + continue; + } + let imported = specifier + .child_by_field_name("name") + .and_then(|node| node_text(node, source)); + if !matches!(imported.as_deref(), Some("fastify" | "default")) { + continue; + } + let local = specifier + .child_by_field_name("alias") + .and_then(|node| node_text(node, source)) + .or(imported); + if let Some(local) = local { + bindings.constructors.insert(local); + } + } + } + _ => {} + } + } +} + +fn apply_fastify_declarator(node: Node<'_>, source: &str, bindings: &mut FastifyBindings) { + let Some(name_node) = node.child_by_field_name("name") else { + return; + }; + let mut names = HashSet::new(); + collect_javascript_binding_names(name_node, source, &mut names); + for name in &names { + invalidate_fastify_binding(name, bindings); + } + let Some(value) = node.child_by_field_name("value") else { + return; + }; + + if expression_is_fastify_require(value, source, bindings) { + if name_node.kind() == "identifier" + && let Some(name) = node_text(name_node, source) + { + bindings.constructors.insert(name); + } else if name_node.kind() == "object_pattern" { + bindings + .constructors + .extend(commonjs_fastify_bindings(name_node, source)); + } + return; + } + + if names.len() != 1 || name_node.kind() != "identifier" { + return; + } + let Some(name) = names.into_iter().next() else { + return; + }; + if expression_constructs_fastify_receiver(value, source, bindings) { + bindings.receivers.insert(name); + } +} + +fn expression_is_fastify_require(node: Node<'_>, source: &str, bindings: &FastifyBindings) -> bool { + let node = javascript_transparent_expression(node); + if bindings.require_shadowed || node.kind() != "call_expression" { + return false; + } + node.child_by_field_name("function") + .and_then(|node| node_text(node, source)) + .as_deref() + == Some("require") + && node + .child_by_field_name("arguments") + .and_then(|arguments| arguments.named_child(0)) + .and_then(|argument| javascript_static_string(argument, source)) + .as_deref() + == Some("fastify") +} + +fn expression_constructs_fastify_receiver( + node: Node<'_>, + source: &str, + bindings: &FastifyBindings, +) -> bool { + let node = javascript_transparent_expression(node); + if node.kind() != "call_expression" { + return false; + } + let Some(function) = node.child_by_field_name("function") else { + return false; + }; + match function.kind() { + "identifier" => { + node_text(function, source).is_some_and(|name| bindings.constructors.contains(&name)) + } + "member_expression" => { + let object = function + .child_by_field_name("object") + .and_then(|node| node_text(node, source)); + let property = function + .child_by_field_name("property") + .and_then(|node| node_text(node, source)); + object.is_some_and(|name| bindings.modules.contains(&name)) + && matches!(property.as_deref(), Some("fastify" | "default")) + } + _ => false, + } +} + +fn invalidate_fastify_binding(name: &str, bindings: &mut FastifyBindings) { + bindings.constructors.remove(name); + bindings.modules.remove(name); + bindings.receivers.remove(name); + if name == "require" { + bindings.require_shadowed = true; + } +} + fn collect_nodes_of_kind<'tree>(node: Node<'tree>, kind: &str, out: &mut Vec>) { if node.kind() == kind { out.push(node); @@ -680,6 +1351,44 @@ fn javascript_import_local_bindings(node: Node<'_>, source: &str) -> HashSet, source: &str) -> Option { + let declaration = if matches!( + node.kind(), + "function_declaration" | "class_declaration" | "enum_declaration" + ) { + node + } else if node.kind() == "export_statement" { + node.named_children(&mut node.walk()).find(|child| { + matches!( + child.kind(), + "function_declaration" | "class_declaration" | "enum_declaration" + ) + })? + } else { + return None; + }; + declaration + .child_by_field_name("name") + .and_then(|name| node_text(name, source)) +} + +fn javascript_transparent_expression(mut node: Node<'_>) -> Node<'_> { + loop { + let expression = match node.kind() { + "parenthesized_expression" + | "as_expression" + | "satisfies_expression" + | "non_null_expression" => node.named_child(0), + "type_assertion" => node.named_child(1), + _ => return node, + }; + let Some(expression) = expression else { + return node; + }; + node = expression; + } +} + fn collect_javascript_written_names(node: Node<'_>, source: &str, names: &mut HashSet) { if matches!( node.kind(), @@ -697,6 +1406,12 @@ fn collect_javascript_written_names(node: Node<'_>, source: &str, names: &mut Ha } return; } + if node.kind() == "update_expression" { + if let Some(argument) = node.named_child(0) { + collect_javascript_binding_names(argument, source, names); + } + return; + } if node.kind() == "for_in_statement" && let Some(left) = node.child_by_field_name("left") && !matches!(left.kind(), "lexical_declaration" | "variable_declaration") @@ -753,7 +1468,34 @@ fn commonjs_router_bindings(node: Node<'_>, source: &str) -> HashSet { let key = child .child_by_field_name("key") .and_then(|node| node_text(node, source)); - if key.as_deref() != Some("Router") { + if key.as_deref() != Some("Router") { + continue; + } + if let Some(value) = child.child_by_field_name("value") { + collect_javascript_binding_names(value, source, &mut bindings); + } + } + _ => {} + } + } + bindings +} + +fn commonjs_fastify_bindings(node: Node<'_>, source: &str) -> HashSet { + let mut bindings = HashSet::new(); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + match child.kind() { + "shorthand_property_identifier_pattern" => { + if node_text(child, source).as_deref() == Some("fastify") { + bindings.insert("fastify".to_string()); + } + } + "pair_pattern" => { + let key = child + .child_by_field_name("key") + .and_then(|node| node_text(node, source)); + if key.as_deref() != Some("fastify") { continue; } if let Some(value) = child.child_by_field_name("value") { @@ -1716,6 +2458,458 @@ app.get("/string-only", documented); Ok(()) } + #[test] + fn test_fastify_query_fixture_matrix_and_line_scan_comparison() -> Result<()> { + let source = r#" +import buildFastify from "fastify"; +import { fastify as createFastify } from "fastify"; +const api = buildFastify(); +const secondary = createFastify(); + +api.get("/simple", simple); +api.post( + "/multiline", + multiline, +); +api.put(`/static-template`, update); +api.route({ + handler: handlers.remove, + "url": "/object", + method: "DELETE", +}); +secondary.patch("/wrapped", wrap(handler)); + +api.get(`/dynamic/${itemId}`, dynamic); +api.get("/prefix" + suffix, concatenated); +api.get(buildPath("/nested-path"), nested); +api.get("/escaped\\tpath", escaped); +api.route({ method: ["GET"], url: "/array-method", handler }); +api.route({ method: methodName, url: "/dynamic-method", handler }); +api.route({ method: "GET", url: makeUrl(), handler }); +api.route({ method: "GET", url: "/missing-handler" }); +api.route({ method: "GET", method: "POST", url: "/duplicate-method", handler }); +api.route({ method: "GET", url: "/spread", handler, ...dynamicOptions }); + +server.get("/unrelated", unrelated); +const example = `api.head("/string-only", documented);`; +// api.options("/comment-only", commented); +/* api.get("/block-comment", commented); */ +"#; + let expected = HashSet::from([ + ("GET".to_string(), "/simple".to_string()), + ("POST".to_string(), "/multiline".to_string()), + ("PUT".to_string(), "/static-template".to_string()), + ("DELETE".to_string(), "/object".to_string()), + ("PATCH".to_string(), "/wrapped".to_string()), + ]); + let lexical = + super::super::collect_framework_routes(Path::new("routes.ts"), "typescript", source) + .into_iter() + .filter(|route| route.framework == "fastify") + .map(|route| (route.method, route.path)) + .collect::>(); + let language: Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); + let tree = parse_javascript(&language, source); + let parser_routes = collect_javascript_fastify_routes( + &language, + JavaScriptDialect::TypeScript, + &tree, + source, + )?; + let parser = parser_routes + .iter() + .map(|route| (route.method.clone(), route.path.clone())) + .collect::>(); + + let lexical_tp = lexical.intersection(&expected).count(); + let lexical_fp = lexical.difference(&expected).count(); + let lexical_fn = expected.difference(&lexical).count(); + let parser_tp = parser.intersection(&expected).count(); + let parser_fp = parser.difference(&expected).count(); + let parser_fn = expected.difference(&parser).count(); + eprintln!( + "fastify route matrix: line_scan tp={lexical_tp} fp={lexical_fp} fn={lexical_fn}; tree_sitter_query tp={parser_tp} fp={parser_fp} fn={parser_fn}" + ); + + assert_eq!((parser_tp, parser_fp, parser_fn), (5, 0, 0)); + assert!(lexical_fp > 0 || lexical_fn > 0); + assert!(parser_routes.iter().all(|route| { + route.extraction_provenance == "tree_sitter_query" + && route.claim_tier == "parser_backed" + && route.confidence == "heuristic" + })); + assert_eq!( + parser_routes + .iter() + .find(|route| route.path == "/object") + .and_then(|route| route.handler.as_deref()), + Some("handlers.remove") + ); + assert_eq!( + parser_routes + .iter() + .find(|route| route.path == "/wrapped") + .and_then(|route| route.handler.as_deref()), + None, + "wrapped handlers must not become name-based handler claims" + ); + Ok(()) + } + + #[test] + fn test_fastify_supports_trace_and_rejects_duplicate_dynamic_urls() -> Result<()> { + let source = r#" +import Fastify from "fastify"; +const api = Fastify(); +api.trace("/trace-direct", directHandler); +api.route({ method: "TRACE", url: "/trace-object", handler: objectHandler }); +api.route({ method: "GET", url: dynamicUrl, url: "/duplicate", handler }); +"#; + let language: Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); + let tree = parse_javascript(&language, source); + let routes = collect_javascript_fastify_routes( + &language, + JavaScriptDialect::TypeScript, + &tree, + source, + )?; + assert_eq!( + routes + .iter() + .map(|route| (route.method.as_str(), route.path.as_str())) + .collect::>(), + HashSet::from([("TRACE", "/trace-direct"), ("TRACE", "/trace-object"),]) + ); + Ok(()) + } + + #[test] + fn test_fastify_uses_all_javascript_grammars_and_import_forms() -> Result<()> { + let cases = [ + ( + tree_sitter_javascript::LANGUAGE.into(), + JavaScriptDialect::JavaScript, + r#"const Fastify = require("fastify");"#, + "Fastify()", + ), + ( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + JavaScriptDialect::TypeScript, + r#"import { fastify as makeServer } from "fastify";"#, + "makeServer() as FastifyInstance", + ), + ( + tree_sitter_typescript::LANGUAGE_TSX.into(), + JavaScriptDialect::Tsx, + r#"import * as FastifyModule from "fastify";"#, + "FastifyModule.fastify() satisfies FastifyInstance", + ), + ( + tree_sitter_javascript::LANGUAGE.into(), + JavaScriptDialect::JavaScript, + r#"const { fastify: makeServer } = require("fastify");"#, + "makeServer()", + ), + ( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + JavaScriptDialect::TypeScript, + r#"const TypedFastify = require("fastify") as typeof import("fastify");"#, + "TypedFastify()", + ), + ]; + for (language, dialect, import, initializer) in cases { + let source = format!( + "{import}\nconst arbitraryName = {initializer};\narbitraryName.get(\"/health\", {{ schema }}, health);" + ); + let tree = parse_javascript(&language, &source); + let routes = collect_javascript_fastify_routes(&language, dialect, &tree, &source)?; + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].path, "/health"); + } + Ok(()) + } + + #[test] + fn test_fastify_receiver_provenance_invalidates_unsafe_ownership() -> Result<()> { + let source = r#" +import Fastify from "fastify"; +const api = Fastify(); +api.get("/owned", owned); +api.decorate("feature", true); +api.get("/after-property-use", afterPropertyUse); + +const reassigned = Fastify(); +if (flag) reassigned = otherFramework(); +reassigned.get("/reassigned", handler); + +const unsupported = wrap(Fastify()); +unsupported.get("/nested-builder", handler); +const factoryReturned = makeServer(); +factoryReturned.get("/factory-returned", handler); +const unrelated = otherFramework(); +unrelated.get("/unrelated", handler); + +const memberAlias = require("fastify"); +memberAlias.fastify = otherFramework; +const memberBuilt = memberAlias.fastify(); +memberBuilt.get("/overwritten-member", handler); + +const nestedAssigned = Fastify(); +const assignmentResult = (nestedAssigned = otherFramework()); +nestedAssigned.get("/nested-assignment", handler); + +let updated = Fastify(); +updated++; +updated.get("/updated", handler); + +const enumReplaced = Fastify(); +enum enumReplaced { Value } +enumReplaced.get("/enum-replaced", handler); + +const exportedReplaced = Fastify(); +export function exportedReplaced() {} +exportedReplaced.get("/exported-replaced", handler); + +function register(api) { + api.get("/injected", handler); + const nested = Fastify(); + nested.get("/nested", handler); +} + +const shadowed = Fastify(); +function shadowed() {} +shadowed.get("/declaration-replaced", handler); +"#; + let language: Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); + let tree = parse_javascript(&language, source); + let routes = collect_javascript_fastify_routes( + &language, + JavaScriptDialect::TypeScript, + &tree, + source, + )?; + assert_eq!( + routes + .iter() + .map(|route| route.path.as_str()) + .collect::>(), + HashSet::from(["/owned", "/after-property-use"]) + ); + Ok(()) + } + + #[test] + fn test_fastify_clean_and_malformed_files_keep_provenance_separate() -> Result<()> { + let clean = r#" +import Fastify from "fastify"; +const api = Fastify(); +api.get("/clean", handler); +"#; + let clean_result = super::super::index_file( + Path::new("clean.ts"), + clean, + &super::super::get_language_for_ext("ts").expect("typescript config"), + None, + None, + )?; + let clean_metadata = clean_result + .nodes + .iter() + .find_map(|node| { + node.canonical_id.as_deref().filter(|value| { + value.contains(r#""framework":"fastify""#) + && value.contains(r#""path":"/clean""#) + }) + }) + .expect("clean Fastify route"); + assert!(clean_metadata.contains(r#""extraction_provenance":"tree_sitter_query""#)); + assert!(!clean_metadata.contains(r#""extraction_provenance":"lexical_fallback""#)); + + let unrelated_error = r#" +import Fastify from "fastify"; +const server = Fastify(); +server.get("/clean", handler); +server.get("/dynamic" + suffix, handler); + + + +broken = ( +"#; + let unrelated_result = super::super::index_file( + Path::new("unrelated-error.ts"), + unrelated_error, + &super::super::get_language_for_ext("ts").expect("typescript config"), + None, + None, + )?; + let fastify_metadata = unrelated_result + .nodes + .iter() + .filter_map(|node| { + node.canonical_id + .as_deref() + .filter(|value| value.contains(r#""framework":"fastify""#)) + }) + .collect::>(); + assert_eq!(fastify_metadata.len(), 1); + assert!(fastify_metadata[0].contains(r#""path":"/clean""#)); + + let malformed = r#" +const Fastify = require("fastify"); +const server = Fastify(); +server.get("/recover" handler); +"#; + let malformed_result = super::super::index_file( + Path::new("malformed.js"), + malformed, + &super::super::get_language_for_ext("js").expect("javascript config"), + None, + None, + )?; + let fallback_metadata = malformed_result + .nodes + .iter() + .find_map(|node| { + node.canonical_id.as_deref().filter(|value| { + value.contains(r#""framework":"fastify""#) + && value.contains(r#""path":"/recover""#) + }) + }) + .expect("error-local Fastify fallback route"); + assert!(fallback_metadata.contains(r#""extraction_provenance":"lexical_fallback""#)); + assert!(fallback_metadata.contains(r#""claim_tier":"structural""#)); + Ok(()) + } + + #[test] + fn test_fastify_malformed_fallback_requires_a_whole_static_path() -> Result<()> { + let malformed = r#" +const Fastify = require("fastify"); +const server = Fastify(); +server.get("/concatenated" + suffix handler); +server.route({ method: "GET", schema: { url: "/nested" }, url: makeUrl("/dynamic"), handler +server.route({ schema: { method: "GET" }, url: "/nested-method", handler +server.route({ method: "GET", method: dynamicMethod, url: "/later-dynamic-method", handler +server.route({ method: "GET", "method": "POST", url: "/duplicate-method", handler +server.route({ method: "GET", url: "/later-dynamic-url", url: dynamicUrl, handler +server.route({ method: "GET", url: "/duplicate-url", "url": "/other-url", handler +server.route({ method: "GET", url: "/spread", handler, ...dynamicOptions +server.route({ method: "GET", url: "/comment-handler", /* handler: commented */ +if (flag) { + server.get("/nested-recovery" handler); +} +"#; + let result = super::super::index_file( + Path::new("dynamic-malformed.js"), + malformed, + &super::super::get_language_for_ext("js").expect("javascript config"), + None, + None, + )?; + assert!(result.nodes.iter().all(|node| { + !node + .canonical_id + .as_deref() + .is_some_and(|value| value.contains(r#""framework":"fastify""#)) + })); + Ok(()) + } + + #[test] + fn test_fastify_handler_edges_require_direct_resolvable_names() -> Result<()> { + let source = r#" +import Fastify from "fastify"; +const api = Fastify(); +api.get("/direct", directHandler); +api.post("/inline", async () => true); +api.put("/wrapped", wrap(directHandler)); +function directHandler() { return true; } +"#; + let result = super::super::index_file( + Path::new("handlers.ts"), + source, + &super::super::get_language_for_ext("ts").expect("typescript config"), + None, + None, + )?; + let route = |path: &str| { + result + .nodes + .iter() + .find(|node| { + node.canonical_id.as_deref().is_some_and(|value| { + value.contains(r#""framework":"fastify""#) + && value.contains(&format!(r#""path":"{path}""#)) + }) + }) + .unwrap_or_else(|| panic!("missing Fastify route {path}")) + }; + let handler = result + .nodes + .iter() + .find(|node| node.serialized_name == "directHandler") + .expect("direct handler"); + assert!(result.edges.iter().any(|edge| { + edge.kind == codestory_contracts::graph::EdgeKind::CALL + && edge.source == route("/direct").id + && edge.target == handler.id + })); + for path in ["/inline", "/wrapped"] { + assert!(result.edges.iter().all(|edge| { + edge.kind != codestory_contracts::graph::EdgeKind::CALL + || edge.source != route(path).id + })); + } + Ok(()) + } + + #[test] + fn test_fastify_indexes_javascript_jsx_typescript_and_tsx_surfaces() -> Result<()> { + for (path, extension, extra) in [ + ("routes.js", "js", "const marker = 'js';"), + ( + "routes.jsx", + "jsx", + "export const View = () =>
jsx
;", + ), + ("routes.ts", "ts", "const marker: string = 'ts';"), + ( + "routes.tsx", + "tsx", + "export const View = () =>
tsx
;", + ), + ] { + let source = format!( + r#" +import Fastify from "fastify"; +const api = Fastify(); +api.get("/health", health); +function health() {{ return true; }} +{extra} +"# + ); + let result = super::super::index_file( + Path::new(path), + &source, + &super::super::get_language_for_ext(extension).expect("language config"), + None, + None, + )?; + let metadata = result + .nodes + .iter() + .find_map(|node| { + node.canonical_id + .as_deref() + .filter(|value| value.contains(r#""framework":"fastify""#)) + }) + .unwrap_or_else(|| panic!("missing Fastify route for {path}")); + assert!(metadata.contains(r#""extraction_provenance":"tree_sitter_query""#)); + assert!(metadata.contains(r#""claim_tier":"parser_backed""#)); + } + Ok(()) + } + #[test] fn test_fastapi_query_fixture_matrix_and_line_scan_comparison() -> Result<()> { let source = r#" diff --git a/crates/codestory-indexer/src/lib.rs b/crates/codestory-indexer/src/lib.rs index 1e03f394..b38a5c57 100644 --- a/crates/codestory-indexer/src/lib.rs +++ b/crates/codestory-indexer/src/lib.rs @@ -16987,6 +16987,9 @@ fn append_framework_routes( let lexical_express = routes .extract_if(.., |route| route.framework == "express") .collect::>(); + let lexical_fastify = routes + .extract_if(.., |route| route.framework == "fastify") + .collect::>(); let dialect = match path.extension().and_then(|extension| extension.to_str()) { Some(extension) if extension.eq_ignore_ascii_case("tsx") => { framework_routes::JavaScriptDialect::Tsx @@ -17025,6 +17028,36 @@ fn append_framework_routes( }), ); } + + let parser_routes = framework_routes::collect_javascript_fastify_routes( + &language_config.language, + dialect, + tree, + source, + )?; + let parser_keys = parser_routes + .iter() + .map(|route| (route.method.clone(), route.path.clone())) + .collect::>(); + routes.extend(parser_routes); + + if tree.root_node().has_error() { + routes.extend( + lexical_fastify + .into_iter() + .filter(|route| { + !parser_keys.contains(&(route.method.clone(), route.path.clone())) + && framework_routes::allow_javascript_fastify_lexical_fallback( + tree, source, route, + ) + }) + .map(|route| { + route + .with_confidence("heuristic") + .with_claim_evidence("lexical_fallback", "structural") + }), + ); + } } for route in routes { diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index 0fea2c4f..fefa4d94 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -496,11 +496,18 @@ const FRAMEWORK_ROUTE_COVERAGE_ENTRIES: &[FrameworkRouteCoverageEntry] = &[ framework: "fastify", language: "javascript/typescript", status: "partial", - coverage_evidence: "validated_by_indexer_regression", + coverage_evidence: "tree_sitter_query_regression", confidence_floor: "heuristic", - handler_link_support: "probable_when_handler_name_resolves", - unsupported_patterns: &["plugin prefixes and schema-only route declarations are partial"], - known_gaps: &["register() prefix propagation is not modeled"], + handler_link_support: "probable_for_direct_handler_names_when_graph_resolution_succeeds", + unsupported_patterns: &[ + "nested, injected, factory-returned, chained, and multi-target receivers are not promoted", + "dynamic paths, method arrays, and nested route builders are not promoted", + "inline and wrapped handlers do not produce name-based handler links", + ], + known_gaps: &[ + "register() prefix propagation is not modeled", + "schema and runtime middleware semantics are not evaluated", + ], promotable: true, }, FrameworkRouteCoverageEntry { @@ -13262,6 +13269,17 @@ mod tests { .handler_link_support .contains("direct_handler_names") ); + let fastify = coverage + .iter() + .find(|entry| entry.framework == "fastify") + .expect("Fastify coverage"); + assert_eq!(fastify.coverage_evidence, "tree_sitter_query_regression"); + assert_eq!(fastify.confidence_floor, "heuristic"); + assert!( + fastify + .handler_link_support + .contains("direct_handler_names") + ); assert!( coverage .iter() diff --git a/docs/architecture/language-support.md b/docs/architecture/language-support.md index 8670047a..6bcd6587 100644 --- a/docs/architecture/language-support.md +++ b/docs/architecture/language-support.md @@ -169,6 +169,19 @@ limited to direct names that graph resolution can match. Mounted prefixes, nested or injected receivers, factory returns, and runtime middleware behavior remain outside this claim tier; malformed-file line recovery is structural. +Fastify direct verb calls, including `TRACE`, and +`route({ method, url, handler })` registrations use the same JavaScript, +TypeScript, and TSX parser-backed boundary when the +module-scope receiver was constructed from an explicit `fastify` ESM or +CommonJS binding. Source-ordered reassignment, shadowing, or unsupported +construction invalidates receiver ownership. Exact claims require one static +method and path; dynamic or escaped strings, method arrays, nested builders, +and nested, injected, or factory-returned receivers are excluded. Direct +identifier and member handlers can retain probable edges, while wrapped and +inline handlers do not gain name-based edges. Plugin prefixes, schema behavior, +and runtime middleware semantics remain heuristic; malformed-file recovery is +structural and error-local. + ## Expansion Checklist Before adding a parser-backed language or widening a public claim: diff --git a/docs/testing/framework-route-coverage.md b/docs/testing/framework-route-coverage.md index 69934719..ee5f3562 100644 --- a/docs/testing/framework-route-coverage.md +++ b/docs/testing/framework-route-coverage.md @@ -15,9 +15,10 @@ status, confidence floor, handler-link support, known gaps, and promotability. - JavaScript/TypeScript: Express module-scope route calls on source-ordered app and router bindings constructed from explicit `express` imports or - `require("express")` use a tree-sitter query. React Router, SvelteKit, - Next.js, Remix, Fastify, Koa, Hono, and NestJS retain their existing - collectors. + `require("express")` use a tree-sitter query. Fastify direct verbs, including + `TRACE`, and static `route({ method, url, handler })` objects use parser-backed + source-ordered receiver provenance from explicit `fastify` imports. React Router, SvelteKit, + Next.js, Remix, Koa, Hono, and NestJS retain their existing collectors. - Astro/Vue: Astro and Nuxt file-convention routes, plus Vue Router object routes. - Python: Django and Flask retain structural collectors. FastAPI decorators on @@ -92,6 +93,23 @@ chained or multi-target bindings, and nested, injected, or factory-returned receivers are not promoted. Error-local lexical recovery stays `claim_tier=structural` and `extraction_provenance=lexical_fallback`. +Fastify route metadata uses the same parser-backed and structural provenance +labels. Direct verb calls accept static quoted paths and substitution-free +template literals. Static `route(...)` objects require exactly one string +method, URL, and handler property, independent of property order. Only direct +identifier or member handlers retain probable name-based edges; wrapped and +inline handlers remain routes without invented edges. Reassigned, shadowed, +nested, injected, factory-returned, and unsupported constructed receivers are +not promoted. Dynamic and escaped paths, method arrays, concatenation, and +nested builders are excluded. Plugin-prefix propagation, schemas, and runtime +middleware behavior remain outside the claim. + +The clean Fastify truth-set regression records the old line scanner at less +than complete recall or precision and the parser query at TP=5, FP=0, FN=0. +The same suite proves clean parses never restore lexical results and confines +malformed-source recovery to an error-local, module-scope, owned receiver with +a static path. + ## Verification Playbook 1. Add or update a fixture for the framework syntax.