diff --git a/graphify/analyze.py b/graphify/analyze.py index ec1e61a99..0707e2be7 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -18,6 +18,14 @@ "Callable", "Type", "ClassVar", "Final", "Literal", "Protocol", "Counter", "defaultdict", "OrderedDict", "datetime", "Enum", "os", "sys", "re", "json", "io", "abc", "typing", + # Swift / Foundation / SwiftUI framework symbols and module imports that + # otherwise dominate god-node rankings on Swift codebases (#2147) + "Foundation", "SwiftUI", "UIKit", "AppKit", "Combine", + "String", "Int", "Double", "Float", "Bool", "Data", "URL", "Date", "UUID", + "Sendable", "Codable", "Decodable", "Encodable", "Equatable", "Hashable", + "Identifiable", "Comparable", "AnyObject", "Error", "LocalizedError", + "NSObject", "NSString", "NSError", "NSLock", + "View", "Color", "Font", "DispatchQueue", }) # Language families — extensions sharing a runtime can legitimately call each other diff --git a/graphify/extract.py b/graphify/extract.py index e5a48dc04..fd42856e3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2191,6 +2191,12 @@ def _key(label: str) -> str: type_qualified = False if not type_name: continue + # A builtin receiver type (Data, NSLock, DispatchQueue, ...) must not + # resolve to a same-named user symbol — the cross-file CALL resolver and + # the TS/Python member-call resolvers already skip these globals (#1726); + # do the same for Swift (#2147). + if type_name in _LANGUAGE_BUILTIN_GLOBALS: + continue type_defs = type_def_nids.get(_key(type_name), []) if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) continue diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index fa2e5b514..148fc18c6 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -29,6 +29,25 @@ "print", "open", "isinstance", "type", "super", "sorted", "reversed", "any", "all", "abs", "round", "next", "iter", "hash", "id", "repr", "callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir", + # Swift standard library / Foundation / SwiftUI (#2147). Value-type + # initializers (Data(x), Int(x), UUID()) and protocol conformance targets + # appear from virtually every file of a Swift codebase, exactly like the + # ECMAScript constructors above. String/Date/URL/Error are already listed. + "Int", "Int8", "Int16", "Int32", "Int64", + "UInt", "UInt8", "UInt16", "UInt32", "UInt64", + "Double", "Float", "Bool", "Character", + "Sendable", "Codable", "Decodable", "Encodable", "Equatable", "Hashable", + "Identifiable", "Comparable", "CaseIterable", "RawRepresentable", + "CustomStringConvertible", "CustomDebugStringConvertible", "AnyObject", + "LocalizedError", + "Data", "UUID", "Decimal", "Calendar", "Locale", "TimeZone", "Bundle", + "IndexPath", "IndexSet", "NotificationCenter", "UserDefaults", + "FileManager", "URLSession", "URLRequest", "URLComponents", + "JSONDecoder", "JSONEncoder", "DateFormatter", "NumberFormatter", + "ISO8601DateFormatter", + "NSObject", "NSString", "NSError", "NSLock", "NSAttributedString", + "DispatchQueue", "DispatchGroup", "OperationQueue", "RunLoop", + "View", "Color", "Font", }) diff --git a/tests/test_swift_builtin_noise.py b/tests/test_swift_builtin_noise.py new file mode 100644 index 000000000..bea4ae000 --- /dev/null +++ b/tests/test_swift_builtin_noise.py @@ -0,0 +1,143 @@ +"""Swift/Foundation/SwiftUI builtins must not become god nodes or bind to user symbols. + +#2147: `_LANGUAGE_BUILTIN_GLOBALS` and `_BUILTIN_NOISE_LABELS` covered only +JS/TS and Python, so on Swift codebases framework types (`Foundation`, +`NSLock`, `View`, `Data`, `Sendable`, ...) ranked as god nodes and the Swift +member-call resolver could bind a builtin-typed receiver (`let d: Data`) to a +same-named user symbol in another file — the same phantom-edge shape #1726 +fixed for TypeScript. +""" +import networkx as nx +import pytest + +from graphify.analyze import god_nodes +from graphify.extract import extract + + +def _labels_by_id(r): + return {n["id"]: n.get("label") for n in r["nodes"]} + + +@pytest.mark.parametrize("builtin_label", [ + "Foundation", "SwiftUI", "NSLock", "Data", "View", "Sendable", "Codable", + "DispatchQueue", "Color", +]) +def test_god_nodes_excludes_swift_builtin_labels(builtin_label: str) -> None: + """Swift framework symbols must be filtered from god_nodes output. + + Constructs a graph where the builtin-labelled node has the highest degree + (the #2147 report shape: `Foundation` at 105 edges, `NSLock` at 102) and a + real project abstraction has lower degree. The builtin must be excluded and + the project symbol kept. + + Args: + builtin_label: The Swift/Foundation/SwiftUI label to test (parametrized). + """ + G = nx.Graph() + G.add_node( + "real_node", + label="AudioStreamer", + source_file="Sources/AudioStreamer.swift", + file_type="code", + source_location="L1", + ) + G.add_node( + "builtin_node", + label=builtin_label, + source_file="", + file_type="code", + source_location="", + ) + for i in range(20): + peer = f"user_type_{i}" + G.add_node( + peer, + label=f"Feature{i}", + source_file=f"Sources/Feature{i}.swift", + file_type="code", + source_location="L1", + ) + G.add_edge( + peer, + "builtin_node", + relation="references", + confidence="EXTRACTED", + source_file=f"Sources/Feature{i}.swift", + weight=1.0, + ) + G.add_edge( + "real_node", + "user_type_0", + relation="calls", + confidence="EXTRACTED", + source_file="Sources/AudioStreamer.swift", + weight=1.0, + ) + + result = god_nodes(G, top_n=10) + result_ids = [r["id"] for r in result] + + assert "builtin_node" not in result_ids, ( + f"god_nodes() should filter Swift builtin '{builtin_label}' " + f"but it appeared in the result: {result}" + ) + assert "real_node" in result_ids, ( + f"god_nodes() should include project symbol 'AudioStreamer' " + f"but it was absent: {result}" + ) + + +def test_swift_builtin_receiver_does_not_bind_to_user_symbol(tmp_path): + # #2147 (same shape as #1726 for TS): a receiver typed with a builtin + # (`let payload: Data`) must not have its member calls bound to a user type + # that happens to be named `Data` in another file. + (tmp_path / "Model.swift").write_text( + "class Data {\n" + " func append(_ s: String) {}\n" + "}\n") + (tmp_path / "Uploader.swift").write_text( + "class Uploader {\n" + " let payload: Data = Data()\n" + " func send() {\n" + " payload.append(\"x\")\n" + " }\n" + "}\n") + r = extract(sorted(tmp_path.glob("*.swift")), cache_root=tmp_path, parallel=False) + lbl = _labels_by_id(r) + by_id = {n["id"]: n for n in r["nodes"]} + data_ids = [n["id"] for n in r["nodes"] + if n.get("label") == "Data" + and str(n.get("source_file", "")).endswith("Model.swift")] + assert data_ids, "the user class Data must still exist as a node" + for e in r["edges"]: + if e.get("target") in data_ids and e.get("relation") in ("calls", "references") \ + and e.get("context") == "call": + src_sf = str(by_id.get(e["source"], {}).get("source_file", "")) + assert not src_sf.endswith("Uploader.swift"), ( + f"builtin-typed receiver bound to user Data: " + f"{lbl.get(e['source'])!r} -> Data ({e})" + ) + + +def test_swift_user_receiver_type_still_resolves(tmp_path): + # Guard must be a no-op for genuine user types: a member call on a + # user-typed property still resolves cross-file (#1356 inference table). + (tmp_path / "Engine.swift").write_text( + "class AudioEngine {\n" + " func play() {}\n" + "}\n") + (tmp_path / "Player.swift").write_text( + "class Player {\n" + " let engine: AudioEngine = AudioEngine()\n" + " func start() {\n" + " engine.play()\n" + " }\n" + "}\n") + r = extract(sorted(tmp_path.glob("*.swift")), cache_root=tmp_path, parallel=False) + lbl = _labels_by_id(r) + resolved = { + (lbl.get(e["source"]), lbl.get(e["target"])) + for e in r["edges"] + if e.get("context") == "call" and "play" in str(lbl.get(e.get("target"), "")).lower() + } + assert resolved, "user-typed member call must still resolve cross-file"