Skip to content

Commit 5400748

Browse files
Your Nameclaude
andcommitted
feat(indexer): implement Dart call-edge extraction (C3)
tree-sitter-dart has no dedicated call-expression node kind — a call (`a.b.c(x)`) and a call-less field access (`a.b.c`) parse as the exact same flat `member_access` node shape: [primary, selector, selector, ...], where the last selector wraps either a dotted name (.foo/?.foo) or an argument_part (the call parens). This is structural, not expressible via the generic call_node_types/call_function_field field-lookup walk_calls uses for every other language. Added dart_call_from_member_access (parser.rs), verified against a real tree-sitter-dart 0.0.4 parse (scratch AST dump this session confirmed the exact shapes for bare print(x), this.foo(x), g.greet(), chained a.b.c(), and the no-call a.b.c case). Wired in as its own language-gated branch in walk_calls (threaded `language: &str` through, mirroring walk_symbols) — mutually exclusive with the generic path since Dart's call_node_types stays empty, so zero behavior change for every other language. Measured on the real dart-lang/args benchmark corpus: 0 -> 2,166 call edges (178 symbols unchanged). Deliberately not covered this pass: constructor_invocation (List<int>.filled(...)-style explicit-type-arg named-constructor calls) and a call through an index_selector mid-chain (list[0].foo()) — both bail rather than guess wrong. Verified: full workspace test suite (849 calm-core + 264 calm-server + integration tests, 0 failed), clippy -D warnings clean, cargo fmt --check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f79c08c commit 5400748

3 files changed

Lines changed: 223 additions & 23 deletions

File tree

benchmarks/resolution/README.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,41 @@ trong `CORPORA`, ghi đè `results.json` với bộ đầy đủ):
128128
| powershell | 142 | 156 | 52.6% | 0.0% | 46.2% | 1.3% | 5.6 | bbc5ac3 |
129129
| groovy | 59 | 19 | 84.2% | 0.0% | 5.3% | 10.5% | 13.2 | 3f97e22 |
130130

