From 5b5ba6dde06af39b3d1da21f6fba2cd7ad652abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20S=C3=B6semann?= Date: Wed, 26 Aug 2026 14:50:42 +0200 Subject: [PATCH 1/3] feat(salesforce): read metadata XML, not just Apex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a Salesforce codebase, .cls and .trigger are a minority of the files. Objects, fields, layouts, list views and permission sets hold a lot of the wiring, and none of it reached the graph — those files are not classified at all today. Salesforce metadata is plain XML, so this is one generic element walk with the stdlib parser and no new dependency: no logic per component type. What a file declares comes from its filename; what it references comes from leaf element text, emitted as sourceless placeholders so the corpus rewire binds them to the real definition. A permission set ends up pointing at NotifyUser.cls without this extractor knowing what a permission set is. Dispatch is by filename, following the .blade.php precedent, because the component kind sits in a compound suffix. Plain .xml stays unclaimed: claiming it would pull every pom.xml and web.xml in every repository into every graph, which is a separate decision. --- graphify/detect.py | 7 + graphify/extract.py | 14 +- graphify/extractors/__init__.py | 2 + graphify/extractors/salesforce_meta_xml.py | 210 +++++++++++++++++++++ tests/test_salesforce_meta_xml.py | 191 +++++++++++++++++++ 5 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 graphify/extractors/salesforce_meta_xml.py create mode 100644 tests/test_salesforce_meta_xml.py diff --git a/graphify/detect.py b/graphify/detect.py index 3668eb6fcf..4e369281de 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -508,6 +508,13 @@ def classify_file(path: Path) -> FileType | None: from graphify.manifest_ingest import is_package_manifest_path if is_package_manifest_path(path): return FileType.CODE + # Salesforce metadata (`Account.object-meta.xml`) is parsed deterministically, + # so it takes the AST path like the manifests above. Its component kind sits in + # a compound suffix, and plain `.xml` is deliberately NOT in CODE_EXTENSIONS — + # claiming it would sweep every pom.xml/web.xml into the graph. + from graphify.extractors.salesforce_meta_xml import is_salesforce_meta_xml_path + if is_salesforce_meta_xml_path(path): + return FileType.CODE # Compound extensions must be checked before simple suffix lookup if path.name.lower().endswith(".blade.php"): return FileType.CODE diff --git a/graphify/extract.py b/graphify/extract.py index f68757a85f..4fce0b68c1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -53,6 +53,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 +from graphify.extractors.salesforce_meta_xml import extract_salesforce_meta_xml, is_salesforce_meta_xml_path # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform # noqa: F401 @@ -5391,6 +5392,13 @@ def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" if path.name.lower().endswith(".blade.php"): return extract_blade + # Salesforce source-format metadata (`Account.object-meta.xml`) carries the + # component kind in a compound suffix, not the extension, so it routes by + # filename like .blade.php above. Plain `.xml` stays unclaimed: claiming it + # would pull every pom.xml/web.xml in every repo into the graph, which is a + # separate decision from supporting Salesforce. + if is_salesforce_meta_xml_path(path): + return extract_salesforce_meta_xml # MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed # by filename before generic .json dispatch so they get MCP-aware nodes # (servers, commands, packages, env vars) instead of opaque JSON keys. @@ -7123,7 +7131,11 @@ def _ignored(p: Path) -> bool: for fname in filenames: p = dp / fname suffix = p.suffix - if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root): + # Salesforce metadata is dispatched by filename, not extension, so + # the extension gate alone would never collect it. + claimed = (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS + or is_salesforce_meta_xml_path(p)) + if claimed and not _ignored(p) and _resolves_under_root(p, containment_root): results.append(p) return sorted(results) # Walk with symlink following + cycle detection diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index 68ff3340c3..f87665cc12 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -28,6 +28,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust +from graphify.extractors.salesforce_meta_xml import extract_salesforce_meta_xml from graphify.extractors.sln import extract_sln from graphify.extractors.sql import extract_sql from graphify.extractors.terraform import extract_terraform @@ -58,6 +59,7 @@ "powershell_manifest": extract_powershell_manifest, "razor": extract_razor, "rust": extract_rust, + "salesforce_meta_xml": extract_salesforce_meta_xml, "sln": extract_sln, "sql": extract_sql, "terraform": extract_terraform, diff --git a/graphify/extractors/salesforce_meta_xml.py b/graphify/extractors/salesforce_meta_xml.py new file mode 100644 index 0000000000..0aa7e9aa1a --- /dev/null +++ b/graphify/extractors/salesforce_meta_xml.py @@ -0,0 +1,210 @@ +"""Salesforce `*-meta.xml` extractor — the Salesforce subset of plain XML. + +Salesforce metadata is ordinary XML, so this is one generic element walk with no +per-component-type logic: objects, fields, permission sets, layouts, flows and +everything else go through the same code path, parsed with the stdlib XML +parser and no new dependency. + +What a file DECLARES comes from its filename (``Memory__c.object-meta.xml`` +declares ``Memory__c``). What it REFERENCES comes from leaf element text, which +is how Salesforce spells cross-component links:: + + + NotifyUser <- a reference to an Apex class + true <- a value, not a reference + + +References are emitted as sourceless placeholders, so the corpus-level rewire +binds them to the real definition — the permission set above ends up pointing +at ``NotifyUser.cls`` without this extractor knowing what a permission set is. +""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + +_META_SUFFIX = "-meta.xml" + +# Stdlib ElementTree does not cap entity expansion, so a crafted file could +# trigger a billion-laughs DoS. Mirrors the project-XML screen in extract.py; +# metadata emitted by the Salesforce CLI never declares a DTD or entity. +_MAX_BYTES = 2 * 1024 * 1024 + + +def _is_safe(src: bytes) -> bool: + lowered = src.lower() + return b" str: + """Strip the metadata namespace every Salesforce file declares.""" + return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag + + +# Sidecars for a companion file graphify already extracts itself (AgentMemory.cls +# next to AgentMemory.cls-meta.xml). They carry only apiVersion/status, so parsing +# them would mint a second node for a component another extractor already owns. +# Kinds whose companion has NO extractor (a Visualforce .page, a static resource) +# are deliberately absent: their metadata file is the only handle on them. +_SIDECAR_KINDS = frozenset({"cls", "trigger", "js"}) + +# Element names whose text names ANOTHER component. Custom API names are +# recognised by their suffix instead (below), so this only has to cover +# references to standard components — Apex classes, pages, standard objects. +# Chosen from the element names that actually carry identifiers in real orgs. +_REFERENCE_ELEMENTS = frozenset({ + "apexClass", "apexPage", "apexTrigger", "object", "field", "fields", + "columns", "recordType", "flow", "tab", "layout", "relatedList", + "customPermission", "namedCredential", "externalCredential", + "application", "controller", "extensions", "targetObject", "sobjectType", +}) + +# Human-facing display text, never an API name. `fullName` and `name` are NOT +# here: they usually restate the component's own name (dropped below by the +# self-reference check), but `CustomSetting__c` in a custom-metadata +# record is a genuine reference, so they go through the normal rules. +_SELF_NAME_ELEMENTS = frozenset({"masterLabel", "label", "description", "motif"}) + +# A name carrying one of these is always an API name, never an enum value, so +# it is a reference wherever it appears — no element vocabulary needed. +_CUSTOM_SUFFIXES = ("__c", "__mdt", "__e", "__x", "__b", "__r", "__Share", "__History") + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$") + + +def _is_api_name(text: str) -> bool: + return bool(_IDENTIFIER_RE.match(text)) and not text.isupper() + + +def is_salesforce_meta_xml_path(path: Path) -> bool: + """True for a Salesforce source-format metadata file this extractor owns. + + Sidecars for a file another extractor already handles (``Foo.cls-meta.xml``) + are excluded, so dispatch and the file scan agree on one answer. + """ + name = path.name + if not name.endswith(_META_SUFFIX): + return False + return _component_of(path)[1] not in _SIDECAR_KINDS + + +def _component_of(path: Path) -> tuple[str, str]: + """``(name, kind)`` a metadata file declares, read from its filename.""" + base = path.name[: -len(_META_SUFFIX)] + name, _, kind = base.rpartition(".") + return (name, kind) if name else (base, "") + + +def _parent_object(path: Path) -> str | None: + """Owning object of a nested component (a field under ``objects/Foo/fields/``). + + Found by looking for an ancestor directory that holds its own + ``.object-meta.xml``, so it needs no hardcoded folder names. + """ + for ancestor in list(path.parents)[:4]: + if (ancestor / f"{ancestor.name}.object-meta.xml").is_file(): + return ancestor.name + return None + + +def extract_salesforce_meta_xml(path: Path) -> dict: + """Extract one Salesforce ``*-meta.xml`` file.""" + name, kind = _component_of(path) + if kind in _SIDECAR_KINDS: + return {"nodes": [], "edges": []} + + try: + src = path.read_bytes() + except OSError: + return {"nodes": [], "edges": [], "error": f"cannot read {path}"} + if len(src) > _MAX_BYTES: + return {"nodes": [], "edges": [], "error": "metadata file too large"} + if not _is_safe(src): + return {"nodes": [], "edges": [], + "error": "refusing XML with DOCTYPE/ENTITY declaration"} + try: + root = ET.fromstring(src) + except ET.ParseError as e: + return {"nodes": [], "edges": [], "error": f"XML parse error: {e}"} + + str_path = str(path) + stem = _file_stem(path) + file_nid = _make_id(str_path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() + + # First line each identifier appears on, so references get a location + # without a second pass over the tree. + line_of: dict[str, int] = {} + for lineno, line in enumerate(src.decode("utf-8", errors="replace").splitlines(), 1): + inner = line.strip() + if inner.startswith("<") and ">" in inner: + value = inner[inner.index(">") + 1:].rsplit("<", 1)[0].strip() + if value: + line_of.setdefault(value, lineno) + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}"}) + + def add_stub(nid: str, label: str) -> None: + """Sourceless placeholder for a component defined in another file. + + Sourced stubs get their id salted per referencing file, which scatters + one component across a node per reference and leaves the real + definition unreferenced (the #1402/#2324 pattern). + """ + 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_nid: str, tgt_nid: str, relation: str, line: int) -> None: + key = (src_nid, tgt_nid, relation) + if src_nid == tgt_nid or key in seen_edges: + return + seen_edges.add(key) + edges.append({"source": src_nid, "target": tgt_nid, "relation": relation, + "confidence": "INFERRED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0}) + + add_node(file_nid, path.name, 1) + comp_nid = _make_id(stem, name) + add_node(comp_nid, name, 1) + add_edge(file_nid, comp_nid, "contains", 1) + + owner = _parent_object(path) + if owner and owner != name: + owner_nid = _make_id(owner) + add_stub(owner_nid, owner) + add_edge(owner_nid, comp_nid, "contains", 1) + + for el in root.iter(): + if len(el): + continue + text = (el.text or "").strip() + if not text or not _is_api_name(text): + continue + tag = _local(el.tag) + if tag in _SELF_NAME_ELEMENTS: + continue + qualified = text.split(".") + by_suffix = any(text.endswith(s) for s in _CUSTOM_SUFFIXES) + if tag not in _REFERENCE_ELEMENTS and not by_suffix and len(qualified) == 1: + continue + line = line_of.get(text, 1) + for part in qualified: + if not _is_api_name(part) or part == name: + continue + ref_nid = _make_id(part) + add_stub(ref_nid, part) + add_edge(comp_nid, ref_nid, "references", line) + + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_salesforce_meta_xml.py b/tests/test_salesforce_meta_xml.py new file mode 100644 index 0000000000..e93ebde75f --- /dev/null +++ b/tests/test_salesforce_meta_xml.py @@ -0,0 +1,191 @@ +"""Tests for the Salesforce ``*-meta.xml`` extractor.""" +from __future__ import annotations + +from pathlib import Path + +from graphify.detect import FileType, classify_file +from graphify.extract import _get_extractor, extract +from graphify.extractors.salesforce_meta_xml import ( + extract_salesforce_meta_xml, + is_salesforce_meta_xml_path, +) + + +def _write(path: Path, body: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def _labels(result: dict) -> set[str]: + return {n["label"] for n in result["nodes"]} + + +def _refs(result: dict) -> set[str]: + by_id = {n["id"]: n for n in result["nodes"]} + return {by_id[e["target"]]["label"] for e in result["edges"] + if e["relation"] == "references"} + + +PERMISSION_SET = """ + + + NotifyUser + true + + + true + Memory__c.Content__c + true + + +""" + +CUSTOM_FIELD = """ + + IsShared__c + + Checkbox + false + +""" + + +def test_component_name_comes_from_the_filename(tmp_path: Path): + f = _write(tmp_path / "MyOrgButlerUser.permissionset-meta.xml", PERMISSION_SET) + assert "MyOrgButlerUser" in _labels(extract_salesforce_meta_xml(f)) + + +def test_apex_class_grant_becomes_a_reference(tmp_path: Path): + f = _write(tmp_path / "MyOrgButlerUser.permissionset-meta.xml", PERMISSION_SET) + assert "NotifyUser" in _refs(extract_salesforce_meta_xml(f)) + + +def test_qualified_field_references_both_object_and_field(tmp_path: Path): + f = _write(tmp_path / "MyOrgButlerUser.permissionset-meta.xml", PERMISSION_SET) + refs = _refs(extract_salesforce_meta_xml(f)) + assert {"Memory__c", "Content__c"} <= refs + + +def test_values_are_not_references(tmp_path: Path): + """Negative control: enum and boolean element text must not become edges. + + `true`, `Checkbox` and `Is Shared` are values, not component names. Turning + them into nodes would build god-nodes that every metadata file links to. + """ + f = _write(tmp_path / "IsShared__c.field-meta.xml", CUSTOM_FIELD) + result = extract_salesforce_meta_xml(f) + assert _refs(result) == set() + assert _labels(result) == {"IsShared__c.field-meta.xml", "IsShared__c"} + + +def test_display_text_is_not_a_reference(tmp_path: Path): + """A `