Skip to content

Commit d7b2ed5

Browse files
ahaviliclaude
andcommitted
fix(query,extract,merge): improve cross-project analysis accuracy
Four fixes addressing gaps in cross-project analysis: 1. Type queries now include member edges. `symbol context`/`impact` on a type (Struct/Class/Enum/Protocol/Trait) now count edges targeting its direct members as callers/read_by/implementors of the type itself, with dedup. Previously `impact FeedList` returned 0 because edges target `FeedList::init`/`::content`, not the parent struct. 2. Generic constructor calls now emit edges. `Mutex<Bool>(false)` parses as `GenericSpecializationExprSyntax` in SwiftSyntax and `constructor_expression` in tree-sitter-swift — neither was handled. SwiftSyntax now unwraps the specialization; tree-sitter extracts the type name from `user_type > type_identifier`. 3. Swift test targets get proper module names. `Tests/FrameBaseTests/` now registers as module `FrameBaseTests` (was landing in unnamed `/`). Falls back to `<Package>Tests` when `Tests/` has no subdirectories. 4. Candidate cap prevents edge explosion for common names. When a name resolves to candidates in >3 distinct files, drop the edge rather than emit N false-positive duplicates (e.g., `horizontal`, `top`). Counting unique files — not raw candidates — preserves legitimate edges to types with multiple extensions in the same file. Verified on lama-ludo-ios + FrameUI/FrameBase externals: edges reduced 453k → 267k (41% noise drop), test files now attributed correctly, cross-module `impact Mutex` returns 23 dependents across FrameUI and FrameBaseTests (was 0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2003d68 commit d7b2ed5

6 files changed

Lines changed: 487 additions & 0 deletions

File tree

grapha-core/src/merge.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::graph::{EdgeKind, Graph, NodeKind};
66
struct NameEntry {
77
id: String,
88
module: Option<String>,
9+
file: String,
910
}
1011

1112
struct ResolveContext<'a> {
@@ -105,6 +106,7 @@ pub fn merge(results: Vec<ExtractionResult>) -> Graph {
105106
.push(NameEntry {
106107
id: node.id.clone(),
107108
module: node.module.clone(),
109+
file: node.file.to_string_lossy().to_string(),
108110
});
109111
}
110112

@@ -449,6 +451,18 @@ fn resolve_candidates(
449451
}
450452
}
451453

