From e4dc776482b5e9e9912fad195ac822d29ead597b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 11 May 2026 14:48:24 -0600 Subject: [PATCH 1/2] feat(native): port F# extractor to Rust Adds tree-sitter-fsharp dependency and a native F# extractor in crates/codegraph-core/src/extractors/fsharp.rs. Registers .fs/.fsx/.fsi with LanguageKind::FSharp and the Rust file_collector, adds FSharp to NATIVE_SUPPORTED_EXTENSIONS on the JS side, and wires FSHARP_AST_TYPES / FSHARP_STRING_CONFIG so the native and JS engines extract identical ast_nodes for F# source. Mirrors extractFSharpSymbols: named/anonymous modules as module, function declarations (with parameter children) as function, type definitions as type / class / record / enum / interface (mapped from the F# node kind), type-member function bindings as method, value bindings as variable, and import declarations + dot-expression / application call extraction. --- Cargo.lock | 11 + crates/codegraph-core/Cargo.toml | 1 + crates/codegraph-core/src/change_detection.rs | 3 +- .../codegraph-core/src/extractors/fsharp.rs | 288 ++++++++++++++++++ .../codegraph-core/src/extractors/helpers.rs | 12 + crates/codegraph-core/src/extractors/mod.rs | 4 + crates/codegraph-core/src/file_collector.rs | 1 + crates/codegraph-core/src/parser_registry.rs | 12 +- src/ast-analysis/rules/index.ts | 7 + src/domain/parser.ts | 3 + .../native-drop-classification.test.ts | 12 +- 11 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 crates/codegraph-core/src/extractors/fsharp.rs diff --git a/Cargo.lock b/Cargo.lock index 413504b0..7add4321 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "tree-sitter-cpp", "tree-sitter-dart", "tree-sitter-elixir", + "tree-sitter-fsharp", "tree-sitter-go", "tree-sitter-haskell", "tree-sitter-hcl", @@ -789,6 +790,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-go" version = "0.23.4" diff --git a/crates/codegraph-core/Cargo.toml b/crates/codegraph-core/Cargo.toml index df4361e1..ba495e23 100644 --- a/crates/codegraph-core/Cargo.toml +++ b/crates/codegraph-core/Cargo.toml @@ -35,6 +35,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" rayon = "1" ignore = "0.4" globset = "0.4" diff --git a/crates/codegraph-core/src/change_detection.rs b/crates/codegraph-core/src/change_detection.rs index 08e4b741..a58f611a 100644 --- a/crates/codegraph-core/src/change_detection.rs +++ b/crates/codegraph-core/src/change_detection.rs @@ -774,7 +774,7 @@ mod tests { #[test] fn detect_removed_skips_unsupported_extensions() { - // Files in WASM-only languages (Clojure, Gleam, Julia, F#) live in + // Files in WASM-only languages (Clojure, Gleam, Julia) 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 @@ -784,7 +784,6 @@ mod tests { "tests/fixtures/clojure/main.clj", "tests/fixtures/gleam/main.gleam", "tests/fixtures/julia/main.jl", - "tests/fixtures/fsharp/Main.fs", ] { 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..a90bf619 --- /dev/null +++ b/crates/codegraph-core/src/extractors/fsharp.rs @@ -0,0 +1,288 @@ +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 params = extract_fsharp_params(node, source); + let module_name = enclosing_module_name(node, source); + let qualified = match module_name { + Some(m) => format!("{}.{}", m, raw_name), + None => raw_name, + }; + + // 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: start_line(node), + 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 `long_identifier` (qualified, e.g. + // `Repository.save`) or `identifier` (bare, e.g. `validateUser`). + // Fall back to the wrapper text if neither exists (e.g. + // operator forms like `( + )`). + let inner = find_child(&func_node, "long_identifier") + .or_else(|| find_child(&func_node, "identifier")); + let name = match inner { + Some(n) => node_text(&n, source).to_string(), + None => node_text(&func_node, source).to_string(), + }; + symbols.calls.push(Call { + name, + 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 b0253189..1ce102bb 100644 --- a/crates/codegraph-core/src/extractors/helpers.rs +++ b/crates/codegraph-core/src/extractors/helpers.rs @@ -360,6 +360,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: &[], +}; + // ── Generic AST node walker ────────────────────────────────────────────────── /// Node types that represent identifiers across languages. diff --git a/crates/codegraph-core/src/extractors/mod.rs b/crates/codegraph-core/src/extractors/mod.rs index 642f29f9..8fe8b5be 100644 --- a/crates/codegraph-core/src/extractors/mod.rs +++ b/crates/codegraph-core/src/extractors/mod.rs @@ -4,6 +4,7 @@ pub mod cpp; pub mod csharp; pub mod dart; pub mod elixir; +pub mod fsharp; pub mod go; pub mod haskell; pub mod hcl; @@ -126,5 +127,8 @@ 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) + } } } diff --git a/crates/codegraph-core/src/file_collector.rs b/crates/codegraph-core/src/file_collector.rs index 0cb15781..dabdf980 100644 --- a/crates/codegraph-core/src/file_collector.rs +++ b/crates/codegraph-core/src/file_collector.rs @@ -36,6 +36,7 @@ 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", "kt", "kts", "swift", "scala", "sh", "bash", "ex", "exs", "lua", "dart", "zig", "hs", "ml", "mli", + "fs", "fsx", "fsi", ]; /// Returns whether `path` has an extension the Rust file_collector would accept. diff --git a/crates/codegraph-core/src/parser_registry.rs b/crates/codegraph-core/src/parser_registry.rs index c87957f2..8ed1cffb 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, } impl LanguageKind { @@ -58,6 +59,7 @@ impl LanguageKind { Self::Haskell => "haskell", Self::Ocaml => "ocaml", Self::OcamlInterface => "ocaml-interface", + Self::FSharp => "fsharp", } } @@ -97,6 +99,7 @@ impl LanguageKind { "hs" => Some(Self::Haskell), "ml" => Some(Self::Ocaml), "mli" => Some(Self::OcamlInterface), + "fs" | "fsx" | "fsi" => Some(Self::FSharp), _ => None, } } @@ -129,6 +132,7 @@ impl LanguageKind { "haskell" => Some(Self::Haskell), "ocaml" => Some(Self::Ocaml), "ocaml-interface" => Some(Self::OcamlInterface), + "fsharp" => Some(Self::FSharp), _ => None, } } @@ -160,6 +164,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(), } } @@ -175,7 +180,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, + OcamlInterface, FSharp, ] } } @@ -244,14 +249,15 @@ mod tests { | LanguageKind::Zig | LanguageKind::Haskell | LanguageKind::Ocaml - | LanguageKind::OcamlInterface => (), + | LanguageKind::OcamlInterface + | LanguageKind::FSharp => (), }; // IMPORTANT: this constant must equal the number of arms in the match // above AND the length of the slice returned by `LanguageKind::all()`. // 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 = 24; + const EXPECTED_LEN: usize = 25; assert_eq!( LanguageKind::all().len(), EXPECTED_LEN, diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index 653cbd59..53a7f766 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -153,6 +153,10 @@ const OCAML_AST_TYPES: Record = { string: 'string', }; +const FSHARP_AST_TYPES: Record = { + string: 'string', +}; + export const AST_TYPE_MAPS: Map> = new Map([ ['javascript', JS_AST_TYPES], ['typescript', JS_AST_TYPES], @@ -177,6 +181,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], ]); // ─── Per-language string-extraction config ─────────────────────────────── @@ -211,6 +216,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: '' }; export const AST_STRING_CONFIGS: Map = new Map([ ['javascript', JS_STRING_CONFIG], @@ -236,6 +242,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], ]); // ─── Per-language "stop-after-collect" kinds ───────────────────────────── diff --git a/src/domain/parser.ts b/src/domain/parser.ts index f1c7dd80..a7283d5e 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -471,6 +471,9 @@ export const NATIVE_SUPPORTED_EXTENSIONS: ReadonlySet = new Set([ '.hs', '.ml', '.mli', + '.fs', + '.fsx', + '.fsi', ]); /** diff --git a/tests/parsers/native-drop-classification.test.ts b/tests/parsers/native-drop-classification.test.ts index 24aee1d5..225db728 100644 --- a/tests/parsers/native-drop-classification.test.ts +++ b/tests/parsers/native-drop-classification.test.ts @@ -15,7 +15,6 @@ 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/b.gleam', 'src/c.clj', 'src/d.jl', @@ -27,9 +26,8 @@ describe('classifyNativeDrops', () => { 'src/j.v', 'src/k.m', ]); - expect(totals['unsupported-by-native']).toBe(11); + expect(totals['unsupported-by-native']).toBe(10); expect(totals['native-extractor-failure']).toBe(0); - expect(byReason['unsupported-by-native'].get('.fs')).toEqual(['src/a.fs']); expect(byReason['unsupported-by-native'].get('.gleam')).toEqual(['src/b.gleam']); expect(byReason['unsupported-by-native'].get('.r')).toEqual(['src/e.R']); }); @@ -50,13 +48,13 @@ 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/b.clj', + 'src/c.clj', 'src/d.gleam', ]); 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('.clj')).toEqual(['src/b.clj', 'src/c.clj']); expect(byReason['unsupported-by-native'].get('.gleam')).toEqual(['src/d.gleam']); }); @@ -77,7 +75,7 @@ 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(false); + expect(NATIVE_SUPPORTED_EXTENSIONS.has('.fs')).toBe(true); expect(NATIVE_SUPPORTED_EXTENSIONS.has('.gleam')).toBe(false); }); }); From d03aabf528b974560da6ead74ab5ca46e0e9f3a2 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 11 May 2026 23:49:56 -0600 Subject: [PATCH 2/2] fix: resolve merge conflicts with main (#1104) --- Cargo.lock | 2 +- tests/parsers/native-drop-classification.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2fd7c597..4b6ace14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "codegraph-core" -version = "3.9.6" +version = "3.10.0" dependencies = [ "globset", "ignore", diff --git a/tests/parsers/native-drop-classification.test.ts b/tests/parsers/native-drop-classification.test.ts index 098b630f..31418a8f 100644 --- a/tests/parsers/native-drop-classification.test.ts +++ b/tests/parsers/native-drop-classification.test.ts @@ -25,7 +25,7 @@ describe('classifyNativeDrops', () => { 'src/j.v', 'src/k.m', ]); - expect(totals['unsupported-by-native']).toBe(10); + expect(totals['unsupported-by-native']).toBe(9); expect(totals['native-extractor-failure']).toBe(0); expect(byReason['unsupported-by-native'].get('.gleam')).toEqual(['src/b.gleam']); expect(byReason['unsupported-by-native'].get('.r')).toEqual(['src/e.R']); @@ -47,14 +47,14 @@ describe('classifyNativeDrops', () => { it('handles a mix of supported and unsupported extensions', () => { const { byReason, totals } = classifyNativeDrops([ 'src/a.ts', - 'src/b.clj', - 'src/c.clj', - 'src/d.gleam', + 'src/b.gleam', + 'src/c.gleam', + 'src/d.jl', ]); expect(totals['native-extractor-failure']).toBe(1); expect(totals['unsupported-by-native']).toBe(3); - expect(byReason['unsupported-by-native'].get('.clj')).toEqual(['src/b.clj', 'src/c.clj']); - expect(byReason['unsupported-by-native'].get('.gleam')).toEqual(['src/d.gleam']); + expect(byReason['unsupported-by-native'].get('.gleam')).toEqual(['src/b.gleam', 'src/c.gleam']); + expect(byReason['unsupported-by-native'].get('.jl')).toEqual(['src/d.jl']); }); it('lowercases extensions so .R and .r share a bucket', () => {