From 36eece13e1bda62b1a90de8ff055a11313b32878 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 15 Aug 2026 20:24:33 -0600 Subject: [PATCH 1/3] fix(native): add missing factory-method type-map heuristic (#2396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS's handleCallExprTypeMap seeds a factory-method heuristic: for any const x = Foo.create() call (member-expression callee, capitalized identifier object, not a builtin global), x is typed as Foo at confidence 0.7 — a common static-factory pattern. The Rust mirror, handle_var_declarator_type_map, only implemented the narrower Object.create({...}) branch with no general equivalent, so the same source file could produce a receiver-type edge on one engine but not the other. Adds the same heuristic to the Rust side, gated identically (member_expression callee, uppercase-starting identifier object, not in JS_BUILTIN_GLOBALS) and using push_scoped_type_map_entry for #2235-consistent scoping. No explicit exclusion of Object.create is needed — "Object" is itself in JS_BUILTIN_GLOBALS, and the new branch is mutually exclusive with the existing identifier-callee return-type-propagation branch by construction (a call's function field is one node, never both kinds). docs check acknowledged --- .../src/extractors/javascript.rs | 60 +++++++++++++++++++ tests/engines/parity.test.ts | 42 +++++++++++++ 2 files changed, 102 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index b4d79f49b..66b56d649 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -325,6 +325,30 @@ fn handle_var_declarator_type_map(node: &Node, source: &[u8], symbols: &mut File ); } } + } else if fn_n.kind() == "member_expression" { + // Factory method heuristic: `const x = Foo.create()` → type Foo, + // confidence 0.7 (#2396). Mirrors TS handleCallExprTypeMap's + // identical fallback. No explicit exclusion of Object.create is + // needed here — "Object" is itself in JS_BUILTIN_GLOBALS, and this + // branch is mutually exclusive with the identifier-callee + // return-type-propagation branch above (a call's `function` field + // is one node, never both kinds). + if let Some(obj_n) = fn_n.child_by_field_name("object") { + if obj_n.kind() == "identifier" { + let obj_name = node_text(&obj_n, source); + let starts_uppercase = + obj_name.chars().next().is_some_and(|c| c.is_uppercase()); + if starts_uppercase && !JS_BUILTIN_GLOBALS.contains(&obj_name) { + push_scoped_type_map_entry( + symbols, + enclosing_qualifier.as_deref(), + var_name, + obj_name.to_string(), + 0.7, + ); + } + } + } } } } @@ -10402,6 +10426,42 @@ mod tests { assert_eq!(tm.unwrap().confidence, 1.0); } + /// Issue #2396: `const x = Foo.create()` must type `x` as `Foo` at + /// confidence 0.7 — the same factory-method heuristic TS's + /// `handleCallExprTypeMap` already implements, previously missing here. + #[test] + fn factory_method_call_seeds_type_map_at_point_seven_confidence() { + let s = parse_js("const client = HttpClient.create();"); + let tm = s.type_map.iter().find(|t| t.name == "client"); + assert!( + tm.is_some(), + "type_map should contain 'client'; got: {:?}", + s.type_map + ); + assert_eq!(tm.unwrap().type_name, "HttpClient"); + assert_eq!(tm.unwrap().confidence, 0.7); + } + + #[test] + fn factory_method_heuristic_ignores_lowercase_receiver() { + let s = parse_js("const result = utils.create();"); + assert!(s.type_map.iter().all(|t| t.name != "result")); + } + + #[test] + fn factory_method_heuristic_ignores_object_create_and_other_builtin_globals() { + let s = parse_js( + "const r = Math.random();\n\ + const d = JSON.parse('{}');\n\ + const p = Promise.resolve(42);\n\ + const o = Object.create({});", + ); + assert!(s.type_map.iter().all(|t| t.name != "r")); + assert!(s.type_map.iter().all(|t| t.name != "d")); + assert!(s.type_map.iter().all(|t| t.name != "p")); + assert!(s.type_map.iter().all(|t| t.name != "o")); + } + /// `this.prop = new Ctor()` outside any class declaration (function-style /// constructor) falls back to the un-scoped `this.prop` key. #[test] diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index 7b6f9bf2d..aa2904bb0 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -989,6 +989,48 @@ const Foo = class { ).toBeUndefined(); }); + // Explicit guard for issue #2396: the native engine had no equivalent of + // TS handleCallExprTypeMap's factory-method heuristic at all (only the + // narrower Object.create branch), so `const x = Foo.create()` typed `x` on + // WASM but not on native — a real dual-engine divergence, not just a test + // gap. The normalize() loop above strips typeMap from the structural + // comparison, so this regression would otherwise slip through undetected. + it('JS — factory method call seeds typeMap at 0.7 on both engines (issue #2396)', () => { + const code = `const client = HttpClient.create();`; + const wasm = wasmExtract(code, 'service.js'); + expect(wasm?.typeMap).toBeInstanceOf(Map); + expect(wasm?.typeMap?.get('client')).toEqual({ type: 'HttpClient', confidence: 0.7 }); + + if (!hasNative) return; + const raw = nativeExtract(code, 'service.js'); + if (raw?.typeMap === undefined) return; + const entries = raw?.typeMap as Array<{ name: string; typeName: string; confidence: number }>; + expect(Array.isArray(entries)).toBe(true); + const clientEntry = entries?.find((e) => e.name === 'client'); + expect(clientEntry, 'native typeMap missing "client" key').toBeDefined(); + expect(clientEntry).toMatchObject({ + name: 'client', + typeName: 'HttpClient', + confidence: 0.7, + }); + }); + + it('JS — factory method heuristic ignores Object.create and lowercase receivers on both engines (issue #2396)', () => { + const code = ` +const o = Object.create({}); +const result = utils.create(); +`; + const wasm = wasmExtract(code, 'service.js'); + expect(wasm?.typeMap?.has('result')).toBe(false); + + if (!hasNative) return; + const raw = nativeExtract(code, 'service.js'); + if (raw?.typeMap === undefined) return; + const entries = raw?.typeMap as Array<{ name: string; typeName: string; confidence: number }>; + expect(entries?.find((e) => e.name === 'o')).toBeUndefined(); + expect(entries?.find((e) => e.name === 'result')).toBeUndefined(); + }); + // Explicit guard for the WASM Python fix in #1189. The structural parity // loop above strips `self` from both sides via normalize(), so a regression // where WASM re-emits self/cls would slip through. Assert it directly. From 6a834e094a4901ed5a6633f868717fe463fb4a1a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 15 Aug 2026 20:37:10 -0600 Subject: [PATCH 2/3] fix(native): match JS's UTF-16-code-unit uppercase check exactly (#2396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of the #2396 fix correctly flagged that the new factory heuristic's capitalization check examined the full Unicode scalar (chars().next().is_uppercase()), while TS's objName[0] !== objName[0].toLowerCase() operates on the first UTF-16 code unit. For an astral-plane leading character (code point > U+FFFF), JS string indexing yields a lone surrogate that never case-folds, so TS's check is always false there — Rust's full-scalar check would incorrectly recognize such identifiers as uppercase, diverging from WASM for this parity-sensitive heuristic. Adds starts_with_uppercase_like_js, replicating the UTF-16-code-unit semantics precisely (treating any surrogate code unit as non-uppercase, matching JS), and uses it in place of the naive scalar check. docs check acknowledged --- .../src/extractors/javascript.rs | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 66b56d649..c8c6ffef9 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -72,6 +72,28 @@ const JS_BUILTIN_GLOBALS: &[&str] = &[ "Stream", ]; +/// Mirrors JS `name[0] !== name[0].toLowerCase()` exactly — the check TS's +/// factory-method heuristic (`handleCallExprTypeMap`) uses to decide whether +/// an identifier "starts with an uppercase letter". JS string indexing +/// operates on UTF-16 code units, not full Unicode scalars: for an +/// astral-plane leading character (code point > U+FFFF, e.g. a Deseret +/// capital letter), `name[0]` is a lone UTF-16 surrogate, which doesn't +/// case-fold and so is never treated as uppercase in JS. Using Rust's +/// `char::is_uppercase()` on the full decoded scalar instead would recognize +/// such characters as uppercase, silently diverging from the WASM engine for +/// this parity-sensitive heuristic (#2396) — so this replicates the +/// UTF-16-code-unit semantics precisely rather than the "more correct" full- +/// scalar check. +fn starts_with_uppercase_like_js(name: &str) -> bool { + let Some(unit) = name.encode_utf16().next() else { + return false; + }; + if (0xD800..=0xDFFF).contains(&unit) { + return false; + } + char::from_u32(unit as u32).is_some_and(|c| c.is_uppercase()) +} + pub struct JsExtractor; impl SymbolExtractor for JsExtractor { @@ -336,9 +358,9 @@ fn handle_var_declarator_type_map(node: &Node, source: &[u8], symbols: &mut File if let Some(obj_n) = fn_n.child_by_field_name("object") { if obj_n.kind() == "identifier" { let obj_name = node_text(&obj_n, source); - let starts_uppercase = - obj_name.chars().next().is_some_and(|c| c.is_uppercase()); - if starts_uppercase && !JS_BUILTIN_GLOBALS.contains(&obj_name) { + if starts_with_uppercase_like_js(obj_name) + && !JS_BUILTIN_GLOBALS.contains(&obj_name) + { push_scoped_type_map_entry( symbols, enclosing_qualifier.as_deref(), @@ -10462,6 +10484,45 @@ mod tests { assert!(s.type_map.iter().all(|t| t.name != "o")); } + // Greptile review on #2396: JS string indexing (`name[0]`) operates on + // UTF-16 code units, not full Unicode scalars, so an astral-plane leading + // character becomes a lone surrogate that never case-folds — TS's + // `objName[0] !== objName[0].toLowerCase()` therefore never recognizes it + // as uppercase. A naive `chars().next().is_uppercase()` in Rust decodes + // the full scalar and WOULD recognize it, silently diverging from WASM + // for this heuristic. + #[test] + fn factory_method_heuristic_matches_js_utf16_semantics_for_a_bmp_letter() { + // 'Ω' (U+03A9 GREEK CAPITAL LETTER OMEGA) is a single UTF-16 code unit + // and IS recognized as uppercase by both `str[0].toLowerCase()` in JS + // and `char::is_uppercase()` in Rust — both engines must agree here. + let s = parse_js("const conn = Ωmega.create();"); + let tm = s.type_map.iter().find(|t| t.name == "conn"); + assert!( + tm.is_some(), + "expected 'conn' to be typed; got {:?}", + s.type_map + ); + assert_eq!(tm.unwrap().type_name, "Ωmega"); + assert_eq!(tm.unwrap().confidence, 0.7); + } + + #[test] + fn factory_method_heuristic_matches_js_utf16_semantics_for_an_astral_letter() { + // '𐐔' (U+10414 DESERET CAPITAL LETTER LONG I) is astral-plane — its + // first UTF-16 code unit is a lone high surrogate, which JS's + // `objName[0].toLowerCase()` leaves unchanged, so + // `objName[0] !== objName[0].toLowerCase()` is false and TS's + // heuristic does NOT fire. Rust must not fire here either, even + // though `'𐐔'.is_uppercase()` is true for the full decoded scalar. + let s = parse_js("const conn = \u{10414}mega.create();"); + assert!( + s.type_map.iter().all(|t| t.name != "conn"), + "must not type 'conn' — matches TS's UTF-16-surrogate semantics; got {:?}", + s.type_map + ); + } + /// `this.prop = new Ctor()` outside any class declaration (function-style /// constructor) falls back to the un-scoped `this.prop` key. #[test] From 9b65507d822da3f8e6a9e4d649f042086fbb0879 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 15 Aug 2026 21:23:15 -0600 Subject: [PATCH 3/3] fix(native): match JS's toLowerCase-diff check, not is_uppercase() (#2396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's second review round on #2396 flagged that char::is_uppercase() and "does lowercasing change this character" — what JS's objName[0] !== objName[0].toLowerCase() actually implements — disagree for Unicode titlecase letters (category Lt, e.g. 'Dž'): is_uppercase() is false for them, but they lowercase to a different character, so TS's heuristic fires while Rust's didn't. starts_with_uppercase_like_js now compares the lowercased code point against the original directly (c.to_lowercase().ne(iter::once(c))) instead of checking is_uppercase(), matching JS's actual condition rather than an approximation of it. docs check acknowledged --- .../src/extractors/javascript.rs | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index c8c6ffef9..a71244f64 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -74,16 +74,20 @@ const JS_BUILTIN_GLOBALS: &[&str] = &[ /// Mirrors JS `name[0] !== name[0].toLowerCase()` exactly — the check TS's /// factory-method heuristic (`handleCallExprTypeMap`) uses to decide whether -/// an identifier "starts with an uppercase letter". JS string indexing -/// operates on UTF-16 code units, not full Unicode scalars: for an -/// astral-plane leading character (code point > U+FFFF, e.g. a Deseret -/// capital letter), `name[0]` is a lone UTF-16 surrogate, which doesn't -/// case-fold and so is never treated as uppercase in JS. Using Rust's -/// `char::is_uppercase()` on the full decoded scalar instead would recognize -/// such characters as uppercase, silently diverging from the WASM engine for -/// this parity-sensitive heuristic (#2396) — so this replicates the -/// UTF-16-code-unit semantics precisely rather than the "more correct" full- -/// scalar check. +/// an identifier "starts with an uppercase letter". Two JS-specific quirks +/// this must replicate precisely, not approximate, or the two engines +/// silently diverge on this parity-sensitive heuristic (#2396): +/// +/// - JS string indexing operates on UTF-16 code units, not full Unicode +/// scalars: for an astral-plane leading character (code point > U+FFFF, +/// e.g. a Deseret capital letter), `name[0]` is a lone UTF-16 surrogate, +/// which doesn't case-fold and so is never treated as uppercase in JS. +/// - The condition is "does lowercasing change this character", not "is +/// this character in Unicode's Uppercase category" — those differ for +/// titlecase letters (Unicode category Lt, e.g. `Dž`), which lowercase to +/// a different character but are neither uppercase nor lowercase per +/// `char::is_uppercase()`/`is_lowercase()`. JS's `.toLowerCase()`-based +/// check treats them as "uppercase-like"; Rust's `is_uppercase()` does not. fn starts_with_uppercase_like_js(name: &str) -> bool { let Some(unit) = name.encode_utf16().next() else { return false; @@ -91,7 +95,10 @@ fn starts_with_uppercase_like_js(name: &str) -> bool { if (0xD800..=0xDFFF).contains(&unit) { return false; } - char::from_u32(unit as u32).is_some_and(|c| c.is_uppercase()) + let Some(c) = char::from_u32(unit as u32) else { + return false; + }; + c.to_lowercase().ne(std::iter::once(c)) } pub struct JsExtractor; @@ -10523,6 +10530,26 @@ mod tests { ); } + // Greptile's second review round on #2396: `char::is_uppercase()` and + // "does lowercasing change this character" (what JS's `.toLowerCase()` + // check actually implements) disagree for Unicode titlecase letters. + #[test] + fn factory_method_heuristic_matches_js_utf16_semantics_for_a_titlecase_letter() { + // 'Dž' (U+01C5 LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON) + // is Unicode category Lt (titlecase) — `char::is_uppercase()` is + // false for it, but JS's `'Dž'.toLowerCase()` ('dž') differs from 'Dž', + // so TS's heuristic DOES fire. Rust must fire here too. + let s = parse_js("const conn = \u{1C5}omega.create();"); + let tm = s.type_map.iter().find(|t| t.name == "conn"); + assert!( + tm.is_some(), + "expected 'conn' to be typed for a titlecase receiver; got {:?}", + s.type_map + ); + assert_eq!(tm.unwrap().type_name, "\u{1C5}omega"); + assert_eq!(tm.unwrap().confidence, 0.7); + } + /// `this.prop = new Ctor()` outside any class declaration (function-style /// constructor) falls back to the un-scoped `this.prop` key. #[test]