454+
// Cap ambiguous resolution: if too many distinct files contain
455+
// candidates after disambiguation, drop the edge entirely. A missing
456+
// edge is better than N false positives (e.g., "horizontal", "top").
457+
// Count unique files, not raw candidates, so a type with extensions
458+
// in the same file isn't penalized.
459+
let unique_files: HashSet<&str> = same_module
460+
.iter()
461+
.map(|c| c.file.as_str())
462+
.collect();
463+
if unique_files.len() > 3 {
464+
return Vec::new();
465+
}
452466
return same_module
453467
.iter()
454468
.map(|candidate| (candidate.id.clone(), 0.4))
@@ -469,6 +483,13 @@ fn resolve_candidates(
469483
return vec![(imported[0].id.clone(), 0.8)];
470484
}
471485
if imported.len() > 1 {
486+
let unique_files: HashSet<&str> = imported
487+
.iter()
488+
.map(|c| c.file.as_str())
489+
.collect();
490+
if unique_files.len() > 3 {
491+
return Vec::new();
492+
}
472493
return imported
473494
.iter()
474495
.map(|candidate| (candidate.id.clone(), 0.3))

grapha-swift/src/module_discovery.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,42 @@ fn discover_swift_packages_recursive(dir: &Path, modules: &mut ModuleMap) {
2727
.entry(module_name)
2828
.or_default()
2929
.push(source_dir);
30+
31+
// Register test targets: each subdirectory under Tests/ becomes a module.
32+
// If Tests/ has no subdirectories, register it as "<PackageName>Tests".
33+
let tests_dir = dir.join("Tests");
34+
if tests_dir.is_dir() {
35+
let mut found_subdirs = false;
36+
if let Ok(entries) = std::fs::read_dir(&tests_dir) {
37+
for entry in entries.flatten() {
38+
let path = entry.path();
39+
if path.is_dir() {
40+
let name = entry.file_name();
41+
let name = name.to_string_lossy();
42+
if !name.starts_with('.') {
43+
modules
44+
.modules
45+
.entry(name.to_string())
46+
.or_default()
47+
.push(path);
48+
found_subdirs = true;
49+
}
50+
}
51+
}
52+
}
53+
if !found_subdirs {
54+
let pkg_name = dir
55+
.file_name()
56+
.and_then(|n| n.to_str())
57+
.unwrap_or("unknown");
58+
modules
59+
.modules
60+
.entry(format!("{pkg_name}Tests"))
61+
.or_default()
62+
.push(tests_dir);
63+
}
64+
}
65+
3066
return;
3167
}
3268

@@ -70,4 +106,42 @@ mod tests {
70106
let modules = discover_swift_modules(dir.path());
71107
assert!(modules.modules.contains_key("MyPackage"));
72108
}
109+
110+
#[test]
111+
fn discovers_test_targets_as_modules() {
112+
let dir = tempfile::tempdir().unwrap();
113+
let pkg_dir = dir.path().join("FrameBase");
114+
let sources_dir = pkg_dir.join("Sources");
115+
let tests_dir = pkg_dir.join("Tests").join("FrameBaseTests");
116+
fs::create_dir_all(&sources_dir).unwrap();
117+
fs::create_dir_all(&tests_dir).unwrap();
118+
fs::write(pkg_dir.join("Package.swift"), "// swift-tools-version:5.5").unwrap();
119+
120+
let modules = discover_swift_modules(dir.path());
121+
assert!(modules.modules.contains_key("FrameBase"));
122+
assert!(
123+
modules.modules.contains_key("FrameBaseTests"),
124+
"test subdirectory should be registered as a module"
125+
);
126+
}
127+
128+
#[test]
129+
fn discovers_test_fallback_when_no_subdirs() {
130+
let dir = tempfile::tempdir().unwrap();
131+
let pkg_dir = dir.path().join("MyPkg");
132+
let sources_dir = pkg_dir.join("Sources");
133+
let tests_dir = pkg_dir.join("Tests");
134+
fs::create_dir_all(&sources_dir).unwrap();
135+
fs::create_dir_all(&tests_dir).unwrap();
136+
// Put a file directly in Tests/ with no subdirs
137+
fs::write(tests_dir.join("MyPkgTests.swift"), "import XCTest").unwrap();
138+
fs::write(pkg_dir.join("Package.swift"), "// swift-tools-version:5.5").unwrap();
139+
140+
let modules = discover_swift_modules(dir.path());
141+
assert!(modules.modules.contains_key("MyPkg"));
142+
assert!(
143+
modules.modules.contains_key("MyPkgTests"),
144+
"Tests/ with no subdirs should fallback to <Package>Tests"
145+
);
146+
}
73147
}

grapha-swift/src/treesitter/extract.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,32 @@ fn extract_calls(
11991199
});
12001200
}
12011201

1202+
// Generic constructor calls: `Type<Generic>(args)` produces a
1203+
// `constructor_expression` node with the type name in `user_type > type_identifier`.
1204+
if node.kind() == "constructor_expression" {
1205+
let mut ctor_cursor = node.walk();
1206+
let type_name = node
1207+
.named_children(&mut ctor_cursor)
1208+
.find(|c| c.kind() == "user_type")
1209+
.and_then(|ut| type_identifier_text(ut, source));
1210+
if let Some(name) = type_name {
1211+
let target_id = make_id(file, module_path, &name);
1212+
let condition = find_enclosing_swift_condition(node, source);
1213+
let async_boundary = detect_swift_async_boundary(node, source);
1214+
result.edges.push(Edge {
1215+
source: caller_id.to_string(),
1216+
target: target_id,
1217+
kind: EdgeKind::Calls,
1218+
confidence: 0.8,
1219+
direction: None,
1220+
operation: None,
1221+
condition,
1222+
async_boundary,
1223+
provenance: node_edge_provenance(file, node, caller_id),
1224+
});
1225+
}
1226+
}
1227+
12021228
// Property access: `foo.bar` generates a navigation_expression.
12031229
// Emit a Reads edge so dependency queries can trace through the access
12041230
// without polluting call lists with field-like symbols.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ private final class CallCollector: SyntaxVisitor {
227227
return (name, operation)
228228
}
229229

230+
// Generic specialization: `Type<Generic>(args)` wraps the base in
231+
// GenericSpecializationExprSyntax. Unwrap to extract the type name.
232+
if let generic = expression.as(GenericSpecializationExprSyntax.self) {
233+
return callTarget(from: generic.expression)
234+
}
235+
230236
return nil
231237
}
232238
}

0 commit comments

Comments
 (0)