From a4401ee0f2e610d68ff98178e9455a18422c288f Mon Sep 17 00:00:00 2001 From: Gadi Evron Date: Thu, 30 Jul 2026 11:14:19 +0300 Subject: [PATCH] feat(swift): add Swift language parser Adds a tree-sitter-based Swift parser (repository scan, function/type extraction, call-graph construction with overload/trailing-closure/actor resolution, reachability filtering, analysis-unit generation), integrated into the post-#199 registry architecture: a single config/languages.json entry provides dispatch, CLI --language choice, markdown fence, Go flag help and detection. Adds Swift @objc/@IBAction entry-point + XPC/CLI input detection, the .swift context-corrector extension, the tree-sitter-swift dependency, per-unit reachability prune telemetry, and the Swift test suite. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + .../internal/languages/registry_test.go | 2 +- config/languages.json | 18 +- libs/openant-core/CLAUDE.md | 2 +- libs/openant-core/DOCUMENTATION.md | 2 +- libs/openant-core/OPENANT.md | 2 +- libs/openant-core/PIPELINE_MANUAL.md | 2 +- libs/openant-core/README.md | 2 +- libs/openant-core/core/parser_adapter.py | 14 + libs/openant-core/parsers/swift/__init__.py | 0 .../parsers/swift/call_graph_builder.py | 1302 +++++++++++++++++ .../parsers/swift/function_extractor.py | 857 +++++++++++ .../parsers/swift/repository_scanner.py | 173 +++ .../parsers/swift/test_pipeline.py | 282 ++++ .../parsers/swift/unit_generator.py | 226 +++ libs/openant-core/pyproject.toml | 1 + libs/openant-core/requirements.txt | 1 + .../tests/parsers/swift/__init__.py | 0 .../tests/parsers/swift/_helpers.py | 56 + .../tests/parsers/swift/conftest.py | 13 + .../parsers/swift/test_empty_seed_keep_all.py | 47 + .../parsers/swift/test_swift_call_graph.py | 216 +++ .../swift/test_swift_callgraph_symmetry.py | 65 + .../parsers/swift/test_swift_extractor.py | 155 ++ .../swift/test_swift_overload_matching.py | 434 ++++++ .../swift/test_swift_prune_telemetry.py | 169 +++ .../tests/parsers/swift/test_swift_scanner.py | 54 + .../swift/test_swift_schema_completeness.py | 60 + .../swift/test_swift_stage5_regressions.py | 134 ++ .../test_swift_toplevel_and_reachability.py | 86 ++ .../test_swift_trailing_closure_labels.py | 213 +++ .../tests/test_language_registry.py | 2 +- .../test_reachability_prune_telemetry.py | 119 ++ .../tests/test_scanner_contract.py | 3 +- .../agentic_enhancer/entry_point_detector.py | 21 + .../utilities/context_corrector.py | 2 +- .../openant-core/utilities/prune_telemetry.py | 82 ++ 37 files changed, 4807 insertions(+), 11 deletions(-) create mode 100644 libs/openant-core/parsers/swift/__init__.py create mode 100644 libs/openant-core/parsers/swift/call_graph_builder.py create mode 100644 libs/openant-core/parsers/swift/function_extractor.py create mode 100644 libs/openant-core/parsers/swift/repository_scanner.py create mode 100644 libs/openant-core/parsers/swift/test_pipeline.py create mode 100644 libs/openant-core/parsers/swift/unit_generator.py create mode 100644 libs/openant-core/tests/parsers/swift/__init__.py create mode 100644 libs/openant-core/tests/parsers/swift/_helpers.py create mode 100644 libs/openant-core/tests/parsers/swift/conftest.py create mode 100644 libs/openant-core/tests/parsers/swift/test_empty_seed_keep_all.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_call_graph.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_callgraph_symmetry.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_extractor.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_overload_matching.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_prune_telemetry.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_scanner.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_schema_completeness.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_stage5_regressions.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_toplevel_and_reachability.py create mode 100644 libs/openant-core/tests/parsers/swift/test_swift_trailing_closure_labels.py create mode 100644 libs/openant-core/tests/test_reachability_prune_telemetry.py create mode 100644 libs/openant-core/utilities/prune_telemetry.py diff --git a/README.md b/README.md index fda64b42..c1b76afa 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ To submit your repo for scanning: - PHP (beta) - Ruby (beta) - Zig (beta) +- Swift (beta) ## Credits diff --git a/apps/openant-cli/internal/languages/registry_test.go b/apps/openant-cli/internal/languages/registry_test.go index 2d0f4982..69e63407 100644 --- a/apps/openant-cli/internal/languages/registry_test.go +++ b/apps/openant-cli/internal/languages/registry_test.go @@ -28,7 +28,7 @@ func TestSupportedMatchesConfig(t *testing.T) { if err != nil { t.Fatalf("Supported() error: %v", err) } - want := []string{"c", "go", "javascript", "php", "python", "ruby", "zig"} + want := []string{"c", "go", "javascript", "php", "python", "ruby", "swift", "zig"} if len(got) != len(want) { t.Fatalf("Supported() = %v, want %v", got, want) } diff --git a/config/languages.json b/config/languages.json index 8cfd5ce3..97d70d84 100644 --- a/config/languages.json +++ b/config/languages.json @@ -7,7 +7,13 @@ "dist", "build", ".git", - "vendor" + "vendor", + ".build", + ".swiftpm", + "DerivedData", + "Pods", + "Carthage", + "xcuserdata" ], "extensions": { ".py": "python", @@ -29,7 +35,8 @@ ".rb": "ruby", ".rake": "ruby", ".php": "php", - ".zig": "zig" + ".zig": "zig", + ".swift": "swift" }, "languages": { "python": { @@ -92,6 +99,13 @@ "fence": "zig", "docker_template": null, "enabled": true + }, + "swift": { + "extensions": [".swift"], + "parser": {"mode": "subprocess", "script": "parsers/swift/test_pipeline.py"}, + "fence": "swift", + "docker_template": null, + "enabled": true } } } diff --git a/libs/openant-core/CLAUDE.md b/libs/openant-core/CLAUDE.md index 439db55e..bbb2927e 100644 --- a/libs/openant-core/CLAUDE.md +++ b/libs/openant-core/CLAUDE.md @@ -21,7 +21,7 @@ The symlink automatically picks up the new binary. Running `make install` would # Project Context -This is OpenAnt, a two-stage SAST tool using Claude for vulnerability analysis. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, and Zig codebases with 4-level cost optimization. +This is OpenAnt, a two-stage SAST tool using Claude for vulnerability analysis. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift codebases with 4-level cost optimization. **Key files to read after context reset:** - `DOCUMENTATION.md` - **Start here** - Index of all documentation diff --git a/libs/openant-core/DOCUMENTATION.md b/libs/openant-core/DOCUMENTATION.md index b94bb0a3..d6570d7c 100644 --- a/libs/openant-core/DOCUMENTATION.md +++ b/libs/openant-core/DOCUMENTATION.md @@ -60,7 +60,7 @@ OpenAnt documentation is organized into three tiers based on audience and purpos - **8-Step Pipeline:** Parse → Generate Units → Entry-Point Filter → Application Context → Context Enhancement → Stage 1 Detection → Stage 2 Verification → Dynamic Testing - **Language-Agnostic Prompts:** The same prompts are used for every supported language - **Two-Stage Analysis:** Stage 1 detects vulnerabilities, Stage 2 uses attacker simulation to verify exploitability -- **Supported Languages:** Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, and Zig +- **Supported Languages:** Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift ### File Naming Conventions diff --git a/libs/openant-core/OPENANT.md b/libs/openant-core/OPENANT.md index 8bf2c263..cb9d2634 100644 --- a/libs/openant-core/OPENANT.md +++ b/libs/openant-core/OPENANT.md @@ -1,6 +1,6 @@ # OpenAnt Architecture Documentation -OpenAnt is an LLM-powered Static Application Security Testing (SAST) tool that uses a two-stage pipeline for vulnerability analysis with 4-level cost optimization. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, and Zig. +OpenAnt is an LLM-powered Static Application Security Testing (SAST) tool that uses a two-stage pipeline for vulnerability analysis with 4-level cost optimization. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift. ## Table of Contents diff --git a/libs/openant-core/PIPELINE_MANUAL.md b/libs/openant-core/PIPELINE_MANUAL.md index 5c70ac3d..d958b084 100644 --- a/libs/openant-core/PIPELINE_MANUAL.md +++ b/libs/openant-core/PIPELINE_MANUAL.md @@ -36,7 +36,7 @@ OpenAnt is a vulnerability analysis tool using Claude. The name "two-stage" refe | 7 | **Stage 2: Verification** | No | Attacker simulation to confirm exploitability | | 8 | **Dynamic Testing** | No | Docker-isolated exploit testing (requires Docker) | -**Supported Languages:** Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, and Zig +**Supported Languages:** Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift **Two-Stage Analysis:** - **Stage 1** asks: "Is this code vulnerable?" diff --git a/libs/openant-core/README.md b/libs/openant-core/README.md index 71680264..7b2ec192 100644 --- a/libs/openant-core/README.md +++ b/libs/openant-core/README.md @@ -2,7 +2,7 @@ **LLM-Powered Static Application Security Testing** -OpenAnt uses Claude to analyze code for security vulnerabilities through a two-stage pipeline: detection followed by verification. Features 4-level cost optimization with CodeQL integration. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, and Zig. +OpenAnt uses Claude to analyze code for security vulnerabilities through a two-stage pipeline: detection followed by verification. Features 4-level cost optimization with CodeQL integration. Supports Python, JavaScript/TypeScript, Go, C/C++, Ruby, PHP, Zig, and Swift. --- diff --git a/libs/openant-core/core/parser_adapter.py b/libs/openant-core/core/parser_adapter.py index cd38e3b4..6941d0ec 100644 --- a/libs/openant-core/core/parser_adapter.py +++ b/libs/openant-core/core/parser_adapter.py @@ -30,6 +30,7 @@ ) from core.schemas import ParseResult from utilities.file_io import open_utf8, read_json, write_json +from utilities.prune_telemetry import compute_prune_telemetry # Root of openant-core (where parsers/ lives) _CORE_ROOT = Path(__file__).parent.parent @@ -559,6 +560,19 @@ def _load_module(name, filename): dataset["metadata"]["reachability_filter"]["warning"] = _blackout print(f" [Warning] {_blackout}", file=sys.stderr) + # Per-unit prune telemetry (ADDITIVE, all-language; advisory — must never crash + # the filter). Merges classification keys + the pruned_units.json sidecar; a + # forward-asymmetry warning is recorded only if a blackout warning did not + # already claim the slot. call_graph/reverse_call_graph are the UN-pruned graphs. + _rf = dataset["metadata"]["reachability_filter"] + _pruned_ids = [u.get("id", "") for u in units if u.get("id", "") not in reachable_ids] + _extra, _asym_warning = compute_prune_telemetry( + reachable_ids, sorted(_pruned_ids), call_graph, reverse_call_graph, output_dir) + _rf.update(_extra) + if _asym_warning and "warning" not in _rf: + _rf["warning"] = _asym_warning + print(f" [Warning] {_asym_warning}", file=sys.stderr) + # Warn about unimplemented higher-level filters if processing_level == "codeql": print( diff --git a/libs/openant-core/parsers/swift/__init__.py b/libs/openant-core/parsers/swift/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/openant-core/parsers/swift/call_graph_builder.py b/libs/openant-core/parsers/swift/call_graph_builder.py new file mode 100644 index 00000000..6ba1db14 --- /dev/null +++ b/libs/openant-core/parsers/swift/call_graph_builder.py @@ -0,0 +1,1302 @@ +""" +Stage 3: Call Graph Builder for Swift + +Builds a bidirectional call graph from extracted Swift declarations. + +Swift is a module-wide-namespace language with NO file-path imports (unlike Zig's +`@import("path")`), heavy overloading, and rich dispatch (self / Self / super / +Type.static / typed member / constructor / protocol). This resolver is a bounded, +tree-sitter-based approximation (NOT a full type checker) tuned to MAXIMIZE +reachability recall while avoiding gross same-name over-connection: + + tier 1 lexical alias (`let f = handler; f()`) + tier 2 member call `recv.method` dispatched on the receiver's static type: + - self/Self -> caller's enclosing type + - typed local/param -> that type + - Type.method -> static/type member on Type + - unknown receiver -> bare-name fallback + tier 3 constructor call `Type(...)` -> that type's init units + tier 4 bare unqualified call -> enclosing type, then same file, then unique + global; ambiguous-across-unrelated-types is DROPPED (namespace-leak) + plus function-reference arguments (`map(transform)`, `use: handleRequest`) + -> caller -> referenced function (callback reachability) +""" + +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple + +from utilities.file_io import write_json + +from tree_sitter import Language, Parser, Node + + +def _load_swift_language() -> Language: + import tree_sitter_swift as ts_swift + return Language(ts_swift.language()) + + +# Swift stdlib / global funcs + Sequence/Collection methods whose names would add +# noise if treated as user calls. Filtered ONLY when no same-file user function +# shadows the name. NOTE: deliberately excludes TYPE names (String/Int/Array/...): +# constructor resolution needs type names live (`Array(...)` is a constructor call, +# not a filtered builtin), and an unresolved type name simply yields no edge anyway. +SWIFT_BUILTINS = { + "print", "debugPrint", "dump", "assert", "assertionFailure", "precondition", + "preconditionFailure", "fatalError", "abort", "min", "max", "abs", "swap", + "zip", "stride", "sequence", "repeatElement", + # Sequence / Collection higher-order + common methods (their trailing-closure + # bodies' inner calls are still attributed to the enclosing unit). + "map", "flatMap", "compactMap", "filter", "forEach", "reduce", "sorted", + "sort", "first", "last", "contains", "allSatisfy", "append", "insert", + "remove", "removeAll", "removeFirst", "removeLast", "joined", "prefix", + "suffix", "enumerated", "reversed", "count", "isEmpty", +} + +# The bare-callable GLOBAL free functions within SWIFT_BUILTINS. A bare ``print()`` / +# ``max()`` is the stdlib global, never an implicit-``self`` method — so a repo METHOD +# named after one of these must NOT bypass the builtin drop, else every bare global call +# phantom-edges to that one method (an in-degree explosion since these are called +# everywhere). The remaining (Collection/Sequence) builtins ARE idiomatically bare +# implicit-self method calls (``filter()`` == ``self.filter()``), so the method +# bypass applies to them. +_SWIFT_GLOBAL_FUNCS = { + "print", "debugPrint", "dump", "assert", "assertionFailure", "precondition", + "preconditionFailure", "fatalError", "abort", "min", "max", "abs", "swap", + "zip", "stride", "sequence", "repeatElement", +} + +# Contextual keywords whose trailing-closure form (`defer {... }`, a stored +# `deinit {... }` / `willSet {... }` / `get {... }` snippet) reparses as a +# call_expression whose callee is the keyword. They are never real user calls +# (all are reserved words, so no repo function can bear the name), so they resolve +# to nothing today — dropping them just removes noise from the call-site tally and +# the health metric, and pre-empts a phantom `deinit`->`deinit` edge if a `deinit` +# snippet were ever indexed by name. +_ACCESSOR_KEYWORD_CALLS = {"deinit", "willSet", "didSet", "get", "set", "defer"} + +# Bare-name calls that stay ambiguous after same-type / same-file / unique +# resolution fan out to at most this many candidates (recall over precision, per +# the maximize-reachability goal); a larger candidate set is dropped to cap the +# namespace-leak blast radius. Tunable after measuring out-degree on real repos. +_AMBIGUOUS_FANOUT_MAX = 3 + + +def _is_subsequence(sub, seq) -> bool: + """True if every element of `sub` appears in `seq` in order (not necessarily + contiguous). Used to check a call's labeled args are an ordered subset of a + candidate's declared labels.""" + it = iter(seq) + return all(x in it for x in sub) + + +class CallGraphBuilder: + """Builds call graphs from extracted Swift functions.""" + + def __init__(self, extractor_output: Dict[str, Any]): + self.functions = extractor_output.get("functions", {}) + self.classes = extractor_output.get("classes", {}) + self.imports = extractor_output.get("imports", {}) + self.repository = extractor_output.get("repository", "") + # Bare names of types actually DECLARED in the repo (class/struct/actor/enum/ + # protocol units — NOT types that are only `extension`-ed). Used to decide the + # constructor-fallback policy: an unmatched ctor call on an external (extended- + # only) type emits no edge (it is the stdlib ctor), while an unmatched call on + # a repo-declared type keeps the recall-first all-inits fallback. + self._repo_declared = {info.get("name") for info in self.classes.values() if info.get("name")} + # bare function/method name -> its return type, ONLY when every unit of that + # name shares one return type (conservative: an overloaded name with divergent + # returns yields no typing, so we never mis-type `let x = f()`). Types more + # receivers -> more typed-member dispatch instead of unknown-receiver drops + # (convergent fix for the real-miss + untyped-tier phantom). + self._func_return = self._build_return_index() + # type bare-name -> direct supertype/protocol bare-names (from the + # extractor's inheritance/extension-conformance clauses). Closed + # transitively so a call dispatches to a method on a superclass or a + # conformed protocol's extension, not only the exact receiver type. + self.inheritance = extractor_output.get("inheritance", {}) + self._inherit_closure = self._compute_inherit_closure() + # Reverse: protocol/base -> transitive conformers/subclasses. A call on a + # receiver typed as a protocol or base class must reach the concrete + # implementations (`any Authorizer`.authorize -> every conformer's + # authorize), Swift's dominant dispatch shape on a protocol-heavy target. + self._conformer_closure = self._compute_reverse_closure() + self.parser = Parser(_load_swift_language()) + self.call_graph: Dict[str, List[str]] = {} + self.reverse_call_graph: Dict[str, List[str]] = {} + # Diagnostics: an unusually low resolved-edge count on a large call-site + # population is a silent-under-connection signal ('s health-check). + self.stats_extra: Dict[str, int] = {} + + def _build_return_index(self) -> Dict[str, str]: + """bare name -> return type, kept only when unambiguous across all its units.""" + by_name: Dict[str, set] = defaultdict(set) + for info in self.functions.values(): + n = info.get("name") + rt = info.get("return_type") + if n and rt: + by_name[n].add(rt) + return {n: next(iter(rts)) for n, rts in by_name.items() if len(rts) == 1} + + def _compute_inherit_closure(self) -> Dict[str, set]: + direct = {k: set(v) for k, v in self.inheritance.items()} + closure: Dict[str, set] = {} + for t in direct: + seen: set = set() + stack = list(direct.get(t, ())) + while stack: + s = stack.pop() + if s in seen: + continue + seen.add(s) + stack.extend(direct.get(s, ())) + closure[t] = seen + return closure + + def _compute_reverse_closure(self) -> Dict[str, set]: + """type bare-name -> ALL transitive conformers/subclasses (reverse edges).""" + reverse: Dict[str, set] = defaultdict(set) + for sub, supers in self.inheritance.items(): + for sup in supers: + reverse[sup].add(sub) + closure: Dict[str, set] = {} + for t in reverse: + seen: set = set() + stack = list(reverse.get(t, ())) + while stack: + s = stack.pop() + if s in seen: + continue + seen.add(s) + stack.extend(reverse.get(s, ())) + closure[t] = seen + return closure + + # -- canonical API ------------------------------------------------------ + + def build_call_graph(self) -> None: + call_graph: Dict[str, List[str]] = defaultdict(list) + reverse_call_graph: Dict[str, List[str]] = defaultdict(list) + + name_to_ids = self._build_name_index() + type_names, ctor_index = self._build_type_index(name_to_ids) + + try: + alias_to_target = self._build_alias_index(name_to_ids) + except Exception: + alias_to_target = {} + + # Real site-level accounting (the old `total_call_sites` deduped + # names per body and `resolved_edges` counted fan-out-inflated edges, so + # neither was a resolution rate). Count actual call occurrences. + sites_total = 0 + sites_resolved = 0 + sites_unresolved_repo_name = 0 + self._ctor_unmatched_sites = 0 + self._unknown_builtin_drops = 0 + self._unknown_builtin_drops_repo_named = 0 + for func_id, func_info in self.functions.items(): + code = func_info.get("code", "") + file_path = func_info.get("file_path", "") + caller_class = func_info.get("class_name") + + var_types, local_names, var_qualified = self._collect_var_types(code, type_names) + call_sites, arg_refs = self._find_calls_in_code(code, file_path) + + for site in call_sites: + sites_total += 1 + ids = self._resolve_call( + site, file_path, caller_class, name_to_ids, type_names, + ctor_index, alias_to_target, func_id, var_types, var_qualified) + if ids: + sites_resolved += 1 + else: + # unresolved but a same-named unit EXISTS in the repo → a + # missed-edge candidate (vs a genuinely external call). + bare = site["text"].rsplit(".", 1)[-1] + if bare in name_to_ids or site["text"] in type_names: + sites_unresolved_repo_name += 1 + for rid in ids: + if rid != func_id: # no self-calls (see SELF_EDGE note in validator) + if rid not in call_graph[func_id]: + call_graph[func_id].append(rid) + if func_id not in reverse_call_graph[rid]: + reverse_call_graph[rid].append(func_id) + + # Function-reference arguments (`register(handler)` / `use: handleRequest`): + # scoped — skip locals (they are values), link only on a UNIQUE match. + for ref in sorted(arg_refs): + if ref in local_names: + continue + ids = name_to_ids.get(ref, []) + if len(ids) == 1 and ids[0] != func_id: + if ids[0] not in call_graph[func_id]: + call_graph[func_id].append(ids[0]) + if func_id not in reverse_call_graph[ids[0]]: + reverse_call_graph[ids[0]].append(func_id) + + # Determinism: emit sorted adjacency lists (set/resolution order must not leak). + self.call_graph = {k: sorted(v) for k, v in call_graph.items()} + self.reverse_call_graph = {k: sorted(v) for k, v in reverse_call_graph.items()} + self.stats_extra.update({ + "call_site_occurrences": sites_total, + "sites_resolved": sites_resolved, + "sites_unresolved_repo_name": sites_unresolved_repo_name, + "site_resolution_rate": round(sites_resolved / sites_total, 3) if sites_total else 0, + "ctor_unmatched_sites": self._ctor_unmatched_sites, + "unknown_builtin_drops": self._unknown_builtin_drops, + "unknown_builtin_drops_repo_named": self._unknown_builtin_drops_repo_named, + "edge_count": sum(len(v) for v in self.call_graph.values()), + }) + + def build(self) -> Dict[str, Any]: + self.build_call_graph() + return self.export() + + def export(self) -> Dict[str, Any]: + return { + "repository": self.repository, + "functions": self.functions, + "classes": self.classes, + "imports": self.imports, + "call_graph": self.call_graph, + "reverse_call_graph": self.reverse_call_graph, + # Carry inheritance through so a saved call_graph.json can re-drive the + # builder (export() dropped it, breaking round-trip re-analysis). + "inheritance": self.inheritance, + "statistics": self.get_statistics(), + } + + def get_statistics(self) -> Dict[str, Any]: + total_edges = sum(len(c) for c in self.call_graph.values()) + num_funcs = len(self.functions) + out_degrees = [len(self.call_graph.get(f, [])) for f in self.functions] + in_degrees = [len(self.reverse_call_graph.get(f, [])) for f in self.functions] + isolated = sum( + 1 for f in self.functions + if not self.call_graph.get(f) and not self.reverse_call_graph.get(f) + ) + stats = { + "total_functions": num_funcs, + "total_edges": total_edges, + "avg_out_degree": round(total_edges / num_funcs, 2) if num_funcs else 0, + "avg_in_degree": round(total_edges / num_funcs, 2) if num_funcs else 0, + "max_out_degree": max(out_degrees) if out_degrees else 0, + "max_in_degree": max(in_degrees) if in_degrees else 0, + "isolated_functions": isolated, + "isolated_ratio": round(isolated / num_funcs, 3) if num_funcs else 0, + } + stats.update(self.stats_extra) + return stats + + def get_dependencies(self, func_id: str, depth: Optional[int] = None) -> List[str]: + """Transitive callees of func_id up to depth (BFS). Canonical cross-parser + interface parity with the Zig/C builders ( + 'keep sibling parsers in lockstep'; these were missing on the Swift builder).""" + max_d = depth if depth is not None else 3 + deps: List[str] = [] + visited = {func_id} + queue = [(func_id, 0)] + while queue: + current, d = queue.pop(0) + if d >= max_d: + continue + for callee in self.call_graph.get(current, []): + if callee not in visited: + visited.add(callee) + deps.append(callee) + queue.append((callee, d + 1)) + return deps + + def get_callers(self, func_id: str, depth: Optional[int] = None) -> List[str]: + """Transitive callers of func_id up to depth (BFS); parity with siblings.""" + max_d = depth if depth is not None else 3 + callers: List[str] = [] + visited = {func_id} + queue = [(func_id, 0)] + while queue: + current, d = queue.pop(0) + if d >= max_d: + continue + for caller in self.reverse_call_graph.get(current, []): + if caller not in visited: + visited.add(caller) + callers.append(caller) + queue.append((caller, d + 1)) + return callers + + def save_results(self, output_path: str, results: Dict[str, Any]) -> None: + write_json(output_path, results) + + # -- indexes ------------------------------------------------------------ + + def _build_name_index(self) -> Dict[str, List[str]]: + name_to_ids: Dict[str, List[str]] = defaultdict(list) + for func_id, func_info in self.functions.items(): + name = func_info.get("name", "") + qn = func_info.get("qualified_name", "") + if name: + name_to_ids[name].append(func_id) + if qn and qn != name: + name_to_ids[qn].append(func_id) + return name_to_ids + + def _build_type_index(self, name_to_ids) -> Tuple[Set[str], Dict[str, List[str]]]: + """Return (known bare type names, ctor_index: type_name -> [init func_ids]). + + A call whose callee is a known type name (`Point(...)`) is a constructor + call — the declared units are `Point.init`, which `name_to_ids['init']` + cannot resolve by the callee text `Point`. Index them by type name. + """ + type_names: Set[str] = set() + for info in self.classes.values(): + n = info.get("name") + if n: + type_names.add(n) + # Also treat any class_name that carries methods as a known type. + for info in self.functions.values(): + cn = info.get("class_name") + if cn: + type_names.add(cn) + + ctor_index: Dict[str, List[str]] = defaultdict(list) + for func_id, info in self.functions.items(): + if info.get("unit_type") == "constructor" and info.get("class_name"): + ctor_index[info["class_name"]].append(func_id) + return type_names, dict(ctor_index) + + def _build_alias_index(self, name_to_ids) -> Dict[str, Dict[str, Set[str]]]: + """Per-function `let f = knownFn` aliases (over-approximated as sets).""" + alias_to_target: Dict[str, Dict[str, Set[str]]] = defaultdict(dict) + for func_id, func_info in self.functions.items(): + code = func_info.get("code", "") + if not code: + continue + try: + tree = self.parser.parse(code.encode("utf-8")) + except Exception: + continue + self._collect_aliases(tree.root_node, code.encode("utf-8"), + name_to_ids, alias_to_target[func_id]) + return alias_to_target + + def _collect_aliases(self, root: Node, source: bytes, name_to_ids, aliases) -> None: + """`let f = handler` where handler is a known function → alias f->handler. + + Iterative worklist walk (never self-recursive) so a pathologically deep + AST cannot overflow the Python stack and abort the whole build. + """ + stack = [root] + while stack: + node = stack.pop() + if node.type == "property_declaration": + name = self._pattern_name(node, source) + # RHS must be a bare identifier naming a known function (not a + # call `= make()`, not an arbitrary expression). + rhs = self._eq_rhs_identifier(node, source) + if name and rhs and rhs in name_to_ids: + aliases.setdefault(name, set()).add(rhs) + elif node.type == "assignment": + # a REASSIGNMENT `f = b` (in a branch) — `var f = a; if c { f = b } + # else { f = d }; f()` must union {a,b,d}, not keep only the initial + # binding. The reassignment is an `assignment` node (LHS + # `directly_assignable_expression > simple_identifier`), which the + # property_declaration walk missed → only the first target survived + # (the zig #167 alias-set-union lesson, unapplied to Swift assignments). + # Flow-insensitive union = recall. Only a BARE-var LHS counts: `self.f`, + # `arr[i]`, `obj.cb` are NOT local-var aliases. + lhs = self._assignment_bare_lhs(node, source) + rhs = self._eq_rhs_identifier(node, source) + if lhs and rhs and rhs in name_to_ids: + aliases.setdefault(lhs, set()).add(rhs) + stack.extend(node.children) + + def _assignment_bare_lhs(self, node: Node, source: bytes) -> Optional[str]: + """LHS var name of `f = ...` iff the LHS is a BARE variable (a + `directly_assignable_expression` wrapping a single simple_identifier). Returns + None for `self.f =...` / `arr[i] =...` / `obj.cb =...` (not local aliases).""" + lhs = next((c for c in node.children if c.type == "directly_assignable_expression"), None) + if lhs is None: + return None + named = [g for g in lhs.children if g.is_named] + if len(named) == 1 and named[0].type == "simple_identifier": + return self._text(named[0], source) + return None + + # -- receiver type model ------------------------------------------------ + + def _collect_var_types(self, code: str, type_names=frozenset()) -> Tuple[Dict[str, str], Set[str], Dict[str, str]]: + """Return (var_types, local_names, var_qualified) for one unit body. + + var_types maps a local var / parameter name -> its base static type, from: + - `let/var x = Type(...)` (constructor-initializer type) + - `let/var x: Type` (type annotation; optional/generic reduced) + - function `parameter` name: Type (the caller's own parameters) + A name redeclared with a conflicting type is dropped (ambiguous). + local_names is every locally-bound identifier (all let/var patterns + + parameters, typed or not) — used to keep an argument that is a local VALUE + from being mistaken for a function reference. + var_qualified maps a name -> the QUALIFIED constructed type for a + `let x = Outer.Inner(...)` binding : `let` is immutable, so the + dynamic type is EXACTLY the constructed nominal — a canonical-identity hint + that _typed_members uses to narrow a bare-name collision (SeqA.Iterator vs + SeqB.Iterator) WITHOUT the SUB-0 build. Recorded only for `let` (a `var` can + be reassigned to another conformer — dropping that edge is a false negative); + conflicting hints for one name are dropped. The hint NARROWS with a recall + floor (empty subset -> keep the full bare set), so it can never drop an edge + the bare path would reach (protects protocol-extension defaults / conformers). + """ + var_types: Dict[str, str] = {} + local_names: Set[str] = set() + var_qualified: Dict[str, str] = {} + ambiguous: Set[str] = set() + qual_ambiguous: Set[str] = set() + if not code: + return var_types, local_names, var_qualified + try: + tree = self.parser.parse(code.encode("utf-8")) + except Exception: + return var_types, local_names, var_qualified + src = code.encode("utf-8") + + def record(name: Optional[str], typ: Optional[str]): + if name: + local_names.add(name) + if not name or not typ: + return + if name in ambiguous: + return + if name in var_types and var_types[name] != typ: + ambiguous.add(name) + var_types.pop(name, None) + else: + var_types[name] = typ + + def record_qual(name: Optional[str], qual: Optional[str]): + if not name or not qual or name in qual_ambiguous: + return + if name in var_qualified and var_qualified[name] != qual: + qual_ambiguous.add(name) + var_qualified.pop(name, None) + else: + var_qualified[name] = qual + + stack = [tree.root_node] + while stack: + node = stack.pop() + if node.type == "property_declaration": + name = self._pattern_name(node, src) + typ = self._annotation_base_type(node, src) + qual, tail = self._let_qualified_ctor(node, src) + # only treat a `let x = A.B()` tail as a type when B is a + # KNOWN repo type — else an uppercase ENUM CASE (`Result.Success(1)`) or + # a static factory is misread as the nonexistent type `Success`, and + # tier-2 typed dispatch dead-ends to no members (dropping a real edge). + if tail is not None and tail not in type_names: + qual = tail = None + if typ is None: + typ = self._ctor_init_type(node, src) + # Unannotated `let x = Outer.Inner()`: _ctor_init_type only reads a + # bare `Inner()` callee, so type the var from the qualified tail too. + if typ is None and tail is not None: + typ = tail + record(name, typ) + record_qual(name, qual) + elif node.type == "parameter": + pname, ptype = self._param_name_type(node, src) + record(pname, ptype) + stack.extend(node.children) + return var_types, local_names, var_qualified + + def _let_qualified_ctor(self, node: Node, src: bytes) -> Tuple[Optional[str], Optional[str]]: + """For a `let x = Outer.Inner(...)` property_declaration return + (qualified_type='Outer.Inner', bare_tail='Inner'); else (None, None). + + Requires: a `let` binding (value_binding_pattern -> `let`); an RHS + call_expression whose callee is a navigation_expression whose final component + is uppercase-initial (a type, not `obj.makeThing()`). `var` bindings and + bare `Inner()` calls return (None, None).""" + vbp = next((c for c in node.children if c.type == "value_binding_pattern"), None) + if vbp is None or not any(c.type == "let" for c in vbp.children): + return None, None + call = next((c for c in node.children if c.type == "call_expression"), None) + if call is None: + return None, None + callee = call.children[0] if call.children else None + if callee is None or callee.type != "navigation_expression": + return None, None + suffix = next((c for c in reversed(callee.children) + if c.type == "navigation_suffix"), None) + if suffix is None: + return None, None + tid = next((c for c in suffix.children if c.type == "simple_identifier"), None) + tail = self._text(tid, src) if tid is not None else None + if not tail or not tail[:1].isupper(): + return None, None + return self._text(callee, src), tail + + def _annotation_base_type(self, node: Node, src: bytes) -> Optional[str]: + """`x: Foo` / `x: Foo?` -> 'Foo'. Binds NOTHING for collection/tuple/ + function types (`x: [Foo]` must NOT bind x to the element type Foo).""" + ann = next((c for c in node.children if c.type == "type_annotation"), None) + if ann is None: + return None + tnode = next((c for c in ann.children if c.type != ":"), None) + return self._nominal_base(tnode, src) + + def _nominal_base(self, tnode: Optional[Node], src: bytes) -> Optional[str]: + """Reduce a type node to its base nominal name, soundly. + + - `optional_type` -> unwrap the wrapped type (`Foo?` -> Foo). + - `user_type` -> its direct `type_identifier` (`Box` -> Box; do + NOT descend `type_arguments`, which would bind the + generic argument instead of the nominal). + - `type_identifier`-> itself. + - array/dictionary/tuple/function/metatype/protocol-composition -> None + (binding a collection variable to its element type is a real bug — a + `[Foo]` local's `.append(...)` must not dispatch to Foo's members). + """ + if tnode is None: + return None + t = tnode.type + if t == "optional_type": + inner = next((c for c in tnode.children if c.type not in ("?", "!")), None) + return self._nominal_base(inner, src) + if t == "user_type": + tid = next((c for c in tnode.children if c.type == "type_identifier"), None) + return self._text(tid, src) if tid is not None else None + if t == "type_identifier": + return self._text(tnode, src) + return None + + def _ctor_init_type(self, node: Node, src: bytes) -> Optional[str]: + """`x = Type(...)` -> 'Type' (constructor), OR `x = foo()` -> foo's unique + return type. The latter types a receiver from a factory/accessor call + (`let c = makeClient(); c.send()`), so `c.send` dispatches on Client instead + of falling through to the unknown-receiver path (fix).""" + rhs = self._eq_rhs_node(node) + if rhs is not None and rhs.type == "call_expression": + callee = rhs.children[0] if rhs.children else None + if callee is not None and callee.type == "simple_identifier": + name = self._text(callee, src) + # Strip leading underscores before the case test so a generated + # `_StorageClass()` (the pattern) is still recognised as a ctor + # (`'_'.isupper()` is False, so it was never typed). + if name.lstrip("_")[:1].isupper(): + return name # constructor call -> the type itself + # lowercase factory/accessor call -> its unique repo return type. + rt = self._func_return.get(name) + if rt is not None: + return rt + return None + + def _param_name_type(self, node: Node, src: bytes) -> Tuple[Optional[str], Optional[str]]: + """(internal_name, base_type) for a `parameter` node: `label name : Type`. + + The internal name (the one usable inside the body) is the identifier just + before `:`; the type node follows `:`. + """ + idents = [g for g in node.children if g.type == "simple_identifier"] + if not idents: + return None, None + internal = self._text(idents[-1], src) if len(idents) >= 2 else self._text(idents[0], src) + seen_colon = False + type_node = None + for c in node.children: + if c.type == ":": + seen_colon = True + continue + if seen_colon and c.type not in (",", "=") and not c.type.endswith("comment"): + type_node = c + break + return internal, self._nominal_base(type_node, src) + + # -- call extraction ---------------------------------------------------- + + def _find_calls_in_code(self, code: str, caller_file: str = ""): + """Return (call_sites, arg_refs). + + call_sites: a LIST of per-occurrence records — {text, labels, arity, + trailing} — for each `call_expression` / `constructor_expression`. `text` + is a bare name (`foo`) or dotted receiver form (`recv.method`) or a bare + type name (generic ctor). Preserving per-site argument labels is what lets + the resolver pick the right init/overload instead of fanning out to all of + them (the dominant phantom-edge class). A parse failure is RECORDED, not + regex-approximated (Swift syntax defeats regex call detection). + arg_refs: bare identifiers passed as arguments that may be function refs. + """ + sites: List[dict] = [] + arg_refs: Set[str] = set() + try: + tree = self.parser.parse(code.encode("utf-8")) + except Exception: + self.stats_extra["reparse_error_bodies"] = self.stats_extra.get("reparse_error_bodies", 0) + 1 + return sites, arg_refs + # tree-sitter returns an ERROR-bearing tree rather than raising, so an + # `except` alone reports 0 failures while real bodies fail to parse. + if tree.root_node.has_error: + self.stats_extra["reparse_error_bodies"] = self.stats_extra.get("reparse_error_bodies", 0) + 1 + self._extract_calls(tree.root_node, code.encode("utf-8"), sites, arg_refs) + + shadowing = self._same_file_function_names(caller_file) + repo_names = self._repo_function_names() + # Filter builtins ONLY on BARE (unqualified) calls (a dotted member call is + # decided by its receiver type) and drop accessor-keyword reparse phantoms. + # A bare builtin-named call is KEPT when its name is a user-declared function + # ANYWHERE in the repo, not just the caller's file: Swift idiomatically splits + # one type across extensions in sibling files, so an implicit-`self` call to + # the type's own `filter()`/`count()`/`remove()` method (name ∈ SWIFT_BUILTINS) + # declared elsewhere would otherwise be silently dropped, taking its whole + # subtree out of reachability — a silent false-negative. The resolver still + # scopes the kept site by caller class/file; an unresolvable one is counted, + # not dropped. + kept = [ + s for s in sites + if s["text"] not in _ACCESSOR_KEYWORD_CALLS + and ( + "." in s["text"] + or s["text"] in shadowing + or (s["text"] in repo_names and s["text"] not in _SWIFT_GLOBAL_FUNCS) + or s["text"] not in SWIFT_BUILTINS + ) + ] + return kept, arg_refs + + def _extract_calls(self, root: Node, source: bytes, sites: List[dict], arg_refs: Set[str]) -> None: + """Iterative worklist walk collecting per-occurrence call-site records. + + Handles `call_expression` (callee = `simple_identifier` plain/ctor, or + `navigation_expression` `recv.method`) AND `constructor_expression` + (`Box(...)` — a generic/explicit ctor whose callee is a `user_type`, + NOT a call_expression, so it was previously invisible → missed edges). + A trailing-closure `lambda_literal` in `call_suffix` is counted toward arity; + calls inside it are visited by the same walk (attributed to the enclosing unit). + """ + stack = [root] + while stack: + node = stack.pop() + if node.type == "call_expression" and not self._is_subscript(node): + callee = node.children[0] if node.children else None + if callee is not None: + if callee.type == "simple_identifier": + labels, arity, trailing, unlabeled_trailing = self._call_labels(node, source) + sites.append({"text": self._text(callee, source), + "labels": labels, "arity": arity, "trailing": trailing, + "unlabeled_trailing": unlabeled_trailing}) + elif callee.type == "navigation_expression": + dotted = self._navigation_text(callee, source) + if dotted: + labels, arity, trailing, unlabeled_trailing = self._call_labels(node, source) + # `member=True` marks EVERY navigation-derived call as a + # member call. A COMPLEX receiver (`makeClient().send()`, + # `items[i].load()`, `opt?.h.run()`) is reduced by + # _navigation_text to the bare method (no dot), so without + # this flag it looked like a bare call and re-entered the + # caller-locality heuristics — bypassing the fix. + sites.append({"text": dotted, "labels": labels, + "arity": arity, "trailing": trailing, + "unlabeled_trailing": unlabeled_trailing, "member": True}) + self._collect_arg_refs(node, source, arg_refs) + elif node.type == "constructor_expression": + # `Box(value: 1)` — callee is a `user_type`; the base nominal is + # the constructor target. Generics-heavy code (NIO/protobuf/collections) + # uses these routinely. + ut = next((c for c in node.children if c.type == "user_type"), None) + tname = None + if ut is not None: + tid = next((g for g in ut.children if g.type == "type_identifier"), None) + if tid is not None: + tname = self._text(tid, source) + if tname: + labels, arity, trailing, unlabeled_trailing = self._call_labels(node, source) + sites.append({"text": tname, "labels": labels, "arity": arity, + "trailing": trailing, "unlabeled_trailing": unlabeled_trailing, + "ctor": True}) + stack.extend(node.children) + + def _call_labels(self, call_node: Node, source: bytes): + """Return (labels, arity, trailing, unlabeled_trailing) for a call/constructor. + + labels: one entry per argument (parenthesized value args AND trailing closures), + in source order — the external label str, or None for a positional/elided-label + argument. A SECONDARY trailing closure (SE-0279: `separator: {... }`) carries + its label spelled as `simple_identifier ':' lambda_literal` directly under + call_suffix — NOT wrapped in a value_argument — so it is extracted here, not by + the value_argument path. The PRIMARY trailing closure's label is elided at the + call site (Swift trailing-closure syntax). + arity: total arguments. trailing: count of trailing closures. + unlabeled_trailing: trailing closures whose label is elided (the primary). The + matcher lets that many REQUIRED decl labels go UNspelled — a trailing closure + fills its parameter without the call spelling the label; requiring it to be + spelled wrongly rejects the true overload (recall loss). + """ + labels: List[Optional[str]] = [] + trailing = 0 + unlabeled_trailing = 0 + suffix = next((c for c in call_node.children if c.type == "call_suffix"), None) + # NOTE: a generic constructor_expression (`Box(value: 1)`) wraps its args in a + # `constructor_suffix` node, which this walk does NOT descend — so generic ctors + # currently yield no labels (a pre-existing over-fan-out gap, tracked as; not + # touched by this fix). Plain call_expression uses call_suffix, handled here. + if suffix is not None: + containers = [suffix] + else: + containers = [c for c in call_node.children + if c.type in ("value_arguments", "lambda_literal")] + for cont in containers: + if cont is None: + continue + children = [cont] if cont.type == "value_arguments" else list(cont.children) + i = 0 + while i < len(children): + c = children[i] + if c.type == "value_arguments": + for arg in c.children: + if arg.type != "value_argument": + continue + lbl = next((g for g in arg.children + if g.type == "value_argument_label"), None) + if lbl is not None: + sid = next((g for g in lbl.children + if g.type == "simple_identifier"), None) + labels.append(self._text(sid, source) if sid is not None else None) + else: + labels.append(None) # positional + i += 1 + elif (c.type == "simple_identifier" and i + 2 < len(children) + and children[i + 1].type == ":" + and children[i + 2].type == "lambda_literal"): + labels.append(self._text(c, source)) # labeled secondary trailing closure + trailing += 1 + i += 3 + elif c.type == "lambda_literal": + labels.append(None) # primary trailing closure (label elided) + trailing += 1 + unlabeled_trailing += 1 + i += 1 + else: + i += 1 + arity = len(labels) + return labels, arity, trailing, unlabeled_trailing + + def _is_subscript(self, call_node: Node) -> bool: + """True for a subscript access `x[i]` (value_arguments led by `[`).""" + suffix = next((c for c in call_node.children if c.type == "call_suffix"), None) + if suffix is None: + return False + va = next((c for c in suffix.children if c.type == "value_arguments"), None) + if va is None or not va.children: + return False + return va.children[0].type == "[" + + def _navigation_text(self, nav: Node, source: bytes) -> Optional[str]: + """Reduce a navigation_expression to `receiver.method`. + + receiver = first child (`simple_identifier` | `self_expression` | + nested `navigation_expression` | call/other expr); method = the + `simple_identifier` under the `navigation_suffix`. + """ + method = None + recv_node = None + for c in nav.children: + if c.type == "navigation_suffix": + sid = next((g for g in c.children if g.type == "simple_identifier"), None) + if sid is not None: + method = self._text(sid, source) + elif recv_node is None and c.type != ".": + recv_node = c + if method is None: + return None + if recv_node is None: + return method + if recv_node.type == "self_expression": + return f"self.{method}" + if recv_node.type == "simple_identifier": + return f"{self._text(recv_node, source)}.{method}" + if recv_node.type == "navigation_expression": + inner = self._navigation_text(recv_node, source) + recv = inner.rsplit(".", 1)[-1] if inner else None + return f"{recv}.{method}" if recv else method + # Complex receiver (call/subscript/paren) — unknown static type; dispatch + # on the bare method name downstream (recall-preserving fallback). + return method + + def _collect_arg_refs(self, call_node: Node, source: bytes, arg_refs: Set[str]) -> None: + suffix = next((c for c in call_node.children if c.type == "call_suffix"), None) + if suffix is None: + return + va = next((c for c in suffix.children if c.type == "value_arguments"), None) + if va is None: + return + for arg in va.children: + if arg.type != "value_argument": + continue + # The value expression is the LAST named child (a `value_argument_label` + # + `:` may precede it). A bare `simple_identifier` value is a candidate + # function reference. Reading only DIRECT `simple_identifier` children + # missed every LABELED ref (`use: handleRequest`, `sorted(by:)`) because + # 0.7.3 wraps the label in a `value_argument_label` node — 6210 sites. + named = [g for g in arg.children if g.is_named] + if not named: + continue + val = named[-1] + if val.type == "simple_identifier": + arg_refs.add(self._text(val, source)) + elif val.type == "selector_expression": + # `#selector(handleTap)` / `action: #selector(foo)` — the target + # method is a function reference (target-action idiom). The target + # id sits INSIDE the selector_expression, so the bare-identifier + # check above missed it. Take the last + # simple_identifier (handles `#selector(Type.method)` too). + sids = [g for g in val.children if g.type == "simple_identifier"] + if sids: + arg_refs.add(self._text(sids[-1], source)) + + def _same_file_function_names(self, caller_file: str) -> Set[str]: + if not caller_file: + return set() + return { + info.get("name", "") + for info in self.functions.values() + if info.get("file_path") == caller_file and info.get("name") + } + + def _repo_function_names(self) -> Set[str]: + """User-declared METHOD names across the repo (cached). + + Only METHOD names (``class_name`` present) bypass the SWIFT_BUILTINS drop: + the rationale is a type's own method (e.g. ``filter()``) split into a sibling + ``extension`` file, reached via implicit ``self``. A FREE function named after + a stdlib builtin (``max``/``min``/``zip``/``print``/…) is NOT covered — bypassing + the drop for it would fabricate a phantom edge at every bare stdlib call of that + name across unrelated files. Free-function shadows stay on the + same-file ``shadowing`` gate. ``self.functions`` is fully populated before any + call extraction, so this snapshot is complete/stable. + """ + cached = getattr(self, "_repo_names_cache", None) + if cached is None: + cached = { + info.get("name", "") + for info in self.functions.values() + if info.get("name") and info.get("class_name") + } + self._repo_names_cache = cached + return cached + + # -- resolution --------------------------------------------------------- + + def _in_file(self, cand_id: str, caller_file: str) -> bool: + """Same-file membership by the structured `file_path` FIELD, not a fragile + func_id string-prefix (`c.startswith(f"{file}:")`) that breaks if a path + contains ':' or the id format changes.""" + return self.functions.get(cand_id, {}).get("file_path") == caller_file + + def _resolve_call(self, site, caller_file, caller_class, name_to_ids, + type_names, ctor_index, alias_to_target, caller_id, + var_types, var_qualified=None) -> List[str]: + call_name = site["text"] + # tier 1: alias expansion (a `let f = knownFn; f()` alias → its targets). + targets = (alias_to_target or {}).get(caller_id, {}).get(call_name) + names = sorted(targets) if targets else [call_name] + + resolved: List[str] = [] + for name in names: + for rid in self._resolve_name(name, site, caller_file, caller_class, + name_to_ids, type_names, ctor_index, + var_types, var_qualified): + if rid not in resolved: + resolved.append(rid) + return resolved + + def _resolve_name(self, call_name, site, caller_file, caller_class, name_to_ids, + type_names, ctor_index, var_types, var_qualified=None) -> List[str]: + # A member call reduced to a bare method (COMPLEX receiver — `foo().bar()`, + # `arr[i].m()`) has no dot in its text but is NOT a call on the caller, so it + # must not use the caller-locality heuristics. Detect it structurally + # from the `member` flag, not from punctuation. + member_unknown_recv = bool(site.get("member")) and "." not in call_name + # tier 2: member call `recv.method` + if "." in call_name and call_name not in name_to_ids: + receiver, _, method = call_name.rpartition(".") + is_super = receiver == "super" + recv_type = None + if receiver in ("self", "Self", "super"): + recv_type = caller_class + elif var_types and receiver in var_types: + recv_type = var_types[receiver] + elif receiver in type_names: + recv_type = receiver # Type.staticMethod / Type.init / Outer.Inner(...) + + # nested-type / qualified constructor `Outer.Inner(...)` — `method` + # is itself a known type name, so this is a constructor of that nested + # type, NOT a method call. Resolve to its (label-matched) inits. + if method in type_names and method not in ("init",): + # `Outer.Inner(...)` / `Msg1.Storage(...)` — pass the FULL qualified + # target (`call_name` = receiver.method) so _match_ctors filters to the + # right nested type's inits, not every same-bare-named type's. + return self._match_ctors(method, ctor_index, site, caller_file, + qualified_type=call_name) + + if recv_type is not None: + if method == "init": + # `Type.init` / `self.init` / `Self.init` -> that type's ctors; + # `super.init` -> the SUPERtype's ctors (P-4: the old code used + # caller_class, binding super.init to the SUBCLASS's own inits). + if is_super: + out = [] + for sup in sorted(self._inherit_closure.get(caller_class, ())): + out.extend(self._match_ctors(sup, ctor_index, site, caller_file)) + return out + return self._match_ctors(recv_type, ctor_index, site, caller_file) + # Known receiver type: dispatch on that type + its hierarchy. `super` + # dispatches on the SUPERtypes only (not the caller's own class). + accept = self._dispatch_types(recv_type, is_super) + members = self._typed_members(accept, recv_type, method, name_to_ids) + # Known receiver: a trailing closure may fill a required label unspelled + #. Safe to relax here — this dispatches on a KNOWN type's own + # overloads (no unknown-receiver decline, no bare-name fan-out cap). + narrowed = self._match_overloads(members, site, allow_unlabeled_trailing=True) + # apply the `let x = Outer.Inner()` qualified-identity + # narrowing AFTER signature matching, not before — filtering bare-name + # matches first kept an arity-INCOMPATIBLE same-qualified candidate and + # dropped the real inherited/compatible one (`v.m()` -> Base.m). Floor: + # an empty subset keeps the full narrowed set (never fewer edges than + # signature matching alone), preserving the SeqA/SeqB collision win. + if var_qualified and not is_super and narrowed: + qh = var_qualified.get(receiver) + if qh: + # guard : the unlabeled-trailing allowance is + # TYPE-BLIND — it can admit a same-qualified DECOY overload + # (`x.run { }` admitting `Outer.Inner.run(name:)`), which the + # var_qualified subset then prefers, EVICTING the strictly-matched + # inherited target (`Base.run(_:)`). Both share the qualified name, + # so no set-diff gate sees the loss. If the relaxation added a + # candidate, skip the subset — mirror the `_match_ctors` + # `relaxed_added` guard (same pattern constructor side). + relaxed_added = ( + site.get("unlabeled_trailing", 0) > 0 + and set(self._compatible_subset(members, site, + allow_unlabeled_trailing=True)) + != set(self._compatible_subset(members, site))) + if not relaxed_added: + subset = [c for c in narrowed + if self.functions.get(c, {}).get("qualified_name", "") + in (f"{qh}.{method}",) + or self.functions.get(c, {}).get("qualified_name", "") + .endswith(f".{qh}.{method}")] + if subset: + return subset + return narrowed + # Unknown receiver type: bare method name (recall) unless it is a stdlib + # name (an unknown-receiver `a.append`/`xs.map` is the external method, + # not a same-named user fn — resolving it fabricates edges). + if method in SWIFT_BUILTINS: + # reason-coding: an unknown-receiver builtin-named call DECLINES + # (X.1 — resolving `x.append()` on an untyped receiver to a repo-defined + # `Foo.append` is a cross-scope guess; most such calls target stdlib + # collections, so a guess adds net phantom). Count the declines — and + # separately the ones where the repo ALSO defines that name (the real + # recall cost of this policy) — so it is observable and revisitable. + self._unknown_builtin_drops = getattr(self, "_unknown_builtin_drops", 0) + 1 + if method in name_to_ids: + self._unknown_builtin_drops_repo_named = getattr( + self, "_unknown_builtin_drops_repo_named", 0) + 1 + return [] + call_name = method + member_unknown_recv = True + + # tier 3: constructor call `Type(...)` / `Self(...)`. + if call_name == "Self" and caller_class: + return self._match_ctors(caller_class, ctor_index, site, caller_file) + if call_name in type_names and call_name not in name_to_ids: + return self._match_ctors(call_name, ctor_index, site, caller_file) + + candidates = name_to_ids.get(call_name, []) + if not candidates: + return [] + + # tier 4: bare unqualified — enclosing type (+ its hierarchy), same file, + # unique, else bounded fan-out. Apply the signature matcher at each step so a + # bare overloaded call narrows to the compatible overloads. + # The enclosing-type preference applies ONLY to a genuinely-bare call `foo()` + # (which could be the caller's own method). An unknown-receiver member call + # `recv.foo()` that fell through here is NOT on the caller — preferring the + # caller's enclosing type wrongly matched EVERY unit sharing the caller's bare + # class_name (e.g. inside `SeqA.Iterator.next`, `base.next()` fanned to all 38 + # `Iterator.next` across files — /, 948 phantom edges on + # swift-async-algorithms). For such calls skip straight to same-file/unique/ + # bounded so a huge ambiguous set is capped/dropped, not matched. + # Both the enclosing-type AND same-file steps are CALLER-LOCALITY heuristics + # (the target is the caller's own method / near the caller). Neither applies + # to an unknown-receiver member call `recv.foo()` — the method is on `recv`, + # not the caller. Applying same-file to it made a `.traverse()` in a giant + # generated protobuf file fan out to all 157 same-name `visitX` methods in + # that file (uncapped same-file tier; +54k edges on a large Swift corpus). + # For an unknown-receiver member call, go straight to unique / bounded-fanout. + if not member_unknown_recv: + if caller_class: + accept = {caller_class} | self._inherit_closure.get(caller_class, set()) + same_type = [c for c in candidates + if self.functions.get(c, {}).get("class_name") in accept] + if same_type: + # a BARE call `run { }` inside a class is implicit `self.run` -- + # this dispatches on the caller's own type hierarchy, so the trailing- + # closure allowance resolves the required-label-filled overload like the + # explicit-receiver path (:908). + # + # RECALL FLOOR : enable the relaxation ONLY when strict + # matching already found a compatible candidate. If strict finds NONE, + # `_match_overloads` returns the FULL same_type set (recall fallback) -- + # and the type-blind relaxation would narrow that to a decoy, EVICTING a + # real target the strict matcher missed for an UNRELATED reason (a + # variadic / parameter-pack callee whose arity is under-counted fails the + # arity bound under BOTH strict and relaxed, so it only ever survives via + # the fallback). Evicted and retained share leaf AND qualified name, so + # no set-diff oracle can see the loss. Gating on a non-empty strict subset + # keeps the fallback intact (0 lost) while still refining a real + # resolution (238 gained) -- strictly dominant on the corpus. + if self._compatible_subset(same_type, site): + return self._match_overloads(same_type, site, + allow_unlabeled_trailing=True) + return self._match_overloads(same_type, site) + same_file = [c for c in candidates if self._in_file(c, caller_file)] + if same_file: + # (same-FILE tier only — the enclosing-type tier above keeps its + # recall fallback, since dropping a real same-type method overload is + # a false negative): an INCOMPATIBLE same-file free-function candidate + # must not block a signature-compatible one in another file (a same- + # file `helper(x:)` shadowed a cross-file `helper(y:)` the call + # `helper(y:1)` actually targets). Prefer the strict-compatible same- + # file subset; if none compatible here but a compatible exists + # elsewhere, fall through so it wins; else keep the recall fallback. + sf_ok = [c for c in same_file + if self._labels_compatible(site, self.functions.get(c, {}))] + if sf_ok: + return sf_ok + any_compatible = any(self._labels_compatible(site, self.functions.get(c, {})) + for c in candidates) + if not any_compatible: + return self._match_overloads(same_file, site) + if len(candidates) == 1: + return candidates + narrowed = self._match_overloads(candidates, site) + if len(narrowed) <= _AMBIGUOUS_FANOUT_MAX: + return narrowed + return [] + + def _dispatch_types(self, recv_type: str, is_super: bool) -> set: + """Accepted declaring types for a member dispatch on `recv_type`. + + Non-super: recv_type + supertypes (inherited/protocol-default) + conformers/ + subclasses (dynamic dispatch). Super: the supertypes ONLY (a `super.m()` call + must reach the superclass implementation, never the caller's own).""" + if is_super: + return set(self._inherit_closure.get(recv_type, set())) + return ({recv_type} + | self._inherit_closure.get(recv_type, set()) + | self._conformer_closure.get(recv_type, set())) + + def _typed_members(self, accept: set, recv_type: str, method: str, name_to_ids) -> List[str]: + matches: List[str] = [] + for cand in name_to_ids.get(method, []): + info = self.functions.get(cand, {}) + cn = info.get("class_name") + qn = info.get("qualified_name", "") + if cn in accept or qn == f"{recv_type}.{method}" \ + or qn.endswith(f".{recv_type}.{method}"): + if cand not in matches: + matches.append(cand) + return matches + + def _match_ctors(self, type_name: str, ctor_index, site, caller_file="", qualified_type=None) -> List[str]: + """Resolve a constructor call to the label/arity-COMPATIBLE inits of the + type. A `Type(...)` call used to link to EVERY init overload (79% of + edges on swift-argument-parser; one `Option(name:)` → all 21 inits). Narrow + by the call's argument labels; unmatched → all-inits fallback + counter + (recall: a missed edge silently prunes the paid scan, an unrecoverable FN). + + Bare-name COLLISION guard: when the bare type name is declared in MANY files + (SwiftProtobuf generates a nested `_StorageClass` per message — 202 units, 30 + files, identical `()` signatures the matcher cannot separate → one call fanned + to ~101 phantom inits, 20,705 edges into that one bucket on a large Swift corpus), + prefer the SAME-FILE init(s): a private/nested storage type is only ever + constructed within its own type's file. A uniquely-named type has all its + inits in one file, so this never narrows a legitimate cross-file `Foo()`. + """ + ids = ctor_index.get(type_name, []) + if not ids: + return [] + matched = self._compatible_subset(ids, site, allow_unlabeled_trailing=True) + # (zero churn): a call with an EXPLICIT qualified target + # (`Msg1.Storage(...)`, `Outer.Inner(...)`) carries canonical identity that the + # bare `ctor_index[type_name]` throws away — it returns every `Storage.init` + # across all files. When the qualified type is known, keep only the inits whose + # qualified_name matches it (`Msg1.Storage.init`). Cut 6→1 on the probe; −289 + # phantom inits on protobuf, −11 on nio. + if matched and qualified_type: + exact = [c for c in matched + if self.functions.get(c, {}).get("qualified_name", "") in + (f"{qualified_type}.init",) + or self.functions.get(c, {}).get("qualified_name", "").endswith(f".{qualified_type}.init")] + if exact: + return exact + if matched: + # The unlabeled-trailing allowance is TYPE-BLIND: a trailing closure may fill + # any required-labeled param, so the allowance can admit a same-file DECOY init + # (e.g. `Widget { }` admitting `init(name: String)`). If it added a candidate, + # skip the same-file tie-break — otherwise that decoy would evict the true + # cross-file init the STRICT matcher already resolved (; both share the + # qualified name, so no set-diff gate can see the loss). Non-trailing-closure + # calls (protobuf `_StorageClass()`) are unaffected: strict == relaxed there. + relaxed_added = (site.get("unlabeled_trailing", 0) > 0 + and set(matched) != set(self._compatible_subset(ids, site))) + if (caller_file and not relaxed_added + and len({self.functions.get(c, {}).get("file_path") + for c in matched}) > 1): + same_file = [c for c in matched if self._in_file(c, caller_file)] + if same_file: + return same_file + return matched + # No compatible init. The fallback policy DEPENDS on whether the type is + # repo-DECLARED or merely repo-EXTENDED (external): + # - external type (String/Data/Logger — extended in-repo, not declared): + # an unmatched call is almost certainly the STDLIB constructor + # (`String(format:)` matches no repo `extension String` init), so emit NO + # edge. Falling back to all repo extension inits fabricated the dominant + # phantom class on security-pcc (String.init in-degree 13,025; avg 4.87 + # edges per ctor site). This is the "extension of an external type" + # hazard from the design review, now measured at scale. + # - repo-declared type: keep the recall-first all-inits fallback (a + # construction of OUR type reaches OUR init; a missed edge silently prunes + # the paid scan). + if type_name not in self._repo_declared: + return [] + self._ctor_unmatched_sites = getattr(self, "_ctor_unmatched_sites", 0) + 1 + return list(ids) + + def _compatible_subset(self, cand_ids: List[str], site, allow_unlabeled_trailing=False) -> List[str]: + """The candidates whose parameter list is compatible with the call site's + labels/arity — NO fallback. Empty means "none matched" (the caller decides + what to do with that). + + ``allow_unlabeled_trailing`` is set for constructor matching AND for a known- + receiver method dispatch on an EXPLICIT receiver, where a trailing-closure + DSL / trailing closure (`Prefix { }`, `Many { } separator: { }`, `r.run { }`) + legitimately fills a required param without spelling its label. It is NOT set for + the bare-name / unknown-receiver fan-out path: broadening the compatible set there + would tip a unique-resolution into a non-unique DECLINE and silently drop a real + edge (measured: it dropped 395 edges corpus-wide, incl. Alamofire `Protected.write`).""" + if len(cand_ids) <= 1: + return list(cand_ids) + return [c for c in cand_ids + if self._sig_compatible(site, self.functions.get(c, {}), allow_unlabeled_trailing)] + + def _match_overloads(self, cand_ids: List[str], site, allow_unlabeled_trailing=False) -> List[str]: + """Filter a same-name candidate set to the signature-compatible ones. + Conformer fan-out (same signature on different types) survives — only same- + name OVERLOADS (different signatures) get narrowed. If NONE are compatible, + keep the full set (recall over precision — for a METHOD, the missed edge is + the expensive error). Constructors use `_compatible_subset` directly so they + can apply the external-type no-edge policy instead of this recall fallback. + + ``allow_unlabeled_trailing`` is set ONLY for a KNOWN-receiver method dispatch on + an EXPLICIT receiver (`r.run { }` where `run(while:)` is required-but- + closure-filled). Like the constructor case, a trailing closure fills its param + without spelling the label; strict matching wrongly resolves to an all-defaulted + decoy and starves the true overload. The other method sites stay STRICT for + SITE-SPECIFIC reasons, not one blanket cap/decline rule : + - bare-name fan-out (the `<= _AMBIGUOUS_FANOUT_MAX` gate below): relaxing can + grow the set past the cap -> the whole set is dropped. THIS is the cap case. + - implicit-self enclosing-type dispatch (`same_type`, returns directly, no cap): + relaxing IS a valid recovery, but a SEPARATE measured change needing + its own recall gate (corpus delta +238/-477) — deferred, not cap-blocked. + - same-file free-function fallback (returns directly, no cap): relaxing is a + measured no-op (0 corpus sites) — left strict for consistency.""" + compat = self._compatible_subset(cand_ids, site, allow_unlabeled_trailing) + return compat if compat else list(cand_ids) + + def _labels_compatible(self, site, cand_info) -> bool: + """Label-only compatibility (subsequence + required-named), WITHOUT the + arity bounds. Used by the fall-through gate (E): `_sig_compatible` + has no variadic model — a variadic decl (`process(_ v: Int...)`) fails the + arity bound yet is really compatible, so vetoing a same-file candidate on + ARITY could redirect a real edge to a phantom cross-file overload. An arity + mismatch only costs precision here (keep the same-file candidate), never recall; + only a LABEL mismatch is trustworthy enough to fall through to another scope.""" + decl_labels = cand_info.get("signature", []) or [] + decl_defaults = cand_info.get("param_defaults") or [False] * len(decl_labels) + if len(decl_defaults) != len(decl_labels): + decl_defaults = [False] * len(decl_labels) + labeled = [l for l in site.get("labels", []) if l] + decl_named = [decl_labels[i] for i in range(len(decl_labels)) + if decl_labels[i] not in (None, "_")] + if not _is_subsequence(labeled, decl_named): + return False + required_named = {decl_labels[i] for i in range(len(decl_labels)) + if not decl_defaults[i] and decl_labels[i] not in (None, "_")} + return required_named.issubset(set(labeled)) + + def _sig_compatible(self, site, cand_info, allow_unlabeled_trailing=False) -> bool: + """Ordered-subsequence-with-defaults matcher. A call binds to a decl if + every labeled arg appears among the decl's labels in order, every REQUIRED + (non-default) NAMED decl label is supplied, and the arity is feasible. + + ``allow_unlabeled_trailing`` (constructor context only): let up to + `unlabeled_trailing` required labels go unspelled — a trailing closure fills its + param without the call spelling the label (`Prefix { }` -> `init(while:)`).""" + decl_labels = cand_info.get("signature", []) or [] + decl_defaults = cand_info.get("param_defaults") or [False] * len(decl_labels) + if len(decl_defaults) != len(decl_labels): + decl_defaults = [False] * len(decl_labels) + call_labels = site.get("labels", []) + arity = site.get("arity", len(call_labels)) + trailing = site.get("trailing", 0) + + labeled = [l for l in call_labels if l] + decl_named = [decl_labels[i] for i in range(len(decl_labels)) if decl_labels[i] not in (None, "_")] + if not _is_subsequence(labeled, decl_named): + return False + required_named = {decl_labels[i] for i in range(len(decl_labels)) + if not decl_defaults[i] and decl_labels[i] not in (None, "_")} + allowance = site.get("unlabeled_trailing", 0) if allow_unlabeled_trailing else 0 + if len(required_named - set(labeled)) > allowance: + return False + n_params = len(decl_labels) + n_required = sum(1 for d in decl_defaults if not d) + # +1 arity slack for variadics / trailing-closure param modeling imperfection. + if arity > n_params + 1: + return False + if arity < n_required - trailing: + return False + return True + + # -- small helpers ------------------------------------------------------ + + def _pattern_name(self, node: Node, src: bytes) -> Optional[str]: + pat = next((c for c in node.children if c.type == "pattern"), None) + if pat is not None: + sid = next((g for g in pat.children if g.type == "simple_identifier"), None) + if sid is not None: + return self._text(sid, src) + return None + + def _eq_rhs_node(self, node: Node) -> Optional[Node]: + seen_eq = False + for c in node.children: + if c.type == "=": + seen_eq = True + continue + if seen_eq and c.type not in (";",): + return c + return None + + def _eq_rhs_identifier(self, node: Node, src: bytes) -> Optional[str]: + rhs = self._eq_rhs_node(node) + if rhs is not None and rhs.type == "simple_identifier": + return self._text(rhs, src) + return None + + def _bare(self, call_name: str) -> str: + return call_name.rsplit(".", 1)[-1] + + def _text(self, node: Node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") diff --git a/libs/openant-core/parsers/swift/function_extractor.py b/libs/openant-core/parsers/swift/function_extractor.py new file mode 100644 index 00000000..8a2de91f --- /dev/null +++ b/libs/openant-core/parsers/swift/function_extractor.py @@ -0,0 +1,857 @@ +""" +Stage 2: Function Extractor for Swift + +Extracts functions, methods, initializers, deinitializers, and call-bearing +property accessors from Swift source files using tree-sitter. + +Node-type names below were verified against tree-sitter-swift 0.7.3 by parsing +representative fixtures (see openant-work/SWIFT-PARSER-DESIGN.md). Guessing +tree-sitter node names silently extracts nothing — every name here is grounded. +""" + +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple + +from utilities.file_io import write_json + +from tree_sitter import Language, Parser, Node + + +def _load_swift_language() -> Language: + """Load the Swift tree-sitter grammar lazily. + + The grammar package (``tree-sitter-swift``) is an optional runtime + dependency. Importing it at module top level would make the entire Swift + parser unimportable in any environment where the package is absent (e.g. a + clean install that does not need Swift support). Resolving it here, on first + use, lets the module import unconditionally and surfaces a clear, actionable + error only when the Swift parser is actually exercised. + """ + try: + import tree_sitter_swift as ts_swift + except ImportError as exc: # pragma: no cover - exercised via the no-dep test + raise ImportError( + "The Swift parser requires the 'tree-sitter-swift' package, which is " + "not installed. Install it with `pip install tree-sitter-swift` " + "(declared in pyproject.toml / requirements.txt)." + ) from exc + return Language(ts_swift.language()) + + +# Container declaration kinds that introduce a type scope. `class_declaration` +# is the tree-sitter-swift node for class / struct / actor / enum / extension +# (disambiguated by its leading keyword child); protocol has its own node. +_CONTAINER_TYPES = {"class_declaration", "protocol_declaration"} + +# The leading keyword child of a class_declaration tells us the real kind. +_CONTAINER_KEYWORDS = {"class", "struct", "actor", "enum", "extension"} + +_VISIBILITY_EXPORTED = {"public", "open"} + +# Declaration node types that are NOT executable top-level statements. Everything +# else at the top level of `main.swift` runs at program start (incl. a +# `try_expression`/`await_expression` daemon root), so `_emit_toplevel` collects by +# EXCLUDING these rather than allow-listing call_expression (which missed async roots). +_TOP_LEVEL_EXCLUDE = { + "import_declaration", "class_declaration", "protocol_declaration", + "function_declaration", "init_declaration", "deinit_declaration", + "typealias_declaration", "associatedtype_declaration", + "operator_declaration", "precedence_group_declaration", + "comment", "multiline_comment", +} + + +class _TypeContext: + """Enclosing-type scope threaded through the walk. + + - ``path``: full nested type path (Outer.Inner) → makes qualified_name / + func_id unique across different outer scopes (prevents sibling collision). + - ``bare``: bare leaf type name (Inner) → the receiver resolver matches a + call's static receiver TYPE, which is bare, so class_name must be bare. + - ``exported``: True iff every enclosing type is public/open (a public member + inside an internal type is NOT externally callable — Swift access rules). + - ``default_public``: the nearest enclosing ``public extension`` / ``public`` + container sets the default member access to public for members without an + explicit visibility modifier. + """ + + __slots__ = ("path", "bare", "exported", "default_public") + + def __init__(self, path: Optional[str], bare: Optional[str], + exported: bool, default_public: bool): + self.path = path + self.bare = bare + self.exported = exported + self.default_public = default_public + + +class FunctionExtractor: + """Extracts functions, methods and accessors from Swift source via tree-sitter.""" + + def __init__(self, repo_path: str, scan_results: Dict[str, Any]): + self.repo_path = Path(repo_path).resolve() + self.scan_results = scan_results + self.parser = Parser(_load_swift_language()) + + # -- public API --------------------------------------------------------- + + def extract(self) -> Dict[str, Any]: + """Extract all declarations from scanned files. + + Returns the functions.json structure (functions, classes, imports, stats). + """ + functions: Dict[str, Any] = {} + classes: Dict[str, Any] = {} + imports: Dict[str, List[str]] = {} + # type bare-name -> set of direct supertype/conformance bare-names. Merges + # class/struct/actor/enum inheritance clauses AND `extension T: P` clauses. + # The call-graph builder closes this transitively for superclass / protocol + # -default dispatch (a call resolves to a method on a supertype/protocol + # extension, not only the exact receiver type). + inheritance: Dict[str, set] = {} + files_processed = 0 + files_with_errors = 0 + + # Prepass: the set of bare type names declared `public`/`open` anywhere. + # A member of an `extension T { public func... }` is public API iff the + # TARGET type T is public — the extension's own (absent) modifier does not + # make an explicitly-public member internal. Without this, 720 public + # security-pcc units were wrongly marked internal, gutting library-mode. + self._public_types, self._repo_types = self._collect_public_types() + + for file_info in self.scan_results.get("files", []): + file_path = file_info["path"] + full_path = self.repo_path / file_path + + try: + with open(full_path, "rb") as f: + source = f.read() + + tree = self.parser.parse(source) + file_imports: List[str] = [] + root_ctx = _TypeContext(None, None, exported=True, default_public=False) + self._walk(tree.root_node, source, file_path, + functions, classes, file_imports, inheritance, root_ctx) + self._emit_toplevel(tree.root_node, source, file_path, functions) + imports[file_path] = file_imports + files_processed += 1 + except Exception as e: # pragma: no cover - defensive per-file guard + # One malformed file must never abort the whole repo parse + # (mirrors parsers/python guard). Record and continue. + print(f"Error processing {file_path}: {e}") + files_with_errors += 1 + + # Post-pass: re-classify framework-owned execution roots as `main` entry + # points using conformance/attribute info now that ALL types + extension + # conformances are known. Covers @main types, Swift-ArgumentParser + # `ParsableCommand.run()`, SwiftUI `App.body`, and `Codable init(from:)` + # (untrusted-payload decode) — daemon/CLI/XPC roots that seed nothing + # otherwise. Conformances arrive via extensions in other files, so this + # must run after the whole-repo walk. + self._apply_framework_entry_points(functions, classes, inheritance) + + return { + "repository": str(self.repo_path), + "extraction_time": datetime.now().isoformat(), + "functions": functions, + "classes": classes, + "imports": imports, + "inheritance": {k: sorted(v) for k, v in inheritance.items()}, + "statistics": { + "total_functions": len(functions), + "total_classes": len(classes), + "files_processed": files_processed, + "files_with_errors": files_with_errors, + }, + } + + def save_results(self, output_path: str, results: Dict[str, Any]) -> None: + write_json(output_path, results) + + # -- walk --------------------------------------------------------------- + + def _walk(self, node: Node, source: bytes, file_path: str, + functions: Dict[str, Any], classes: Dict[str, Any], + imports_list: List[str], inheritance: Dict[str, set], + ctx: _TypeContext, top_level: bool = True) -> None: + """Recursively walk the AST, threading the enclosing-type context. + + `top_level` is True only for the direct children of `source_file` (true + module scope). It distinguishes a file-scope global `let h = {…}` from a + LOCAL `let x = …` inside a function body — both have `ctx.bare is None` + (function bodies reset the type context, E-04), so ctx alone can't tell them + apart, and must only emit the file-scope ones (not every local var).""" + child_ctx = ctx + + if node.type == "import_declaration": + mod = self._import_module(node, source) + if mod: + imports_list.append(mod) + + elif node.type in _CONTAINER_TYPES: + info = self._container_info(node, source, ctx) + if info is not None: + bare, full_path, exported, default_public, is_type_decl, supers, container_attrs = info + if is_type_decl: # class/struct/actor/enum/protocol (not extension) + struct_id = f"{file_path}:{full_path}" + classes[struct_id] = { + "name": bare, + "qualified_name": full_path, + "file_path": file_path, + "start_line": node.start_point[0] + 1, + "end_line": node.end_point[0] + 1, + "inherits": sorted(supers), + # Container attributes (@main / @objcMembers / …) so the + # framework-entry post-pass can seed an @main type's root. + "decorators": container_attrs, + } + if supers: + inheritance.setdefault(bare, set()).update(supers) + child_ctx = _TypeContext(full_path, bare, exported, default_public) + + elif node.type in ("function_declaration", "init_declaration", "deinit_declaration"): + self._emit_function(node, source, file_path, functions, ctx) + # Descend into the body with a FUNCTION scope (no enclosing type): a + # nested local `func` is function-scoped, NOT a method of the enclosing + # type. Threading the type context down made `func m(){ func h(){} }` + # emit `Outer.h` with class_name=Outer — a phantom method (83 on the + # real target). A local TYPE still starts a fresh type scope via the + # container branch, so nested types are unaffected. + child_ctx = _TypeContext(None, None, exported=False, default_public=False) + + elif node.type == "property_declaration": + self._emit_property_accessors(node, source, file_path, functions, ctx, top_level) + + elif node.type == "subscript_declaration": + self._emit_subscript_accessors(node, source, file_path, functions, ctx) + + # Children of `source_file` are module scope (top_level); anything deeper is + # inside a type/function/block and is NOT a file-scope global. + child_top = node.type == "source_file" + for child in node.children: + self._walk(child, source, file_path, functions, classes, + imports_list, inheritance, child_ctx, child_top) + + def _emit_toplevel(self, root: Node, source: bytes, file_path: str, + functions: Dict[str, Any]) -> None: + """Synthesize a `main` unit for a ``main.swift`` file's top-level code. + + Swift runs executable top-level statements only in ``main.swift`` (or + script mode). Those statements are direct `call_expression` / + `property_declaration` children of `source_file` — inside NO function + unit, so their calls (and everything transitively reachable from the + program's real entry) would be invisible. The repo-wide N4 keep-all net + does NOT fire when any other entry point exists, so a mixed repo (one + `@main` app + several `main.swift` tools) would silently prune the tools. + Emit one `unit_type='main'` unit per such file so it seeds reachability. + """ + if Path(file_path).name.lower() != "main.swift": + return + # Collect ALL executable top-level statements via a DENYLIST of declaration + # nodes (not an allowlist): a daemon root is `try await Daemon().start()`, + # which parses as a top-level `try_expression`/`await_expression` — an + # allowlist of `call_expression` missed it, blacking out the real entry of + # every async daemon (cb_attestationd, ensemblewardend,...). Anything that + # is not an import/type/func/typealias/operator declaration or a comment runs + # at program start in main.swift and belongs in the synthetic main unit. + parts = [] + start_line = None + end_line = None + for c in root.children: + if not c.is_named: + continue + if c.type in _TOP_LEVEL_EXCLUDE: + continue + parts.append(self._text(c, source)) + ln = c.start_point[0] + 1 + start_line = ln if start_line is None else min(start_line, ln) + end_line = (c.end_point[0] + 1) if end_line is None else max(end_line, c.end_point[0] + 1) + if not parts: + return + func_id = f"{file_path}:" + functions[func_id] = { + "name": "main", + "qualified_name": "", + "file_path": file_path, + "start_line": start_line or 1, + "end_line": end_line or 1, + "code": "\n".join(parts), + "class_name": None, + "module_name": None, + "parameters": [], + "unit_type": "main", + "signature": [], + "param_defaults": [], + "decorators": [], + "is_exported": False, + "is_static": False, + } + + # -- containers --------------------------------------------------------- + + def _container_info(self, node: Node, source: bytes, ctx: _TypeContext): + """Return (bare, full_path, exported, default_public, is_type_decl, supers) + for a type/extension/protocol declaration, or None if unnamed. + + Generic parameters (``) are stripped from the identity: the nominal + declaration is `Box`, not `Box` (constraints are not part of identity). + For an `extension`, the target name comes from the `user_type` child and + the full dotted path is preserved as the bare leaf's qualifier; we do NOT + create a class unit for an extension (it augments an existing type). + """ + kw = None + for c in node.children: + if c.type in _CONTAINER_KEYWORDS or c.type == "protocol": + kw = c.type + break + is_extension = (kw == "extension") + is_type_decl = node.type == "protocol_declaration" or ( + kw in {"class", "struct", "actor", "enum"} + ) + + # Name: `type_identifier` direct child (class/struct/actor/enum/protocol) + # or the type_identifier(s) under the `user_type` child (extension, possibly + # qualified: `extension Foundation.Data`, `extension Outer.Inner.Baz`). The + # bare LEAF is the receiver-dispatch key; the FULL dotted path qualifies the + # member ids so `extension Outer.Inner` members read Outer.Inner.m (not Baz.m). + bare = None + ext_full = None + if is_extension: + ut = next((c for c in node.children if c.type == "user_type"), None) + if ut is not None: + tids = [g for g in ut.children if g.type == "type_identifier"] + if tids: + bare = self._text(tids[-1], source) + ext_full = ".".join(self._text(t, source) for t in tids) + else: + tid = next((c for c in node.children if c.type == "type_identifier"), None) + if tid is not None: + bare = self._text(tid, source) + if not bare: + return None + + vis, _static, attrs, has_modifiers = self._modifier_info(node, source) + type_public = vis in _VISIBILITY_EXPORTED + + if is_extension: + full_path = ext_full or bare + # An extension member is public API iff the TARGET type is public-in-repo + # OR external (e.g. `public extension String`) — never when the target is + # a repo type declared internal. The extension's own (usually absent) + # modifier does NOT make an explicitly-public member internal + #. + target_ok = (bare in getattr(self, "_public_types", ()) + or bare not in getattr(self, "_repo_types", ())) + exported = ctx.exported and target_ok + # Only `public extension` makes UNMODIFIED members public by default. + default_public = type_public + else: + full_path = f"{ctx.path}.{bare}" if ctx.path else bare + # A type with no explicit modifier is module-internal → not exported. + exported = ctx.exported and type_public + # class/struct/actor/enum members default to internal. + default_public = False + + supers = self._supertypes(node, source) + return bare, full_path, exported, default_public, is_type_decl, supers, attrs + + def _supertypes(self, node: Node, source: bytes) -> set: + """Bare names in the inheritance clause (superclass + conformed protocols). + + `class Impl: Base, Proto {...}` / `extension Foo: Bar {...}` → the + `inheritance_specifier` children each hold a `user_type > type_identifier`. + These feed the call-graph builder's superclass / protocol-default dispatch. + """ + supers: set = set() + for c in node.children: + if c.type == "inheritance_specifier": + tid = self._base_type_identifier(c, source) + if tid: + supers.add(tid) + return supers + + def _base_type_identifier(self, node: Node, source: bytes) -> Optional[str]: + """First `type_identifier` anywhere under ``node`` (breadth-first).""" + stack = [node] + while stack: + n = stack.pop(0) + if n.type == "type_identifier": + return self._text(n, source) + stack.extend(n.children) + return None + + def _collect_public_types(self) -> tuple: + """Return (public_type_names, all_repo_type_names) — bare names of every + type declaration in the repo, and the subset declared `public`/`open`. + Extensions do NOT count (only a real declaration sets a type's access). Used + to decide extension-member export: a member is public iff its target type is + public-in-repo OR external (not a repo type at all) — never when the target + is a repo type that is internal. One lightweight extra parse per file.""" + public: set = set() + all_types: set = set() + for file_info in self.scan_results.get("files", []): + try: + with open(self.repo_path / file_info["path"], "rb") as f: + src = f.read() + except OSError: + continue + try: + tree = self.parser.parse(src) + except Exception: + continue + stack = [tree.root_node] + while stack: + n = stack.pop() + if n.type == "protocol_declaration" or ( + n.type == "class_declaration" + and any(c.type in {"class", "struct", "actor", "enum"} for c in n.children) + ): + tid = next((c for c in n.children if c.type == "type_identifier"), None) + if tid is not None: + name = self._text(tid, src) + all_types.add(name) + vis, _s, _a, _h = self._modifier_info(n, src) + if vis in _VISIBILITY_EXPORTED: + public.add(name) + stack.extend(n.children) + return public, all_types + + # Framework protocols whose conforming types own a runtime-invoked execution + # root — seed those roots so a daemon/CLI/decode entry surface isn't pruned. + _COMMAND_PROTOS = {"ParsableCommand", "AsyncParsableCommand", "ParsableArguments"} + _APP_PROTOS = {"App", "Scene"} + _DECODE_PROTOS = {"Decodable", "Codable"} + + def _apply_framework_entry_points(self, functions: Dict[str, Any], + classes: Dict[str, Any], inheritance: Dict[str, set]) -> None: + """Re-classify runtime-invoked execution roots as `unit_type='main'`. + + Swift binaries frequently have NO literal `main`: an `@main` type's entry + member, a `ParsableCommand.run()` (ArgumentParser calls it), a SwiftUI + `App.body`, or `Codable init(from:)` decoding untrusted input are all + runtime-invoked. Without seeding these, whole daemons/CLIs/XPC decoders are + pruned (14/27 @main binaries + 924/1357 pccvre CLI units on the real + target). Conformances arrive via extensions across files, so this runs + after the whole-repo walk over the transitive conformance closure. + """ + direct = {k: set(v) for k, v in inheritance.items()} + closures: Dict[str, set] = {} + for t in direct: + seen: set = set() + stack = list(direct.get(t, ())) + while stack: + s = stack.pop() + if s in seen: + continue + seen.add(s) + stack.extend(direct.get(s, ())) + closures[t] = seen + main_types = {c["name"] for c in classes.values() if "@main" in (c.get("decorators") or [])} + for f in functions.values(): + cls = f.get("class_name") + if not cls: + continue + name = f.get("name") + supers = closures.get(cls, set()) | {cls} + if ((cls in main_types and name in ("main", "run", "callAsFunction", "body")) + or (name == "run" and (supers & self._COMMAND_PROTOS)) + or (name == "body" and (supers & self._APP_PROTOS)) + or (name == "init" and (supers & self._DECODE_PROTOS) + and "from" in (f.get("signature") or []))): + f["unit_type"] = "main" + + # -- functions ---------------------------------------------------------- + + def _emit_function(self, node: Node, source: bytes, file_path: str, + functions: Dict[str, Any], ctx: _TypeContext) -> None: + """Emit a func / init / deinit unit.""" + if node.type == "init_declaration": + name = "init" + elif node.type == "deinit_declaration": + name = "deinit" + else: + name = self._decl_name(node, source) + if not name: + return + + vis, is_static, attrs, has_modifiers = self._modifier_info(node, source) + params, labels, defaults = self._params_and_labels(node, source) + + if ctx.bare: + qualified_name = f"{ctx.path}.{name}" + class_name = ctx.bare + unit_type = "constructor" if name == "init" else "method" + else: + qualified_name = name + class_name = None + unit_type = "function" + # A `main` entry (top-level or `@main` type's `static func main`) is the + # program execution root — classify as 'main' so the reachability seeder + # recognises it (ENTRY_POINT_TYPES). Over-approximating main is safe. + if name == "main": + unit_type = "main" + + exported = self._member_exported(vis, ctx) + self._store(functions, file_path, qualified_name, class_name, unit_type, + name, labels, params, attrs, exported, is_static, node, source, + defaults=defaults, return_type=self._return_base_type(node, source)) + + def _return_base_type(self, node: Node, source: bytes) -> Optional[str]: + """Base nominal name of a function's `-> ReturnType`, or None. + + Conservative (used to type `let x = foo()` receivers): the node right after + `->` — a `user_type` yields its `type_identifier`; an `optional_type` is + unwrapped; collection/tuple/function/opaque/`some`/`any` types yield None so + we never type a receiver we can't resolve to a single nominal. `init` returns + its own type; `Self`-returning funcs yield None (contextual).""" + seen_arrow = False + for c in node.children: + if c.type == "->": + seen_arrow = True + continue + if seen_arrow and c.is_named: + return self._nominal_of(c, source) + return None + + def _nominal_of(self, tnode: Node, source: bytes) -> Optional[str]: + if tnode.type == "optional_type": + inner = next((c for c in tnode.children if c.type not in ("?", "!")), None) + return self._nominal_of(inner, source) if inner is not None else None + if tnode.type == "user_type": + tid = next((c for c in tnode.children if c.type == "type_identifier"), None) + return self._text(tid, source) if tid is not None else None + if tnode.type == "type_identifier": + return self._text(tnode, source) + return None + + def _emit_property_accessors(self, node: Node, source: bytes, file_path: str, + functions: Dict[str, Any], ctx: _TypeContext, + top_level: bool = False) -> None: + """Emit call-bearing accessor units for a property_declaration. + + Swift hides real execution behind properties: computed getters/setters, + willSet/didSet observers, and call-bearing lazy initializers. Their bodies + routinely contain security-relevant calls, so omitting them would prune + those callees as unreachable. Each accessor becomes a synthetic method + unit (schema-compatible unit_type 'method'). + """ + prop = self._pattern_name(node, source) + if not prop: + return + vis, is_static, attrs, _ = self._modifier_info(node, source) + exported = self._member_exported(vis, ctx) + base_q = f"{ctx.path}.{prop}" if ctx.path else prop + + # Accessors: computed get/set + willSet/didSet observers. No early break — + # a property can carry an initializer AND observers, and breaking after the + # initializer dropped the observer units. + for child in node.children: + if child.type == "computed_property": + self._emit_computed_accessors(child, source, file_path, functions, + ctx, base_q, attrs, exported, is_static) + elif child.type == "willset_didset_block": + for clause in child.children: + if clause.type == "willset_clause": + self._store_accessor(functions, file_path, f"{base_q}.willSet", + ctx, attrs, exported, is_static, clause, source) + elif clause.type == "didset_clause": + self._store_accessor(functions, file_path, f"{base_q}.didSet", + ctx, attrs, exported, is_static, clause, source) + + # Stored/lazy initializer: `static let shared = Manager()` (the singleton), + # `lazy var c = makeClient()`, stored closures `let h: T = {... }`, and + # try/await/ternary-wrapped initializers. Store ONLY the initializer RHS node + # — storing the whole property_declaration double-attributed a `didSet` body + # the observer unit already owns. + # also emit at FILE scope (ctx.bare is None) — a global stored + # closure/factory in an ordinary file (`Config.swift: let handler = { sink() }`) + # was previously NEVER extracted (the branch was gated in-type only), so its + # body's calls were invisible to the whole pipeline. Guard main.swift, whose + # top-level synthesis already captures its file-scope statements (avoid double). + is_main_file = Path(file_path).name.lower() == "main.swift" + # Emit for: an IN-TYPE property (ctx.bare set) OR a true FILE-SCOPE global + # (top_level, non-main). A LOCAL var inside a function body is neither + # (top_level False, ctx.bare None) → not emitted, so we don't fabricate a unit + # per local `let` (the over-emission the first draft caused: +5249 local + # vars on security-pcc). + if ctx.bare is not None or (top_level and not is_main_file): + rhs = self._eq_rhs(node) + if rhs is not None and self._is_call_bearing(rhs): + self._store_accessor(functions, file_path, base_q, ctx, attrs, + exported, is_static, rhs, source) + + def _eq_rhs(self, node: Node) -> Optional[Node]: + """The first named node after `=` in a property_declaration (the initializer).""" + seen_eq = False + for c in node.children: + if c.type == "=": + seen_eq = True + continue + if seen_eq and c.is_named: + return c + return None + + def _is_call_bearing(self, node: Node) -> bool: + """True if a `call_expression` or closure (`lambda_literal`) appears anywhere + under ``node`` — so a literal initializer (`= 0`) emits no spurious unit while + `= try makeClient()` / `= { work() }` / `= a ? f() : g()` do.""" + stack = [node] + while stack: + n = stack.pop() + if n.type in ("call_expression", "lambda_literal"): + return True + stack.extend(n.children) + return False + + def _emit_computed_accessors(self, comp: Node, source: bytes, file_path: str, + functions: Dict[str, Any], ctx: _TypeContext, + base_q: str, attrs, exported, is_static) -> None: + getters = [c for c in comp.children if c.type == "computed_getter"] + setters = [c for c in comp.children if c.type == "computed_setter"] + if not getters and not setters: + # Getter-only shorthand: `var x: Int { return f() }` — body is a bare + # `statements` under computed_property. Emit as the getter. + if any(c.type == "statements" for c in comp.children): + self._store_accessor(functions, file_path, base_q, ctx, attrs, + exported, is_static, comp, source) + return + for g in getters: + self._store_accessor(functions, file_path, base_q, ctx, attrs, + exported, is_static, g, source) + for s in setters: + self._store_accessor(functions, file_path, f"{base_q}.set", ctx, attrs, + exported, is_static, s, source) + + def _emit_subscript_accessors(self, node: Node, source: bytes, file_path: str, + functions: Dict[str, Any], ctx: _TypeContext) -> None: + vis, is_static, attrs, _ = self._modifier_info(node, source) + exported = self._member_exported(vis, ctx) + base_q = f"{ctx.path}.subscript" if ctx.path else "subscript" + comp = next((c for c in node.children if c.type == "computed_property"), None) + if comp is not None: + self._emit_computed_accessors(comp, source, file_path, functions, ctx, + base_q, attrs, exported, is_static) + + # -- storage / overload-safe ids --------------------------------------- + + def _store(self, functions, file_path, qualified_name, class_name, unit_type, + name, labels, params, attrs, exported, is_static, node, source, + defaults=None, return_type=None) -> None: + start_line = node.start_point[0] + 1 + end_line = node.end_point[0] + 1 + func_id = self._unique_id(functions, file_path, qualified_name, labels, start_line) + functions[func_id] = { + "name": name, + "qualified_name": qualified_name, + "file_path": file_path, + "start_line": start_line, + "end_line": end_line, + "code": self._text(node, source), + "class_name": class_name, + "module_name": None, + "parameters": params, + "unit_type": unit_type, + # Swift-specific metadata used by the call-graph resolver / reachability. + "signature": labels, # external argument labels (overload key) + "param_defaults": defaults if defaults is not None else [False] * len(labels), + "return_type": return_type, # base nominal of `-> T` (types `let x = f()` receivers) + "decorators": attrs, # @attributes (entry_point_detector reads these) + "is_exported": exported, + "is_static": is_static, + } + + def _store_accessor(self, functions, file_path, qualified_name, ctx, attrs, + exported, is_static, node, source) -> None: + """Store a synthetic accessor unit (getter/setter/observer/lazy-init).""" + start_line = node.start_point[0] + 1 + end_line = node.end_point[0] + 1 + func_id = self._unique_id(functions, file_path, qualified_name, [], start_line) + functions[func_id] = { + "name": qualified_name.rsplit(".", 1)[-1], + "qualified_name": qualified_name, + "file_path": file_path, + "start_line": start_line, + "end_line": end_line, + "code": self._text(node, source), + "class_name": ctx.bare, + "module_name": None, + "parameters": [], + "unit_type": "method", + "signature": [], + "param_defaults": [], + "decorators": attrs, + "is_exported": exported, + "is_static": is_static, + } + + def _unique_id(self, functions, file_path, qualified_name, labels, start_line) -> str: + """Build a collision-free func_id. + + Swift heavily overloads functions and initializers, so `file:qualified_name` + is NOT unique — a plain `functions[base] =...` would silently overwrite + earlier overloads/inits (dropping whole units and their edges). On a + collision, disambiguate by the external argument labels, then by start + line as a final tiebreak. The name / qualified_name indexes still collect + all overloads under the shared name, so a bare-name call resolves to the + bounded overload set (reachability-safe). + """ + base = f"{file_path}:{qualified_name}" + if base not in functions: + return base + sig = "(" + ",".join(labels) + ")" + cand = base + sig + if cand not in functions: + return cand + return f"{cand}#{start_line}" + + # -- node helpers ------------------------------------------------------- + + def _decl_name(self, node: Node, source: bytes) -> Optional[str]: + """First direct `simple_identifier` child = the declared function name. + + Parameters' identifiers live under `parameter` nodes (not direct + children), so scanning DIRECT children yields the function name, never a + parameter name. Operator functions (`func ==`) have no simple_identifier + and return None (operators are deferred; not emitted as units in v1). + """ + for c in node.children: + if c.type == "simple_identifier": + return self._text(c, source) + # Operator function (`func ==`, `prefix func !`, `func <>`): no + # simple_identifier — the operator token / `custom_operator` sits right + # after `func`, before `(`. Emit it so the operator BODY's out-edges (e.g. + # `constantTimeCompare(...)` in an Equatable `==`) are not lost (385 bodies + # on the real target). Operator USES aren't call_expression, so no in-edges. + after_func = False + for c in node.children: + if c.type == "func": + after_func = True + continue + if after_func: + if c.type == "(": + break + txt = self._text(c, source).strip() + if txt: + return txt + return None + + def _pattern_name(self, node: Node, source: bytes) -> Optional[str]: + """Property name from `pattern > simple_identifier`.""" + pat = next((c for c in node.children if c.type == "pattern"), None) + if pat is not None: + sid = next((g for g in pat.children if g.type == "simple_identifier"), None) + if sid is not None: + return self._text(sid, source) + return None + + def _modifier_info(self, node: Node, source: bytes) -> Tuple[Optional[str], bool, List[str], bool]: + """Return (visibility, is_static, attributes, has_modifiers_block). + + Reads the optional `modifiers` child plus any bare `class`/`static` + keyword children (tree-sitter-swift emits `class func` as a bare `class` + keyword child, `static func` as modifiers>property_modifier). + """ + vis = None + is_static = False + attrs: List[str] = [] + has_modifiers = False + # Bare `class`/`static` keyword before `func` marks a type method. + for c in node.children: + if c.type == "class": + is_static = True + elif c.type == "static": + is_static = True + mods = next((c for c in node.children if c.type == "modifiers"), None) + if mods is not None: + has_modifiers = True + for m in mods.children: + if m.type == "visibility_modifier": + vis = self._text(m, source).strip() + elif m.type == "property_modifier": + if self._text(m, source).strip() in ("static", "class"): + is_static = True + elif m.type == "attribute": + name = self._attribute_name(m, source) + if name: + attrs.append(name) + return vis, is_static, attrs, has_modifiers + + def _attribute_name(self, attr: Node, source: bytes) -> Optional[str]: + """`@main` / `@objc` / `@MainActor` → '@main' etc. (base name, args dropped).""" + ut = next((c for c in attr.children if c.type == "user_type"), None) + if ut is not None: + tid = next((g for g in ut.children if g.type == "type_identifier"), None) + if tid is not None: + return "@" + self._text(tid, source) + # Some attributes render the identifier directly. + tid = next((c for c in attr.children if c.type in ("type_identifier", "simple_identifier")), None) + if tid is not None: + return "@" + self._text(tid, source) + return None + + def _params_and_labels(self, node: Node, source: bytes): + """Return (param_internal_names, external_labels, has_default_flags). + + A `parameter` node is `[external_label]? internal_name : type`. When two + simple_identifiers are present the first is the external label (`_` means + the label is omitted at call sites); with one, it serves as both. A default + value shows up as `=` + expression SIBLINGS following the `parameter` node + (not inside it) — verified against tree-sitter-swift 0.7.3. The default flag + drives the call-site signature matcher: a defaulted param may be omitted at + the call, a required (non-default) named param may not. + """ + params: List[str] = [] + labels: List[str] = [] + defaults: List[bool] = [] + kids = node.children + for i, c in enumerate(kids): + if c.type != "parameter": + continue + idents = [g for g in c.children if g.type == "simple_identifier"] + if not idents: + continue + if len(idents) >= 2: + label = self._text(idents[0], source) + internal = self._text(idents[1], source) + else: + label = internal = self._text(idents[0], source) + # Look ahead: a `=` before the next parameter/`)` marks a default. + has_def = False + for j in range(i + 1, len(kids)): + t = kids[j].type + if t == "=": + has_def = True + break + if t in ("parameter", ")"): + break + params.append(internal) + labels.append(label) + defaults.append(has_def) + return params, labels, defaults + + def _member_exported(self, vis: Optional[str], ctx: _TypeContext) -> bool: + """A member is externally exported iff every enclosing type is public/open + AND the member is public/open (or inherits public default from a + `public extension`/`public` container). A public method in an internal + type is not part of the public API.""" + if not ctx.exported: + return False + if vis in _VISIBILITY_EXPORTED: + return True + if vis is None: # no explicit modifier → inherit container default + return ctx.default_public + return False # private / fileprivate / internal + + def _import_module(self, node: Node, source: bytes) -> Optional[str]: + """`import Foundation` → 'Foundation'. Swift imports are module-level, not + file paths — recorded for provenance only (resolution is module-wide).""" + for c in node.children: + if c.type in ("identifier", "simple_identifier", "type_identifier"): + return self._text(c, source) + # `import class Foo.Bar` etc.: take the last identifier-ish token. + txt = self._text(node, source).replace("import", "", 1).strip() + return txt.split()[-1].split(".")[0] if txt else None + + def _text(self, node: Node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") diff --git a/libs/openant-core/parsers/swift/repository_scanner.py b/libs/openant-core/parsers/swift/repository_scanner.py new file mode 100644 index 00000000..884112a8 --- /dev/null +++ b/libs/openant-core/parsers/swift/repository_scanner.py @@ -0,0 +1,173 @@ +""" +Stage 1: Repository Scanner for Swift + +Enumerates all Swift source files in a repository. +""" + +import os +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional + +from core.repo_walk import walk_repository +from utilities.file_io import write_json +from utilities.path_filters import should_exclude_directory + + +class RepositoryScanner: + """Scans a repository for Swift source files.""" + + # Directories to exclude from scanning. Beyond the shared VCS/dep dirs, this + # covers the Swift/Xcode build-output trees whose contents are generated, not + # source: SwiftPM's `.build`, Xcode `DerivedData`/`xcuserdata`, CocoaPods + # `Pods`, Carthage `Carthage`, and the `.swiftpm` metadata dir. + EXCLUDE_DIRS = { + ".git", + "vendor", + "node_modules", + "__pycache__", + ".venv", + "venv", + "build", + "dist", + "target", + # Swift / Xcode build + dependency output (generated, not source) + ".build", + ".swiftpm", + "DerivedData", + "xcuserdata", + "Pods", + "Carthage", + } + + # Native Swift test conventions. Directory names are matched as whole path + # segments; filenames are matched by anchored stem prefix/suffix. Matching a + # bare "test"/"spec" as a substring (the naive behaviour) would misclassify + # ordinary names like ``latest``/``contest``/``fastest.swift`` as tests. The + # dominant Swift/XCTest conventions are a ``Tests``/``UITests`` directory and a + # ``FooTests.swift`` / ``FooTest.swift`` / ``FooSpec.swift`` filename. + TEST_DIR_NAMES = {"test", "tests", "spec", "specs", "uitests", "unittests"} + TEST_FILE_PREFIXES = ("test_", "spec_") + # Underscore-anchored suffixes are matched case-INSENSITIVELY (the `_` is the + # word boundary). The dominant XCTest convention `FooTests.swift` has NO + # underscore, so it is matched case-SENSITIVELY on the CamelCase boundary: + # matching a bare lowercase "test.swift" would wrongly flag ``latest.swift`` + # (the exact 'anchoring beats substring' hazard). `Greatest.swift` (lowercase + # t) does not match `Test.swift`; `ContrastTest.swift` does. + TEST_FILE_SUFFIXES_CI = ("_test.swift", "_tests.swift", "_spec.swift", "_specs.swift") + TEST_FILE_SUFFIXES_CS = ("Tests.swift", "Test.swift", "Spec.swift", "Specs.swift") + + def __init__( + self, + repo_path: str, + skip_tests: bool = False, + exclude_patterns: Optional[List[str]] = None, + ): + self.repo_path = Path(repo_path).resolve() + self.skip_tests = skip_tests + self.exclude_patterns = exclude_patterns or [] + + def scan(self) -> Dict[str, Any]: + """ + Scan the repository for Swift files. + + Returns scan_results.json structure: + { + "repository": "/path/to/repo", + "scan_time": "2025-01-15T10:30:00", + "files": [{"path": "Sources/App/main.swift", "size": 1234}, ...], + "statistics": {...} + } + """ + files: List[Dict[str, Any]] = [] + stats: dict = {} + + # Directory pruning: shared VCS/dep dirs + Swift/Xcode build output + + # (optionally) test directories. Given a bare directory name. + def _should_exclude(name: str) -> bool: + return ( + name in self.EXCLUDE_DIRS + or self._matches_exclude_pattern(name) + or (self.skip_tests and self._is_test_directory(name)) + ) + + # Called per regular file with a forward-slash (POSIX) relative path, so + # unit IDs are stable across OSes. + def _on_file(entry, relative_path: str) -> None: + # Case-insensitive: filesystems (macOS/Windows) and users may spell + # the extension .SWIFT/.Swift; skipping those silently loses files. + if not entry.name.lower().endswith(".swift"): + return + if self.skip_tests and self._is_test_file(relative_path): + return + try: + size = entry.stat().st_size + except OSError: + size = 0 + files.append({"path": relative_path, "size": size}) + + # Traversal delegated to the shared core/repo_walk.py: an explicit + # iterator stack that records unreadable/too-deep subtrees in `stats` + # rather than silently dropping them (os.walk vanishes a path past + # PATH_MAX), and bounds symlink cycles. Matches the other parsers. + walk_repository( + self.repo_path, + should_exclude_directory=_should_exclude, + on_file=_on_file, + stats=stats, + ) + + total_size = sum(f["size"] for f in files) + + return { + "repository": str(self.repo_path), + "scan_time": datetime.now().isoformat(), + "files": files, + "statistics": { + "total_files": len(files), + "total_size_bytes": total_size, + "directories_scanned": stats.get("directories_scanned", 0), + "directories_excluded": stats.get("directories_excluded", 0), + "directories_unreadable": stats.get("directories_unreadable", 0), + }, + } + + def _matches_exclude_pattern(self, name: str) -> bool: + """Check if a name matches any exclude pattern (whole-segment match).""" + return should_exclude_directory(name, self.exclude_patterns) + + def _is_test_directory(self, dirname: str) -> bool: + """Check if a directory name indicates test code. + + Exact (whole-name) match so that ``latest``/``contest``/``attestation`` + are not misclassified as test directories. + """ + return dirname.lower() in self.TEST_DIR_NAMES + + def _is_test_file(self, filepath: str) -> bool: + """Check if a file path indicates test code. + + A file is a test iff one of its directory components is a test directory, + or its filename is anchored (stem prefix ``test_``/``spec_`` or suffix + ``Tests.swift``/``Test.swift``/``Spec.swift`` — matched case-insensitively). + Anchoring stops ordinary names like ``Sources/fastest.swift`` or + ``latest/main.swift`` from matching, while still catching the dominant + XCTest ``FooTests.swift`` convention (which has no underscore). + """ + p = Path(filepath) + # Directory components: whole-segment, case-insensitive. + if any(part.lower() in self.TEST_DIR_NAMES for part in p.parts[:-1]): + return True + name = p.name + name_lower = name.lower() + if name_lower.startswith(self.TEST_FILE_PREFIXES): + return True + if name_lower.endswith(self.TEST_FILE_SUFFIXES_CI): # underscore-anchored, CI + return True + if name.endswith(self.TEST_FILE_SUFFIXES_CS): # CamelCase XCTest, case-sensitive + return True + return False + + def save_results(self, output_path: str, results: Dict[str, Any]) -> None: + """Save scan results to a JSON file.""" + write_json(output_path, results) diff --git a/libs/openant-core/parsers/swift/test_pipeline.py b/libs/openant-core/parsers/swift/test_pipeline.py new file mode 100644 index 00000000..5c51dce3 --- /dev/null +++ b/libs/openant-core/parsers/swift/test_pipeline.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Swift Parser Pipeline Orchestrator + +Entry point for parsing Swift repositories. Wires together the 4-stage pipeline: +1. Repository Scanner +2. Function Extractor +3. Call Graph Builder +4. Unit Generator + +Usage: + python test_pipeline.py \ + --output \ + --processing-level \ + --skip-tests \ + --name +""" + +import argparse +import sys +from pathlib import Path + +# Put openant-core on sys.path BEFORE importing `utilities`/`parsers` — otherwise a +# direct `python parsers/swift/test_pipeline.py` invocation (the documented usage) +# crashes at import time. It only worked because parser_adapter runs this with +# cwd=openant-core, which masked the ordering bug (the Zig sibling has it too). +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from utilities.file_io import write_json # noqa: E402 +from utilities.prune_telemetry import compute_prune_telemetry # noqa: E402 +from parsers.swift.repository_scanner import RepositoryScanner # noqa: E402 +from parsers.swift.function_extractor import FunctionExtractor # noqa: E402 +from parsers.swift.call_graph_builder import CallGraphBuilder # noqa: E402 +from parsers.swift.unit_generator import UnitGenerator # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser( + description="Parse Swift repositories for vulnerability analysis" + ) + parser.add_argument("repo_path", help="Path to the Swift repository") + parser.add_argument("--output", "-o", required=True, help="Output directory for results") + parser.add_argument( + "--processing-level", + choices=["all", "reachable", "codeql", "exploitable"], + default="all", + help="Processing level for filtering functions", + ) + parser.add_argument("--skip-tests", action="store_true", help="Skip test files and functions") + parser.add_argument("--name", help="Dataset name (defaults to repo directory name)") + parser.add_argument( + "--library-mode", + action="store_true", + help="Seed the exported public API as entry points (for libraries/frameworks with no main/route/CLI)", + ) + parser.add_argument("--dependency-depth", type=int, default=3, help="Maximum depth for dependency resolution") + + args = parser.parse_args() + + repo_path = Path(args.repo_path).resolve() + output_dir = Path(args.output).resolve() + + if not repo_path.exists(): + print(f"Error: Repository path does not exist: {repo_path}", file=sys.stderr) + return 1 + + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[Swift Parser] Parsing repository: {repo_path}", file=sys.stderr) + print(f"[Swift Parser] Output directory: {output_dir}", file=sys.stderr) + print(f"[Swift Parser] Processing level: {args.processing_level}", file=sys.stderr) + print(f"[Swift Parser] Skip tests: {args.skip_tests}", file=sys.stderr) + + try: + # Stage 1: Repository Scanner + print("[Swift Parser] Stage 1: Scanning repository...", file=sys.stderr) + scanner = RepositoryScanner(str(repo_path), skip_tests=args.skip_tests) + scan_results = scanner.scan() + scanner.save_results(str(output_dir / "scan_results.json"), scan_results) + print(f" Found {scan_results['statistics']['total_files']} Swift files", file=sys.stderr) + + if scan_results["statistics"]["total_files"] == 0: + print("[Swift Parser] No Swift files found in repository", file=sys.stderr) + empty_dataset = { + "name": args.name or repo_path.name, + "repository": str(repo_path), + "units": [], + "statistics": {"total_units": 0, "by_type": {}}, + "metadata": {"generator": "swift_unit_generator.py"}, + } + write_json(output_dir / "dataset.json", empty_dataset) + write_json(output_dir / "analyzer_output.json", {"repository": str(repo_path), "functions": {}}) + return 0 + + # Stage 2: Function Extractor + print("[Swift Parser] Stage 2: Extracting functions...", file=sys.stderr) + extractor = FunctionExtractor(str(repo_path), scan_results) + extractor_output = extractor.extract() + print(f" Extracted {extractor_output['statistics']['total_functions']} functions", file=sys.stderr) + print(f" Extracted {extractor_output['statistics']['total_classes']} types", file=sys.stderr) + + # Stage 3: Call Graph Builder + print("[Swift Parser] Stage 3: Building call graph...", file=sys.stderr) + call_graph_builder = CallGraphBuilder(extractor_output) + call_graph_output = call_graph_builder.build() + call_graph_builder.save_results(str(output_dir / "call_graph.json"), call_graph_output) + stats = call_graph_output["statistics"] + print(f" Built graph with {stats['total_edges']} edges", file=sys.stderr) + _warn_under_connection(stats) + + # Apply processing level filters + if args.processing_level != "all": + call_graph_output = apply_processing_filter( + call_graph_output, args.processing_level, str(repo_path), + output_dir=str(output_dir), library_mode=args.library_mode, + ) + print(f" After {args.processing_level} filter: {len(call_graph_output['functions'])} functions", file=sys.stderr) + + # Stage 4: Unit Generator + print("[Swift Parser] Stage 4: Generating analysis units...", file=sys.stderr) + generator = UnitGenerator(call_graph_output, str(repo_path), dependency_depth=args.dependency_depth) + dataset, analyzer_output = generator.generate(name=args.name) + # B3: surface the reachability-filter record (prune telemetry + invariant) in + # dataset metadata — parity with js/go/c/ruby/php, which all write this block. + _rf = call_graph_output.get("_reachability_filter") + if _rf is not None: + dataset.setdefault("metadata", {})["reachability_filter"] = _rf + generator.save_results(str(output_dir), dataset, analyzer_output) + print(f" Generated {dataset['statistics']['total_units']} units", file=sys.stderr) + + print("[Swift Parser] Pipeline complete!", file=sys.stderr) + return 0 + + except Exception as e: + print(f"[Swift Parser] Error: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return 1 + + +def _warn_under_connection(stats: dict) -> None: + """Advisory silent-under-connection guard (Sol: "N4 is not enough"). + + The repo-wide keep-all net only fires on a ZERO entry-point seed. A call + graph that extracted many functions but resolved almost no edges (e.g. an AST + node-name mismatch broke call extraction) would still have a valid seed, so + reachability keeps only the seed + its ~nothing and silently prunes the repo. + Surface that shape loudly; it never changes results. + """ + funcs = stats.get("total_functions", 0) + call_sites = stats.get("total_call_sites", 0) + resolved = stats.get("resolved_edges", 0) + isolated_ratio = stats.get("isolated_ratio", 0) + reparse_errors = stats.get("reparse_error_bodies", 0) + if reparse_errors: + print(f" [Warning] {reparse_errors} unit bodies reparsed with syntax errors " + f"during call extraction (some out-edges may be missing).", file=sys.stderr) + if funcs >= 50 and call_sites >= funcs and resolved == 0: + print(f" [Warning] {funcs} functions with {call_sites} call sites resolved " + f"ZERO edges — call resolution looks broken (silent under-connection). " + f"Reachability will prune almost everything.", file=sys.stderr) + elif funcs >= 100 and isolated_ratio >= 0.9: + print(f" [Warning] {isolated_ratio*100:.0f}% of functions are isolated (no " + f"caller/callee) — call resolution may be under-connecting.", file=sys.stderr) + + +def apply_processing_filter(call_graph_output: dict, level: str, repo_path: str, + output_dir: str = None, library_mode: bool = False) -> dict: + if level in ("reachable", "codeql", "exploitable"): + return apply_reachability_filter(call_graph_output, repo_path, + output_dir=output_dir, library_mode=library_mode) + return call_graph_output + + +def apply_reachability_filter(call_graph_output: dict, repo_path: str, + output_dir: str = None, library_mode: bool = False) -> dict: + """Filter to functions reachable from entry points. + + Uses the real EntryPointDetector / ReachabilityAnalyzer contract, matching + core/parser_adapter.apply_reachability_filter and the C/Go/PHP/Ruby/Zig + sibling pipelines, incl. the N4 empty-seed keep-all net. + + B3: when ``output_dir`` is provided, emits the per-unit prune telemetry + + forward-asymmetry invariant + pruned_units.json sidecar via the SHARED helper + (utilities.prune_telemetry.compute_prune_telemetry), and stashes the reachability-filter + record on ``result["_reachability_filter"]`` for main() to merge into dataset + metadata. ADDITIVE only — never changes which functions survive. ``output_dir`` is + optional so the existing 3-arg callers (tests/parsers/swift/test_empty_seed_keep_all) + keep working; without it the sidecar is skipped but the record is still stashed. + """ + try: + from utilities.agentic_enhancer.entry_point_detector import ( + EntryPointDetector, blackout_warning, library_seed_ids, + ) + from utilities.agentic_enhancer.reachability_analyzer import ReachabilityAnalyzer + except ImportError: + print(" Warning: Reachability analyzer not available, skipping filter", file=sys.stderr) + return call_graph_output + + functions = call_graph_output.get("functions", {}) + call_graph = call_graph_output.get("call_graph", {}) + reverse_call_graph = call_graph_output.get("reverse_call_graph", {}) + + detector = EntryPointDetector(functions, call_graph) + entry_points = detector.detect_entry_points() + + if library_mode: + entry_points = entry_points | library_seed_ids(functions) + + analyzer = ReachabilityAnalyzer( + functions=functions, + reverse_call_graph=reverse_call_graph, + entry_points=entry_points, + ) + reachable = analyzer.get_all_reachable() + + # N4: empty-seed safety-net — no entry points => keep all + warn, never a + # silent 0-unit blackout (the dominant failure for library/framework targets). + if not entry_points and functions: + print(" [Warning] No entry points detected — keeping all units unfiltered " + "to avoid a silent blackout.", file=sys.stderr) + reachable = set(functions.keys()) + + filtered_functions = {fid: finfo for fid, finfo in functions.items() if fid in reachable} + result = call_graph_output.copy() + result["functions"] = filtered_functions + result["call_graph"] = { + k: [v for v in vs if v in reachable] + for k, vs in call_graph.items() if k in reachable + } + result["reverse_call_graph"] = { + k: [v for v in vs if v in reachable] + for k, vs in reverse_call_graph.items() if k in reachable + } + + _blackout = blackout_warning(detector.entry_point_details, len(functions), + len(filtered_functions), library_mode=library_mode) + if _blackout: + print(f" [Warning] {_blackout}", file=sys.stderr) + + # B3: per-unit prune telemetry via the shared utilities.prune_telemetry helper. Best-effort — a telemetry + # failure must NEVER fail a Swift scan (main()'s bare except turns any raise into rc=1). + try: + original_count = len(functions) + reduction_pct = (round((1 - len(filtered_functions) / original_count) * 100, 1) + if original_count > 0 else 0) + rf = { + "original_units": original_count, + "entry_points": len(entry_points), + "reachable_units": len(filtered_functions), + "filtered_out": original_count - len(filtered_functions), + "reduction_percentage": reduction_pct, + } + if not entry_points: + # N4 empty-seed keep-all: mirror core's REDUCED schema (no pruned_* keys, no + # sidecar) — reachable filtering was not really applied. Contract pinned for + # core by test_reachability_prune_telemetry::test_empty_entrypoints_passthrough. + # Match core's int 0 exactly (core hardcodes 0 on this branch, not round()=0.0). + rf["reduction_percentage"] = 0 + rf["warning"] = ("No entry points detected — kept all units unfiltered to " + "avoid a silent blackout; reachable filtering was NOT applied.") + else: + # Feed the UN-pruned graphs (`call_graph`/`reverse_call_graph`), NOT + # `result[...]`: a pruned graph forces the asymmetry invariant to a + # manufactured 0. Highest-risk line in this change. + pruned_ids = sorted(set(functions) - set(filtered_functions)) + _extra, _asym_warning = compute_prune_telemetry( + reachable, pruned_ids, call_graph, reverse_call_graph, output_dir) + rf.update(_extra) + if _asym_warning: + rf["warning"] = _asym_warning + if _blackout: # blackout warning takes precedence (core parity) + rf["warning"] = _blackout + result["_reachability_filter"] = rf + except Exception as _e: # telemetry is advisory; never break the scan + print(f" [Warning] prune telemetry skipped: {_e}", file=sys.stderr) + + return result + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/openant-core/parsers/swift/unit_generator.py b/libs/openant-core/parsers/swift/unit_generator.py new file mode 100644 index 00000000..2c053438 --- /dev/null +++ b/libs/openant-core/parsers/swift/unit_generator.py @@ -0,0 +1,226 @@ +""" +Stage 4: Unit Generator for Swift + +Creates self-contained analysis units with dependency context (parity with the +other parsers' unit generators). +""" + +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, List, Optional, Set + +from utilities.file_io import write_json + + +class UnitGenerator: + """Generates analysis units from call graph data.""" + + # File boundary marker using Swift comment syntax + FILE_BOUNDARY = "\n\n// ========== File Boundary ==========\n\n" + + def __init__( + self, + call_graph_output: Dict[str, Any], + repo_path: str, + dependency_depth: int = 3, + ): + self.functions = call_graph_output.get("functions", {}) + self.classes = call_graph_output.get("classes", {}) + self.call_graph = call_graph_output.get("call_graph", {}) + self.reverse_call_graph = call_graph_output.get("reverse_call_graph", {}) + self.repository = repo_path + self.dependency_depth = dependency_depth + + def generate(self, name: Optional[str] = None) -> tuple[Dict[str, Any], Dict[str, Any]]: + """Generate (dataset.json, analyzer_output.json).""" + units = [] + dataset_name = name or Path(self.repository).name + + for func_id, func_info in self.functions.items(): + units.append(self._generate_unit(func_id, func_info)) + + by_type: Dict[str, int] = {} + units_with_upstream = 0 + units_with_downstream = 0 + total_upstream = 0 + total_downstream = 0 + for unit in units: + by_type[unit["unit_type"]] = by_type.get(unit["unit_type"], 0) + 1 + dep_meta = unit["code"]["dependency_metadata"] + if dep_meta["total_upstream"] > 0: + units_with_upstream += 1 + total_upstream += dep_meta["total_upstream"] + if dep_meta["total_downstream"] > 0: + units_with_downstream += 1 + total_downstream += dep_meta["total_downstream"] + + avg_upstream = total_upstream / len(units) if units else 0 + avg_downstream = total_downstream / len(units) if units else 0 + + dataset = { + "name": dataset_name, + "repository": self.repository, + "units": units, + "statistics": { + "total_units": len(units), + "by_type": by_type, + "units_with_upstream": units_with_upstream, + "units_with_downstream": units_with_downstream, + "units_enhanced": len([u for u in units if u["code"]["primary_origin"]["deps_inlined"]]), + "avg_upstream": round(avg_upstream, 2), + "avg_downstream": round(avg_downstream, 2), + }, + "metadata": { + "generator": "swift_unit_generator.py", + "generated_at": datetime.now().isoformat(), + "dependency_depth": self.dependency_depth, + }, + } + + analyzer_output = { + "repository": self.repository, + "functions": { + func_id: { + "name": func_info["name"], + "unitType": func_info["unit_type"], + "code": func_info["code"], + "filePath": func_info["file_path"], + "startLine": func_info["start_line"], + "endLine": func_info["end_line"], + "isExported": self._is_exported(func_info), + "parameters": func_info.get("parameters", []), + "className": func_info.get("class_name"), + } + for func_id, func_info in self.functions.items() + }, + "call_graph": self.call_graph, + "reverse_call_graph": self.reverse_call_graph, + } + + return dataset, analyzer_output + + def _generate_unit(self, func_id: str, func_info: Dict[str, Any]) -> Dict[str, Any]: + upstream = self._get_dependencies(func_id, self.call_graph, self.dependency_depth) + downstream = self._get_dependencies(func_id, self.reverse_call_graph, self.dependency_depth) + + direct_calls = self.call_graph.get(func_id, []) + direct_callers = self.reverse_call_graph.get(func_id, []) + + primary_code, files_included = self._build_enhanced_code(func_id, func_info, upstream) + original_length = len(func_info.get("code", "")) + enhanced_length = len(primary_code) + + return { + "id": func_id, + "unit_type": func_info["unit_type"], + "code": { + "primary_code": primary_code, + "primary_origin": { + "file_path": func_info["file_path"], + "start_line": func_info["start_line"], + "end_line": func_info["end_line"], + "function_name": func_info["name"], + "class_name": func_info.get("class_name"), + "deps_inlined": len(upstream) > 0, + "files_included": files_included, + "original_length": original_length, + "enhanced_length": enhanced_length, + }, + "dependencies": [], + "dependency_metadata": { + "depth": self.dependency_depth, + "total_upstream": len(upstream), + "total_downstream": len(downstream), + "direct_calls": len(direct_calls), + "direct_callers": len(direct_callers), + }, + }, + "ground_truth": { + "status": "UNKNOWN", + "vulnerability_types": [], + "issues": [], + "annotation_source": None, + "annotation_key": None, + "notes": None, + }, + "metadata": { + "parameters": func_info.get("parameters", []), + "generator": "swift_unit_generator.py", + "direct_calls": direct_calls, + "direct_callers": direct_callers, + # Swift-specific fields the extractor produces — thread them through + # so they don't silently drop between producer and unit (PR-138 + # field-drift family; guarded by the schema-completeness test). + "decorators": func_info.get("decorators", []), + "is_exported": func_info.get("is_exported", False), + "is_static": func_info.get("is_static", False), + "signature": func_info.get("signature", []), + }, + } + + def _get_dependencies(self, func_id: str, graph: Dict[str, List[str]], max_depth: int) -> Set[str]: + dependencies: Set[str] = set() + current_level = {func_id} + for _ in range(max_depth): + next_level: Set[str] = set() + for fid in current_level: + for dep in graph.get(fid, []): + if dep not in dependencies and dep != func_id: + dependencies.add(dep) + next_level.add(dep) + current_level = next_level + if not current_level: + break + return dependencies + + def _build_enhanced_code(self, func_id: str, func_info: Dict[str, Any], upstream: Set[str]) -> tuple[str, List[str]]: + primary_code = func_info.get("code", "") + files_included = [func_info["file_path"]] + if not upstream: + return primary_code, files_included + + # Sort the dependency set before assembling — `upstream` is a set, and its + # iteration order would otherwise vary with PYTHONHASHSEED, making the + # `primary_code` sent to the LLM (and thus dataset.json) non-deterministic. + deps_by_file: Dict[str, List[str]] = {} + for dep_id in sorted(upstream): + dep_info = self.functions.get(dep_id) + if dep_info: + deps_by_file.setdefault(dep_info["file_path"], []).append(dep_id) + + code_parts = [primary_code] + for file_path, dep_ids in sorted(deps_by_file.items()): + if file_path == func_info["file_path"]: + # Same-file deps STILL need a boundary so the consumer's + # split_on_boundary keeps them out of the ">>> ANALYZE THIS FUNCTION + # ONLY <<<" target block (part[0]); without it a same-file dependency's + # body was analyzed as if it were the target. Matches the Python + # reference parser, which boundaries every dependency. + same_file_code = [] + for dep_id in dep_ids: + dep_info = self.functions.get(dep_id) + if dep_info: + same_file_code.append(dep_info.get("code", "")) + if same_file_code: + code_parts.append(self.FILE_BOUNDARY + "\n".join(same_file_code)) + else: + if file_path not in files_included: + files_included.append(file_path) + file_code = [self.functions[d].get("code", "") for d in dep_ids if d in self.functions] + if file_code: + code_parts.append(self.FILE_BOUNDARY + "\n".join(file_code)) + + return "\n\n".join(code_parts), files_included + + def _is_exported(self, func_info: Dict[str, Any]) -> bool: + """Read the extractor's structured `is_exported` (public/open, honouring + the enclosing type's access). NO Swift-source text heuristic: a + `startswith("public ")` fallback would misfire on `@available(...) public + func` and on extension-inherited visibility.""" + return bool(func_info.get("is_exported", False)) + + def save_results(self, output_dir: str, dataset: Dict[str, Any], analyzer_output: Dict[str, Any]) -> None: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + write_json(output_path / "dataset.json", dataset) + write_json(output_path / "analyzer_output.json", analyzer_output) diff --git a/libs/openant-core/pyproject.toml b/libs/openant-core/pyproject.toml index b573b316..9f33660f 100644 --- a/libs/openant-core/pyproject.toml +++ b/libs/openant-core/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "tree-sitter-ruby>=0.21.0", "tree-sitter-php>=0.22.0", "tree-sitter-zig>=1.1.2", + "tree-sitter-swift>=0.7.3", ] [project.optional-dependencies] diff --git a/libs/openant-core/requirements.txt b/libs/openant-core/requirements.txt index 9d0d9b66..59d7a246 100644 --- a/libs/openant-core/requirements.txt +++ b/libs/openant-core/requirements.txt @@ -25,3 +25,4 @@ tree-sitter-cpp>=0.21.0 tree-sitter-ruby>=0.21.0 tree-sitter-php>=0.22.0 tree-sitter-zig>=1.1.2 +tree-sitter-swift>=0.7.3 diff --git a/libs/openant-core/tests/parsers/swift/__init__.py b/libs/openant-core/tests/parsers/swift/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/openant-core/tests/parsers/swift/_helpers.py b/libs/openant-core/tests/parsers/swift/_helpers.py new file mode 100644 index 00000000..21fd0d38 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/_helpers.py @@ -0,0 +1,56 @@ +"""Shared loaders for the Swift-parser tests. + +The parser stage modules share bare names across languages +(`function_extractor.py`, `call_graph_builder.py`, ...). Load the Swift ones by +file path under unique module names so they never collide with the sibling +C/Zig/Python extractors in ``sys.modules`` (same isolation pattern the Zig tests +use). Not collected by pytest (no ``test_`` prefix).""" + +import importlib.util +import pathlib + +_CORE = pathlib.Path(__file__).resolve().parents[3] + + +def _load(relpath: str, uniqname: str): + spec = importlib.util.spec_from_file_location(uniqname, _CORE / relpath) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +RepositoryScanner = _load("parsers/swift/repository_scanner.py", "swift_scanner_iso").RepositoryScanner +FunctionExtractor = _load("parsers/swift/function_extractor.py", "swift_fe_iso").FunctionExtractor +CallGraphBuilder = _load("parsers/swift/call_graph_builder.py", "swift_cgb_iso").CallGraphBuilder +UnitGenerator = _load("parsers/swift/unit_generator.py", "swift_ug_iso").UnitGenerator + + +def extract(tmp_path, files: dict, skip_tests: bool = True) -> dict: + """Write ``files`` (relpath -> source) under tmp_path, run scanner+extractor.""" + for name, src in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(src) + scan = RepositoryScanner(str(tmp_path), skip_tests=skip_tests).scan() + return FunctionExtractor(str(tmp_path), scan).extract() + + +def build(tmp_path, files: dict, skip_tests: bool = True): + """Full extract -> call graph. Returns (extractor_output, callgraph_output).""" + ext = extract(tmp_path, files, skip_tests=skip_tests) + cg = CallGraphBuilder(ext).build() + return ext, cg + + +def leaf(func_id: str) -> str: + """Drop the ``file:`` prefix from a func_id for readable assertions.""" + return func_id.split(":", 1)[1] if ":" in func_id else func_id + + +def edges(cg: dict): + """Set of (caller_leaf, callee_leaf) edges.""" + out = set() + for caller, callees in cg["call_graph"].items(): + for c in callees: + out.add((leaf(caller), leaf(c))) + return out diff --git a/libs/openant-core/tests/parsers/swift/conftest.py b/libs/openant-core/tests/parsers/swift/conftest.py new file mode 100644 index 00000000..1dff2a42 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/conftest.py @@ -0,0 +1,13 @@ +"""Skip the Swift parser tests (don't ERROR) on a runner without tree-sitter-swift. + +Every test here loads the Swift parser stages, which `import tree_sitter` / +`tree_sitter_swift`. Without the wheel the module import raises at COLLECTION time, +which pytest reports as an error that fails the whole job — on the CI matrix +(macos/windows) that would take down the Python suite. Guard it the same way the +zig/javascript parser tests guard their native grammars (importorskip precedent), +but once for the whole directory via collect_ignore_glob. +""" +import importlib.util + +if importlib.util.find_spec("tree_sitter_swift") is None: + collect_ignore_glob = ["test_*.py"] diff --git a/libs/openant-core/tests/parsers/swift/test_empty_seed_keep_all.py b/libs/openant-core/tests/parsers/swift/test_empty_seed_keep_all.py new file mode 100644 index 00000000..8ab8f687 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_empty_seed_keep_all.py @@ -0,0 +1,47 @@ +"""N4 empty-seed keep-all guard for the Swift pipeline (the guard all 6 sibling +parsers ship — PRs 154-159). A library/framework target with no structural entry +point must degrade to keep-all + warn, NEVER a silent 0-unit blackout.""" + +import importlib.util +import pathlib +import sys + +_HERE = pathlib.Path(__file__).resolve().parent +_CORE = _HERE.parents[2] +sys.path.insert(0, str(_HERE)) +sys.path.insert(0, str(_CORE)) +from _helpers import build # noqa: E402 + + +def _load_pipeline(): + spec = importlib.util.spec_from_file_location( + "swift_pipeline_iso", _CORE / "parsers" / "swift" / "test_pipeline.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_no_entry_points_keeps_all_units(tmp_path): + # Pure library: ordinary functions, no main / route / @objc / input pattern. + _, cg = build(tmp_path, {"lib.swift": """ + func a() { b() } + func b() { c() } + func c() {} + """}) + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cg, str(tmp_path), library_mode=False) + # N4: with zero seedable entry points, every unit is kept (not a silent blackout). + assert len(result["functions"]) == len(cg["functions"]) == 3 + + +def test_library_mode_seeds_public_surface(tmp_path): + _, cg = build(tmp_path, {"lib.swift": """ + public func exposed() { helper() } + func helper() {} + func unreached() {} + """}) + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cg, str(tmp_path), library_mode=True) + kept = {fid.split(":", 1)[1] for fid in result["functions"]} + assert "exposed" in kept and "helper" in kept # public seed + its callee + assert "unreached" not in kept # not reachable from the public API diff --git a/libs/openant-core/tests/parsers/swift/test_swift_call_graph.py b/libs/openant-core/tests/parsers/swift/test_swift_call_graph.py new file mode 100644 index 00000000..e8e4c182 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_call_graph.py @@ -0,0 +1,216 @@ +"""Swift call-graph resolution + regression tests (Sol/Fable review items).""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_constructor_call_resolves_to_init(tmp_path): + """`Point(...)` (callee text `Point`) must resolve to `Point.init` (Fable F1).""" + _, cg = build(tmp_path, {"P.swift": """ + struct Point { init(x: Int) { seed() } } + func make() { let p = Point(x: 1) } + """}) + assert ("make", "Point.init") in edges(cg) + + +def test_self_dispatch_uses_enclosing_type(tmp_path): + _, cg = build(tmp_path, {"S.swift": """ + class S { func a() { self.b() } func b() {} } + """}) + assert ("S.a", "S.b") in edges(cg) + + +def test_superclass_method_resolves_via_inheritance(tmp_path): + """A bare/inherited call resolves to a method on the superclass (Fable F3).""" + _, cg = build(tmp_path, {"H.swift": """ + class Base { func base() {} } + class Sub: Base { func run() { base() } } + """}) + assert ("Sub.run", "Base.base") in edges(cg) + + +def test_protocol_extension_default_dispatch(tmp_path): + """A conformer's call resolves to a protocol-extension default impl (Fable F3).""" + _, cg = build(tmp_path, {"P.swift": """ + protocol Greeter { func hello() } + extension Greeter { func hello() { defaultHello() } } + func defaultHello() {} + struct Impl: Greeter { + func use() { let g: Greeter = self; g.hello() } + } + """}) + assert ("Impl.use", "Greeter.hello") in edges(cg) + + +def test_typed_local_member_dispatch(tmp_path): + _, cg = build(tmp_path, {"T.swift": """ + struct A { func go() {} } + struct B { func go() {} } + func caller() { let a = A(); a.go() } + """}) + e = edges(cg) + assert ("caller", "A.go") in e + assert ("caller", "B.go") not in e, "must not over-connect to B.go" + + +def test_subscript_access_is_not_a_call(tmp_path): + """`items[i]` must NOT create an edge to a function named `items` (Fable F9).""" + _, cg = build(tmp_path, {"S.swift": """ + func items() {} + func read() { let xs = [1,2,3]; let y = xs[0] } + """}) + assert ("read", "items") not in edges(cg) + + +def test_deinit_snippet_no_phantom_self_edge(tmp_path): + """Standalone `deinit {}` reparses as a call to `deinit`; must be dropped so + deinit units don't gain phantom deinit->deinit edges (Fable F11).""" + _, cg = build(tmp_path, {"D.swift": """ + class A { deinit { cleanup() } } + class B { deinit { teardown() } } + func cleanup() {} + func teardown() {} + """}) + e = edges(cg) + assert ("A.deinit", "cleanup") in e # real call kept + # no deinit -> deinit phantom + assert not any(caller.endswith("deinit") and callee.endswith("deinit") for caller, callee in e) + + +def test_function_reference_argument_edge(tmp_path): + """A known function passed as an argument is a callback target (Fable F13).""" + _, cg = build(tmp_path, {"C.swift": """ + func handler() {} + func register(_ cb: () -> Void) {} + func setup() { register(handler) } + """}) + assert ("setup", "handler") in edges(cg) + + +def test_optional_typed_receiver_dispatches(tmp_path): + """`let x: T?` must still type-dispatch `x.m()` (unwrap optional_type, Fable F5).""" + _, cg = build(tmp_path, {"O.swift": """ + struct T { func m() {} } + func f() { let x: T? = nil; x?.m() } + """}) + assert ("f", "T.m") in edges(cg) + + +def test_array_typed_var_not_bound_to_element(tmp_path): + """`let a: [Foo]` must NOT bind `a` to Foo, so `a.append()` does not dispatch + to a Foo member named append (Fable F5).""" + _, cg = build(tmp_path, {"A.swift": """ + struct Foo { func append() {} } + func f() { let a: [Foo] = []; a.append() } + """}) + assert ("f", "Foo.append") not in edges(cg) + + +def test_trailing_closure_body_calls_attributed_to_caller(tmp_path): + """Calls inside a trailing closure are attributed to the enclosing unit.""" + _, cg = build(tmp_path, {"T.swift": """ + func validate() {} + func run() { doWork { validate() } } + func doWork(_ f: () -> Void) {} + """}) + e = edges(cg) + assert ("run", "validate") in e + assert ("run", "doWork") in e + + +def test_ambiguous_bare_call_bounded_fanout(tmp_path): + """A bare call ambiguous across <=3 unrelated types fans out (recall); a large + fan-out is dropped (Fable F17 / namespace-leak guard).""" + # Candidates are CROSS-FILE (so the same-file tier does not short-circuit) + # and the caller is top-level (no enclosing type). 3 unrelated `handle` -> + # bounded fan-out keeps all 3. + _, cg3 = build(tmp_path / "r3", { + "a.swift": "struct A { func handle() {} }", + "b.swift": "struct B { func handle() {} }", + "c.swift": "struct C { func handle() {} }", + "d.swift": "func dispatch() { handle() }", + }) + handled = {callee for caller, callee in edges(cg3) if caller == "dispatch"} + assert handled == {"A.handle", "B.handle", "C.handle"} + + # 4 unrelated `handle` -> gross fan-out dropped. + _, cg4 = build(tmp_path / "r4", { + "a.swift": "struct A { func handle() {} }", + "b.swift": "struct B { func handle() {} }", + "c.swift": "struct C { func handle() {} }", + "e.swift": "struct D { func handle() {} }", + "d.swift": "func dispatch() { handle() }", + }) + handled4 = {callee for caller, callee in edges(cg4) if caller == "dispatch"} + assert handled4 == set(), "gross fan-out must drop to avoid namespace leak" + + +def test_cross_file_builtin_named_method_not_dropped(tmp_path): + """SW-1 regression: a bare implicit-`self` call to a user method whose name + collides with a Swift builtin (`filter`) but is declared in a SIBLING file + (idiomatic type-split-across-extensions) must NOT be silently dropped — else + the whole subtree behind it goes unreachable and unanalyzed (silent FN).""" + _, cg = build(tmp_path, { + "A.swift": "class Repo { func fetch() { filter() } }", + "B.swift": "extension Repo { func filter() { dangerousSink() } func dangerousSink() {} }", + }) + e = edges(cg) + assert ("Repo.fetch", "Repo.filter") in e, "cross-file builtin-named self call dropped" + assert ("Repo.filter", "Repo.dangerousSink") in e, "subtree must stay reachable" + + +def test_free_function_builtin_shadow_does_not_over_connect(tmp_path): + """R2D-2: the SW-1 builtin-bypass must cover METHODS only. A bare stdlib call + (`max`) must NOT fabricate an edge just because the repo declares a FREE function + of the same name in another file — that would phantom-edge every stdlib call.""" + _, cg = build(tmp_path, { + "A.swift": "func process() { let m = max(1, 2) }", + "B.swift": "func max(_ x: Int, _ y: Int) -> Int { return x }", + }) + callees = {callee for caller, callee in edges(cg) if caller == "process"} + assert "max" not in callees and "B.max" not in callees, "stdlib max() phantom-edged to free fn" + + +def test_builtin_named_method_does_not_phantom_global_call(tmp_path): + """R3A-3: a repo METHOD named after a bare-callable stdlib GLOBAL (`print`) must NOT + draw a phantom edge from every bare `print()` call — only collection-method builtins + (filter/map/...) get the SW-1 implicit-self bypass, not the bare globals.""" + _, cg = build(tmp_path, { + "A.swift": 'func work() { print("hi") }', + "B.swift": "class Logger { func print(_ s: String) {} }", + }) + callees = {callee for caller, callee in edges(cg) if caller == "work"} + assert "Logger.print" not in callees and "print" not in callees, "bare print() phantom-edged" + + +def test_actor_methods_extracted_and_call_graphed(tmp_path): + """`actor` is a first-class container keyword in the extractor but had no fixture. + An actor's methods must be extracted and their intra-actor call edges built (Swift + concurrency is common; a missed actor method = a silent unanalyzed unit).""" + _, cg = build(tmp_path, {"A.swift": """ + actor BankAccount { + var balance = 0 + func deposit(_ n: Int) { record(n) } + func record(_ n: Int) { balance += n } + } + """}) + e = edges(cg) + assert ("BankAccount.deposit", "BankAccount.record") in e, "actor-isolated method call edge missing" + + +def test_same_file_dependency_is_boundaried_out_of_target(tmp_path): + """R3B-2: a same-file dependency's body must sit AFTER the FILE_BOUNDARY (context), + not inside the target's 'ANALYZE THIS FUNCTION ONLY' block (split_on_boundary part[0]).""" + from _helpers import extract, CallGraphBuilder, UnitGenerator + ext = extract(tmp_path, { + "A.swift": "func target(){ helper() }\nfunc helper(){ danger() }\nfunc danger(){}\n"}) + cg = CallGraphBuilder(ext).build() + dataset, _ = UnitGenerator(cg, str(tmp_path)).generate(name="s") + target = [u for u in dataset["units"] if "target" in u["id"]][0] + primary = target["code"]["primary_code"] + target_block = primary.split("File Boundary")[0] + assert "func helper" not in target_block, "same-file dep leaked into the target block" + assert "File Boundary" in primary diff --git a/libs/openant-core/tests/parsers/swift/test_swift_callgraph_symmetry.py b/libs/openant-core/tests/parsers/swift/test_swift_callgraph_symmetry.py new file mode 100644 index 00000000..c2a54e5b --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_callgraph_symmetry.py @@ -0,0 +1,65 @@ +"""Y-test (per-parser call-graph symmetry) for Swift. + +Stronger than the minimal keys-subset check: asserts exact BIDIRECTIONAL +consistency (Sol §"Tests are too weak") — B in call_graph[A] iff A in +reverse_call_graph[B] — plus every graph key is a real function id. A parser that +populates one direction but not the other silently corrupts reachability (which +BFS-walks the reverse graph).""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build # noqa: E402 + +_REPO = { + "A.swift": """ + protocol P { func req() } + extension P { func req() { helper() } } + func helper() {} + class Base { func base() {} } + public class Impl: Base, P { + public init() {} + public func req() { self.base() } + func run() { let x = Impl(); x.req() } + var status: Int { return compute() } + private func compute() -> Int { return 0 } + } + """, + "main.swift": """ + let app = Impl() + app.run() + """, +} + + +def test_callgraph_keys_are_functions(tmp_path): + _, cg = build(tmp_path, _REPO) + fids = set(cg["functions"].keys()) + for k, callees in cg["call_graph"].items(): + assert k in fids, f"call_graph key {k} is not a function id" + for c in callees: + assert c in fids, f"callee {c} is not a function id" + for k, callers in cg["reverse_call_graph"].items(): + assert k in fids + for c in callers: + assert c in fids + + +def test_callgraph_bidirectional_consistency(tmp_path): + _, cg = build(tmp_path, _REPO) + fwd = cg["call_graph"] + rev = cg["reverse_call_graph"] + + fwd_edges = {(a, b) for a, bs in fwd.items() for b in bs} + rev_edges = {(a, b) for b, as_ in rev.items() for a in as_} + assert fwd_edges == rev_edges, ( + "forward and reverse call graphs disagree: " + f"only-forward={fwd_edges - rev_edges}, only-reverse={rev_edges - fwd_edges}" + ) + + +def test_no_self_edges(tmp_path): + _, cg = build(tmp_path, _REPO) + for a, bs in cg["call_graph"].items(): + assert a not in bs, f"self-edge on {a}" diff --git a/libs/openant-core/tests/parsers/swift/test_swift_extractor.py b/libs/openant-core/tests/parsers/swift/test_swift_extractor.py new file mode 100644 index 00000000..1427da25 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_extractor.py @@ -0,0 +1,155 @@ +"""Swift function-extractor contract + regression tests. + +Grounded in the Sol/Fable pre-implementation reviews. Each test guards a +concrete Swift extraction hazard that would silently drop or mis-key a unit. +""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import extract, leaf # noqa: E402 + + +def _ids(ext): + return {leaf(fid) for fid in ext["functions"]} + + +def _by_leaf(ext, name): + return [f for fid, f in ext["functions"].items() if leaf(fid) == name] + + +def test_overloaded_inits_not_overwritten(tmp_path): + """Swift overloads/initializers must NOT collide on func_id (silent overwrite + was Sol's #1 blocker: `functions.update()` drops all but the last).""" + ext = extract(tmp_path, {"A.swift": """ + struct A { + init() {} + init(path: String) {} + init(data: Int) {} + } + func parse(_ s: String) {} + func parse(_ n: Int) {} + """}) + ids = list(ext["functions"].keys()) + inits = [i for i in ids if i.endswith("A.init") or ":A.init(" in i] + assert len(inits) == 3, f"expected 3 distinct init units, got {inits}" + parses = [i for i in ids if leaf(i).startswith("parse")] + assert len(parses) == 2, f"expected 2 distinct parse overloads, got {parses}" + + +def test_init_and_deinit_named(tmp_path): + """init_declaration/deinit_declaration have no simple_identifier child; the + extractor must synthesize the names (else the units are dropped).""" + ext = extract(tmp_path, {"C.swift": """ + class C { + init() {} + deinit { cleanup() } + } + """}) + names = {f["name"] for f in ext["functions"].values()} + assert "init" in names + assert "deinit" in names + ctor = _by_leaf(ext, "C.init") + assert ctor and ctor[0]["unit_type"] == "constructor" + + +def test_nested_type_qualified_name_no_collision(tmp_path): + """Two `Inner` types under different outer types must not collide.""" + ext = extract(tmp_path, {"N.swift": """ + struct Outer1 { struct Inner { func f() {} } } + struct Outer2 { struct Inner { func f() {} } } + """}) + ids = _ids(ext) + assert "Outer1.Inner.f" in ids + assert "Outer2.Inner.f" in ids + # class_name stays the BARE leaf for receiver dispatch. + fs = _by_leaf(ext, "Outer1.Inner.f") + assert fs and fs[0]["class_name"] == "Inner" + + +def test_public_extension_member_extracted_and_exported(tmp_path): + """A func inside a `public extension` is extracted (0.7.3 sometimes wraps the + first member in an ERROR node — the all-children walk descends it) and + inherits the extension's public access (Fable F6/F10).""" + ext = extract(tmp_path, {"E.swift": """ + public extension Foo { + func bar() { work() } + } + """}) + bars = _by_leaf(ext, "Foo.bar") + assert bars, "func inside public extension must be extracted" + assert bars[0]["class_name"] == "Foo" + assert bars[0]["is_exported"] is True, "public-extension member inherits public" + + +def test_public_member_in_internal_type_not_exported(tmp_path): + """A public method inside an INTERNAL type is not public API (Fable F6).""" + ext = extract(tmp_path, {"T.swift": """ + struct Internal { + public func looksPublic() {} + } + public struct Exposed { + public func realPublic() {} + } + """}) + assert _by_leaf(ext, "Internal.looksPublic")[0]["is_exported"] is False + assert _by_leaf(ext, "Exposed.realPublic")[0]["is_exported"] is True + + +def test_public_class_members_default_internal(tmp_path): + """Members of a `public class` still default to internal (only `public + extension` members inherit public) — Fable F6 refinement.""" + ext = extract(tmp_path, {"P.swift": """ + public class Svc { + func helper() {} + } + """}) + assert _by_leaf(ext, "Svc.helper")[0]["is_exported"] is False + + +def test_generic_type_identity_strips_params(tmp_path): + """`Box` nominal identity is `Box`, not `Box` (Sol §generic).""" + ext = extract(tmp_path, {"G.swift": """ + struct Box { func unwrap() -> T { return get() } } + """}) + assert "Box.unwrap" in _ids(ext) + + +def test_computed_property_and_observers_emitted(tmp_path): + """Computed getters/setters + willSet/didSet are call-bearing units (the + single biggest reachability lever per Sol Q2 / Fable F4).""" + ext = extract(tmp_path, {"A.swift": """ + class A { + var computed: Int { get { return calcGet() } set { applySet() } } + var observed: Int = 0 { willSet { onWill() } didSet { onDid() } } + } + """}) + ids = _ids(ext) + assert "A.computed" in ids # getter + assert "A.computed.set" in ids # setter + assert "A.observed.willSet" in ids + assert "A.observed.didSet" in ids + + +def test_main_classification(tmp_path): + """A `main` function classifies as unit_type 'main' (an ENTRY_POINT_TYPE). + (The `@main` attribute sits on the TYPE, not the method — name==main is the + signal that seeds reachability.) Function-level attributes are captured.""" + ext = extract(tmp_path, {"App.swift": """ + @main struct App { static func main() { run() } } + class H { @objc func onEvent() {} } + """}) + m = _by_leaf(ext, "App.main") + assert m and m[0]["unit_type"] == "main" + ev = _by_leaf(ext, "H.onEvent") + assert ev and "@objc" in ev[0]["decorators"] + + +def test_inheritance_recorded(tmp_path): + """Superclass + protocol conformances are recorded for dispatch (Fable F3).""" + ext = extract(tmp_path, {"I.swift": """ + class Impl: Base, Proto { func f() {} } + extension Impl: Extra {} + """}) + assert set(ext["inheritance"].get("Impl", [])) >= {"Base", "Proto", "Extra"} diff --git a/libs/openant-core/tests/parsers/swift/test_swift_overload_matching.py b/libs/openant-core/tests/parsers/swift/test_swift_overload_matching.py new file mode 100644 index 00000000..818bdfac --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_overload_matching.py @@ -0,0 +1,434 @@ +"""Regression tests for the constructor/method overload phantom-edge fixes +(experiment findings F1-F4 + P-4). Before these, a `Type(...)` call linked to +EVERY init overload and a typed method call to EVERY same-name overload — ~53% of +edges on the pilot repos were phantom (inflation 2.8-2.95x). The call-site +signature matcher (labels + arity + defaults) narrows to the compatible overloads. +""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, extract, edges # noqa: E402 + + +def test_ctor_label_match_selects_one_overload(tmp_path): + """F1: one `Opt(name:)` call resolves to ONLY the init(name:) overload, not all + three inits (the dominant phantom-edge class).""" + _, cg = build(tmp_path, {"a.swift": """ + struct Opt { + init(name: String) { seedA() } + init(flag: Bool) { seedB() } + init(name: String, help: String) { seedC() } + } + func seedA(){}; func seedB(){}; func seedC(){} + func caller() { let o = Opt(name: "x") } + """}) + ctor_edges = {b for a, b in edges(cg) if a == "caller" and ".init" in b} + assert ctor_edges == {"Opt.init"}, f"expected only init(name:), got {ctor_edges}" + + +def test_ctor_default_args_omittable(tmp_path): + """F7: a defaulted param may be omitted — `C(name:)` matches init(name:parsing:) + where parsing has a default.""" + _, cg = build(tmp_path, {"a.swift": """ + struct C { init(name: String, parsing: Int = 0) { seed() } } + func seed() {} + func f() { let c = C(name: "x") } + """}) + assert ("f", "C.init") in edges(cg) + + +def test_method_overload_narrowed_by_labels(tmp_path): + """F2: a typed-receiver call to an overloaded method links to the matching + overload only, not every same-name overload.""" + _, cg = build(tmp_path, {"a.swift": """ + struct API { + func request(url: String) { hitA() } + func request(url: String, retries: Int) { hitB() } + } + func hitA(){}; func hitB(){} + func caller() { let a = API(); a.request(url: "x") } + """}) + e = edges(cg) + # the 1-arg call selects request(url:) — must NOT also link request(url:retries:) + assert ("caller", "API.request") in e + reqs = {b for a, b in e if a == "caller" and "request" in b} + assert len(reqs) == 1, f"expected 1 request overload, got {reqs}" + + +def test_conformer_fanout_survives_matching(tmp_path): + """The matcher narrows same-TYPE overloads but must NOT drop conformer fan-out + (same signature on different types) — that dynamic dispatch is real.""" + _, cg = build(tmp_path, {"a.swift": """ + protocol P { func handle() } + struct A: P { func handle() { doA() } } + struct B: P { func handle() { doB() } } + func doA(){}; func doB(){} + func dispatch(p: P) { p.handle() } + """}) + handled = {b for a, b in edges(cg) if a == "dispatch"} + assert handled == {"A.handle", "B.handle"}, f"conformer fan-out lost: {handled}" + + +def test_generic_constructor_resolves(tmp_path): + """F3: `Box(value:)` parses as constructor_expression — must still edge.""" + _, cg = build(tmp_path, {"a.swift": """ + struct Box { init(value: T) { seed() } } + func seed() {} + func f() { let b = Box(value: 3) } + """}) + assert ("f", "Box.init") in edges(cg) + + +def test_nested_type_constructor_resolves(tmp_path): + """F4: `Outer.Inner(x:)` must resolve to the nested type's init (was []).""" + _, cg = build(tmp_path, {"a.swift": """ + struct Outer { struct Inner { init(x: Int) { seed() } } } + func seed() {} + func f() { let i = Outer.Inner(x: 1) } + """}) + assert ("f", "Outer.Inner.init") in edges(cg) + + +def test_super_init_resolves_to_superclass(tmp_path): + """P-4: `super.init()` must resolve to the SUPERclass's init, never the caller's + own (subclass) init.""" + _, cg = build(tmp_path, {"a.swift": """ + class Base { init() { baseSeed() } } + class Sub: Base { init(x: Int) { super.init() } } + func baseSeed() {} + """}) + e = edges(cg) + assert ("Sub.init", "Base.init") in e + assert ("Sub.init", "Sub.init") not in e + + +def test_param_defaults_extracted(tmp_path): + """The extractor records per-param has_default (drives the matcher).""" + ext = extract(tmp_path, {"a.swift": """ + struct C { init(a: Int, b: Int = 3, c: Int = 4) {} } + """}) + inits = [f for f in ext["functions"].values() if f["name"] == "init"] + assert inits + assert inits[0]["param_defaults"] == [False, True, True] + + +def test_no_recall_loss_unmatched_falls_back(tmp_path): + """An unmatched ctor call on a REPO-DECLARED type falls back to all inits + (recall) rather than dropping the edge — a missed edge would silently prune the + paid scan.""" + # positional-only call against a labeled-only init: subsequence match fails → + # fallback keeps the edge because C is repo-declared. + _, cg = build(tmp_path, {"a.swift": """ + struct C { init(name: String) { seed() } } + func seed() {} + func f() { let c = C("x") } + """}) + assert ("f", "C.init") in edges(cg) + + +def test_external_type_ctor_no_phantom_fallback(tmp_path): + """A construction of an EXTERNAL type (repo-EXTENDED, not declared) whose labels + match no repo extension init emits NO edge — it is the stdlib constructor. On + security-pcc this killed 12,000 phantom edges (String.init 13025→1026): a stdlib + `String(format:)` was fanning to all 17 repo `extension String` inits.""" + _, cg = build(tmp_path, {"a.swift": """ + extension String { init(cBuffer: Int) { seed() } } + func seed() {} + func f() { + let a = String(cBuffer: 1) // matches the repo extension init -> edge + let b = String(format: "%d", 3) // stdlib ctor, no repo match -> NO edge + } + """}) + e = edges(cg) + assert ("f", "String.init") in e # the real extension-init call resolves + # exactly one String.init edge from f (the cBuffer one), not a fan-out + string_edges = [b for a, b in e if a == "f" and b == "String.init"] + assert len(string_edges) == 1 + + +def test_bare_name_collision_prefers_same_file(tmp_path): + """When a bare type name is declared in MANY files with identical signatures + (SwiftProtobuf's per-message `_StorageClass` — 202 units/30 files), a + constructor call prefers the SAME-FILE init. Cut protobuf 2.95x→1.41x.""" + files = {} + for i in range(3): + files[f"m{i}.swift"] = f""" + struct Msg{i} {{ + final class Storage {{ init() {{ seed{i}() }} }} + func make() {{ let s = Storage() }} + }} + func seed{i}() {{}} + """ + _, cg = build(tmp_path, files) + e = edges(cg) + # Msg0.make constructs its OWN Storage — must not fan to Msg1/Msg2's Storage.init + make0_ctor = [b for a, b in e if a == "Msg0.make" and b.endswith("Storage.init")] + # resolves to a Storage.init, and NOT all three files' Storage.init + assert len(make0_ctor) == 1 + + +def test_return_type_typing_disambiguates_receiver(tmp_path): + """A local typed by a factory's unique return type dispatches on that type, not + the unknown-receiver path (Sol/Fable convergent F6 fix).""" + _, cg = build(tmp_path, {"a.swift": """ + struct Client { func send() { hit() } } + struct Other { func send() {} } + func hit() {} + func makeClient() -> Client { return Client() } + func caller() { let c = makeClient(); c.send() } + """}) + sends = {b for a, b in edges(cg) if a == "caller" and "send" in b} + assert sends == {"Client.send"}, f"return-type typing failed: {sends}" + + +def test_underscore_type_ctor_typed(tmp_path): + """FR-7: an underscore-prefixed generated type (`_Storage()`) is recognised as a + constructor call (the leading-underscore isupper gap).""" + _, cg = build(tmp_path, {"a.swift": """ + struct _Storage { init() { seed() } } + func seed() {} + func f() { let s = _Storage() } + """}) + assert ("f", "_Storage.init") in edges(cg) + + +def test_selector_target_captured(tmp_path): + """PR-lessons NEW-3: `#selector(handleTap)` target-action edge is captured.""" + _, cg = build(tmp_path, {"a.swift": """ + class C { func setup() { btn.addTarget(self, action: #selector(handleTap)) } + @objc func handleTap() { work() } } + func work() {} + """}) + assert ("C.setup", "C.handleTap") in edges(cg) + + +def test_canonical_interface_methods_present(tmp_path): + """PR-lessons NEW-4 (sibling lockstep): Swift builder has get_dependencies/ + get_callers like the Zig/C builders.""" + from _helpers import CallGraphBuilder, FunctionExtractor, RepositoryScanner # noqa + import tempfile, os + d = str(tmp_path) + (tmp_path / "a.swift").write_text("func a(){ b() }\nfunc b(){ c() }\nfunc c(){}") + b = CallGraphBuilder(FunctionExtractor(d, RepositoryScanner(d).scan()).extract()) + b.build_call_graph() + a_id = [f for f in b.functions if f.endswith(":a")][0] + deps = {x.split(":", 1)[1] for x in b.get_dependencies(a_id)} + assert {"b", "c"} <= deps # transitive callees + c_id = [f for f in b.functions if f.endswith(":c")][0] + assert any(x.endswith(":a") for x in b.get_callers(c_id)) + + +def test_unknown_receiver_member_no_enclosing_type_fanout(tmp_path): + """INV-N2/FR-2: an unknown-receiver member call `base.next()` must NOT fan to + every unit sharing the caller's bare class_name. Inside SeqA.Iterator.next, + `base.next()` (base untyped) must not link to SeqB/SeqC's Iterator.next.""" + files = {} + for i, nm in enumerate(["A", "B", "C", "D"]): + body = "let x = base.next()" if i == 0 else "work()" + files[f"{nm}.swift"] = f"struct Seq{nm} {{ struct Iterator {{ func next() {{ {body} }} }} }}" + files["w.swift"] = "func work() {}" + _, cg = build(tmp_path, files) + q = {a: cg["functions"][a]["qualified_name"] for a in cg["functions"]} + seqa = [a for a in cg["call_graph"] if q.get(a) == "SeqA.Iterator.next"] + nexts = {q[b] for a in seqa for b in cg["call_graph"][a] + if cg["functions"].get(b, {}).get("name") == "next"} + assert nexts == set(), f"unknown-receiver base.next() fanned out: {nexts}" + + +def test_bare_self_call_still_uses_enclosing_type(tmp_path): + """Control: a genuine bare call `foo()` inside a type still prefers the enclosing + type (only unknown-receiver MEMBER calls skip that heuristic).""" + _, cg = build(tmp_path, {"a.swift": "struct T { func a() { b() } func b() {} }"}) + assert ("T.a", "T.b") in edges(cg) + + +def test_unknown_receiver_no_uncapped_same_file_fanout(tmp_path): + """FR-6: an unknown-receiver member call must not fan to all same-name methods in + the caller's (large) file via the uncapped same-file tier.""" + # one file with a caller making an unknown-receiver call + 5 same-name visit methods + src = "struct Big {\n func drive() { v.visit() }\n" + src += "".join(f" func visit() {{ s{i}() }}\n" for i in range(5)) # 5 same-file visit (no, same name collides on id) + src += "}\n" + "".join(f"func s{i}(){{}}\n" for i in range(5)) + _, cg = build(tmp_path, {"a.swift": src}) + # v.visit() unknown receiver -> must not fan to all 5+ same-file visit units (>K -> drop) + drive = [a for a in cg["call_graph"] if cg["functions"].get(a, {}).get("name") == "drive"] + visits = {b for a in drive for b in cg["call_graph"].get(a, []) if cg["functions"].get(b, {}).get("name") == "visit"} + assert len(visits) <= 3, f"same-file uncapped fan-out: {len(visits)} visit edges" + + +def test_complex_receiver_no_nb1_bypass(tmp_path): + """Sol-A: a complex-receiver member call (`items[i].load()`) must NOT re-enter the + caller-locality heuristics and fan across same-bare-named nested types.""" + files = {} + for i, nm in enumerate(["A", "B", "C", "D"]): + body = "items[0].load()" if i == 0 else "noop()" + files[f"{nm}.swift"] = f"struct Box{nm} {{ struct Thing {{ func load() {{ {body} }} }} }}" + files["w.swift"] = "func noop() {}" + _, cg = build(tmp_path, files) + q = {a: cg["functions"][a]["qualified_name"] for a in cg["functions"]} + loads = {q[b] for a in cg["call_graph"] if q.get(a) == "BoxA.Thing.load" + for b in cg["call_graph"][a] if cg["functions"].get(b, {}).get("name") == "load"} + assert loads == set(), f"complex receiver bypassed NB-1: {loads}" + + +def test_conditional_alias_union(tmp_path): + """NEW-5: a conditionally-reassigned closure var unions all branch targets.""" + _, cg = build(tmp_path, {"a.swift": + "func a(){};func b(){};func d(){};func caller(){ var g = a; if cond { g = b } else { g = d }; g() }"}) + al = {cg["functions"][x]["qualified_name"] for a, bs in cg["call_graph"].items() + if cg["functions"].get(a, {}).get("name") == "caller" for x in bs} + assert al == {"a", "b", "d"}, f"conditional alias union failed: {al}" + + +def test_qualified_ctor_filter(tmp_path): + """FR-3: `Msg1.Storage()` resolves to Msg1's Storage.init only, not all files'.""" + files = {f"m{i}.swift": f"struct Msg{i} {{ struct Storage {{ init() {{ s{i}() }} }} }}\nfunc s{i}(){{}}" + for i in range(3)} + files["c.swift"] = "func mk(){ let x = Msg1.Storage() }" + _, cg = build(tmp_path, files) + inits = {cg["functions"][b]["qualified_name"] for a, bs in cg["call_graph"].items() + if cg["functions"].get(a, {}).get("name") == "mk" + for b in bs if ".init" in cg["functions"][b].get("qualified_name", "")} + assert inits == {"Msg1.Storage.init"}, f"qualified ctor filter failed: {inits}" + + +def test_file_scope_stored_closure_extracted(tmp_path): + """NEW-1: a file-scope stored closure in an ordinary (non-main.swift) file is + extracted as a unit so its body's calls are visible (was an extraction blackout).""" + ext = extract(tmp_path, {"Config.swift": + "func sink(_ x: Int){}\nlet handler: (Int)->Void = { x in sink(x) }\nfunc caller(){ handler(1) }"}) + names = {f["qualified_name"] for f in ext["functions"].values()} + assert "handler" in names, f"file-scope closure not extracted: {names}" + _, cg = build(tmp_path, {"Config.swift": + "func sink(_ x: Int){}\nlet handler: (Int)->Void = { x in sink(x) }\nfunc caller(){ handler(1) }"}) + e = {(cg["functions"][a]["name"], cg["functions"][b]["name"]) + for a, bs in cg["call_graph"].items() for b in bs} + assert ("handler", "sink") in e and ("caller", "handler") in e + + +def test_local_var_not_emitted_as_unit(tmp_path): + """NEW-1 over-emission guard: a LOCAL `let` inside a function body is NOT a + file-scope global and must not become a unit (top_level gate).""" + ext = extract(tmp_path, {"a.swift": + "func mk()->Int{return 0}\nfunc use(_ x:Int){}\nfunc work(){ let localVar = mk(); use(localVar) }"}) + names = {f["qualified_name"] for f in ext["functions"].values()} + assert "localVar" not in names, f"local var wrongly emitted: {names}" + + +def test_main_swift_not_double_counted(tmp_path): + """NEW-1 guard: a main.swift file-scope binding is not emitted twice (top-level + synthesis already covers it).""" + ext = extract(tmp_path, {"main.swift": "let x = boot()\nfunc boot(){}"}) + qns = [f["qualified_name"] for f in ext["functions"].values() if f["qualified_name"] in ("", "x")] + assert qns == [""], f"main.swift double-counted: {qns}" + + +def test_solc_qualified_ctor_narrows_bare_collision(tmp_path): + """Sol-C: `let it = SeqA.Iterator(); it.next()` dispatches to SeqA.Iterator.next + ONLY — a `let x = Outer.Inner()` receiver carries canonical identity that narrows + the bare-class_name collision with an unrelated SeqB.Iterator.next.""" + _, cg = build(tmp_path, { + "a.swift": ("enum SeqA { struct Iterator { func next() { sinkA() } } }\n" + "func sinkA() {}\n" + "func drive() { let it = SeqA.Iterator(); it.next() }\n"), + "b.swift": ("enum SeqB { struct Iterator { func next() { sinkB() } } }\n" + "func sinkB() {}\n"), + }) + e = edges(cg) + assert ("drive", "SeqA.Iterator.next") in e + assert ("drive", "SeqB.Iterator.next") not in e, "Sol-C phantom cross-type edge" + + +def test_solc_var_reassign_conformer_keeps_both(tmp_path): + """Recall floor (Fable case 1): a `var` reassigned across two conformers has BOTH + edges genuine — the let-only hint must NOT fire, so both survive.""" + _, cg = build(tmp_path, { + "a.swift": ("protocol IterP { func next() }\n" + "struct SeqA { struct Iterator: IterP { func next() { sinkA() } } }\n" + "func sinkA() {}\n" + "func drive(cond: Bool) {\n" + " var it: IterP = SeqA.Iterator()\n" + " if cond { it = SeqB.Iterator() }\n" + " it.next()\n" + "}\n"), + "b.swift": ("struct SeqB { struct Iterator: IterP { func next() { sinkB() } } }\n" + "func sinkB() {}\n"), + }) + e = edges(cg) + assert ("drive", "SeqA.Iterator.next") in e and ("drive", "SeqB.Iterator.next") in e + + +def test_solc_protocol_ext_default_kept_by_recall_floor(tmp_path): + """Recall floor (Fable case 2): when `next` is a protocol-EXTENSION default (not on + the concrete Inner type), the qualified hint's subset is empty -> keep the full bare + set so the real P.next edge is never dropped.""" + _, cg = build(tmp_path, { + "a.swift": ("protocol P { }\n" + "extension P { func next() { sinkP() } }\n" + "func sinkP() {}\n" + "struct SeqA { struct Iterator: P {} }\n" + "func drive() { let it: P = SeqA.Iterator(); it.next() }\n"), + }) + e = edges(cg) + assert ("drive", "P.next") in e, "recall floor dropped the protocol-extension default" + + +def test_sole_incompatible_samefile_no_block_compatible_crossfile(tmp_path): + """Sol-E: a same-file `helper(x:)` incompatible with the call `helper(y:1)` must + not block the compatible cross-file `helper(y:)` — else `drive` cannot reach + `sink` (reachable only through the correct overload).""" + _, cg = build(tmp_path, { + "a.swift": "func helper(x: Int) {}\nfunc drive() { helper(y: 1) }\n", + "b.swift": "func helper(y: Int) { sink() }\nfunc sink() {}\n", + }) + fns, g = cg["functions"], cg["call_graph"] + drive = next(i for i, f in fns.items() if f["name"] == "drive") + reach, stack = set(), [drive] + while stack: + for v in g.get(stack.pop(), []): + if v not in reach: + reach.add(v); stack.append(v) + assert "sink" in {fns[r]["name"] for r in reach}, \ + "Sol-E: compatible cross-file helper(y:)->sink not reached" + + +def test_c1_uppercase_enum_case_not_mistyped(tmp_path): + """C1 (Fable): `let r = Result2.Success(1)` is an enum-case construction, not an + `Outer.Inner()` ctor — must not type `r` as the nonexistent type `Success` and + dead-end `r.handle()`.""" + _, cg = build(tmp_path, {"a.swift": + "enum Result2 { case Success(Int); case Failure(String); func handle() { sink() } }\n" + "func sink() {}\nfunc caller() { let r = Result2.Success(1); r.handle() }\n"}) + assert ("caller", "Result2.handle") in edges(cg) + + +def test_c2_qualified_hint_narrows_after_signature_matching(tmp_path): + """C2 (Fable): `let v = Outer2.Inner(); v.m()` dispatches to the inherited, + arity-compatible Base.m — not the arity-incompatible Inner.m(x:) that a name-only + qualified filter (applied before overload matching) would wrongly keep.""" + _, cg = build(tmp_path, {"a.swift": + "class Base { func m() { sink3() } }\nfunc sink3() {}\n" + "class Outer2 { class Inner: Base { func m(x: Int) { } } }\n" + "func caller3() { let v = Outer2.Inner(); v.m() }\n"}) + e = edges(cg) + assert ("caller3", "Base.m") in e and ("caller3", "Outer2.Inner.m") not in e + + +def test_e_variadic_samefile_kept_over_crossfile_phantom(tmp_path): + """E (Fable): a variadic same-file `process(Int...)` fails _sig_compatible's arity + bound but is the real target of `process(1,2,3)`; the label-only fall-through gate + must keep it (reach sink2), not redirect to a cross-file String overload.""" + _, cg = build(tmp_path, { + "a.swift": "func process(_ values: Int...) { sink2() }\nfunc sink2() {}\nfunc caller2() { process(1, 2, 3) }\n", + "b.swift": "func process(_ s: String, _ t: String = \"x\", _ u: String = \"y\") { }\n", + }) + fns, g = cg["functions"], cg["call_graph"] + c2 = next(i for i, f in fns.items() if f["name"] == "caller2") + reach, st = set(), [c2] + while st: + for v in g.get(st.pop(), []): + if v not in reach: + reach.add(v); st.append(v) + assert "sink2" in {fns[r]["name"] for r in reach}, "E: variadic same-file process dropped" diff --git a/libs/openant-core/tests/parsers/swift/test_swift_prune_telemetry.py b/libs/openant-core/tests/parsers/swift/test_swift_prune_telemetry.py new file mode 100644 index 00000000..4ab14373 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_prune_telemetry.py @@ -0,0 +1,169 @@ +"""B3: per-unit prune telemetry on the Swift *production* filter path. + +The default `reachable` Swift path shells out to `parsers/swift/test_pipeline.py`, +whose local reachability filter historically emitted NO prune telemetry / sidecar / +asymmetry invariant (unlike the instrumented core filter, which only ran on the +Python parse path and the `--llm-reachability` re-filter). This wires the Swift +filter to the SHARED core telemetry helper, so the same auditability holds on the +default Swift path — additively, with zero change to which units survive. + +Mirrors tests/test_reachability_prune_telemetry.py, but drives the Swift filter. + +Note on the invariant: the Swift call-graph builder writes forward+reverse edges in +lockstep (symmetric by construction), so a real Swift repo cannot produce a forward +asymmetry. The invariant is therefore a REGRESSION GUARD; to prove it fires *through* +the Swift path we hand-build an asymmetric call_graph_output (same technique the core +test uses). The load-bearing signal for real Swift repos is the orphan/dead_cluster +sidecar, exercised by the end-to-end test below. +""" +import importlib.util +import json +import pathlib +import sys + +_HERE = pathlib.Path(__file__).resolve().parent +_CORE = _HERE.parents[2] # libs/openant-core +sys.path.insert(0, str(_HERE)) +sys.path.insert(0, str(_CORE)) + + +def _load_pipeline(): + spec = importlib.util.spec_from_file_location( + "swift_pipeline_iso_b3", _CORE / "parsers" / "swift" / "test_pipeline.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _rf(result): + """The stashed reachability-filter telemetry the Swift filter attaches.""" + return result.get("_reachability_filter") + + +# --- unit-level: the filter emits telemetry + sidecar, over the PRE-FILTER graph --- + +def test_swift_filter_prune_buckets_and_sidecar(tmp_path): + # main -> A (kept). B orphan. D orphan, D -> C (C dead_cluster, caller D pruned). + fns = {k: {} for k in ["a.swift:A", "a.swift:B", "b.swift:C", "b.swift:D"]} + fns["a.swift:main"] = {"name": "main"} # structural entry point + cg = {"a.swift:main": ["a.swift:A"], "b.swift:D": ["b.swift:C"]} + rcg = {"a.swift:A": ["a.swift:main"], "b.swift:C": ["b.swift:D"]} + cgo = {"functions": fns, "call_graph": cg, "reverse_call_graph": rcg} + + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cgo, str(tmp_path), output_dir=str(tmp_path)) + m = _rf(result) + assert m is not None, "Swift filter must stash reachability_filter telemetry" + assert m["reachable_units"] == 2 # main, A + assert m["filtered_out"] == 3 # B, C, D + assert m["pruned_orphan_count"] == 2 # B, D (no caller) + assert m["pruned_in_dead_cluster_count"] == 1 # C (caller D pruned) + assert m["pruned_forward_called_by_reachable_count"] == 0 + for k in ("original_units", "entry_points", "reachable_units", "filtered_out", + "reduction_percentage"): + assert k in m + # sidecar written to output_dir with every pruned unit + classification + side = json.loads((tmp_path / m["pruned_units_path"]).read_text()) + ids = {u["id"]: u for u in side["units"]} + assert set(ids) == {"a.swift:B", "b.swift:C", "b.swift:D"} + assert ids["a.swift:B"]["bucket"] == "orphan" + assert ids["b.swift:C"]["bucket"] == "dead_cluster" + + +def test_swift_filter_forward_asymmetry_invariant_fires(tmp_path): + # main -> A -> X forward, but reverse[X] MISSING A => X pruned yet reachable A + # forward-calls it. The invariant must catch it THROUGH the Swift filter. + fns = {"a:main": {"name": "main"}, "a:A": {}, "a:X": {}} + cg = {"a:main": ["a:A"], "a:A": ["a:X"]} # forward: A calls X + rcg = {"a:A": ["a:main"]} # reverse: X's caller MISSING + cgo = {"functions": fns, "call_graph": cg, "reverse_call_graph": rcg} + + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cgo, str(tmp_path), output_dir=str(tmp_path)) + m = _rf(result) + assert m["reachable_units"] == 2 # main, A (X pruned) + assert m["pruned_forward_called_by_reachable_count"] == 1 # X — the asymmetry victim + assert "warning" in m and "asymmetr" in m["warning"].lower() + + +def test_swift_filter_uses_prefilter_graph_not_pruned(tmp_path): + """Guard the single highest-risk line: the invariant MUST be computed over the + UN-pruned graph. If the pruned graph were fed to the helper, asym would be a + manufactured 0 (every edge to a pruned node already stripped).""" + fns = {"a:main": {"name": "main"}, "a:A": {}, "a:X": {}} + cg = {"a:main": ["a:A"], "a:A": ["a:X"]} + rcg = {"a:A": ["a:main"]} + cgo = {"functions": fns, "call_graph": cg, "reverse_call_graph": rcg} + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cgo, str(tmp_path), output_dir=str(tmp_path)) + # returned (filtered) graph has X stripped from A's out-edges ... + assert "a:X" not in result["call_graph"].get("a:A", []) + # ... yet the invariant still saw the asymmetry (proves pre-filter graph was used) + assert _rf(result)["pruned_forward_called_by_reachable_count"] == 1 + + +def test_swift_filter_empty_seed_reduced_schema(tmp_path): + """No entry points -> N4 keep-all. Must mirror core's reduced schema: NO pruned_* + keys and NO sidecar (pinned for core by test_reachability_prune_telemetry).""" + fns = {"a:foo": {}, "a:bar": {}} # nothing entry-point-shaped + cgo = {"functions": fns, "call_graph": {}, "reverse_call_graph": {}} + pipeline = _load_pipeline() + result = pipeline.apply_reachability_filter(cgo, str(tmp_path), output_dir=str(tmp_path)) + m = _rf(result) + assert m["filtered_out"] == 0 and m["reachable_units"] == 2 + assert "pruned_orphan_count" not in m # reduced schema on keep-all + assert not (tmp_path / "pruned_units.json").exists() # no sidecar when nothing pruned + # exact parity with core's empty-seed record: int 0, not float 0.0 + assert m["reduction_percentage"] == 0 and isinstance(m["reduction_percentage"], int) + + +def test_swift_filter_output_dir_optional_and_neutral(tmp_path): + """output_dir is optional (existing 3-arg callers). Its presence must not change + which functions survive (telemetry is additive-only).""" + fns = {"a:main": {"name": "main"}, "a:A": {}, "a:orphan": {}} + cg = {"a:main": ["a:A"]} + rcg = {"a:A": ["a:main"]} + cgo = lambda: {"functions": dict(fns), "call_graph": dict(cg), + "reverse_call_graph": dict(rcg)} + pipeline = _load_pipeline() + no_dir = pipeline.apply_reachability_filter(cgo(), str(tmp_path)) # 3-arg form + with_dir = pipeline.apply_reachability_filter(cgo(), str(tmp_path), output_dir=str(tmp_path)) + # neutrality: which functions survive is identical with or without output_dir + assert set(no_dir["functions"]) == set(with_dir["functions"]) == {"a:main", "a:A"} + # output_dir=None => telemetry still computed, but no sidecar file + assert "pruned_units_path" not in no_dir["_reachability_filter"] + # output_dir given + a unit pruned => sidecar written + assert with_dir["_reachability_filter"]["pruned_units_path"] == "pruned_units.json" + assert (tmp_path / "pruned_units.json").exists() + + +# --- end-to-end: the real production main() writes the metadata + sidecar --- + +def test_swift_pipeline_main_writes_reachability_metadata(tmp_path, monkeypatch): + """Drive the actual production entry (main()) on a synthetic main.swift repo: + dataset.json must carry metadata.reachability_filter and a pruned_units.json + sidecar must sit beside it in output_dir.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "main.swift").write_text( + "let x = reached()\n" + "func reached() { deep() }\n" + "func deep() {}\n" + "func orphan() { orphanCallee() }\n" + "func orphanCallee() {}\n" + ) + out = tmp_path / "out" + out.mkdir() + pipeline = _load_pipeline() + monkeypatch.setattr(sys, "argv", [ + "test_pipeline.py", str(repo), "--output", str(out), + "--processing-level", "reachable", + ]) + rc = pipeline.main() + assert rc == 0 + dataset = json.loads((out / "dataset.json").read_text()) + rf = dataset.get("metadata", {}).get("reachability_filter") + assert rf is not None, "main() must merge reachability_filter into dataset metadata" + assert "pruned_forward_called_by_reachable_count" in rf + # orphan / orphanCallee are unreachable from main -> pruned -> sidecar present + assert (out / "pruned_units.json").exists() diff --git a/libs/openant-core/tests/parsers/swift/test_swift_scanner.py b/libs/openant-core/tests/parsers/swift/test_swift_scanner.py new file mode 100644 index 00000000..bf73bf59 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_scanner.py @@ -0,0 +1,54 @@ +"""Swift repository-scanner tests: anchored test detection, case-insensitive +extension, generated-dir exclusion (the recurring 'anchoring beats substring' +family from the OpenAnt PR history).""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import RepositoryScanner # noqa: E402 + + +def _scan(tmp_path, files, skip_tests=True): + for name, src in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(src) + res = RepositoryScanner(str(tmp_path), skip_tests=skip_tests).scan() + return {f["path"] for f in res["files"]} + + +def test_case_insensitive_extension(tmp_path): + found = _scan(tmp_path, {"A.SWIFT": "func a(){}", "B.Swift": "func b(){}"}) + assert {"A.SWIFT", "B.Swift"} <= found + + +def test_test_file_and_dir_anchoring(tmp_path): + found = _scan(tmp_path, { + "Sources/Server.swift": "func s(){}", + "Sources/latest.swift": "func l(){}", # 'latest' is NOT a test + "Sources/ServerTests.swift": "func t(){}", # anchored suffix -> test + "Tests/AppTests/FooTests.swift": "func f(){}", # test dir + }, skip_tests=True) + assert "Sources/Server.swift" in found + assert "Sources/latest.swift" in found, "'latest.swift' must not be treated as a test" + assert "Sources/ServerTests.swift" not in found + assert "Tests/AppTests/FooTests.swift" not in found + + +def test_test_files_included_when_not_skipping(tmp_path): + found = _scan(tmp_path, { + "Sources/Server.swift": "func s(){}", + "Tests/AppTests/FooTests.swift": "func f(){}", + }, skip_tests=False) + assert "Tests/AppTests/FooTests.swift" in found + + +def test_generated_dirs_excluded(tmp_path): + found = _scan(tmp_path, { + "Sources/App.swift": "func a(){}", + ".build/gen.swift": "func g(){}", + "Pods/Dep/Dep.swift": "func d(){}", + "DerivedData/x.swift": "func x(){}", + }) + assert found == {"Sources/App.swift"} diff --git a/libs/openant-core/tests/parsers/swift/test_swift_schema_completeness.py b/libs/openant-core/tests/parsers/swift/test_swift_schema_completeness.py new file mode 100644 index 00000000..b3d84f27 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_schema_completeness.py @@ -0,0 +1,60 @@ +"""B-schema (extractor -> unit field contract) for Swift. + +Guards the producer/consumer field drift family (BUG 29): a field the extractor +produces must survive into the generated unit / analyzer_output, machine-checked +so a future field addition that forgets to thread through fails loudly.""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 + +_SRC = { + "S.swift": """ + public struct Widget { + public init(id: Int) {} + public func render() -> Int { return compute() } + private func compute() -> Int { return 0 } + } + """ +} + + +def _run(tmp_path): + ext = extract(tmp_path, _SRC) + cg = CallGraphBuilder(ext).build() + dataset, analyzer = UnitGenerator(cg, str(tmp_path)).generate(name="s") + return ext, dataset, analyzer + + +def test_every_extracted_function_has_a_unit(tmp_path): + ext, dataset, _ = _run(tmp_path) + unit_ids = {u["id"] for u in dataset["units"]} + assert unit_ids == set(ext["functions"].keys()) + + +def test_unit_required_fields_present(tmp_path): + _, dataset, _ = _run(tmp_path) + for u in dataset["units"]: + assert "id" in u and "unit_type" in u + origin = u["code"]["primary_origin"] + for key in ("file_path", "start_line", "end_line", "function_name", "class_name"): + assert key in origin, f"missing {key} in primary_origin of {u['id']}" + assert "dependency_metadata" in u["code"] + + +def test_analyzer_output_camelcase_and_isexported_roundtrip(tmp_path): + ext, _, analyzer = _run(tmp_path) + for fid, af in analyzer["functions"].items(): + for key in ("name", "unitType", "code", "filePath", "startLine", + "endLine", "isExported", "parameters", "className"): + assert key in af, f"analyzer function {fid} missing {key}" + # is_exported must be carried from the extractor, NOT recomputed from a + # code-prefix heuristic (Fable F23). + assert af["isExported"] == bool(ext["functions"][fid]["is_exported"]) + + # the public API surface is actually flagged exported + exported = {fid.split(":", 1)[1] for fid, af in analyzer["functions"].items() if af["isExported"]} + assert "Widget.render" in exported + assert "Widget.compute" not in exported diff --git a/libs/openant-core/tests/parsers/swift/test_swift_stage5_regressions.py b/libs/openant-core/tests/parsers/swift/test_swift_stage5_regressions.py new file mode 100644 index 00000000..caa690ba --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_stage5_regressions.py @@ -0,0 +1,134 @@ +"""Regression tests for the Stage-5 post-build audit fixes (Sol / Fable / +independent / auditor / expert). Each guards a confirmed real-target defect.""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, extract, edges, leaf # noqa: E402 + + +def test_toplevel_try_await_daemon_root(tmp_path): + """FH2/E-01: a `try await Daemon().start()` root parses as a top-level + try_expression — it must still become a main unit (daemon-root blackout).""" + ext = extract(tmp_path, {"main.swift": """ + import Foundation + try await CloudBoardDaemon().start(port: 9000) + """}) + tops = [f for f in ext["functions"].values() if f["qualified_name"] == ""] + assert tops and tops[0]["unit_type"] == "main" + assert "start" in tops[0]["code"] + + +def test_parsable_command_run_seeded(tmp_path): + """FH4/E-02: `ParsableCommand.run()` is runtime-invoked → seeded as main.""" + ext = extract(tmp_path, {"Cmd.swift": """ + struct Tool: ParsableCommand { func run() throws { doWork() } } + func doWork() {} + """}) + run = [f for f in ext["functions"].values() if f["name"] == "run"] + assert run and run[0]["unit_type"] == "main" + + +def test_codable_init_from_seeded(tmp_path): + """ES4: `Codable init(from:)` decodes untrusted input → seeded.""" + ext = extract(tmp_path, {"M.swift": """ + struct Msg: Decodable { init(from decoder: Decoder) throws { parse() } } + func parse() {} + """}) + inits = [f for f in ext["functions"].values() if f["name"] == "init"] + assert inits and inits[0]["unit_type"] == "main" + + +def test_dotted_builtin_member_call_kept(tmp_path): + """CG-01/S3: a typed receiver's method named like a builtin (`self.append`) + must resolve, not be dropped by the builtin filter.""" + _, cg = build(tmp_path, {"M.swift": """ + struct Metrics { + func record() { self.append(1) } + func append(_ x: Int) { sink() } + } + func sink() {} + """}) + assert ("Metrics.record", "Metrics.append") in edges(cg) + + +def test_untyped_builtin_receiver_not_mislinked(tmp_path): + """CG-01 guard: an UNKNOWN-receiver builtin method must NOT link to a same-named + user method (`xs.map` is stdlib, not the repo `map`).""" + _, cg = build(tmp_path, {"M.swift": """ + func map(_ f: Int) {} + func run(xs: [Int]) { xs.map { $0 } } + """}) + assert ("run", "map") not in edges(cg) + + +def test_arg_ref_labeled_captured_and_scoped(tmp_path): + """CG-02: labeled function-ref arg captured; CG-03: a local value is NOT.""" + _, cg = build(tmp_path, {"M.swift": """ + func handleRequest() {} + func register(use: () -> Void) {} + func setup() { register(use: handleRequest) } + func other(value: Int) { consume(value) } + func consume(_ v: Int) {} + """}) + e = edges(cg) + assert ("setup", "handleRequest") in e # labeled fn-ref captured (CG-02) + # `value` is a local param, not a function ref — even though a func could share + # the name, it must not fabricate an edge (CG-03 scoping). + assert not any(callee == "value" for _, callee in e) + + +def test_external_receiver_not_bound_to_caller(tmp_path): + """CG-04: a typed EXTERNAL receiver whose method misses must NOT fall back to + the caller's own same-named method.""" + _, cg = build(tmp_path, {"M.swift": """ + struct Release { func encode() { audit() } func run() { let e = JSONEncoder(); e.encode(self) } } + func audit() {} + """}) + # e is JSONEncoder (external); e.encode must NOT bind to Release.encode. + assert ("Release.run", "Release.encode") not in edges(cg) + + +def test_protocol_existential_reaches_conformers(tmp_path): + """S1: a call on a protocol-typed receiver reaches the conformers' witnesses.""" + _, cg = build(tmp_path, {"M.swift": """ + protocol Authorizer { func authorize() } + struct Policy: Authorizer { func authorize() { checkPolicy() } } + func checkPolicy() {} + func dispatch(a: Authorizer) { a.authorize() } + """}) + assert ("dispatch", "Policy.authorize") in edges(cg) + + +def test_local_func_not_phantom_method(tmp_path): + """E-04: a nested local func is function-scoped, not a method of the type.""" + ext = extract(tmp_path, {"M.swift": """ + class Outer { func method() { func helper() {} } } + """}) + helpers = [f for f in ext["functions"].values() if f["name"] == "helper"] + assert helpers and helpers[0]["class_name"] is None + + +def test_self_constructor_resolves(tmp_path): + """S6a: `Self(...)` resolves to the caller type's constructors.""" + _, cg = build(tmp_path, {"M.swift": """ + struct Token { init(v: Int) { seed() } static func make() -> Token { return Self(v: 1) } } + func seed() {} + """}) + assert ("Token.make", "Token.init") in edges(cg) + + +def test_public_extension_of_public_type_exported(tmp_path): + """S4a/E-03: a public member of an unmodified extension of a PUBLIC repo type + is public API (library-mode seed).""" + ext = extract(tmp_path, {"C.swift": """ + public struct Client {} + extension Client { public func send() {} } + """}) + send = [f for f in ext["functions"].values() if leaf_name(f) == "send"] + assert send and send[0]["is_exported"] is True + + +def leaf_name(f): + return f["name"] diff --git a/libs/openant-core/tests/parsers/swift/test_swift_toplevel_and_reachability.py b/libs/openant-core/tests/parsers/swift/test_swift_toplevel_and_reachability.py new file mode 100644 index 00000000..d5c94e91 --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_toplevel_and_reachability.py @@ -0,0 +1,86 @@ +"""Top-level main.swift synthesis (Fable F8) + entry-point seeding / reachability +for a library-heavy target (the security-pcc shape).""" + +import importlib.util +import pathlib +import sys + +_HERE = pathlib.Path(__file__).resolve().parent +_CORE = _HERE.parents[2] +sys.path.insert(0, str(_HERE)) +from _helpers import extract, build # noqa: E402 + + +def _load(rel, name): + spec = importlib.util.spec_from_file_location(name, _CORE / rel) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +_EPD = _load("utilities/agentic_enhancer/entry_point_detector.py", "swift_epd_iso2") + + +def test_toplevel_main_swift_synthesized(tmp_path): + """main.swift top-level executable code becomes a `main` unit (else the tool's + real entry + everything it reaches is invisible).""" + ext = extract(tmp_path, {"main.swift": """ + import Foundation + let s = start() + runTool() + """}) + tops = [f for fid, f in ext["functions"].items() if f["qualified_name"] == ""] + assert tops, "main.swift top-level code must synthesize a unit" + assert tops[0]["unit_type"] == "main" + + +def test_non_main_file_no_toplevel_unit(tmp_path): + """Only main.swift synthesizes a top-level unit (top-level code elsewhere is a + compile error in a normal target).""" + ext = extract(tmp_path, {"Other.swift": "func f() { g() }\nfunc g() {}"}) + assert not any(f["qualified_name"] == "" for f in ext["functions"].values()) + + +def test_main_seeds_reachability(tmp_path): + """The synthesized main unit is a structural entry point.""" + ext, _ = build(tmp_path, {"main.swift": "let x = boot()\nfunc boot() {}"}) + detector = _EPD.EntryPointDetector(ext["functions"], {}) + eps = detector.detect_entry_points() + top = [fid for fid, f in ext["functions"].items() if f["qualified_name"] == ""][0] + assert top in eps + + +def test_library_mode_seeds_public_api(tmp_path): + """A pure library (no main/route) is seeded via its public/open API surface; + internal-only functions are not part of the seed (Fable F6).""" + ext, _ = build(tmp_path, {"Lib.swift": """ + public struct API { + public func publicEntry() {} + func internalHelper() {} + } + """}) + funcs = ext["functions"] + # No structural entry points (no main/route/handler). + detector = _EPD.EntryPointDetector(funcs, {}) + assert not any(r for r in (detector.detect_entry_points())) + # Library seeding picks up the public method, not the internal one. + seeds = _EPD.library_seed_ids(funcs) + seed_leaves = {s.split(":", 1)[1] for s in seeds} + assert "API.publicEntry" in seed_leaves + assert "API.internalHelper" not in seed_leaves + + +def test_objc_method_seeds_without_public(tmp_path): + """An @objc method is externally callable via the ObjC runtime even when not + public → seeded as an entry point (Fable F7).""" + ext = extract(tmp_path, {"C.swift": """ + class Handler { + @objc func onEvent() {} + func plain() {} + } + """}) + detector = _EPD.EntryPointDetector(ext["functions"], {}) + eps = detector.detect_entry_points() + leaves = {fid.split(":", 1)[1] for fid in eps} + assert "Handler.onEvent" in leaves + assert "Handler.plain" not in leaves diff --git a/libs/openant-core/tests/parsers/swift/test_swift_trailing_closure_labels.py b/libs/openant-core/tests/parsers/swift/test_swift_trailing_closure_labels.py new file mode 100644 index 00000000..89ee6a3c --- /dev/null +++ b/libs/openant-core/tests/parsers/swift/test_swift_trailing_closure_labels.py @@ -0,0 +1,213 @@ +"""SE-0279 trailing-closure argument labels in call resolution. + +`Many { } separator: { } terminator: { }` (result-builder DSL) carries the secondary +trailing-closure labels as `simple_identifier ':' lambda_literal` directly under +call_suffix — NOT as value_arguments. Before the fix, `_call_labels` returned labels=[] +for these, so the overload matcher could not narrow (Many fanned to all 48 init overloads, +a 57%-of-edges phantom flood) and — worse — a call like `Prefix { }` misresolved to an +all-defaulted overload while the true `init(while:)` (whose closure param is required but +filled by the primary trailing closure, label elided) was wrongly rejected (a recall loss). + +The fix: extract the secondary labels and count the unlabeled primary; the matcher lets +that many REQUIRED labels go unspelled (a trailing closure fills its param without spelling +the label). Additive to recall — proven drop-only==0 on the 60-repo corpus. +""" +import importlib.util +import pathlib +import sys + +import tree_sitter_swift as tss +from tree_sitter import Language, Parser + +_HERE = pathlib.Path(__file__).resolve().parent +_CORE = _HERE.parents[2] +sys.path.insert(0, str(_CORE)) + + +def _cgb(): + spec = importlib.util.spec_from_file_location( + "swift_cgb_tcl", _CORE / "parsers" / "swift" / "call_graph_builder.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + # minimal extractor_output; we only exercise pure helpers + return mod.CallGraphBuilder({"functions": {}, "classes": {}, "files": {}}) + + +def _first_call(src: bytes, name: str): + lang = Language(tss.language()) + root = Parser(lang).parse(src).root_node + found = [] + + def walk(n): + if n.type in ("call_expression", "constructor_expression"): + head = src[n.start_byte:n.end_byte].decode() + if head.startswith(name): + found.append(n) + for c in n.children: + walk(c) + walk(root) + return found[0] + + +def test_call_labels_extracts_secondary_trailing_closure_labels(): + b = _cgb() + src = b"let x = Many { a() } separator: { b() } terminator: { c() }\n" + node = _first_call(src, "Many") + labels, arity, trailing, unlabeled = b._call_labels(node, src) + assert "separator" in labels and "terminator" in labels # were dropped before the fix + assert trailing == 3 + assert unlabeled == 1 # the primary `{ a() }` + assert arity == 3 + + +def test_call_labels_primary_only_is_one_unlabeled(): + b = _cgb() + src = b"let x = Prefix { p() }\n" + node = _first_call(src, "Prefix") + labels, arity, trailing, unlabeled = b._call_labels(node, src) + assert labels == [None] and trailing == 1 and unlabeled == 1 + + +def test_sig_compatible_accepts_trailing_closure_filled_required_param(): + """The recall fix: `Prefix { }` (labels=[], one unlabeled trailing closure) must match + `init(while:)` whose `while` is REQUIRED but filled by the primary closure. Before the + fix `required_named ⊆ labeled` rejected it -> the true overload was starved.""" + b = _cgb() + site = {"labels": [None], "arity": 1, "trailing": 1, "unlabeled_trailing": 1} + init_while = {"signature": ["while"], "param_defaults": [False]} + # constructor context (allow_unlabeled_trailing=True): the primary closure fills `while` + assert b._sig_compatible(site, init_while, allow_unlabeled_trailing=True) is True + # method/unknown-receiver context (default False): stays strict — broadening there would + # tip a unique resolution into a decline and drop a real edge (the 395-edge regression) + assert b._sig_compatible(site, init_while) is False + # and a spelled-label call with no unlabeled trailing still requires the label + site_no_trailing = {"labels": [], "arity": 0, "trailing": 0, "unlabeled_trailing": 0} + assert b._sig_compatible(site_no_trailing, init_while, allow_unlabeled_trailing=True) is False + + +def test_sig_compatible_narrows_many_by_secondary_labels(): + """`Many { } separator: { } terminator: { }` must match the (element:separator:terminator:) + overload and REJECT (element:separator:) — the secondary labels now narrow it.""" + b = _cgb() + site = {"labels": [None, "separator", "terminator"], "arity": 3, "trailing": 3, + "unlabeled_trailing": 1} + est = {"signature": ["element", "separator", "terminator"], + "param_defaults": [False, False, False]} + es = {"signature": ["element", "separator"], "param_defaults": [False, False]} + assert b._sig_compatible(site, est, allow_unlabeled_trailing=True) is True + assert b._sig_compatible(site, es, allow_unlabeled_trailing=True) is False # terminator not in decl + + +def test_match_overloads_known_receiver_relaxes_trailing_closure(): + """CL-2: a KNOWN-receiver method call `r.run { }` where `run(while:)` is required but + filled by the primary trailing closure must reach `run(while:)`, not just the + all-defaulted `run(times:)` decoy. The relaxation is passed ONLY on the known-receiver + dispatch; the method DEFAULT (bare-name / unknown-receiver) stays strict so it can't + exceed the ambiguity cap or trip the unknown-receiver decline.""" + b = _cgb() + b.functions = { + "F:Runner.run(while)": {"signature": ["while"], "param_defaults": [False], + "qualified_name": "Runner.run", "class_name": "Runner"}, + "F:Runner.run(times)": {"signature": ["times"], "param_defaults": [True], + "qualified_name": "Runner.run", "class_name": "Runner"}, + } + cands = ["F:Runner.run(while)", "F:Runner.run(times)"] + site = {"labels": [None], "arity": 1, "trailing": 1, "unlabeled_trailing": 1} + relaxed = b._match_overloads(cands, site, allow_unlabeled_trailing=True) + assert "F:Runner.run(while)" in relaxed # true target reached (CL-2 fixed) + strict = b._match_overloads(cands, site) # method default = strict + assert "F:Runner.run(while)" not in strict # the bug: decoy-only, true target starved + + +def _resolve_go_edges(tmp_path, src: str): + """Run the real scan->extract->build pipeline on `src` and return the + qualified names Caller.go resolves to.""" + from parsers.swift.repository_scanner import RepositoryScanner + from parsers.swift.function_extractor import FunctionExtractor + from parsers.swift.call_graph_builder import CallGraphBuilder + (tmp_path / "Sources").mkdir() + (tmp_path / "Sources" / "Poc.swift").write_text(src) + scan = RepositoryScanner(str(tmp_path)).scan() + ext = FunctionExtractor(str(tmp_path), scan).extract() + cg = CallGraphBuilder(ext).build() + graph = cg.get("call_graph", cg) + for caller, callees in graph.items(): + if "Caller" in caller and "go" in caller: + return [cg.get("functions", {}).get(x, {}).get("qualified_name", x) + for x in callees] + return [] + + +def test_c2_subset_keeps_inherited_target_when_relaxation_admits_decoy(tmp_path): + """cx3/CL-2 guard: the TYPE-BLIND unlabeled-trailing allowance can admit a + same-qualified DECOY overload (`Outer.Inner.run(name:)`), which the var_qualified + subset then prefers, EVICTING the strictly-matched inherited target (`Base.run(_:)`). + Both share the qualified name so no set-diff gate sees the loss. The `relaxed_added` + guard (mirroring `_match_ctors`) must keep the inherited target.""" + names = _resolve_go_edges(tmp_path, ( + "class Base { func run(_ body: () -> Void) { realTargetMarker() } }\n" + "enum Outer { class Inner: Base { func run(name: String) { decoyMarker() } } }\n" + "class Caller { func go() { let x = Outer.Inner(); x.run { } } }\n")) + assert any("Base.run" in n for n in names), f"inherited Base.run evicted: {names}" + + +def test_c2_subset_still_narrows_qualified_identity_without_trailing_closure(tmp_path): + """The guard must NOT disable legitimate var_qualified narrowing: a spelled call + `x.next(idx:)` with no trailing closure (no relaxation) must still narrow to the + receiver's qualified type (`SeqB.next`), not fan to the same-leaf `SeqA.next`.""" + names = _resolve_go_edges(tmp_path, ( + "class SeqA { func next(idx: Int) { aMarker() } }\n" + "class SeqB { func next(idx: Int) { bMarker() } }\n" + "class Caller { func go() { let x = SeqB(); x.next(idx: 1) } }\n")) + assert names == ["SeqB.next"], f"var_qualified narrowing regressed: {names}" + + +def _resolve_go_sigs(tmp_path, src: str): + """Like _resolve_go_edges but returns the SIGNATURES of the reached callees + (overloads sharing a qualified name are distinguished by signature).""" + from parsers.swift.repository_scanner import RepositoryScanner + from parsers.swift.function_extractor import FunctionExtractor + from parsers.swift.call_graph_builder import CallGraphBuilder + (tmp_path / "Sources").mkdir() + (tmp_path / "Sources" / "Poc.swift").write_text(src) + cg = CallGraphBuilder(FunctionExtractor( + str(tmp_path), RepositoryScanner(str(tmp_path)).scan()).extract()).build() + graph = cg.get("call_graph", cg) + for caller, callees in graph.items(): + if "Caller" in caller and "go" in caller: + return [tuple(cg.get("functions", {}).get(x, {}).get("signature") or []) + for x in callees] + return [] + + +def test_bare_implicit_self_call_reaches_trailing_closure_overload(tmp_path): + """CL-2b: a BARE call `run { }` inside a class is implicit `self.run` and dispatches + on the enclosing-type tier (`same_type`), which was left strict by CL-2. The true + `run(while:)` (required label filled by the primary trailing closure) was starved to + the all-defaulted `run(times:)` decoy. Relaxing the enclosing-type dispatch (no cx3 + guard needed -- it returns directly, no var_qualified subset / fan-out cap) recovers + it. This is the implicit-self parity of the explicit-receiver CL-2 fix.""" + sigs = _resolve_go_sigs(tmp_path, ( + "class Caller {\n" + " func run(while cond: () -> Bool) { realTargetMarker() }\n" + " func run(times: Int = 1) { decoyMarker() }\n" + " func go() { run { return false } }\n" + "}\n")) + assert ("while",) in sigs, f"bare implicit-self run(while:) starved: {sigs}" + + +def test_cl2b_recall_floor_keeps_variadic_target_strict_found_nothing(tmp_path): + """CL-2b recall floor (Fable): when STRICT matching finds no compatible same_type + overload, `_match_overloads` returns the full recall fallback. The type-blind + relaxation must NOT replace that fallback -- a variadic (or parameter-pack) callee + under-counts its arity and fails the arity bound under both strict and relaxed, so it + survives ONLY via the fallback. Relaxing here would narrow to a decoy and EVICT the + real target (same leaf AND qualified name -> invisible to set-diff oracles). The floor + (relax only when the strict subset is non-empty) keeps the real target reachable.""" + sigs = _resolve_go_sigs(tmp_path, ( + "class Caller {\n" + " func put(_ items: Int..., done: () -> Void) { realTargetMarker(); done() }\n" + " func put(_ a: Int, _ b: Int, _ c: Int, into bucket: String) { decoyMarker() }\n" + " func go() { put(1, 2, 3) { self.realTargetMarker() } }\n" + "}\n")) + assert ("_", "done") in sigs, f"variadic put(_:done:) evicted by relaxation: {sigs}" diff --git a/libs/openant-core/tests/test_language_registry.py b/libs/openant-core/tests/test_language_registry.py index 4ad03a5c..17266fdc 100644 --- a/libs/openant-core/tests/test_language_registry.py +++ b/libs/openant-core/tests/test_language_registry.py @@ -109,7 +109,7 @@ def test_only_javascript_declares_a_bootstrap(self): class TestSupportedLanguages: def test_matches_the_known_set(self): assert supported_languages() == [ - "c", "go", "javascript", "php", "python", "ruby", "zig", + "c", "go", "javascript", "php", "python", "ruby", "swift", "zig", ] def test_is_sorted_and_deterministic(self): diff --git a/libs/openant-core/tests/test_reachability_prune_telemetry.py b/libs/openant-core/tests/test_reachability_prune_telemetry.py new file mode 100644 index 00000000..112c0536 --- /dev/null +++ b/libs/openant-core/tests/test_reachability_prune_telemetry.py @@ -0,0 +1,119 @@ +"""Per-unit reachability-prune telemetry (Sol+Fable design). + +The default `reachable` filter prunes units unreachable from entry points via a forward +BFS over call_graph edges. A known-incomplete graph => genuinely-reachable units silently +pruned. This telemetry makes every prune auditable (additive, all-language, no behavior +change) and surfaces the two hard signals: + - pruned_forward_called_by_reachable: a reachable unit forward-calls a pruned one => + call_graph/reverse_call_graph ASYMMETRY (a reachable unit was silently pruned). Expect 0. + - pruned_orphan: no (non-self) caller => the missing-edge ROOT candidate (first-class suspect). + - pruned_in_dead_cluster: has a pruned caller => downstream shadow of a broken chain. +""" +import importlib.util +import json +import pathlib + +_CORE = pathlib.Path(__file__).resolve().parents[1] # libs/openant-core + + +def _load_mod(): + spec = importlib.util.spec_from_file_location("pa", _CORE / "core" / "parser_adapter.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _load_filter(): + return _load_mod().apply_reachability_filter + + +def test_null_call_graph_does_not_crash_telemetry(tmp_path): + # A malformed call_graph.json can carry a present-but-null graph (get(...,{}) only + # defends a MISSING key). The ADVISORY telemetry must not crash the filter step — + # this guards BOTH callers of compute_prune_telemetry (the core one is unwrapped). + (tmp_path / "call_graph.json").write_text(json.dumps( + {"functions": {"f:main": {}, "f:dead": {}}, "call_graph": None, "reverse_call_graph": {}})) + ds = _load_mod().apply_reachability_filter( + {"units": [{"id": "f:main"}, {"id": "f:dead"}]}, str(tmp_path), "reachable", + extra_entry_points={"f:main"}) + rf = ds["metadata"]["reachability_filter"] + assert rf["filtered_out"] == 1 # f:dead pruned, no crash + assert rf["pruned_forward_called_by_reachable_count"] == 0 # null graph => no false asym + + +def _load_helper(): + spec = importlib.util.spec_from_file_location( + "pt", _CORE / "utilities" / "prune_telemetry.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.compute_prune_telemetry + + +def test_compute_prune_telemetry_tolerates_null_graphs(): + extra, warn = _load_helper()({"f:a"}, ["f:b"], None, None, None) + assert extra == {"pruned_orphan_count": 1, "pruned_in_dead_cluster_count": 0, + "pruned_forward_called_by_reachable_count": 0, "pruned_by_file": {"f": 1}} + assert warn is None + + +def _run(tmp_path, functions, call_graph, reverse_call_graph, unit_ids, entry): + out = tmp_path + (out / "call_graph.json").write_text(json.dumps({ + "functions": functions, "call_graph": call_graph, + "reverse_call_graph": reverse_call_graph, + })) + dataset = {"units": [{"id": i} for i in unit_ids]} + apply = _load_filter() + ds = apply(dataset, str(out), "reachable", extra_entry_points={entry}) + return ds["metadata"]["reachability_filter"] + + +def test_prune_buckets_and_sidecar(tmp_path): + # E -> A (kept). B orphan. D orphan, D -> C (C in a dead cluster with pruned caller D). + fns = {k: {} for k in ["a.swift:E", "a.swift:A", "a.swift:B", "b.swift:C", "b.swift:D"]} + cg = {"a.swift:E": ["a.swift:A"], "b.swift:D": ["b.swift:C"]} + rcg = {"a.swift:A": ["a.swift:E"], "b.swift:C": ["b.swift:D"]} + m = _run(tmp_path, fns, cg, rcg, list(fns), "a.swift:E") + assert m["reachable_units"] == 2 # E, A + assert m["filtered_out"] == 3 # B, C, D + assert m["pruned_orphan_count"] == 2 # B, D (no caller) + assert m["pruned_in_dead_cluster_count"] == 1 # C (caller D is pruned) + assert m["pruned_forward_called_by_reachable_count"] == 0 # invariant: symmetric graph + # existing keys must be unchanged (additive only) + for k in ("original_units", "entry_points", "reachable_units", "filtered_out", "reduction_percentage"): + assert k in m + # sidecar written with EVERY pruned unit + classification + side = json.loads((tmp_path / m["pruned_units_path"]).read_text()) + ids = {u["id"]: u for u in side["units"]} + assert set(ids) == {"a.swift:B", "b.swift:C", "b.swift:D"} + assert ids["a.swift:B"]["bucket"] == "orphan" + assert ids["b.swift:C"]["bucket"] == "dead_cluster" + assert "b.swift" in m["pruned_by_file"] + + +def test_forward_asymmetry_invariant_fires(tmp_path): + # E -> A -> X in the FORWARD graph, but reverse_call_graph[X] is MISSING A (asymmetry). + # BFS uses reverse => X pruned, yet reachable A forward-calls it => a silently-pruned + # reachable unit. The invariant must catch it (count > 0 + warning). + fns = {k: {} for k in ["a:E", "a:A", "a:X"]} + cg = {"a:E": ["a:A"], "a:A": ["a:X"]} # forward: A calls X + rcg = {"a:A": ["a:E"]} # reverse: X's caller A is MISSING + m = _run(tmp_path, fns, cg, rcg, list(fns), "a:E") + assert m["reachable_units"] == 2 # E, A only (X pruned) + assert m["pruned_forward_called_by_reachable_count"] == 1 # X — the asymmetry victim + assert "warning" in m and "asymmetr" in m["warning"].lower() + + +def test_empty_entrypoints_passthrough_schema_unchanged(tmp_path): + # No entry points -> early pass-through branch. Telemetry keys must NOT be added there + # (that branch has its own schema); nothing was pruned. + fns = {"a:foo": {}, "a:bar": {}} + m = _run.__wrapped__ if False else None + out = tmp_path + (out / "call_graph.json").write_text(json.dumps( + {"functions": fns, "call_graph": {}, "reverse_call_graph": {}})) + ds = _load_filter()({"units": [{"id": i} for i in fns]}, str(out), "reachable") + rf = ds["metadata"]["reachability_filter"] + assert rf["filtered_out"] == 0 and rf.get("reachable_units") == 2 + assert "pruned_orphan_count" not in rf # early branch untouched + assert not (out / "pruned_units.json").exists() # no sidecar when nothing pruned diff --git a/libs/openant-core/tests/test_scanner_contract.py b/libs/openant-core/tests/test_scanner_contract.py index 85b852af..cc744a57 100644 --- a/libs/openant-core/tests/test_scanner_contract.py +++ b/libs/openant-core/tests/test_scanner_contract.py @@ -164,7 +164,8 @@ def test_deeply_nested_code_is_scanned_or_recorded_as_a_gap(language, tmp_path): repo = tmp_path / "repo" repo.mkdir() ext = {"python": ".py", "c": ".c", "php": ".php", "ruby": ".rb", - "zig": ".zig", "javascript": ".js", "go": ".go"}.get(language, ".py") + "zig": ".zig", "javascript": ".js", "go": ".go", + "swift": ".swift"}.get(language, ".py") build_deep_nest(repo / "deep", 600, f"planted{ext}", "x = 1\n") try: diff --git a/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py b/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py index 11bc24e2..42dea945 100644 --- a/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py +++ b/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py @@ -97,6 +97,14 @@ def _unit_type(func_data: Dict) -> str: r'@(Get|Post|Put|Delete|Patch)\(', r'@Controller\(', r'@WebSocketGateway', + # Swift: ObjC-exposed / Interface-Builder-invoked methods are externally + # callable via the ObjC runtime / UI even when they are not `public`, so a + # method carrying one is an entry-point root. The Swift extractor emits these + # as `decorators` (base name, e.g. '@objc', '@IBAction'). Over-seeding an + # externally-invokable method is reachability-safe. + r'@objc\b', + r'@IBAction\b', + r'@IBSegueAction\b', ] # PHP 8 routing attributes (Symfony / API-Platform): `#[Route(...)]`, `#[Get]`, @@ -172,6 +180,19 @@ def _unit_type(func_data: Dict) -> str: # ->toArray() / ->input(...) / ->all() r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(query|request|cookies|attributes|headers|files)\s*->\s*(get|all)\s*\(', r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(get|getPayload|getContent|toArray|input|all)\s*\(', + # Swift daemon / CLI / stdin / XPC / network input surfaces. Apple's + # security-pcc and similar frameworks expose their attack surface through + # daemon request handling (XPC listeners, network listeners) and CLI/stdin — + # not a web `main`. A method that reads one of these IS a user-input entry + # point even when it is `internal` (so public-API seeding alone would miss it). + r'CommandLine\.arguments', + r'ProcessInfo\.processInfo\.(arguments|environment)', + r'\breadLine\s*\(', + r'FileHandle\.standardInput', + r'\bNSXPCListener\b', + r'\bshouldAcceptNewConnection\b', + r'\bxpc_connection_', + r'\bNWListener\b', ] # Patterns that indicate module-level scripts with user input diff --git a/libs/openant-core/utilities/context_corrector.py b/libs/openant-core/utilities/context_corrector.py index 2a23c952..3316c5b6 100644 --- a/libs/openant-core/utilities/context_corrector.py +++ b/libs/openant-core/utilities/context_corrector.py @@ -180,7 +180,7 @@ def gather_source_files(repo_path: str, extensions: list[str] = None) -> list[di # JS / TS family + templates '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.ejs', '.pug', '.hbs', '.json', # other parsed languages - '.go', '.py', '.rb', '.rake', '.php', '.rs', '.zig', + '.go', '.py', '.rb', '.rake', '.php', '.rs', '.zig', '.swift', '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', ] diff --git a/libs/openant-core/utilities/prune_telemetry.py b/libs/openant-core/utilities/prune_telemetry.py new file mode 100644 index 00000000..7c9e7519 --- /dev/null +++ b/libs/openant-core/utilities/prune_telemetry.py @@ -0,0 +1,82 @@ +"""Shared per-unit reachability-prune telemetry (Sol+Fable design). + +Lives in ``utilities`` so BOTH the core (Python) reachability filter and the +per-language parser filters (Swift today; the class-based siblings can adopt it) +import it without a parser->core layering inversion — parsers already depend on +``utilities`` (file_io, agentic_enhancer), never on ``core``. + +ADDITIVE, all-language, NO change to which units survive. +""" +import os +import sys + +from utilities.file_io import open_utf8 + + +def compute_prune_telemetry(reachable_ids, pruned_ids, call_graph, reverse_call_graph, + output_dir=None): + """Classify every pruned unit + enforce the forward-asymmetry invariant. + + Classifies each prune as ``orphan`` (no non-self caller => missing-edge ROOT + candidate) or ``dead_cluster`` (has a pruned caller => downstream shadow), and + computes the hard INVARIANT ``pruned_forward_called_by_reachable_count`` (a REACHABLE + unit forward-calling a PRUNED one = call_graph/reverse_call_graph asymmetry = a + definitively wrong prune; expect 0). + + Contract: + - ``pruned_ids`` is iterated as given — pass a SORTED list for a deterministic + ``pruned_by_file`` tie-ordering. + - ``call_graph``/``reverse_call_graph`` MUST be the UN-pruned graphs. Feeding a + pruned graph (every edge to a pruned node already stripped) forces the invariant + to a manufactured 0 — silently disabling it. This is the single highest-risk arg. + - When ``output_dir`` is given and something was pruned, writes ``pruned_units.json`` + (best-effort; a telemetry failure never propagates). + + Returns ``(extra_rf_keys, asym_warning_or_None)`` — the caller merges the keys into + its own base rf (original_units/entry_points/... stay caller-owned) and applies its + own warning precedence (e.g. a blackout warning may override the asymmetry one). + + Telemetry is ADVISORY and must never crash the filter step, so a malformed + present-but-null graph is treated as empty (consistent with the callers' missing-key + defaulting) rather than raising. + """ + from collections import Counter + call_graph = call_graph or {} + reverse_call_graph = reverse_call_graph or {} + pruned_set = set(pruned_ids) + pruned_records, by_file = [], Counter() + orphan_ct = cluster_ct = 0 + for pid in pruned_ids: + callers = [c for c in (reverse_call_graph.get(pid) or []) if c != pid] + bucket = "dead_cluster" if callers else "orphan" + orphan_ct += bucket == "orphan" + cluster_ct += bucket == "dead_cluster" + by_file[pid.split(":", 1)[0]] += 1 + pruned_records.append({"id": pid, "file": pid.split(":", 1)[0], + "callers": sorted(callers), "bucket": bucket}) + asym = {callee for c in reachable_ids for callee in (call_graph.get(c) or []) + if callee in pruned_set} + extra = { + "pruned_orphan_count": orphan_ct, + "pruned_in_dead_cluster_count": cluster_ct, + "pruned_forward_called_by_reachable_count": len(asym), + "pruned_by_file": dict(by_file.most_common(20)), + } + if pruned_ids and output_dir: # full sidecar (uncapped, id-sorted); best-effort + try: + import json as _json + # open_utf8 (not bare open) per the repo file-io convention enforced by + # tests/test_file_io.py::test_no_bare_open_in_non_test_code. + with open_utf8(os.path.join(output_dir, "pruned_units.json"), "w") as _f: + _json.dump({"schema_version": 1, + "units": sorted(pruned_records, key=lambda r: r["id"])}, _f, indent=2) + extra["pruned_units_path"] = "pruned_units.json" + except Exception as _e: + print(f" [Warning] could not write pruned_units.json: {_e}", file=sys.stderr) + asym_warning = None + if asym: + asym_warning = ( + f"{len(asym)} pruned unit(s) are forward-called by a REACHABLE unit " + "(call_graph/reverse_call_graph ASYMMETRY) — genuinely-reachable units were silently " + "pruned. Investigate graph symmetry (test_callgraph_symmetry).") + return extra, asym_warning