131-
**`dart` = 0 edges, đúng là 0 thật, không phải lỗi đo** — tài liệu hoá từ Phase C
132-
([[calm-25-language-expansion-research]]): grammar Dart không có node kind cho call-expression, nên
133-
`walk_calls` không có gì để trích xuất — 178 symbols (class/method) vẫn được index đầy đủ, chỉ riêng
134-
call-graph là khoảng trống đã biết trước, có chủ đích (deliberate scope cut, không phải bug).
131+
**`dart` = 0 edges lúc đo lần này (2026-07-11), đúng là 0 thật, không phải lỗi đo** — tài liệu hoá
132+
từ Phase C ([[calm-25-language-expansion-research]]): grammar Dart không có node kind cho
133+
call-expression, nên `walk_calls` không có gì để trích xuất — 178 symbols (class/method) vẫn được
134+
index đầy đủ, chỉ riêng call-graph là khoảng trống đã biết trước, có chủ đích (deliberate scope cut,
135+
không phải bug). **SUPERSEDED 2026-07-28 — xem cập nhật ngay bên dưới: đã cài đặt Dart call-edge
136+
extraction (C3, Tier B audit); số 0 edges này không còn đúng ở build hiện tại.**
137+
138+
**Cập nhật 2026-07-28 — Dart call-edge extraction (C3), đo thật lại trên cùng corpus:**
139+
tree-sitter-dart thật ra không thiếu node kind cho call — nó dùng chung `member_access`/`selector`/
140+
`argument_part` cho CẢ call lẫn field access thuần (`a.b.c()``a.b.c` parse ra cùng shape,
141+
khác nhau ở việc selector cuối có bọc `argument_part` hay không) — không phải "không có node
142+
cho call" như ghi chú Phase C phía trên (giả định ban đầu đó sai, đã sửa). Cài một nhánh trích
143+
xuất riêng cho Dart trong `walk_calls` (`dart_call_from_member_access`, `parser.rs`) thay vì cơ
144+
chế `call_node_types`/`call_function_field` chung (không biểu đạt được luật "selector cuối có
145+
phải call hay không"). Đo lại trên cùng corpus `dart-lang/args` (build release, cùng lệnh ở trên):
146+
147+
| | trước (0 edges) | sau (C3) |
148+
|---|---:|---:|
149+
| symbols | 178 | 178 (không đổi — extraction symbol vẫn luôn đúng) |
150+
| edges | 0 | **2,166** |
151+
| resolved% || 7.8% |
152+
| textual% || 11.2% |
153+
| **ambiguous%** || **80.9%** |
154+
155+
Không có `formal`/`inferred` (0% cả hai — Dart chưa có SCIP provider, và Tier-2 type_map chỉ có cho
156+
5 ngôn ngữ Tier-0 gốc). `ambiguous%` cao (80.9%) cùng nguyên nhân gốc đã thấy ở Kotlin/OCaml/C++:
157+
corpus args là 1 lib CLI-parser nhỏ với nhiều method tên phổ biến (`parse`, `format`, …) trùng lặp,
158+
fan-out `MAX_CALLEE_CANDIDATES` cao — không phải lỗi của lần cài đặt này, mà là giới hạn chung của
159+
mọi ngôn ngữ Tier-0.5 chưa có SCIP provider thật, giống mọi dòng khác trong bảng Phase B/C ở trên.
160+
161+
**Nợ ghi nhận, chưa cài trong lượt này:** `constructor_invocation` (dạng `List<int>.filled(...)`,
162+
có type argument tường minh trước dot-named-constructor) — hình dạng node RIÊNG, hiếm hơn
163+
`member_access`, cố ý bỏ qua cho lượt đầu này (xem doc comment của `dart_call_from_member_access`).
164+
Cũng bỏ qua có chủ đích: 1 call đi qua `index_selector` giữa chuỗi (vd `list[0].foo()`) — bail
165+
thay vì đoán sai, không silently miscount.
135166

136167
**`inferred%` = 0.0% cho toàn bộ 11 ngôn ngữ mới** — hợp lý, không phải lỗi: Tier-2 (`type_map`
137168
receiver inference) hiện chỉ implement cho các ngôn ngữ Tier-0 gốc (Python/JS/TS/Java/C#) — 11 ngôn

crates/calm-core/src/indexer/lang_constants.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,10 +1047,20 @@ pub static LANGUAGES: &[LanguageSpec] = &[
10471047
// guessed:
10481048
// - No dedicated call-expression node kind at all (calls are a generic
10491049
// member_access/selector/argument_part postfix chain shared with
1050-
// plain field access) — call-graph extraction is a DELIBERATE,
1051-
// documented scope cut for this first pass (`call_node_types` is
1052-
// empty), not an oversight. See `test_dart_real_grammar_symbols_are_
1053-
// accurate` in parser.rs, which locks this gap in place.
1050+
// plain field access) — `call_node_types` stays EMPTY on purpose
1051+
// (C3, Tier B audit: call extraction is handled by a dedicated
1052+
// `language == "dart"` branch in `walk_calls`/`dart_call_from_
1053+
// member_access` instead, since the generic `call_node_types`/
1054+
// `call_function_field` mechanism can't express "a member_access is
1055+
// only a call when its LAST selector wraps an argument_part" — see
1056+
// that function's doc comment). Recognized: bare `foo(x)`,
1057+
// `this.foo(x)`, `recv.foo(x)`, chained `a.b.c(x)`. Deliberately NOT
1058+
// covered (bails rather than guessing): a call through an
1059+
// `index_selector` in the chain (`list[0].foo()`), and the narrower
1060+
// `constructor_invocation` shape (`List<int>.filled(...)`, an
1061+
// explicit-type-argument named-constructor call) — both real but
1062+
// comparatively rare shapes left for a follow-up pass. See
1063+
// `test_dart_real_grammar_symbols_and_calls_are_accurate` in parser.rs.
10541064
// - A method/constructor's name sits 2 levels deep with no field path
10551065
// all the way down (`class_member_definition` wraps `method_signature`
10561066
// or `declaration`, which in turn wraps an unnamed `function_signature`/

crates/calm-core/src/indexer/parser.rs

Lines changed: 174 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,92 @@ fn elixir_def_arity(def_call_node: tree_sitter::Node) -> Option<i64> {
523523
}
524524
}
525525

526+
/// Dart (C3, Tier B audit): tree-sitter-dart has no dedicated call-expression
527+
/// node kind — a call like `a.b.c(x)` and a call-less field access `a.b.c`
528+
/// parse as the SAME flat `member_access` node shape: `[primary, selector,
529+
/// selector, ...]`, where each `selector` wraps either a dotted-name
530+
/// (`unconditional_assignable_selector`/`conditional_assignable_selector`,
531+
/// i.e. `.foo`/`?.foo`) or an `argument_part` (the call parens). Recognizing
532+
/// a call is therefore structural — "does the LAST selector wrap an
533+
/// `argument_part`?" — not expressible via the generic `call_node_types`/
534+
/// `call_function_field` field-lookup `walk_calls` uses for every other
535+
/// language, so it's handled as its own language-gated branch there instead
536+
/// (same precedent as `go_receiver_type`/`elixir_def_arity` for other
537+
/// grammar shapes the generic path can't cover). Verified against a real
538+
/// parse of tree-sitter-dart 0.0.4 (bare `print(x)`, `this.foo(x)`,
539+
/// `g.greet()`, `Greeter("world")`, chained `a.b.c()`, and the no-call
540+
/// `a.b.c` field access — see `test_dart_real_grammar_symbols_and_calls_are_
541+
/// accurate`), not guessed.
542+
///
543+
/// Returns `(receiver, callee, arg_count)` when `node` (a `member_access`)
544+
/// is really a call site; `None` when it's a call-less field/index-access
545+
/// chain, or a shape this first pass doesn't cover (a call on a
546+
/// parenthesized/function-literal primary, or an `index_selector` — e.g.
547+
/// `list[0]` — in the middle of the chain) — deliberately bails rather than
548+
/// guessing wrong, same risk tolerance `split_receiver_callee` already
549+
/// applies for every other language.
550+
fn dart_call_from_member_access(
551+
node: tree_sitter::Node,
552+
source: &str,
553+
) -> Option<(Option<String>, String, Option<i64>)> {
554+
let mut cursor = node.walk();
555+
let named: Vec<tree_sitter::Node> = node.named_children(&mut cursor).collect();
556+
if named.len() < 2 {
557+
return None;
558+
}
559+
let last = named[named.len() - 1];
560+
if last.kind() != "selector" {
561+
return None;
562+
}
563+
let argument_part = last.named_child(0)?;
564+
if argument_part.kind() != "argument_part" {
565+
return None; // last selector isn't a call — plain field/index access chain
566+
}
567+
let arg_count = count_arguments_node(argument_part);
568+
569+
// Dotted-name selector (`.foo`/`?.foo`) -> the identifier's own text, or
570+
// `None` for an `index_selector` (`[i]`) — a shape this pass skips.
571+
fn dotted_name(sel: tree_sitter::Node, source: &str) -> Option<String> {
572+
let inner = sel.named_child(0)?;
573+
match inner.kind() {
574+
"unconditional_assignable_selector" | "conditional_assignable_selector" => {
575+
let ident = inner.named_child(0)?;
576+
(ident.kind() == "identifier").then(|| source[ident.byte_range()].to_string())
577+
}
578+
_ => None,
579+
}
580+
}
581+
582+
// Everything before the call selector: primary + zero or more dotted
583+
// selectors. The callee is the last dotted segment (or the primary
584+
// itself for a bare call); the receiver, if any, is the segment right
585+
// before that — the same "receiver = last segment" convention
586+
// `split_receiver_callee` already uses for every other language's
587+
// chained access (e.g. `a.b.c()` gets receiver "b", not the full "a.b").
588+
let pre = &named[..named.len() - 1];
589+
if pre.len() == 1 {
590+
let prim = pre[0];
591+
if !matches!(prim.kind(), "identifier" | "this" | "super") {
592+
return None;
593+
}
594+
return Some((None, source[prim.byte_range()].to_string(), arg_count));
595+
}
596+
597+
let callee_sel = pre[pre.len() - 1];
598+
if callee_sel.kind() != "selector" {
599+
return None;
600+
}
601+
let callee = dotted_name(callee_sel, source)?;
602+
603+
let receiver_node = pre[pre.len() - 2];
604+
let receiver = if receiver_node.kind() == "selector" {
605+
dotted_name(receiver_node, source)
606+
} else {
607+
Some(source[receiver_node.byte_range()].to_string())
608+
};
609+
Some((receiver, callee, arg_count))
610+
}
611+
526612
/// Walks backward through contiguous same-kind comment siblings immediately
527613
/// preceding `node` — no blank-line gap between any two, nor between the
528614
/// last one and `node` itself — and joins them in source order. Line-
@@ -1416,6 +1502,7 @@ fn walk_calls(
14161502
node: tree_sitter::Node,
14171503
source: &str,
14181504
consts: &crate::indexer::lang_constants::LangConstants,
1505+
language: &str,
14191506
enclosing: Option<(String, usize)>,
14201507
enclosing_class: Option<String>,
14211508
out: &mut Vec<RawCall>,
@@ -1444,7 +1531,33 @@ fn walk_calls(
14441531
enclosing_class.clone()
14451532
};
14461533

1447-
if consts.call_node_types.contains(&node.kind())
1534+
// Dart (C3, Tier B audit): no dedicated call-expression node kind exists
1535+
// in this grammar at all (see `dart_call_from_member_access`'s doc
1536+
// comment) — handled as its own language-gated branch rather than
1537+
// through `call_node_types`/`call_function_field` below, which assumes a
1538+
// call node names its callee via one fixed field or first-child
1539+
// position. `call_node_types` stays empty for Dart in `lang_constants.rs`
1540+
// on purpose, so the generic branch below can never also fire for a
1541+
// `member_access` node — the two paths are mutually exclusive by
1542+
// construction, not just by this `else`.
1543+
if language == "dart"
1544+
&& node.kind() == "member_access"
1545+
&& let Some((enc_name, enc_line)) = &current
1546+
&& let Some((receiver, callee, arg_count)) = dart_call_from_member_access(node, source)
1547+
{
1548+
out.push(RawCall {
1549+
enclosing_name: enc_name.clone(),
1550+
enclosing_line: *enc_line,
1551+
enclosing_class: child_class.clone(),
1552+
callee,
1553+
receiver_is_type_path: receiver.as_deref().is_some_and(is_type_like),
1554+
receiver,
1555+
module_hint: None,
1556+
looks_option_or_result_chained: looks_option_or_result_chained(node, source),
1557+
line: node.start_position().row + 1,
1558+
arg_count,
1559+
});
1560+
} else if consts.call_node_types.contains(&node.kind())
14481561
&& !is_definition_macro_call(node, source, consts)
14491562
&& let Some((enc_name, enc_line)) = &current
14501563
&& let field_name = consts
@@ -1562,6 +1675,7 @@ fn walk_calls(
15621675
child,
15631676
source,
15641677
consts,
1678+
language,
15651679
current.clone(),
15661680
child_class.clone(),
15671681
out,
@@ -1599,7 +1713,15 @@ pub fn extract_calls_from_tree(
15991713
return Vec::new();
16001714
};
16011715
let mut out = Vec::new();
1602-
walk_calls(tree.root_node(), source, &consts, None, None, &mut out);
1716+
walk_calls(
1717+
tree.root_node(),
1718+
source,
1719+
&consts,
1720+
language,
1721+
None,
1722+
None,
1723+
&mut out,
1724+
);
16031725
out
16041726
}
16051727

@@ -4448,16 +4570,16 @@ class Foo {
44484570

44494571
#[test]
44504572
#[cfg(feature = "lang-dart")]
4451-
fn test_dart_real_grammar_symbols_are_accurate() {
4573+
fn test_dart_real_grammar_symbols_and_calls_are_accurate() {
44524574
// tree-sitter-dart 0.0.4 (an older, hand-written grammar — see
44534575
// lang_constants.rs's Dart entry) has no dedicated call-expression
4454-
// node kind at all (calls are a generic member_access/selector/
4455-
// argument_part postfix chain shared with plain field access) —
4456-
// call-graph extraction is a documented scope cut, not an oversight.
4457-
// This test locks that gap in place (asserts calls stay empty) so a
4458-
// future accidental `call_node_types` entry that doesn't actually
4459-
// work correctly gets caught instead of silently shipping partial
4460-
// call edges.
4576+
// node kind at all — a call and a call-less field access share the
4577+
// same `member_access`/`selector`/`argument_part` postfix-chain
4578+
// shape. Call extraction (C3, Tier B audit) is handled by a
4579+
// dedicated `dart_call_from_member_access` branch in `walk_calls`,
4580+
// not the generic `call_node_types` mechanism — see that function's
4581+
// doc comment for why, and its own scratch-verified real-grammar
4582+
// shapes.
44614583
let code = "class Greeter {\n String name;\n\n Greeter(this.name);\n\n String greet() {\n print(\"hello\");\n return this.formatGreeting(name);\n }\n\n String formatGreeting(String n) {\n return \"Hello, \" + n;\n }\n}\n\nabstract class Named {\n String getName();\n}\n\nvoid main() {\n var g = Greeter(\"world\");\n g.greet();\n}\n";
44624584
let symbols = extract_symbols(code, "dart", "a.dart").unwrap();
44634585
let names = shallow_names(&symbols);
@@ -4503,12 +4625,49 @@ class Foo {
45034625
);
45044626

45054627
let calls = extract_calls(code, "dart", "a.dart").unwrap();
4506-
assert!(
4507-
calls.is_empty(),
4508-
"Dart call-graph extraction is a documented scope cut (no clean \
4509-
call-expression node in this grammar) — see this test's doc comment; \
4510-
calls: {calls:?}"
4628+
assert_eq!(
4629+
calls.len(),
4630+
4,
4631+
"expected print(\"hello\"), this.formatGreeting(name), Greeter(\"world\"), \
4632+
g.greet() — the constructor DECLARATION `Greeter(this.name);` must NOT \
4633+
itself count as a 5th call; calls: {calls:?}"
4634+
);
4635+
4636+
let print_call = calls
4637+
.iter()
4638+
.find(|c| c.callee == "print")
4639+
.expect("bare print(\"hello\") call should be found");
4640+
assert_eq!(print_call.enclosing_name, "greet");
4641+
assert_eq!(print_call.receiver, None);
4642+
assert_eq!(print_call.arg_count, Some(1));
4643+
4644+
let format_greeting_call = calls
4645+
.iter()
4646+
.find(|c| c.callee == "formatGreeting")
4647+
.expect("this.formatGreeting(name) call should be found");
4648+
assert_eq!(format_greeting_call.enclosing_name, "greet");
4649+
assert_eq!(
4650+
format_greeting_call.receiver.as_deref(),
4651+
Some("this"),
4652+
"receiver should be \"this\""
45114653
);
4654+
assert_eq!(format_greeting_call.arg_count, Some(1));
4655+
4656+
let greeter_call = calls
4657+
.iter()
4658+
.find(|c| c.callee == "Greeter")
4659+
.expect("Greeter(\"world\") constructor call should be found");
4660+
assert_eq!(greeter_call.enclosing_name, "main");
4661+
assert_eq!(greeter_call.receiver, None);
4662+
assert_eq!(greeter_call.arg_count, Some(1));
4663+
4664+
let greet_call = calls
4665+
.iter()
4666+
.find(|c| c.callee == "greet")
4667+
.expect("g.greet() call should be found");
4668+
assert_eq!(greet_call.enclosing_name, "main");
4669+
assert_eq!(greet_call.receiver.as_deref(), Some("g"));
4670+
assert_eq!(greet_call.arg_count, Some(0));
45124671
}
45134672

45144673
#[test]

0 commit comments

Comments
 (0)