Skip to content

Commit 41be60e

Browse files
committed
fix(swift): harden symbol workflow accuracy
1 parent 2e68f83 commit 41be60e

14 files changed

Lines changed: 279 additions & 15 deletions

File tree

grapha-core/src/graph.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::path::PathBuf;
66
#[serde(rename_all = "snake_case")]
77
pub enum NodeKind {
88
Function,
9+
Class,
910
Struct,
1011
Enum,
1112
Trait,
@@ -157,6 +158,9 @@ mod tests {
157158
let json = serde_json::to_string(&NodeKind::Function).unwrap();
158159
assert_eq!(json, "\"function\"");
159160

161+
let json = serde_json::to_string(&NodeKind::Class).unwrap();
162+
assert_eq!(json, "\"class\"");
163+
160164
let json = serde_json::to_string(&NodeKind::Struct).unwrap();
161165
assert_eq!(json, "\"struct\"");
162166
}

grapha-core/src/normalize.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::collections::HashMap;
22

3-
use crate::graph::{Edge, EdgeKind, FlowDirection, Graph, Node, Visibility};
3+
use crate::graph::{Edge, EdgeKind, FlowDirection, Graph, Node, NodeKind, Visibility};
44

55
pub fn normalize_graph(mut graph: Graph) -> Graph {
66
fn visibility_rank(visibility: &Visibility) -> u8 {
@@ -11,7 +11,15 @@ pub fn normalize_graph(mut graph: Graph) -> Graph {
1111
}
1212
}
1313

14+
fn merged_kind(existing: NodeKind, incoming: NodeKind) -> NodeKind {
15+
match (existing, incoming) {
16+
(NodeKind::Struct, NodeKind::Class) => NodeKind::Class,
17+
_ => existing,
18+
}
19+
}
20+
1421
fn merge_node(existing: &mut Node, incoming: Node) {
22+
existing.kind = merged_kind(existing.kind, incoming.kind);
1523
if visibility_rank(&incoming.visibility) > visibility_rank(&existing.visibility) {
1624
existing.visibility = incoming.visibility;
1725
}
@@ -268,6 +276,54 @@ mod tests {
268276
assert_eq!(normalized.nodes[0].module.as_deref(), Some("Room"));
269277
}
270278

279+
#[test]
280+
fn normalize_graph_prefers_class_over_struct_for_same_symbol() {
281+
let graph = Graph {
282+
version: "0.1.0".to_string(),
283+
nodes: vec![
284+
Node {
285+
id: "AppDelegate".to_string(),
286+
kind: NodeKind::Struct,
287+
name: "AppDelegate".to_string(),
288+
file: PathBuf::from("AppDelegate.swift"),
289+
span: Span {
290+
start: [0, 0],
291+
end: [1, 0],
292+
},
293+
visibility: Visibility::Crate,
294+
metadata: HashMap::new(),
295+
role: None,
296+
signature: None,
297+
doc_comment: None,
298+
module: None,
299+
snippet: None,
300+
},
301+
Node {
302+
id: "AppDelegate".to_string(),
303+
kind: NodeKind::Class,
304+
name: "AppDelegate".to_string(),
305+
file: PathBuf::from("AppDelegate.swift"),
306+
span: Span {
307+
start: [0, 0],
308+
end: [1, 0],
309+
},
310+
visibility: Visibility::Crate,
311+
metadata: HashMap::new(),
312+
role: None,
313+
signature: None,
314+
doc_comment: None,
315+
module: None,
316+
snippet: None,
317+
},
318+
],
319+
edges: vec![],
320+
};
321+
322+
let normalized = normalize_graph(graph);
323+
assert_eq!(normalized.nodes.len(), 1);
324+
assert_eq!(normalized.nodes[0].kind, NodeKind::Class);
325+
}
326+
271327
#[test]
272328
fn fingerprint_changes_when_effect_shape_changes() {
273329
let base = Edge {

grapha-swift/src/binary.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ fn read_str(string_table: &[u8], offset: u32, len: u32) -> Option<&str> {
118118
fn decode_node_kind(v: u8) -> Option<NodeKind> {
119119
match v {
120120
0 => Some(NodeKind::Function),
121+
9 => Some(NodeKind::Class),
121122
1 => Some(NodeKind::Struct),
122123
2 => Some(NodeKind::Enum),
123124
3 => Some(NodeKind::Protocol),

grapha-swift/src/swiftsyntax.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ mod tests {
7575

7676
let config = find_node(&result, "Config", NodeKind::Struct);
7777
let configurable = find_node(&result, "Configurable", NodeKind::Protocol);
78-
let app_delegate = find_node(&result, "AppDelegate", NodeKind::Struct);
78+
let app_delegate = find_node(&result, "AppDelegate", NodeKind::Class);
7979
let launch = find_node(&result, "launch", NodeKind::Function);
8080
let default_config = find_node(&result, "defaultConfig", NodeKind::Function);
8181
let theme = find_node(&result, "Theme", NodeKind::Enum);
@@ -122,7 +122,7 @@ mod tests {
122122
let result = extract_with_swiftsyntax(source.as_bytes(), fixture_path())
123123
.expect("SwiftSyntax extraction should succeed");
124124

125-
let worker = find_node(&result, "Worker", NodeKind::Struct);
125+
let worker = find_node(&result, "Worker", NodeKind::Class);
126126
let extension_node = result
127127
.nodes
128128
.iter()

grapha-swift/src/treesitter.rs

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ fn walk_node(
298298
file,
299299
module_path,
300300
parent_id,
301-
NodeKind::Struct,
301+
NodeKind::Class,
302302
result,
303303
);
304304
}
@@ -1177,7 +1177,7 @@ fn extract_calls_from_text(
11771177
result.edges.push(Edge {
11781178
source: caller_id.to_string(),
11791179
target: target_id,
1180-
kind: EdgeKind::Calls,
1180+
kind: EdgeKind::Reads,
11811181
confidence: 0.5,
11821182
direction: None,
11831183
operation: Some(prefix),
@@ -1777,7 +1777,7 @@ fn extract_nav_prefix(node: tree_sitter::Node, source: &[u8]) -> Option<String>
17771777
}
17781778

17791779
/// Recursively scan for `call_expression` and `navigation_expression` nodes,
1780-
/// emitting Calls edges for function calls and TypeRef edges for property accesses.
1780+
/// emitting Calls edges for function calls and Reads edges for property accesses.
17811781
fn extract_calls(
17821782
node: tree_sitter::Node,
17831783
source: &[u8],
@@ -1806,8 +1806,8 @@ fn extract_calls(
18061806
}
18071807

18081808
// Property access: `foo.bar` generates a navigation_expression.
1809-
// Emit a Calls edge so that property reads appear in the graph
1810-
// and impact analysis can trace through them.
1809+
// Emit a Reads edge so dependency queries can trace through the access
1810+
// without polluting call lists with field-like symbols.
18111811
// Skip if the parent is a call_expression (already handled above as the callee name).
18121812
if node.kind() == "navigation_expression"
18131813
&& !matches!(node.parent().map(|p| p.kind()), Some("call_expression"))
@@ -1830,7 +1830,7 @@ fn extract_calls(
18301830
result.edges.push(Edge {
18311831
source: caller_id.to_string(),
18321832
target: target_id,
1833-
kind: EdgeKind::Calls,
1833+
kind: EdgeKind::Reads,
18341834
confidence: 0.6,
18351835
direction: None,
18361836
operation: prefix,
@@ -1971,6 +1971,10 @@ fn dependency_read_name(
19711971
return None;
19721972
}
19731973

1974+
if is_syntactic_argument_label(node, source) {
1975+
return None;
1976+
}
1977+
19741978
let name = node.utf8_text(source).ok()?.trim().to_string();
19751979
if name.is_empty() || name == "_" || uppercase_identifier(&name) {
19761980
return None;
@@ -1989,6 +1993,48 @@ fn dependency_read_name(
19891993
Some(name)
19901994
}
19911995

1996+
fn is_syntactic_argument_label(node: tree_sitter::Node, source: &[u8]) -> bool {
1997+
let mut cursor = node.parent();
1998+
let mut inside_call_syntax = false;
1999+
while let Some(parent) = cursor {
2000+
if matches!(
2001+
parent.kind(),
2002+
"value_argument"
2003+
| "value_arguments"
2004+
| "call_suffix"
2005+
| "call_expression"
2006+
| "navigation_suffix"
2007+
| "navigation_expression"
2008+
) {
2009+
inside_call_syntax = true;
2010+
break;
2011+
}
2012+
if matches!(
2013+
parent.kind(),
2014+
"function_declaration"
2015+
| "protocol_function_declaration"
2016+
| "init_declaration"
2017+
| "closure_parameter"
2018+
| "parameter"
2019+
) {
2020+
break;
2021+
}
2022+
cursor = parent.parent();
2023+
}
2024+
2025+
if !inside_call_syntax {
2026+
return false;
2027+
}
2028+
2029+
let remaining = &source[node.end_byte()..];
2030+
let offset = remaining
2031+
.iter()
2032+
.position(|b| !b.is_ascii_whitespace())
2033+
.unwrap_or(remaining.len());
2034+
2035+
remaining.get(offset).copied() == Some(b':')
2036+
}
2037+
19922038
fn emit_dependency_read(
19932039
node: tree_sitter::Node,
19942040
source: &[u8],
@@ -3711,7 +3757,7 @@ mod tests {
37113757
fn extracts_class() {
37123758
let result = extract("public class AppDelegate { }");
37133759
let node = find_node(&result, "AppDelegate");
3714-
assert_eq!(node.kind, NodeKind::Struct); // classes map to Struct in our model
3760+
assert_eq!(node.kind, NodeKind::Class);
37153761
assert_eq!(node.visibility, Visibility::Public);
37163762
}
37173763

@@ -3882,6 +3928,31 @@ mod tests {
38823928
assert_eq!(call_edge.provenance[0].symbol_id, "test.swift::launch");
38833929
}
38843930

3931+
#[test]
3932+
fn property_accesses_become_reads_not_calls() {
3933+
let result = extract(
3934+
r#"
3935+
struct Model { let value: Int }
3936+
func launch(model: Model) {
3937+
let current = model.value
3938+
}
3939+
"#,
3940+
);
3941+
3942+
assert!(has_edge(
3943+
&result,
3944+
"test.swift::launch",
3945+
"test.swift::value",
3946+
EdgeKind::Reads,
3947+
));
3948+
assert!(!has_edge(
3949+
&result,
3950+
"test.swift::launch",
3951+
"test.swift::value",
3952+
EdgeKind::Calls,
3953+
));
3954+
}
3955+
38853956
#[test]
38863957
fn extracts_condition_on_call_inside_if() {
38873958
let result = extract(
@@ -4368,6 +4439,65 @@ mod tests {
43684439
assert!(has_read("test.swift::RoomPage::minimizeGameRoom"));
43694440
}
43704441

4442+
#[test]
4443+
fn skips_argument_labels_when_enriching_property_reads() {
4444+
let result = extract_with_localization(
4445+
r#"
4446+
import SwiftUI
4447+
4448+
struct GeneralButtonType {
4449+
let type: Int
4450+
}
4451+
4452+
struct AudioButton {
4453+
let label: String
4454+
}
4455+
4456+
struct LoginPage: View {
4457+
@State private var isNextValid = false
4458+
4459+
private var loginButton: some View {
4460+
Button {
4461+
} label: {
4462+
Text("Login")
4463+
}
4464+
.buttonStyle(.app(type: .yellowH112))
4465+
.disabled(isNextValid.negated)
4466+
}
4467+
4468+
var body: some View { loginButton }
4469+
}
4470+
"#,
4471+
);
4472+
let graph = grapha_core::merge(vec![result]);
4473+
4474+
let read_targets: Vec<_> = graph
4475+
.edges
4476+
.iter()
4477+
.filter(|edge| {
4478+
edge.source == "test.swift::LoginPage::loginButton" && edge.kind == EdgeKind::Reads
4479+
})
4480+
.map(|edge| edge.target.as_str())
4481+
.collect();
4482+
4483+
assert!(
4484+
read_targets.contains(&"test.swift::LoginPage::isNextValid"),
4485+
"real property reads should still be preserved"
4486+
);
4487+
assert!(
4488+
read_targets
4489+
.iter()
4490+
.all(|target| !target.ends_with("::label")),
4491+
"trailing closure labels should not become dependency reads: {read_targets:?}"
4492+
);
4493+
assert!(
4494+
read_targets
4495+
.iter()
4496+
.all(|target| !target.ends_with("::type")),
4497+
"named argument labels should not become dependency reads: {read_targets:?}"
4498+
);
4499+
}
4500+
43714501
#[test]
43724502
fn marks_swiftui_dynamic_properties_as_invalidation_sources() {
43734503
let result = extract_with_localization(

grapha-swift/swift-bridge/Sources/GraphaSwiftBridge/IndexStoreReader.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ private func mapSymbolKind(_ raw: UInt64) -> UInt8? {
380380
switch raw {
381381
case 5: return 2 // enum
382382
case 6: return 1 // struct
383-
case 7: return 1 // Class → struct in grapha
383+
case 7: return 9 // class
384384
case 8: return 3 // protocol
385385
case 9: return 4 // extension
386386
case 11: return 5 // type_alias

grapha-swift/swift-bridge/Sources/GraphaSwiftBridge/SwiftSyntaxExtractor.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct BridgeNodeRole: Encodable {
1616

1717
enum BridgeNodeKind: String, Encodable {
1818
case function
19+
case `class`
1920
case `struct`
2021
case `enum`
2122
case `protocol`
@@ -278,7 +279,7 @@ private final class SwiftSyntaxExtractor {
278279
if let node = decl.as(ClassDeclSyntax.self) {
279280
processNominal(
280281
name: normalizeIdentifier(node.name.text),
281-
kind: .struct,
282+
kind: .class,
282283
syntax: Syntax(node),
283284
modifiers: node.modifiers,
284285
attributes: node.attributes,

grapha/src/filter.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub fn parse_filter(filter: &str) -> anyhow::Result<HashSet<NodeKind>> {
88
for part in filter.split(',') {
99
let kind = match part.trim() {
1010
"fn" | "function" => NodeKind::Function,
11+
"class" => NodeKind::Class,
1112
"struct" => NodeKind::Struct,
1213
"enum" => NodeKind::Enum,
1314
"trait" => NodeKind::Trait,

grapha/src/query.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ fn kind_preference(kind: NodeKind) -> usize {
8181
NodeKind::Function => 0,
8282
NodeKind::Property => 1,
8383
NodeKind::Variant | NodeKind::Field => 2,
84-
NodeKind::Struct
84+
NodeKind::Class
85+
| NodeKind::Struct
8586
| NodeKind::Enum
8687
| NodeKind::Trait
8788
| NodeKind::Module

grapha/src/query/dataflow.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,11 @@ pub fn query_dataflow(
396396
// outgoing dataflow edges. Suggest its member functions instead.
397397
if matches!(
398398
entry_node.kind,
399-
NodeKind::Struct | NodeKind::Enum | NodeKind::Protocol | NodeKind::Extension
399+
NodeKind::Class
400+
| NodeKind::Struct
401+
| NodeKind::Enum
402+
| NodeKind::Protocol
403+
| NodeKind::Extension
400404
) {
401405
let members: Vec<&str> = graph
402406
.edges

0 commit comments

Comments
 (0)