From 03fc564bca8b6fb90d2311ab05703d1ccfa8238b Mon Sep 17 00:00:00 2001 From: bompus Date: Mon, 2 Nov 2026 09:10:53 -0700 Subject: [PATCH] fix(resolution): three review follow-ups on the batch-3a ports - A multiline `export default (\n v\n) => v` head is anonymous too: the classifier reads from the marker's line to the end of the file, as upstream's readDefaultExport scans the whole statement, so an unfinished parenthesized head no longer counts as an expression default. - A receiver proven to be an instance (`new C().m`, an `initialize`-typed `@x`) can only reach instance methods: an own class method no longer stands in for one an ancestor declares, so `Child.new.flush` reaches `Parent#flush` rather than `Child.flush` the class method. - A `Const.new` tail sliced at byte 200 could land inside a multibyte character and panic (caught per-ref, leaving the call unresolved); the regexes now read a char-boundary-safe prefix. --- __tests__/default-export-declaration.test.ts | 11 ++++++++++ __tests__/ruby-constructed-receivers.test.ts | 23 ++++++++++++++++++++ __tests__/ruby-ivar-receivers.test.ts | 18 +++++++++++++++ codegraph-kernel/src/resolve/query.rs | 7 ++++-- codegraph-kernel/src/resolve/ruby_paths.rs | 9 +++++--- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/__tests__/default-export-declaration.test.ts b/__tests__/default-export-declaration.test.ts index 02d3034f6..ac7671031 100644 --- a/__tests__/default-export-declaration.test.ts +++ b/__tests__/default-export-declaration.test.ts @@ -273,6 +273,11 @@ export function check(s: string, ok: boolean) { return s } export default Main +`, + 'src/gen/multi.ts': `export function helper() { return 1; } +export default ( + value +) => value; `, 'src/gen/use.ts': `import Issue from "./issue" import Render from "./render" @@ -280,6 +285,7 @@ import Write from "./write" import Persisted from "./model" import Plain from "./plain" import Unbalanced from "./unbalanced" +import Multi from "./multi" export function generate() { Issue() @@ -288,6 +294,7 @@ export function generate() { new Persisted() Plain() Unbalanced() + Multi() } `, 'src/use.tsx': `import handler from "./api/vans" @@ -442,6 +449,10 @@ describe('an `export default` line inside a template literal', () => { expect(generated()).toContain('src/gen/model.ts:Model'); }); + it('binds a multiline anonymous arrow default to nothing, not to a function beside it', () => { + expect(generated()).not.toContain('src/gen/multi.ts:helper'); + }); + it('leaves regexes, strings and division alone, and a scan it cannot finish', () => { const targets = generated(); expect(targets).toContain('src/gen/plain.ts:Main'); diff --git a/__tests__/ruby-constructed-receivers.test.ts b/__tests__/ruby-constructed-receivers.test.ts index 93f86c568..55cd741c2 100644 --- a/__tests__/ruby-constructed-receivers.test.ts +++ b/__tests__/ruby-constructed-receivers.test.ts @@ -59,6 +59,18 @@ class OtherWriter false end end + +class ParentWriter + def flush + nil + end +end + +class ChildWriter < ParentWriter + def self.flush + nil + end +end `, 'app/billing.rb': `module Billing class Card @@ -120,6 +132,10 @@ end def outside(env) Request.new(env).preferred_type end + + def inherited_flush + ChildWriter.new.flush + end end `, 'lib/rack/show_exceptions.rb': `module Rack @@ -249,6 +265,13 @@ describe('Ruby bang and question methods, and calls on .new (#684)', () => { expect(callsAt(graph, 'app/counter.rb', 25)).toEqual(['Billing::Card::charge!:3']); }); + it('links an instance method a superclass declares before a class method its child defines', async () => { + const graph = await index(); + // `ChildWriter.new` is an instance: its own `self.flush` is a class + // method, so the call reaches `ParentWriter#flush` instead. + expect(callsAt(graph, 'app/counter.rb', 41)).toEqual(['ParentWriter::flush:38']); + }); + it('links nothing for a class the project does not declare', async () => { const graph = await index(); expect(callsAt(graph, 'app/counter.rb', 37)).toEqual([]); diff --git a/__tests__/ruby-ivar-receivers.test.ts b/__tests__/ruby-ivar-receivers.test.ts index dfd30813b..e28c097e1 100644 --- a/__tests__/ruby-ivar-receivers.test.ts +++ b/__tests__/ruby-ivar-receivers.test.ts @@ -85,6 +85,17 @@ module Mixin @writer.flush end end +`, + 'app/wide.rb': `class WideHolder + def initialize + @wide = CacheWriter.new + @label = "${'x'.repeat(184)}é" + end + + def via_wide + @wide.flush + end +end `, }; @@ -149,4 +160,11 @@ describe('Ruby calls on an instance variable initialize types', () => { expect(callsAt(graph, 'app/holder.rb', 32)).toEqual([]); expect(callsAt(graph, 'app/holder.rb', 38)).toEqual([]); }); + + it('still types an ivar when a multibyte character sits at the scan edge of its initializer', async () => { + const graph = await index(); + // `@wide`'s tail crosses byte 200 inside `é`; a byte-indexed slice would + // panic (caught per-ref) and leave the call unresolved. + expect(callsAt(graph, 'app/wide.rb', 8)).toEqual(['CacheWriter::flush:2']); + }); }); diff --git a/codegraph-kernel/src/resolve/query.rs b/codegraph-kernel/src/resolve/query.rs index 8adda04fb..fd41d19ed 100644 --- a/codegraph-kernel/src/resolve/query.rs +++ b/codegraph-kernel/src/resolve/query.rs @@ -599,8 +599,11 @@ impl KernelResolver { if !marker_lines.is_empty() { let mut anonymous = false; for &line in marker_lines.iter() { - let head = text.split('\n').nth(line.saturating_sub(1) as usize).unwrap_or(""); - if export_default_head_is_anonymous(head) { + // A default's head may span lines (`export default (\n x\n) => x`): + // upstream's readDefaultExport scans the whole statement, so the + // classifier gets the source from the marker's line to the end. + let head = text.split('\n').skip(line.saturating_sub(1) as usize).collect::>().join("\n"); + if export_default_head_is_anonymous(&head) { anonymous = true; } else { default_expr = true; diff --git a/codegraph-kernel/src/resolve/ruby_paths.rs b/codegraph-kernel/src/resolve/ruby_paths.rs index d8b4e7137..e58a68180 100644 --- a/codegraph-kernel/src/resolve/ruby_paths.rs +++ b/codegraph-kernel/src/resolve/ruby_paths.rs @@ -242,7 +242,10 @@ impl KernelResolver { let own: Vec> = self .nodes_by_qualified_name(&format!("{owner_qn}::{name}"))? .iter() - .filter(|n| n.language == "ruby" && n.kind == "method" && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) + // A receiver bound to an instance (`new C().m`, a typed `@x`) can + // only reach instance methods: an own class method does not stand + // in for one an ancestor declares. + .filter(|n| n.language == "ruby" && n.kind == "method" && !(kind == "instance" && n.is_static) && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) .cloned() .collect(); let own_pick = if kind == "value" { @@ -454,10 +457,10 @@ fn constructed_value(s: &str, from: usize) -> Option<(String, bool)> { let tail = &s[i.min(s.len())..]; let ok = match tail.chars().next() { None | Some('\n') | Some(';') | Some('{') => true, - _ => re!(r#"^[ \t]+(?:$|\n|;|\{|[\w:@$"'\[])"#).is_match(&tail[..tail.len().min(200)]), + _ => re!(r#"^[ \t]+(?:$|\n|;|\{|[\w:@$"'\[])"#).is_match(tail.get(..200).unwrap_or(tail)), }; // A chain may go on at the start of the next line: `Foo.new\n .tap {…}`. - let continued = re!(r"^[ \t]*\n\s*&?\.").is_match(&tail[..tail.len().min(200)]); + let continued = re!(r"^[ \t]*\n\s*&?\.").is_match(tail.get(..200).unwrap_or(tail)); (ok && !continued).then(|| (m[2].to_string(), m.get(1).is_some())) }