-
-
Notifications
You must be signed in to change notification settings - Fork 10.8k
feat(apex): parse Apex with tree-sitter, add calls edges #3122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v8
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,85 @@ | ||
| """Apex extractor. Moved verbatim from graphify/extract.py.""" | ||
| """Apex extractor: tree-sitter when the grammar is installed, regex otherwise.""" | ||
| from __future__ import annotations | ||
|
|
||
|
|
||
| from pathlib import Path | ||
|
|
||
| from graphify.extractors.base import _file_stem, _make_id | ||
| from graphify.extractors.models import LanguageConfig | ||
|
|
||
| # The sfapex grammar (aheber/tree-sitter-sfapex) uses the same node type and field | ||
| # names as tree-sitter-java for declarations and calls, so the shared engine walk | ||
| # drives it unchanged. Apex has no import statements, hence no import_types. | ||
| _APEX_CONFIG = LanguageConfig( | ||
| ts_module="tree_sitter_language_pack", | ||
| ts_language_pack_name="apex", | ||
| class_types=frozenset({ | ||
| "class_declaration", "interface_declaration", "enum_declaration", | ||
| "trigger_declaration", | ||
| }), | ||
| function_types=frozenset({"method_declaration", "constructor_declaration"}), | ||
| call_types=frozenset({"method_invocation"}), | ||
| call_function_field="name", | ||
| function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}), | ||
| ) | ||
|
|
||
|
|
||
| def extract_apex(path: Path) -> dict: | ||
| """Extract an Apex .cls or .trigger file. | ||
|
|
||
| Prefers the real parser; falls back to the regex extractor when the grammar | ||
| is not installed, mirroring how Pascal treats its optional grammar. The | ||
| fallback keeps every Apex corpus working without the extra, at lower | ||
| fidelity and with no `calls` edges. | ||
| """ | ||
| ast = _extract_apex_ast(path) | ||
| return ast if ast is not None else _extract_apex_regex(path) | ||
|
|
||
|
|
||
| def _extract_apex_ast(path: Path) -> dict | None: | ||
| """Engine walk plus the Apex-only constructs the generic walk cannot know. | ||
|
|
||
| Returns None when the grammar is unavailable or the file does not parse, so | ||
| the caller can fall back rather than emit a half-empty result. | ||
| """ | ||
| try: | ||
| from tree_sitter_language_pack import get_parser | ||
| except Exception: | ||
| return None | ||
| try: | ||
| parser = get_parser("apex") | ||
| source = path.read_bytes() | ||
| except Exception: | ||
| return None | ||
|
|
||
| from graphify.extractors.engine import _extract_generic | ||
|
|
||
| result = _extract_generic(path, _APEX_CONFIG) | ||
| if result.get("error"): | ||
| return None | ||
| try: | ||
| root = parser.parse(source).root_node | ||
| except Exception: | ||
| return None | ||
| # tree-sitter is error-tolerant: it returns a tree with ERROR nodes instead of | ||
| # raising, so a file the grammar cannot handle would otherwise yield a | ||
| # confidently wrong AST. Hand those to the regex path, which degrades | ||
| # predictably rather than inventing structure. | ||
| if root.has_error: | ||
| return None | ||
| _add_apex_specifics(path, root, source, result) | ||
| raw_calls = result.get("raw_calls") | ||
| if raw_calls: | ||
| result["raw_calls"] = [ | ||
| rc for rc in raw_calls | ||
| if str(rc.get("callee", "")).lower() not in _APEX_BUILTIN_METHODS | ||
| ] | ||
| return result | ||
|
|
||
|
|
||
| def _extract_apex_regex(path: Path) -> dict: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
high coupling complexity (Ca·Ce = 12). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| """Extract classes, interfaces, enums, methods, and Salesforce constructs from | ||
| Apex .cls and .trigger files using regex (no tree-sitter grammar on PyPI).""" | ||
| Apex .cls and .trigger files using regex. Fallback for a missing grammar.""" | ||
| import re as _re | ||
| try: | ||
| source = path.read_text(encoding="utf-8", errors="replace") | ||
|
|
@@ -213,3 +284,218 @@ def add_edge(src: str, tgt: str, relation: str, line: int, | |
| add_edge(src, dml_nid, "uses", lineno, confidence="INFERRED") | ||
|
|
||
| return {"nodes": nodes, "edges": edges} | ||
|
|
||
|
|
||
| # ── Apex constructs the generic engine walk has no concept of ───────────────── | ||
|
|
||
| _DML_TYPES = frozenset({"insert", "update", "delete", "upsert", "merge", "undelete"}) | ||
|
|
||
| # Annotations that make a method an entry point reachable from outside Apex — | ||
| # Lightning, Flow, and the Apex REST verbs. Without the REST ones a | ||
| # @RestResource class looks unreachable, since nothing in the corpus calls it. | ||
| _ENTRY_POINT_ANNOTATIONS = frozenset({ | ||
| "auraenabled", "invocablemethod", "remoteaction", | ||
| "httpget", "httppost", "httpput", "httpdelete", "httppatch", | ||
| }) | ||
|
|
||
| # Collection and primitive constructors. `new List<Account>()` appears in nearly | ||
| # every method, so treating it as a call site builds a god-node that collects an | ||
| # edge from the whole codebase and tells you nothing — the same reason | ||
| # base.py filters language built-ins. Platform types that do carry meaning | ||
| # (HttpRequest, and any custom type) are deliberately NOT filtered. | ||
| # Collection and Map/Set/String methods. These are the receiver's methods, not a | ||
| # user method, but the cross-file call resolver matches unresolved calls by bare | ||
| # name — so `rows.add(x)` in twenty classes all bind to a user class that happens | ||
| # to define `add`, inventing twenty dependencies. Same-file calls are resolved | ||
| # against real declarations before this applies, so a class calling its own | ||
| # `add()` keeps its edge. Deliberately conservative: `execute` and `send` are NOT | ||
| # here, because they are commonly real user methods. | ||
| # Note the asymmetry that makes this necessary: a name defined by MANY classes is | ||
| # already safe, because the resolver refuses to bind an ambiguous name. The | ||
| # damage comes from a name defined by exactly ONE class — `send` in one class | ||
| # collects every `new Http().send(req)` in the codebase. `execute` is left out | ||
| # for exactly that reason: it is declared by every invocable class, so it is | ||
| # ambiguous and only ever resolves within a file. | ||
| _APEX_BUILTIN_METHODS = frozenset({ | ||
| # Collections and Map/Set | ||
| "add", "addall", "get", "put", "putall", "size", "isempty", "clear", | ||
| "contains", "containskey", "keyset", "values", "remove", "indexof", "sort", | ||
| "deepclone", "clone", | ||
| # Http/HttpRequest/HttpResponse | ||
| "send", "getbody", "setbody", "getstatuscode", "setstatuscode", | ||
| "setheader", "getheader", "setendpoint", "setmethod", | ||
| # JSON, String, Object | ||
| "serialize", "deserialize", "deserializeuntyped", "escapesinglequotes", | ||
| "isblank", "isnotblank", "valueof", "tostring", "equals", "hashcode", | ||
| }) | ||
|
|
||
| _APEX_BUILTIN_CONSTRUCTORS = frozenset({ | ||
| "list", "set", "map", "blob", "object", | ||
| "string", "integer", "long", "double", "decimal", "boolean", | ||
| "date", "datetime", "time", "id", | ||
| }) | ||
|
|
||
|
|
||
| def _apex_text(node, source: bytes) -> str: | ||
| return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | ||
|
|
||
|
|
||
| def _add_apex_specifics(path: Path, root, source: bytes, result: dict) -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 8 callees (efferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 8 callees (efferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| """Add inheritance, SObject usage, DML and entry points to an engine result. | ||
|
|
||
| The engine walk covers declarations and calls, which are shaped like Java. | ||
| Everything here is Apex-only: `extends`/`implements` (the engine's handling | ||
| is gated on the Java grammar), the SObject a trigger fires on, the SObject | ||
| behind a SOQL `FROM` or SOSL `RETURNING`, and DML statements. Node ids reuse | ||
| the engine's scheme so the two halves land on the same nodes. | ||
| """ | ||
| str_path = str(path) | ||
| stem = _file_stem(path) | ||
| file_nid = _make_id(str_path) | ||
| nodes: list[dict] = result["nodes"] | ||
| edges: list[dict] = result["edges"] | ||
| seen_ids: set[str] = {n["id"] for n in nodes if n.get("id")} | ||
| seen_edges = {(e.get("source"), e.get("target"), e.get("relation")) for e in edges} | ||
|
|
||
| def add_stub(nid: str, label: str) -> None: | ||
| """Sourceless placeholder — see the note in _extract_apex_regex.""" | ||
| if nid not in seen_ids: | ||
| seen_ids.add(nid) | ||
| nodes.append({"id": nid, "label": label, "file_type": "code", | ||
| "source_file": "", "source_location": ""}) | ||
|
|
||
| def add_edge(src: str, tgt: str, relation: str, line: int) -> None: | ||
| key = (src, tgt, relation) | ||
| if src == tgt or key in seen_edges: | ||
| return | ||
| seen_edges.add(key) | ||
| edges.append({"source": src, "target": tgt, "relation": relation, | ||
| "confidence": "INFERRED", "source_file": str_path, | ||
| "source_location": f"L{line}", "weight": 1.0}) | ||
|
|
||
| def type_ref(name: str) -> str: | ||
| local = _make_id(stem, name) | ||
| if local in seen_ids: | ||
| return local | ||
| nid = _make_id(name) | ||
| add_stub(nid, name) | ||
| return nid | ||
|
|
||
| def named(node, name: str): | ||
| return node.child_by_field_name(name) | ||
|
|
||
| def enclosing_callable(node) -> str: | ||
| """Nearest enclosing method/constructor node id, else the type or file.""" | ||
| cur = node.parent | ||
| while cur is not None: | ||
| if cur.type in ("method_declaration", "constructor_declaration"): | ||
| name_node = named(cur, "name") | ||
| if name_node is not None: | ||
| nid = _make_id(enclosing_owner(cur), | ||
| _apex_text(name_node, source)) | ||
| if nid in seen_ids: | ||
| return nid | ||
| cur = cur.parent | ||
| return enclosing_owner(node) | ||
|
|
||
| def enclosing_owner(node) -> str: | ||
| """Nearest enclosing type or trigger node id, else the file node.""" | ||
| cur = node.parent | ||
| while cur is not None: | ||
| if cur.type in ("class_declaration", "interface_declaration", | ||
| "enum_declaration", "trigger_declaration"): | ||
| name_node = named(cur, "name") | ||
| if name_node is not None: | ||
| nid = _make_id(stem, _apex_text(name_node, source)) | ||
| if nid in seen_ids: | ||
| return nid | ||
| cur = cur.parent | ||
| return file_nid | ||
|
|
||
| stack = [root] | ||
| while stack: | ||
| node = stack.pop() | ||
| stack.extend(node.children) | ||
| t = node.type | ||
| line = node.start_point[0] + 1 | ||
|
|
||
| if t in ("class_declaration", "interface_declaration"): | ||
| name_node = named(node, "name") | ||
| if name_node is None: | ||
| continue | ||
| owner = _make_id(stem, _apex_text(name_node, source)) | ||
| if owner not in seen_ids: | ||
| continue | ||
| for child in node.children: | ||
| if child.type == "superclass": | ||
| for sub in child.children: | ||
| if sub.type in ("type_identifier", "scoped_type_identifier"): | ||
| add_edge(owner, type_ref(_apex_text(sub, source)), | ||
| "extends", line) | ||
| elif child.type in ("interfaces", "extends_interfaces"): | ||
| for sub in child.named_children: | ||
| for entry in (sub.named_children if sub.type == "type_list" else [sub]): | ||
| if entry.type in ("type_identifier", "scoped_type_identifier", | ||
| "generic_type"): | ||
| raw = _apex_text(entry, source).split("<", 1)[0].strip() | ||
| relation = ("extends" if t == "interface_declaration" | ||
| else "implements") | ||
| add_edge(owner, type_ref(raw), relation, line) | ||
|
|
||
| elif t == "trigger_declaration": | ||
| name_node, obj_node = named(node, "name"), named(node, "object") | ||
| if name_node is not None and obj_node is not None: | ||
| trig = _make_id(stem, _apex_text(name_node, source)) | ||
| if trig in seen_ids: | ||
| add_edge(trig, type_ref(_apex_text(obj_node, source)), "uses", line) | ||
|
|
||
| elif t == "storage_identifier" and node.parent is not None \ | ||
| and node.parent.type == "from_clause": | ||
| add_edge(enclosing_owner(node), | ||
| type_ref(_apex_text(node, source).split(".")[0]), "uses", line) | ||
|
|
||
| elif t == "sobject_return": | ||
| for sub in node.children: | ||
| if sub.type == "identifier": | ||
| add_edge(enclosing_owner(node), | ||
| type_ref(_apex_text(sub, source)), "uses", line) | ||
| break | ||
|
|
||
| elif t == "object_creation_expression": | ||
| # `new Other()` is a call site whose callee sits in the `type` field, | ||
| # so the shared call walk (which reads `name`) never sees it. | ||
| type_node = named(node, "type") | ||
| if type_node is not None: | ||
| raw = _apex_text(type_node, source).split("<", 1)[0].strip() | ||
| if (raw and raw[:1].isalpha() | ||
| and raw.lower() not in _APEX_BUILTIN_CONSTRUCTORS): | ||
| add_edge(enclosing_callable(node), type_ref(raw), "calls", line) | ||
|
|
||
| elif t == "dml_type": | ||
| op = _apex_text(node, source).strip().lower() | ||
| if op in _DML_TYPES: | ||
| dml_nid = _make_id(f"dml_{op}") | ||
| if dml_nid not in seen_ids: | ||
| seen_ids.add(dml_nid) | ||
| nodes.append({"id": dml_nid, "label": op, "file_type": "code", | ||
| "source_file": str_path, | ||
| "source_location": f"L{line}"}) | ||
| add_edge(enclosing_owner(node), dml_nid, "uses", line) | ||
|
|
||
| elif t in ("method_declaration", "constructor_declaration"): | ||
| name_node = named(node, "name") | ||
| if name_node is None: | ||
| continue | ||
| owner = enclosing_owner(node) | ||
| method_nid = _make_id(owner, _apex_text(name_node, source)) | ||
| if method_nid not in seen_ids: | ||
| continue | ||
| for mods in node.children: | ||
| if mods.type != "modifiers": | ||
| continue | ||
| for anno in mods.children: | ||
| if anno.type != "annotation": | ||
| continue | ||
| raw = _apex_text(anno, source).lstrip("@").split("(")[0].strip().lower() | ||
| if raw in _ENTRY_POINT_ANNOTATIONS: | ||
| add_edge(file_nid, method_nid, "contains", line) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_extract_apex_regex()high coupling complexity (Ca·Ce = 12).
Grounded coupling-delta finding (deterministic), not an LLM guess.