diff --git a/Cargo.lock b/Cargo.lock index f1455c76..8874b953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,7 @@ dependencies = [ "tree-sitter-dart", "tree-sitter-elixir", "tree-sitter-erlang", + "tree-sitter-fsharp", "tree-sitter-gleam", "tree-sitter-go", "tree-sitter-groovy", @@ -828,6 +829,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-fsharp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054fba748f8bf3604fc14191b4e7da66d1b887de0e285e32cf6dbd2a3db3fc42" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-gleam" version = "1.0.0" diff --git a/crates/codegraph-core/Cargo.toml b/crates/codegraph-core/Cargo.toml index 98e40210..dd6cc938 100644 --- a/crates/codegraph-core/Cargo.toml +++ b/crates/codegraph-core/Cargo.toml @@ -36,6 +36,7 @@ tree-sitter-dart = "0.0.4" tree-sitter-zig = "1" tree-sitter-haskell = "0.23" tree-sitter-ocaml = "0.24" +tree-sitter-fsharp = "0.3" tree-sitter-objc = "3" tree-sitter-gleam = "1" tree-sitter-julia = "0.23" diff --git a/crates/codegraph-core/src/change_detection.rs b/crates/codegraph-core/src/change_detection.rs index ea203c93..a22daf31 100644 --- a/crates/codegraph-core/src/change_detection.rs +++ b/crates/codegraph-core/src/change_detection.rs @@ -132,7 +132,7 @@ fn load_file_hashes(conn: &Connection) -> Option> { /// found on disk are treated as removed. /// /// Files whose extension is outside the Rust file_collector's supported set -/// (e.g. `.fs`, `.fsx` — WASM-only languages) are skipped: +/// (e.g. `.v` — WASM-only languages) are skipped: /// the orchestrator's narrower collector never sees them, so absence from /// `current` is a capability boundary, not a deletion. Their `nodes` and /// `file_hashes` rows are owned by the JS-side WASM backfill (#967, #1068) @@ -774,15 +774,15 @@ mod tests { #[test] fn detect_removed_skips_unsupported_extensions() { - // Files in WASM-only languages (F#, F# Script) live in + // Files in WASM-only languages (Verilog) live in // `file_hashes` because the JS-side WASM backfill writes them, but // Rust's narrower file_collector never collects them. Without this // skip, every incremental rebuild would flag them as removed and // purge their rows — the #1066 ~2s floor. let mut existing = HashMap::new(); for path in [ - "tests/fixtures/fsharp/Main.fs", - "tests/fixtures/fsharp/Main.fsx", + "tests/fixtures/verilog/main.v", + "tests/fixtures/verilog/util.sv", ] { existing.insert( path.to_string(), diff --git a/crates/codegraph-core/src/extractors/fsharp.rs b/crates/codegraph-core/src/extractors/fsharp.rs new file mode 100644 index 00000000..90008417 --- /dev/null +++ b/crates/codegraph-core/src/extractors/fsharp.rs @@ -0,0 +1,302 @@ +use tree_sitter::{Node, Tree}; +use crate::cfg::build_function_cfg; +use crate::complexity::compute_all_metrics; +use crate::types::*; +use super::helpers::*; +use super::SymbolExtractor; + +pub struct FSharpExtractor; + +impl SymbolExtractor for FSharpExtractor { + fn extract(&self, tree: &Tree, source: &[u8], file_path: &str) -> FileSymbols { + let mut symbols = FileSymbols::new(file_path.to_string()); + walk_tree(&tree.root_node(), source, &mut symbols, match_fsharp_node); + walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &FSHARP_AST_CONFIG); + symbols + } +} + +fn match_fsharp_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + match node.kind() { + "named_module" => handle_named_module(node, source, symbols), + "function_declaration_left" => handle_function_decl(node, source, symbols), + "type_definition" => handle_type_def(node, source, symbols), + "import_decl" => handle_import_decl(node, source, symbols), + "application_expression" => handle_application(node, source, symbols), + "dot_expression" => handle_dot_expression(node, source, symbols), + _ => {} + } +} + +/// Find the enclosing `named_module` and return its identifier text. +fn enclosing_module_name(node: &Node, source: &[u8]) -> Option { + let module = find_parent_of_type(node, "named_module")?; + let id = find_child(&module, "long_identifier")?; + Some(node_text(&id, source).to_string()) +} + +fn handle_named_module(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let name_node = match find_child(node, "long_identifier") { + Some(n) => n, + None => return, + }; + symbols.definitions.push(Definition { + name: node_text(&name_node, source).to_string(), + kind: "module".to_string(), + line: start_line(node), + end_line: Some(end_line(node)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); +} + +fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + // function_declaration_left: first child is the function name identifier, + // followed by argument_patterns. + let name_node = match find_child(node, "identifier") { + Some(n) => n, + None => return, + }; + let raw_name = node_text(&name_node, source).to_string(); + let line = start_line(node); + + // Avoid duplicates — the DFS walk also visits the inner curried + // `function_declaration_left` of multi-parameter functions + // (e.g. `let add x y = …`), which would otherwise push the same + // `(name, line)` definition twice. Mirrors the JS extractor's guard, + // which compares against the raw (unqualified) identifier text. + if symbols + .definitions + .iter() + .any(|d| d.name == raw_name && d.line == line) + { + return; + } + + let module_name = enclosing_module_name(node, source); + let qualified = match module_name { + Some(m) => format!("{}.{}", m, raw_name), + None => raw_name, + }; + + let params = extract_fsharp_params(node, source); + + // JS extractor uses the parent's endLine (the function_or_value_defn) for + // a tighter bound; do the same to preserve parity. + let end = node.parent().unwrap_or(*node); + + symbols.definitions.push(Definition { + name: qualified, + kind: "function".to_string(), + line, + end_line: Some(end_line(&end)), + decorators: None, + complexity: compute_all_metrics(&end, source, "fsharp"), + cfg: build_function_cfg(&end, "fsharp", source), + children: opt_children(params), + }); +} + +fn extract_fsharp_params(decl_left: &Node, source: &[u8]) -> Vec { + let mut params = Vec::new(); + if let Some(arg_patterns) = find_child(decl_left, "argument_patterns") { + collect_param_identifiers(&arg_patterns, source, &mut params); + } + params +} + +fn collect_param_identifiers(node: &Node, source: &[u8], params: &mut Vec) { + if node.kind() == "identifier" { + params.push(child_def( + node_text(node, source).to_string(), + "parameter", + start_line(node), + )); + return; + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + collect_param_identifiers(&child, source, params); + } + } +} + +fn handle_type_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + // type_definition contains union_type_defn, record_type_defn, etc. + for i in 0..node.child_count() { + let child = match node.child(i) { + Some(c) => c, + None => continue, + }; + let kind = child.kind(); + if !matches!( + kind, + "union_type_defn" + | "record_type_defn" + | "type_abbreviation_defn" + | "class_type_defn" + | "interface_type_defn" + | "type_defn" + ) { + continue; + } + + let name = match find_child(&child, "type_name") { + Some(type_name) => find_child(&type_name, "identifier") + .map(|n| node_text(&n, source).to_string()) + .unwrap_or_else(|| node_text(&type_name, source).to_string()), + None => match find_child(&child, "identifier") { + Some(id) => node_text(&id, source).to_string(), + None => continue, + }, + }; + + let mut children: Vec = Vec::new(); + extract_type_members(&child, source, &mut children); + + symbols.definitions.push(Definition { + name, + kind: determine_type_kind(kind).to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: None, + cfg: None, + children: opt_children(children), + }); + } +} + +fn determine_type_kind(node_kind: &str) -> &'static str { + match node_kind { + "union_type_defn" => "enum", + "record_type_defn" => "record", + "class_type_defn" => "class", + "interface_type_defn" => "interface", + _ => "type", + } +} + +fn extract_type_members(type_defn: &Node, source: &[u8], children: &mut Vec) { + for i in 0..type_defn.child_count() { + let child = match type_defn.child(i) { + Some(c) => c, + None => continue, + }; + + match child.kind() { + "union_type_case" => { + if let Some(name) = find_child(&child, "identifier") { + children.push(child_def( + node_text(&name, source).to_string(), + "property", + start_line(&child), + )); + } + } + "record_field" => { + let name_node = child + .child_by_field_name("name") + .or_else(|| find_child(&child, "identifier")); + if let Some(name) = name_node { + children.push(child_def( + node_text(&name, source).to_string(), + "property", + start_line(&child), + )); + } + } + // Recurse into container nodes that hold cases/fields. + "union_type_cases" | "record_fields" => { + extract_type_members(&child, source, children); + } + _ => {} + } + } +} + +fn handle_import_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let module_node = match find_child(node, "long_identifier") { + Some(n) => n, + None => return, + }; + + let source_name = node_text(&module_node, source).to_string(); + let last = source_name + .split('.') + .last() + .unwrap_or(&source_name) + .to_string(); + + symbols + .imports + .push(Import::new(source_name, vec![last], start_line(node))); +} + +fn handle_application(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let func_node = match node.child(0) { + Some(n) => n, + None => return, + }; + + // Mirrors the JS extractor's `handleApplication`: the full dotted name + // (e.g. `Service.createUser`) is stored in `name`. Splitting `name` into + // `(receiver, method)` would diverge from the JS engine's output and + // change which resolution rules fire downstream. + match func_node.kind() { + "identifier" | "long_identifier" => { + symbols.calls.push(Call { + name: node_text(&func_node, source).to_string(), + line: start_line(node), + dynamic: None, + receiver: None, + }); + } + "long_identifier_or_op" => { + // Inner child is either `identifier` (bare, e.g. `validateUser`) or + // `long_identifier` (qualified, e.g. `Repository.save`). Order + // matches the JS extractor (`identifier` first). Operator forms + // like `( + )` have neither child; we emit nothing in that case, + // mirroring the JS extractor's silent skip. + if let Some(inner) = find_child(&func_node, "identifier") + .or_else(|| find_child(&func_node, "long_identifier")) + { + symbols.calls.push(Call { + name: node_text(&inner, source).to_string(), + line: start_line(node), + dynamic: None, + receiver: None, + }); + } + } + _ => {} + } +} + +fn handle_dot_expression(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + // Mirrors the JS extractor's `handleDotExpression`: collect identifier + // segments and emit `name = last`, `receiver = everything-before`. + let mut parts: Vec = Vec::new(); + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + match child.kind() { + "identifier" | "long_identifier" => { + parts.push(node_text(&child, source).to_string()); + } + _ => {} + } + } + } + if parts.len() >= 2 { + let method = parts.last().cloned().unwrap_or_default(); + let receiver = parts[..parts.len() - 1].join("."); + symbols.calls.push(Call { + name: method, + line: start_line(node), + dynamic: None, + receiver: Some(receiver), + }); + } +} diff --git a/crates/codegraph-core/src/extractors/helpers.rs b/crates/codegraph-core/src/extractors/helpers.rs index 65a31b50..129fd287 100644 --- a/crates/codegraph-core/src/extractors/helpers.rs +++ b/crates/codegraph-core/src/extractors/helpers.rs @@ -374,6 +374,18 @@ pub const OCAML_AST_CONFIG: LangAstConfig = LangAstConfig { string_prefixes: &[], }; +// F# string nodes in tree-sitter-fsharp surface under the `string` kind inside +// `const` literals. The grammar exposes no dedicated raw-string or regex form. +pub const FSHARP_AST_CONFIG: LangAstConfig = LangAstConfig { + new_types: &[], + throw_types: &[], + await_types: &[], + string_types: &["string"], + regex_types: &[], + quote_chars: &['"'], + string_prefixes: &[], +}; + /// Objective-C string literals use the `@"..."` prefix. The shared /// `build_string_node` strips a leading `@` before applying prefixes, so we /// don't need to list it explicitly here. diff --git a/crates/codegraph-core/src/extractors/mod.rs b/crates/codegraph-core/src/extractors/mod.rs index 30d5c988..a69b5963 100644 --- a/crates/codegraph-core/src/extractors/mod.rs +++ b/crates/codegraph-core/src/extractors/mod.rs @@ -7,6 +7,7 @@ pub mod cuda; pub mod dart; pub mod elixir; pub mod erlang; +pub mod fsharp; pub mod gleam; pub mod go; pub mod groovy; @@ -138,6 +139,9 @@ pub fn extract_symbols_with_opts( LanguageKind::Ocaml | LanguageKind::OcamlInterface => { ocaml::OcamlExtractor.extract_with_opts(tree, source, file_path, include_ast_nodes) } + LanguageKind::FSharp => { + fsharp::FSharpExtractor.extract_with_opts(tree, source, file_path, include_ast_nodes) + } LanguageKind::ObjC => { objc::ObjCExtractor.extract_with_opts(tree, source, file_path, include_ast_nodes) } diff --git a/crates/codegraph-core/src/file_collector.rs b/crates/codegraph-core/src/file_collector.rs index 60b77f9e..dc18cd62 100644 --- a/crates/codegraph-core/src/file_collector.rs +++ b/crates/codegraph-core/src/file_collector.rs @@ -44,7 +44,8 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[ "js", "jsx", "mjs", "cjs", "ts", "tsx", "d.ts", "py", "pyi", "go", "rs", "java", "cs", "rb", "rake", "gemspec", "php", "phtml", "tf", "hcl", "c", "h", "cpp", "cc", "cxx", "hpp", "cu", "cuh", "kt", "kts", "swift", "scala", "sh", "bash", "ex", "exs", "lua", "dart", "zig", "hs", - "ml", "mli", "m", "jl", "gleam", "clj", "cljs", "cljc", "erl", "hrl", "groovy", "gvy", "sol", + "ml", "mli", "fs", "fsx", "fsi", "m", "jl", "gleam", "clj", "cljs", "cljc", "erl", "hrl", + "groovy", "gvy", "sol", // R is case-sensitive: both `.r` and `.R` are conventional. "r", "R", ]; @@ -54,7 +55,7 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[ /// Mirrors the predicate at the heart of `collect_files`: a file is collected /// if `LanguageKind::from_extension` recognizes it OR its raw extension is in /// `SUPPORTED_EXTENSIONS`. Exposed for `change_detection::detect_removed_files` -/// so that files outside Rust's capability (e.g. WASM-only `.fs`) are +/// so that files outside Rust's capability (e.g. WASM-only `.v`) are /// not flagged as "removed" merely because the orchestrator's narrower /// collector never sees them. pub fn is_supported_extension(path: &str) -> bool { diff --git a/crates/codegraph-core/src/parser_registry.rs b/crates/codegraph-core/src/parser_registry.rs index 960c5014..6b28ed6b 100644 --- a/crates/codegraph-core/src/parser_registry.rs +++ b/crates/codegraph-core/src/parser_registry.rs @@ -27,6 +27,7 @@ pub enum LanguageKind { Haskell, Ocaml, OcamlInterface, + FSharp, ObjC, Gleam, Julia, @@ -67,6 +68,7 @@ impl LanguageKind { Self::Haskell => "haskell", Self::Ocaml => "ocaml", Self::OcamlInterface => "ocaml-interface", + Self::FSharp => "fsharp", Self::ObjC => "objc", Self::Gleam => "gleam", Self::Julia => "julia", @@ -116,6 +118,7 @@ impl LanguageKind { "hs" => Some(Self::Haskell), "ml" => Some(Self::Ocaml), "mli" => Some(Self::OcamlInterface), + "fs" | "fsx" | "fsi" => Some(Self::FSharp), "m" => Some(Self::ObjC), "gleam" => Some(Self::Gleam), "jl" => Some(Self::Julia), @@ -158,6 +161,7 @@ impl LanguageKind { "haskell" => Some(Self::Haskell), "ocaml" => Some(Self::Ocaml), "ocaml-interface" => Some(Self::OcamlInterface), + "fsharp" => Some(Self::FSharp), "objc" => Some(Self::ObjC), "gleam" => Some(Self::Gleam), "julia" => Some(Self::Julia), @@ -198,6 +202,7 @@ impl LanguageKind { Self::Haskell => tree_sitter_haskell::LANGUAGE.into(), Self::Ocaml => tree_sitter_ocaml::LANGUAGE_OCAML.into(), Self::OcamlInterface => tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE.into(), + Self::FSharp => tree_sitter_fsharp::LANGUAGE_FSHARP.into(), Self::ObjC => tree_sitter_objc::LANGUAGE.into(), Self::Gleam => tree_sitter_gleam::LANGUAGE.into(), Self::Julia => tree_sitter_julia::LANGUAGE.into(), @@ -222,7 +227,7 @@ impl LanguageKind { &[ JavaScript, TypeScript, Tsx, Python, Go, Rust, Java, CSharp, Ruby, Php, Hcl, C, Cpp, Kotlin, Swift, Scala, Bash, Elixir, Lua, Dart, Zig, Haskell, Ocaml, - OcamlInterface, ObjC, Gleam, Julia, Cuda, Clojure, Erlang, Groovy, R, Solidity, + OcamlInterface, FSharp, ObjC, Gleam, Julia, Cuda, Clojure, Erlang, Groovy, R, Solidity, ] } } @@ -292,6 +297,7 @@ mod tests { | LanguageKind::Haskell | LanguageKind::Ocaml | LanguageKind::OcamlInterface + | LanguageKind::FSharp | LanguageKind::ObjC | LanguageKind::Gleam | LanguageKind::Julia @@ -307,7 +313,7 @@ mod tests { // Because both checks require the same manual update, they reinforce // each other: a developer who updates the match is reminded to also // update `all()` and this count. - const EXPECTED_LEN: usize = 33; + const EXPECTED_LEN: usize = 34; assert_eq!( LanguageKind::all().len(), EXPECTED_LEN, diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index 8cededf1..ed4f8ca3 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -158,6 +158,10 @@ const OCAML_AST_TYPES: Record = { string: 'string', }; +const FSHARP_AST_TYPES: Record = { + string: 'string', +}; + const OBJC_AST_TYPES: Record = { throw_statement: 'throw', string_literal: 'string', @@ -228,6 +232,7 @@ export const AST_TYPE_MAPS: Map> = new Map([ ['haskell', HASKELL_AST_TYPES], ['ocaml', OCAML_AST_TYPES], ['ocaml-interface', OCAML_AST_TYPES], + ['fsharp', FSHARP_AST_TYPES], ['objc', OBJC_AST_TYPES], ['gleam', GLEAM_AST_TYPES], ['julia', JULIA_AST_TYPES], @@ -272,6 +277,7 @@ const DART_STRING_CONFIG: AstStringConfig = { quoteChars: '\'"', stringPrefixes: const ZIG_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; const HASKELL_STRING_CONFIG: AstStringConfig = { quoteChars: '"\'', stringPrefixes: '' }; const OCAML_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; +const FSHARP_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; const OBJC_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; const GLEAM_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; const JULIA_STRING_CONFIG: AstStringConfig = { quoteChars: '"', stringPrefixes: '' }; @@ -306,6 +312,7 @@ export const AST_STRING_CONFIGS: Map = new Map([ ['haskell', HASKELL_STRING_CONFIG], ['ocaml', OCAML_STRING_CONFIG], ['ocaml-interface', OCAML_STRING_CONFIG], + ['fsharp', FSHARP_STRING_CONFIG], ['objc', OBJC_STRING_CONFIG], ['gleam', GLEAM_STRING_CONFIG], ['julia', JULIA_STRING_CONFIG], diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 4908cd19..a9e7587c 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -473,6 +473,9 @@ export const NATIVE_SUPPORTED_EXTENSIONS: ReadonlySet = new Set([ '.hs', '.ml', '.mli', + '.fs', + '.fsx', + '.fsi', '.m', '.gleam', '.jl', diff --git a/tests/parsers/native-drop-classification.test.ts b/tests/parsers/native-drop-classification.test.ts index add4a59f..1ed38d56 100644 --- a/tests/parsers/native-drop-classification.test.ts +++ b/tests/parsers/native-drop-classification.test.ts @@ -14,11 +14,11 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..'); describe('classifyNativeDrops', () => { it('groups WASM-only languages under unsupported-by-native', () => { - const { byReason, totals } = classifyNativeDrops(['src/a.fs', 'src/h.fsx', 'src/j.v']); - expect(totals['unsupported-by-native']).toBe(3); + const { byReason, totals } = classifyNativeDrops(['src/j.v', 'src/k.sv']); + expect(totals['unsupported-by-native']).toBe(2); expect(totals['native-extractor-failure']).toBe(0); - expect(byReason['unsupported-by-native'].get('.fs')).toEqual(['src/a.fs']); - expect(byReason['unsupported-by-native'].get('.fsx')).toEqual(['src/h.fsx']); + expect(byReason['unsupported-by-native'].get('.v')).toEqual(['src/j.v']); + expect(byReason['unsupported-by-native'].get('.sv')).toEqual(['src/k.sv']); }); it('flags natively-supported extensions as native-extractor-failure', () => { @@ -37,14 +37,14 @@ describe('classifyNativeDrops', () => { it('handles a mix of supported and unsupported extensions', () => { const { byReason, totals } = classifyNativeDrops([ 'src/a.ts', - 'src/b.fs', - 'src/c.fs', - 'src/d.fsx', + 'src/b.v', + 'src/c.v', + 'src/d.sv', ]); expect(totals['native-extractor-failure']).toBe(1); expect(totals['unsupported-by-native']).toBe(3); - expect(byReason['unsupported-by-native'].get('.fs')).toEqual(['src/b.fs', 'src/c.fs']); - expect(byReason['unsupported-by-native'].get('.fsx')).toEqual(['src/d.fsx']); + expect(byReason['unsupported-by-native'].get('.v')).toEqual(['src/b.v', 'src/c.v']); + expect(byReason['unsupported-by-native'].get('.sv')).toEqual(['src/d.sv']); }); it('lowercases extensions so .R and .r share a bucket', () => { @@ -66,9 +66,12 @@ describe('classifyNativeDrops', () => { it('exposes the native-supported extension set for callers', () => { expect(NATIVE_SUPPORTED_EXTENSIONS.has('.ts')).toBe(true); expect(NATIVE_SUPPORTED_EXTENSIONS.has('.py')).toBe(true); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.fs')).toBe(true); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.fsx')).toBe(true); expect(NATIVE_SUPPORTED_EXTENSIONS.has('.gleam')).toBe(true); - expect(NATIVE_SUPPORTED_EXTENSIONS.has('.fs')).toBe(false); - expect(NATIVE_SUPPORTED_EXTENSIONS.has('.fsx')).toBe(false); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.m')).toBe(true); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.v')).toBe(false); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.sv')).toBe(false); }); });