graphify-sf 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/PKG-INFO +28 -3
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/README.md +26 -2
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/__main__.py +21 -4
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/build.py +52 -4
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/detect.py +96 -12
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/apex.py +71 -23
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/PKG-INFO +28 -3
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/requires.txt +1 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/pyproject.toml +2 -1
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_build.py +82 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect.py +96 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_apex.py +120 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/LICENSE +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/__init__.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/_bundled_skill.md +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/analyze.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/cache.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/cluster.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/export.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/__init__.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/_ids.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/agentforce.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/aura.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/automation.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/config.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/doc.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/flow.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/layout.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/lwc.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/object.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/profile.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/visualforce.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/llm.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/report.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/security.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/serve.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/validate.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/watch.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/SOURCES.txt +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/dependency_links.txt +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/entry_points.txt +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/top_level.txt +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/setup.cfg +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_analyze.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_cli.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_cluster.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect_docs.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect_skip_dirs.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_agentforce.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_config.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_doc.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_flow.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_lwc.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_object.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_pipeline.py +0 -0
- {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_llm.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphify-sf
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Turn any Salesforce SFDX project into a queryable knowledge graph — fully offline, no org connection required.
|
|
5
5
|
Author: Ray Kuo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,7 @@ License-File: LICENSE
|
|
|
25
25
|
Requires-Dist: networkx>=3.0
|
|
26
26
|
Requires-Dist: datasketch>=1.6
|
|
27
27
|
Requires-Dist: rapidfuzz>=3.0
|
|
28
|
+
Requires-Dist: pathspec>=0.12
|
|
28
29
|
Requires-Dist: idna>=3.15
|
|
29
30
|
Provides-Extra: leiden
|
|
30
31
|
Requires-Dist: graspologic; python_version < "3.13" and extra == "leiden"
|
|
@@ -700,9 +701,33 @@ All configuration via environment variables:
|
|
|
700
701
|
| `GRAPHIFY_SF_REBUILD_MEMORY_LIMIT_MB` | `` | Cap memory usage for background watch rebuilds (MB) |
|
|
701
702
|
| `CLAUDE_CONFIG_DIR` | `~/.claude` | Override Claude Code config directory — only set if your Claude Code config lives at a non-standard path (company-managed dotfiles, CI, or multiple installations) |
|
|
702
703
|
|
|
703
|
-
### `.graphifysfignore`
|
|
704
|
+
### Ignoring files (`.gitignore`, `.forceignore`, `.graphifysfignore`)
|
|
704
705
|
|
|
705
|
-
|
|
706
|
+
By default graphify-sf **respects your project's `.gitignore` and `.forceignore`** so that
|
|
707
|
+
deprecated metadata, `__tests__`, queues, editor/lint scaffolding, and build output don't become
|
|
708
|
+
noise in the graph. Patterns use full gitignore semantics (negation `!keep`, anchored `/foo`,
|
|
709
|
+
directory `foo/`, `**`), parsed with `pathspec` — the same way git and the `sf` CLI read them. The
|
|
710
|
+
ignore files are found by walking **up** from the path you scan, so `graphify-sf force-app` still
|
|
711
|
+
honors the `.gitignore`/`.forceignore` at your project root.
|
|
712
|
+
|
|
713
|
+
After a scan, any skipped files are reported honestly:
|
|
714
|
+
|
|
715
|
+
```
|
|
716
|
+
[graphify-sf] 5573 metadata files found, 57 skipped
|
|
717
|
+
[graphify-sf] skipped 57 files (.forceignore: 57) — use --include-ignored to include them
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
To scan **everything** regardless of `.gitignore`/`.forceignore` (e.g. you suspect a real metadata
|
|
721
|
+
file was excluded):
|
|
722
|
+
|
|
723
|
+
```bash
|
|
724
|
+
graphify-sf <path> --include-ignored
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
#### `.graphifysfignore`
|
|
728
|
+
|
|
729
|
+
For graphify-specific exclusions independent of git/SF, place a `.graphifysfignore` file at your
|
|
730
|
+
project root (glob patterns, always applied):
|
|
706
731
|
|
|
707
732
|
```
|
|
708
733
|
# Exclude test data
|
|
@@ -625,9 +625,33 @@ All configuration via environment variables:
|
|
|
625
625
|
| `GRAPHIFY_SF_REBUILD_MEMORY_LIMIT_MB` | `` | Cap memory usage for background watch rebuilds (MB) |
|
|
626
626
|
| `CLAUDE_CONFIG_DIR` | `~/.claude` | Override Claude Code config directory — only set if your Claude Code config lives at a non-standard path (company-managed dotfiles, CI, or multiple installations) |
|
|
627
627
|
|
|
628
|
-
### `.graphifysfignore`
|
|
628
|
+
### Ignoring files (`.gitignore`, `.forceignore`, `.graphifysfignore`)
|
|
629
629
|
|
|
630
|
-
|
|
630
|
+
By default graphify-sf **respects your project's `.gitignore` and `.forceignore`** so that
|
|
631
|
+
deprecated metadata, `__tests__`, queues, editor/lint scaffolding, and build output don't become
|
|
632
|
+
noise in the graph. Patterns use full gitignore semantics (negation `!keep`, anchored `/foo`,
|
|
633
|
+
directory `foo/`, `**`), parsed with `pathspec` — the same way git and the `sf` CLI read them. The
|
|
634
|
+
ignore files are found by walking **up** from the path you scan, so `graphify-sf force-app` still
|
|
635
|
+
honors the `.gitignore`/`.forceignore` at your project root.
|
|
636
|
+
|
|
637
|
+
After a scan, any skipped files are reported honestly:
|
|
638
|
+
|
|
639
|
+
```
|
|
640
|
+
[graphify-sf] 5573 metadata files found, 57 skipped
|
|
641
|
+
[graphify-sf] skipped 57 files (.forceignore: 57) — use --include-ignored to include them
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
To scan **everything** regardless of `.gitignore`/`.forceignore` (e.g. you suspect a real metadata
|
|
645
|
+
file was excluded):
|
|
646
|
+
|
|
647
|
+
```bash
|
|
648
|
+
graphify-sf <path> --include-ignored
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
#### `.graphifysfignore`
|
|
652
|
+
|
|
653
|
+
For graphify-specific exclusions independent of git/SF, place a `.graphifysfignore` file at your
|
|
654
|
+
project root (glob patterns, always applied):
|
|
631
655
|
|
|
632
656
|
```
|
|
633
657
|
# Exclude test data
|
|
@@ -183,6 +183,7 @@ def _run_pipeline(
|
|
|
183
183
|
force: bool = False,
|
|
184
184
|
backend: str | None = None,
|
|
185
185
|
token_budget: int = 40_000,
|
|
186
|
+
respect_ignore: bool = True,
|
|
186
187
|
) -> None:
|
|
187
188
|
"""Full pipeline: detect → extract → [LLM] → build → cluster → report → export."""
|
|
188
189
|
from graphify_sf.analyze import god_nodes, suggest_questions, surprising_connections
|
|
@@ -201,18 +202,27 @@ def _run_pipeline(
|
|
|
201
202
|
|
|
202
203
|
if incremental:
|
|
203
204
|
print(f"[graphify-sf] incremental scan of {target}")
|
|
204
|
-
detection = detect_incremental(target, manifest_path=str(manifest_path))
|
|
205
|
+
detection = detect_incremental(target, manifest_path=str(manifest_path), respect_ignore=respect_ignore)
|
|
205
206
|
else:
|
|
206
207
|
print(f"[graphify-sf] scanning {target}")
|
|
207
|
-
detection = detect(target)
|
|
208
|
+
detection = detect(target, respect_ignore=respect_ignore)
|
|
208
209
|
|
|
209
210
|
total = detection.get("total_files", 0)
|
|
210
|
-
|
|
211
|
+
skipped_count = detection.get("skipped_count", 0)
|
|
212
|
+
skipped_by_source = detection.get("skipped_by_source", {})
|
|
211
213
|
warning = detection.get("warning", "")
|
|
212
214
|
if warning:
|
|
213
215
|
print(f"[graphify-sf] WARNING: {warning}", file=sys.stderr)
|
|
214
216
|
|
|
215
|
-
print(f"[graphify-sf] {total} metadata files found, {
|
|
217
|
+
print(f"[graphify-sf] {total} metadata files found, {skipped_count} skipped")
|
|
218
|
+
# Honesty-layer: never silently drop files — show which ignore source skipped what.
|
|
219
|
+
if skipped_count:
|
|
220
|
+
nonzero = {k: v for k, v in skipped_by_source.items() if v}
|
|
221
|
+
if nonzero:
|
|
222
|
+
detail = ", ".join(f"{k}: {v}" for k, v in nonzero.items())
|
|
223
|
+
print(f"[graphify-sf] skipped {skipped_count} files ({detail}) — use --include-ignored to include them")
|
|
224
|
+
elif not respect_ignore:
|
|
225
|
+
print("[graphify-sf] --include-ignored: .gitignore/.forceignore not applied")
|
|
216
226
|
|
|
217
227
|
if total == 0:
|
|
218
228
|
print("[graphify-sf] no Salesforce metadata files found — nothing to extract", file=sys.stderr)
|
|
@@ -1564,6 +1574,8 @@ def main() -> None:
|
|
|
1564
1574
|
print(" --directed build directed graph")
|
|
1565
1575
|
print(" --no-viz skip graph.html generation")
|
|
1566
1576
|
print(" --force overwrite graph.json even if new graph is smaller")
|
|
1577
|
+
print(" --include-ignored scan files matched by .gitignore/.forceignore")
|
|
1578
|
+
print(" (default: those files are skipped)")
|
|
1567
1579
|
print(" --max-workers N parallel extraction worker count")
|
|
1568
1580
|
print(" --backend <name> add AI semantic extraction layer")
|
|
1569
1581
|
print(" backends: claude, gemini, kimi, openai, bedrock, ollama, auto")
|
|
@@ -1929,6 +1941,7 @@ def main() -> None:
|
|
|
1929
1941
|
max_workers = None
|
|
1930
1942
|
backend: str | None = None
|
|
1931
1943
|
token_budget = 40_000
|
|
1944
|
+
respect_ignore = True
|
|
1932
1945
|
|
|
1933
1946
|
i = 1
|
|
1934
1947
|
while i < len(args):
|
|
@@ -1951,6 +1964,9 @@ def main() -> None:
|
|
|
1951
1964
|
elif a == "--force":
|
|
1952
1965
|
force = True
|
|
1953
1966
|
i += 1
|
|
1967
|
+
elif a in ("--include-ignored", "--no-respect-ignore"):
|
|
1968
|
+
respect_ignore = False
|
|
1969
|
+
i += 1
|
|
1954
1970
|
elif a == "--max-workers" and i + 1 < len(args):
|
|
1955
1971
|
max_workers = int(args[i + 1])
|
|
1956
1972
|
i += 2
|
|
@@ -1983,6 +1999,7 @@ def main() -> None:
|
|
|
1983
1999
|
force=force,
|
|
1984
2000
|
backend=backend,
|
|
1985
2001
|
token_budget=token_budget,
|
|
2002
|
+
respect_ignore=respect_ignore,
|
|
1986
2003
|
)
|
|
1987
2004
|
return
|
|
1988
2005
|
|
|
@@ -23,6 +23,27 @@ def _norm_source_file(p: str | None) -> str | None:
|
|
|
23
23
|
return p.replace("\\", "/") if p else p
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
# Relation priority for the rare case where a simple (non-multi) graph must pick
|
|
27
|
+
# ONE relation for a node-pair that has two (e.g. self-referencing lookups, where
|
|
28
|
+
# object→field `contains` collides with field→object `references`). Higher wins.
|
|
29
|
+
# Structural ownership (`contains`) outranks looser relations so that
|
|
30
|
+
# componentsOnObject / bfsImpact keep working. Unknown relations get a low default.
|
|
31
|
+
_RELATION_PRIORITY: dict[str, int] = {
|
|
32
|
+
"contains": 100,
|
|
33
|
+
"master_detail": 90,
|
|
34
|
+
"calls": 70,
|
|
35
|
+
"triggers": 70,
|
|
36
|
+
"dml": 60,
|
|
37
|
+
"queries": 55,
|
|
38
|
+
"references": 50,
|
|
39
|
+
"grants": 40,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _relation_priority(relation: str | None) -> int:
|
|
44
|
+
return _RELATION_PRIORITY.get(relation or "", 10)
|
|
45
|
+
|
|
46
|
+
|
|
26
47
|
def edge_data(G: nx.Graph, u: str, v: str) -> dict:
|
|
27
48
|
raw = G[u][v]
|
|
28
49
|
if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
|
|
@@ -87,6 +108,17 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
|
|
87
108
|
attrs["source_file"] = _norm_source_file(attrs["source_file"])
|
|
88
109
|
attrs["_src"] = src
|
|
89
110
|
attrs["_tgt"] = tgt
|
|
111
|
+
# In a simple (non-multi) graph a node-pair holds ONE edge, so adding a
|
|
112
|
+
# second edge between the same pair overwrites the first. This happens for
|
|
113
|
+
# self-referencing lookups (object→field `contains` + field→object
|
|
114
|
+
# `references` are the same undirected pair). Keep the structurally more
|
|
115
|
+
# important relation: `contains` (ownership, used by componentsOnObject /
|
|
116
|
+
# bfsImpact) must win over a weaker relation rather than be overwritten.
|
|
117
|
+
# (Full multi-relation preservation is the 0.4.0 MultiGraph work.)
|
|
118
|
+
if not isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) and G.has_edge(src, tgt):
|
|
119
|
+
existing_rel = G.get_edge_data(src, tgt).get("relation")
|
|
120
|
+
if _relation_priority(existing_rel) >= _relation_priority(attrs.get("relation")):
|
|
121
|
+
continue # keep the existing higher/equal-priority edge
|
|
90
122
|
G.add_edge(src, tgt, **attrs)
|
|
91
123
|
|
|
92
124
|
hyperedges = extraction.get("hyperedges", [])
|
|
@@ -337,15 +369,31 @@ def _norm_label(label: str) -> str:
|
|
|
337
369
|
|
|
338
370
|
|
|
339
371
|
def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
340
|
-
"""Merge nodes that share a normalised label, rewriting edge references.
|
|
372
|
+
"""Merge nodes that share a normalised label AND sf_type, rewriting edge references.
|
|
373
|
+
|
|
374
|
+
The merge is gated on ``sf_type`` so that distinct components which happen to
|
|
375
|
+
normalise to the same label are NOT conflated — e.g. an ApexClass and an
|
|
376
|
+
LWCComponent named ``CauRefreshIntervention``, or a CustomObject and its
|
|
377
|
+
CustomTab/Settings. Merging across types previously rewrote their edges onto the
|
|
378
|
+
wrong survivor and dropped real ``contains``/``references`` edges (notably the
|
|
379
|
+
object→field ownership edge when an object's label collided with a permset/tab).
|
|
380
|
+
Same-type merges (the intended case: chunked duplicates carrying a ``_cN`` suffix)
|
|
381
|
+
are preserved.
|
|
382
|
+
"""
|
|
341
383
|
_CHUNK_SUFFIX = re.compile(r"_c\d+$")
|
|
342
|
-
canonical: dict[str, dict] = {}
|
|
384
|
+
canonical: dict[tuple[str, str], dict] = {}
|
|
343
385
|
remap: dict[str, str] = {}
|
|
344
386
|
|
|
345
387
|
for node in nodes:
|
|
346
|
-
|
|
347
|
-
if not
|
|
388
|
+
label_key = _norm_label(node.get("label", node.get("id", "")))
|
|
389
|
+
if not label_key:
|
|
348
390
|
continue
|
|
391
|
+
# Only nodes of the same sf_type may merge, so distinct components that
|
|
392
|
+
# happen to share a normalised label are not conflated. When sf_type is
|
|
393
|
+
# absent (synthetic/untyped nodes), fall back to label-only merging so the
|
|
394
|
+
# historical behaviour is preserved for those.
|
|
395
|
+
type_key = node.get("sf_type") or ""
|
|
396
|
+
key = (type_key, label_key)
|
|
349
397
|
existing = canonical.get(key)
|
|
350
398
|
if existing is None:
|
|
351
399
|
canonical[key] = node
|
|
@@ -2,9 +2,15 @@ import fnmatch
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
from enum import Enum
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
try:
|
|
10
|
+
import pathspec # gitwildmatch — correct .gitignore/.forceignore semantics
|
|
11
|
+
except ImportError: # pragma: no cover - pathspec is a core dependency
|
|
12
|
+
pathspec = None
|
|
13
|
+
|
|
8
14
|
|
|
9
15
|
class SFFileType(str, Enum):
|
|
10
16
|
APEX = "apex"
|
|
@@ -180,7 +186,7 @@ def _load_sfgraphignore(root: Path) -> list[str]:
|
|
|
180
186
|
|
|
181
187
|
|
|
182
188
|
def _is_ignored(path: Path, root: Path, patterns: list[str]) -> bool:
|
|
183
|
-
"""Return True if the path matches any
|
|
189
|
+
"""Return True if the path matches any .graphifysfignore pattern (fnmatch)."""
|
|
184
190
|
if not patterns:
|
|
185
191
|
return False
|
|
186
192
|
try:
|
|
@@ -195,6 +201,59 @@ def _is_ignored(path: Path, root: Path, patterns: list[str]) -> bool:
|
|
|
195
201
|
return False
|
|
196
202
|
|
|
197
203
|
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# .gitignore / .forceignore support (gitwildmatch semantics via pathspec)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Files honored by default. A user can opt out entirely with respect_ignore=False
|
|
208
|
+
# (CLI: --include-ignored) to scan everything regardless of these declarations.
|
|
209
|
+
_IGNORE_FILES = (".gitignore", ".forceignore")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _load_ignore_specs(root: Path) -> dict[str, tuple[Path, object]]:
|
|
213
|
+
"""Load .gitignore / .forceignore as pathspec matchers, searching upward.
|
|
214
|
+
|
|
215
|
+
SFDX ignore files conventionally live at the project root, but users often
|
|
216
|
+
point graphify at a subdir (e.g. ``force-app``). Like git/sf, we walk up from
|
|
217
|
+
``root`` to the filesystem root to find the nearest ``.gitignore`` and
|
|
218
|
+
``.forceignore``. Each spec is returned with the directory it was found in, so
|
|
219
|
+
path matching is anchored relative to that base (correct gitwildmatch semantics).
|
|
220
|
+
|
|
221
|
+
Returns {filename: (base_dir, PathSpec)}.
|
|
222
|
+
"""
|
|
223
|
+
specs: dict[str, tuple[Path, object]] = {}
|
|
224
|
+
if pathspec is None:
|
|
225
|
+
return specs
|
|
226
|
+
for name in _IGNORE_FILES:
|
|
227
|
+
d = root
|
|
228
|
+
while True:
|
|
229
|
+
f = d / name
|
|
230
|
+
if f.exists():
|
|
231
|
+
try:
|
|
232
|
+
lines = f.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
233
|
+
specs[name] = (d, pathspec.PathSpec.from_lines("gitignore", lines))
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
break
|
|
237
|
+
if d.parent == d:
|
|
238
|
+
break
|
|
239
|
+
d = d.parent
|
|
240
|
+
return specs
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _matched_ignore_file(path: Path, root: Path, specs: dict[str, tuple[Path, object]]) -> str | None:
|
|
244
|
+
"""Return the name of the first ignore file whose patterns match `path`, else None."""
|
|
245
|
+
if not specs:
|
|
246
|
+
return None
|
|
247
|
+
for name, (base, spec) in specs.items():
|
|
248
|
+
try:
|
|
249
|
+
rel = path.relative_to(base).as_posix()
|
|
250
|
+
except ValueError:
|
|
251
|
+
continue
|
|
252
|
+
if spec.match_file(rel): # type: ignore[attr-defined]
|
|
253
|
+
return name
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
198
257
|
def extract_pdf_text(path: Path) -> str:
|
|
199
258
|
"""Extract plain text from a PDF using pypdf. Returns '' if pypdf not installed."""
|
|
200
259
|
try:
|
|
@@ -312,16 +371,32 @@ def _classify_doc_file(path: Path) -> DocFileType | None:
|
|
|
312
371
|
return None
|
|
313
372
|
|
|
314
373
|
|
|
315
|
-
def detect(root: Path) -> dict:
|
|
316
|
-
"""Scan the SFDX project and return detected files grouped by type.
|
|
374
|
+
def detect(root: Path, *, respect_ignore: bool = True) -> dict:
|
|
375
|
+
"""Scan the SFDX project and return detected files grouped by type.
|
|
376
|
+
|
|
377
|
+
When ``respect_ignore`` is True (default), files matching the project's
|
|
378
|
+
``.gitignore`` or ``.forceignore`` are skipped (gitwildmatch semantics).
|
|
379
|
+
Pass ``respect_ignore=False`` (CLI: ``--include-ignored``) to scan everything
|
|
380
|
+
regardless of those declarations. The hardcoded ``_SKIP_DIRS`` floor and the
|
|
381
|
+
``.graphifysfignore`` file are always applied.
|
|
382
|
+
"""
|
|
317
383
|
root = root.resolve()
|
|
318
384
|
files = {ft.value: [] for ft in SFFileType}
|
|
319
385
|
doc_files: dict[str, list[str]] = {ft.value: [] for ft in DocFileType}
|
|
320
386
|
bundle_dirs = {"lwc": [], "aura": []}
|
|
321
387
|
skipped = []
|
|
388
|
+
# Per-source skip counts for an honest summary line (never silently drop).
|
|
389
|
+
skipped_by_source: dict[str, int] = {".gitignore": 0, ".forceignore": 0, ".graphifysfignore": 0}
|
|
322
390
|
ignore_patterns = _load_sfgraphignore(root)
|
|
391
|
+
ignore_specs = _load_ignore_specs(root) if respect_ignore else {}
|
|
323
392
|
converted_dir = root / "graphify-sf-out" / "converted"
|
|
324
393
|
|
|
394
|
+
def _skip_reason(path: Path) -> str | None:
|
|
395
|
+
"""Return the ignore-source name if path should be skipped, else None."""
|
|
396
|
+
if _is_ignored(path, root, ignore_patterns):
|
|
397
|
+
return ".graphifysfignore"
|
|
398
|
+
return _matched_ignore_file(path, root, ignore_specs)
|
|
399
|
+
|
|
325
400
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
326
401
|
dp = Path(dirpath)
|
|
327
402
|
|
|
@@ -330,20 +405,30 @@ def detect(root: Path) -> dict:
|
|
|
330
405
|
|
|
331
406
|
# Check for LWC/Aura bundles
|
|
332
407
|
if dp.parent.name == "lwc" and _is_lwc_bundle(dp):
|
|
333
|
-
|
|
408
|
+
reason = _skip_reason(dp)
|
|
409
|
+
if reason is None:
|
|
334
410
|
bundle_dirs["lwc"].append(str(dp))
|
|
411
|
+
else:
|
|
412
|
+
skipped.append(str(dp))
|
|
413
|
+
skipped_by_source[reason] = skipped_by_source.get(reason, 0) + 1
|
|
335
414
|
continue
|
|
336
415
|
|
|
337
416
|
if dp.parent.name == "aura" and _is_aura_bundle(dp):
|
|
338
|
-
|
|
417
|
+
reason = _skip_reason(dp)
|
|
418
|
+
if reason is None:
|
|
339
419
|
bundle_dirs["aura"].append(str(dp))
|
|
420
|
+
else:
|
|
421
|
+
skipped.append(str(dp))
|
|
422
|
+
skipped_by_source[reason] = skipped_by_source.get(reason, 0) + 1
|
|
340
423
|
continue
|
|
341
424
|
|
|
342
425
|
# Classify individual files
|
|
343
426
|
for fname in filenames:
|
|
344
427
|
path = dp / fname
|
|
345
|
-
|
|
428
|
+
reason = _skip_reason(path)
|
|
429
|
+
if reason is not None:
|
|
346
430
|
skipped.append(str(path))
|
|
431
|
+
skipped_by_source[reason] = skipped_by_source.get(reason, 0) + 1
|
|
347
432
|
continue
|
|
348
433
|
# Try SF classification first
|
|
349
434
|
ftype = _classify_file(path)
|
|
@@ -361,16 +446,12 @@ def detect(root: Path) -> dict:
|
|
|
361
446
|
if md_path:
|
|
362
447
|
doc_files[dtype.value].append(str(md_path))
|
|
363
448
|
else:
|
|
364
|
-
import sys
|
|
365
|
-
|
|
366
449
|
print(
|
|
367
450
|
f"[graphify-sf] WARNING: {path.name} skipped — "
|
|
368
451
|
"install graphify-sf[docs] to enable office file support",
|
|
369
452
|
file=sys.stderr,
|
|
370
453
|
)
|
|
371
454
|
except Exception as exc:
|
|
372
|
-
import sys
|
|
373
|
-
|
|
374
455
|
print(f"[graphify-sf] WARNING: office conversion failed for {path}: {exc}", file=sys.stderr)
|
|
375
456
|
else:
|
|
376
457
|
doc_files[dtype.value].append(str(path))
|
|
@@ -389,6 +470,9 @@ def detect(root: Path) -> dict:
|
|
|
389
470
|
"bundle_dirs": bundle_dirs,
|
|
390
471
|
"warning": None,
|
|
391
472
|
"skipped": skipped,
|
|
473
|
+
"skipped_count": len(skipped),
|
|
474
|
+
"skipped_by_source": skipped_by_source,
|
|
475
|
+
"respect_ignore": respect_ignore,
|
|
392
476
|
}
|
|
393
477
|
|
|
394
478
|
|
|
@@ -431,9 +515,9 @@ def save_manifest(
|
|
|
431
515
|
Path(manifest_path).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
432
516
|
|
|
433
517
|
|
|
434
|
-
def detect_incremental(root: Path, manifest_path: str) -> dict:
|
|
518
|
+
def detect_incremental(root: Path, manifest_path: str, *, respect_ignore: bool = True) -> dict:
|
|
435
519
|
"""Like detect(), but returns only new or modified files since the last run."""
|
|
436
|
-
full = detect(root)
|
|
520
|
+
full = detect(root, respect_ignore=respect_ignore)
|
|
437
521
|
manifest = load_manifest(manifest_path)
|
|
438
522
|
|
|
439
523
|
if not manifest:
|
|
@@ -46,6 +46,18 @@ _TRIGGER_RE = re.compile(
|
|
|
46
46
|
# Apex → Flow: Flow.Interview.FlowApiName
|
|
47
47
|
_FLOW_INVOKE_RE = re.compile(r"Flow\.Interview\.(\w+)", re.IGNORECASE)
|
|
48
48
|
|
|
49
|
+
# Variable declaration patterns used to resolve DML operands to their declared SObject type.
|
|
50
|
+
# Simple: TypeName varName [= ...] ; or TypeName varName , or TypeName varName )
|
|
51
|
+
_VAR_DECL_SIMPLE_RE = re.compile(
|
|
52
|
+
r"\b([A-Z]\w*)\s+([a-z]\w*)\s*(?=[=;,)])",
|
|
53
|
+
re.MULTILINE,
|
|
54
|
+
)
|
|
55
|
+
# Generic: List<T>/Set<T>/Map<K,V> varName [= ...] ; or , or )
|
|
56
|
+
_VAR_DECL_GENERIC_RE = re.compile(
|
|
57
|
+
r"\b(?:List|Set|Map)\s*<([^>]+)>\s+([a-z]\w*)\s*(?=[=;,)])",
|
|
58
|
+
re.IGNORECASE | re.MULTILINE,
|
|
59
|
+
)
|
|
60
|
+
|
|
49
61
|
# Apex keywords that look like calls but aren't class references
|
|
50
62
|
_APEX_KEYWORDS = frozenset(
|
|
51
63
|
{
|
|
@@ -134,6 +146,34 @@ _APEX_KEYWORDS = frozenset(
|
|
|
134
146
|
}
|
|
135
147
|
)
|
|
136
148
|
|
|
149
|
+
|
|
150
|
+
def _extract_var_types(text: str) -> dict[str, str]:
|
|
151
|
+
"""Return a varName → declared SObject type map from class source text.
|
|
152
|
+
|
|
153
|
+
Handles simple declarations (TypeName var), generic collections
|
|
154
|
+
(List<T>/Set<T>/Map<K,V> var), and method parameters.
|
|
155
|
+
Variable names must start with a lowercase letter; types must be PascalCase
|
|
156
|
+
and not be an Apex keyword/primitive.
|
|
157
|
+
"""
|
|
158
|
+
result: dict[str, str] = {}
|
|
159
|
+
# Generic declarations first (higher priority, e.g. List<Account> accs)
|
|
160
|
+
for m in _VAR_DECL_GENERIC_RE.finditer(text):
|
|
161
|
+
inner = m.group(1)
|
|
162
|
+
var_name = m.group(2)
|
|
163
|
+
parts = [p.strip() for p in inner.split(",")]
|
|
164
|
+
# For Map<K, V> take the last type arg; strip any nested generics
|
|
165
|
+
inner_type = re.sub(r"<[^>]*>", "", parts[-1]).strip()
|
|
166
|
+
if inner_type and inner_type[0].isupper() and inner_type.lower() not in _APEX_KEYWORDS:
|
|
167
|
+
result.setdefault(var_name, inner_type)
|
|
168
|
+
# Simple declarations: TypeName varName
|
|
169
|
+
for m in _VAR_DECL_SIMPLE_RE.finditer(text):
|
|
170
|
+
type_name = m.group(1)
|
|
171
|
+
var_name = m.group(2)
|
|
172
|
+
if type_name.lower() not in _APEX_KEYWORDS:
|
|
173
|
+
result.setdefault(var_name, type_name)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
137
177
|
# Minimum length and shape heuristics for a plausible Apex class name in _raw_calls.
|
|
138
178
|
# Returns False for things like single-letter variables, all-caps constants,
|
|
139
179
|
# or names that look like local variables rather than class references.
|
|
@@ -316,14 +356,13 @@ def extract_apex_class(path: Path) -> dict:
|
|
|
316
356
|
)
|
|
317
357
|
)
|
|
318
358
|
|
|
319
|
-
# DML → dml edges.
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
# Dedup
|
|
326
|
-
# object yields two edges, not one collapsed dml edge.
|
|
359
|
+
# DML → dml edges. Each edge carries an "operation" field (insert→create, etc.).
|
|
360
|
+
# Resolution order for the DML operand:
|
|
361
|
+
# (a) look up the varName→Type map built by _extract_var_types;
|
|
362
|
+
# (b) if the operand is already PascalCase and not a keyword, use it directly
|
|
363
|
+
# (backward-compat: handles `insert Account;` style);
|
|
364
|
+
# (c) otherwise skip — no edge for unresolvable variables (honesty).
|
|
365
|
+
# Dedup by (resolved_type, operation) so insert+update on the same object → two edges.
|
|
327
366
|
_DML_VERB_TO_OP = {
|
|
328
367
|
"insert": "create",
|
|
329
368
|
"update": "update",
|
|
@@ -332,26 +371,35 @@ def extract_apex_class(path: Path) -> dict:
|
|
|
332
371
|
"merge": "merge",
|
|
333
372
|
"undelete": "undelete",
|
|
334
373
|
}
|
|
374
|
+
var_types = _extract_var_types(text)
|
|
335
375
|
seen_dml_ops: set[tuple[str, str]] = set()
|
|
336
376
|
for dm in _DML_RE.finditer(text):
|
|
337
377
|
operation = _DML_VERB_TO_OP[dm.group(1).lower()]
|
|
338
378
|
obj_var = dm.group(2)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
#
|
|
379
|
+
if obj_var.lower() in _APEX_KEYWORDS:
|
|
380
|
+
continue
|
|
381
|
+
resolved_type = var_types.get(obj_var)
|
|
382
|
+
if resolved_type is None:
|
|
383
|
+
# Fallback: operand is itself PascalCase — treat as the type name
|
|
344
384
|
if obj_var[0].isupper():
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
385
|
+
resolved_type = obj_var
|
|
386
|
+
else:
|
|
387
|
+
continue # lowercase variable with no declaration — skip
|
|
388
|
+
if resolved_type.lower() in _APEX_KEYWORDS:
|
|
389
|
+
continue # primitive or system type — not a DML-able SObject
|
|
390
|
+
key = (resolved_type, operation)
|
|
391
|
+
if key not in seen_dml_ops:
|
|
392
|
+
seen_dml_ops.add(key)
|
|
393
|
+
edge = _make_edge(
|
|
394
|
+
class_nid,
|
|
395
|
+
object_id(resolved_type),
|
|
396
|
+
"dml",
|
|
397
|
+
"INFERRED",
|
|
398
|
+
str_path,
|
|
399
|
+
confidence_score=0.7,
|
|
400
|
+
)
|
|
401
|
+
edge["operation"] = operation
|
|
402
|
+
edges.append(edge)
|
|
355
403
|
|
|
356
404
|
# Apex → Flow invocations via Flow.Interview.FlowName
|
|
357
405
|
seen_flow_invokes: set[str] = set()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphify-sf
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Turn any Salesforce SFDX project into a queryable knowledge graph — fully offline, no org connection required.
|
|
5
5
|
Author: Ray Kuo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,7 @@ License-File: LICENSE
|
|
|
25
25
|
Requires-Dist: networkx>=3.0
|
|
26
26
|
Requires-Dist: datasketch>=1.6
|
|
27
27
|
Requires-Dist: rapidfuzz>=3.0
|
|
28
|
+
Requires-Dist: pathspec>=0.12
|
|
28
29
|
Requires-Dist: idna>=3.15
|
|
29
30
|
Provides-Extra: leiden
|
|
30
31
|
Requires-Dist: graspologic; python_version < "3.13" and extra == "leiden"
|
|
@@ -700,9 +701,33 @@ All configuration via environment variables:
|
|
|
700
701
|
| `GRAPHIFY_SF_REBUILD_MEMORY_LIMIT_MB` | `` | Cap memory usage for background watch rebuilds (MB) |
|
|
701
702
|
| `CLAUDE_CONFIG_DIR` | `~/.claude` | Override Claude Code config directory — only set if your Claude Code config lives at a non-standard path (company-managed dotfiles, CI, or multiple installations) |
|
|
702
703
|
|
|
703
|
-
### `.graphifysfignore`
|
|
704
|
+
### Ignoring files (`.gitignore`, `.forceignore`, `.graphifysfignore`)
|
|
704
705
|
|
|
705
|
-
|
|
706
|
+
By default graphify-sf **respects your project's `.gitignore` and `.forceignore`** so that
|
|
707
|
+
deprecated metadata, `__tests__`, queues, editor/lint scaffolding, and build output don't become
|
|
708
|
+
noise in the graph. Patterns use full gitignore semantics (negation `!keep`, anchored `/foo`,
|
|
709
|
+
directory `foo/`, `**`), parsed with `pathspec` — the same way git and the `sf` CLI read them. The
|
|
710
|
+
ignore files are found by walking **up** from the path you scan, so `graphify-sf force-app` still
|
|
711
|
+
honors the `.gitignore`/`.forceignore` at your project root.
|
|
712
|
+
|
|
713
|
+
After a scan, any skipped files are reported honestly:
|
|
714
|
+
|
|
715
|
+
```
|
|
716
|
+
[graphify-sf] 5573 metadata files found, 57 skipped
|
|
717
|
+
[graphify-sf] skipped 57 files (.forceignore: 57) — use --include-ignored to include them
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
To scan **everything** regardless of `.gitignore`/`.forceignore` (e.g. you suspect a real metadata
|
|
721
|
+
file was excluded):
|
|
722
|
+
|
|
723
|
+
```bash
|
|
724
|
+
graphify-sf <path> --include-ignored
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
#### `.graphifysfignore`
|
|
728
|
+
|
|
729
|
+
For graphify-specific exclusions independent of git/SF, place a `.graphifysfignore` file at your
|
|
730
|
+
project root (glob patterns, always applied):
|
|
706
731
|
|
|
707
732
|
```
|
|
708
733
|
# Exclude test data
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphify-sf"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.6"
|
|
8
8
|
description = "Turn any Salesforce SFDX project into a queryable knowledge graph — fully offline, no org connection required."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -33,6 +33,7 @@ dependencies = [
|
|
|
33
33
|
"networkx>=3.0",
|
|
34
34
|
"datasketch>=1.6",
|
|
35
35
|
"rapidfuzz>=3.0",
|
|
36
|
+
"pathspec>=0.12", # gitwildmatch parsing for .gitignore/.forceignore
|
|
36
37
|
"idna>=3.15", # CVE-2026-45409 fix — transitive via requests
|
|
37
38
|
]
|
|
38
39
|
|
|
@@ -213,6 +213,88 @@ def test_deduplicate_removes_self_loops():
|
|
|
213
213
|
assert len(deduped_edges) == 0
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Regression: dedup must NOT merge nodes of different sf_type (0.3.6 bug fix)
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_dedup_does_not_merge_across_sf_type():
|
|
222
|
+
"""A CustomObject and a CustomTab/PermissionSet that normalise to the same
|
|
223
|
+
label must remain SEPARATE nodes — previously they were conflated and the
|
|
224
|
+
object's edges were rewritten onto the wrong survivor."""
|
|
225
|
+
from graphify_sf.build import deduplicate_by_label
|
|
226
|
+
|
|
227
|
+
nodes = [
|
|
228
|
+
{"id": "object_supporterconnection__c", "label": "SupporterConnection__c", "sf_type": "CustomObject"},
|
|
229
|
+
{"id": "customtab_supporterconnection__c", "label": "SupporterConnection__c", "sf_type": "CustomTab"},
|
|
230
|
+
]
|
|
231
|
+
edges = []
|
|
232
|
+
deduped_nodes, _ = deduplicate_by_label(nodes, edges)
|
|
233
|
+
ids = {n["id"] for n in deduped_nodes}
|
|
234
|
+
assert "object_supporterconnection__c" in ids
|
|
235
|
+
assert "customtab_supporterconnection__c" in ids
|
|
236
|
+
assert len(deduped_nodes) == 2, "different sf_type must not merge"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_dedup_still_merges_same_sf_type_chunks():
|
|
240
|
+
"""Same-type chunked duplicates (the intended case) still merge."""
|
|
241
|
+
from graphify_sf.build import deduplicate_by_label
|
|
242
|
+
|
|
243
|
+
nodes = [
|
|
244
|
+
{"id": "doc_readme", "label": "README", "sf_type": "Document"},
|
|
245
|
+
{"id": "doc_readme_c1", "label": "README", "sf_type": "Document"}, # chunk
|
|
246
|
+
]
|
|
247
|
+
edges = [{"source": "doc_readme_c1", "target": "doc_readme", "relation": "references"}]
|
|
248
|
+
deduped_nodes, _ = deduplicate_by_label(nodes, edges)
|
|
249
|
+
assert len(deduped_nodes) == 1, "same-type chunk duplicates should merge"
|
|
250
|
+
assert deduped_nodes[0]["id"] == "doc_readme"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_self_lookup_keeps_contains_edge():
|
|
254
|
+
"""A self-referencing lookup field (object→field `contains` collides with
|
|
255
|
+
field→object `references` in the undirected simple graph) must keep the
|
|
256
|
+
`contains` ownership edge — it outranks `references`."""
|
|
257
|
+
from graphify_sf.build import build_from_json
|
|
258
|
+
|
|
259
|
+
extraction = {
|
|
260
|
+
"nodes": [
|
|
261
|
+
{"id": "object_account", "label": "Account", "sf_type": "CustomObject", "file_type": "object"},
|
|
262
|
+
{
|
|
263
|
+
"id": "field_account_parent__c",
|
|
264
|
+
"label": "Account.Parent__c",
|
|
265
|
+
"sf_type": "CustomField",
|
|
266
|
+
"file_type": "object",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
"edges": [
|
|
270
|
+
{"source": "object_account", "target": "field_account_parent__c", "relation": "contains"},
|
|
271
|
+
{"source": "field_account_parent__c", "target": "object_account", "relation": "references"},
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
G = build_from_json(extraction) # undirected (default)
|
|
275
|
+
assert G.has_edge("object_account", "field_account_parent__c")
|
|
276
|
+
assert G.get_edge_data("object_account", "field_account_parent__c")["relation"] == "contains"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_contains_not_overwritten_regardless_of_edge_order():
|
|
280
|
+
"""contains must win even when the weaker relation is added second."""
|
|
281
|
+
from graphify_sf.build import build_from_json
|
|
282
|
+
|
|
283
|
+
extraction = {
|
|
284
|
+
"nodes": [
|
|
285
|
+
{"id": "object_x", "label": "X", "sf_type": "CustomObject"},
|
|
286
|
+
{"id": "field_x_self__c", "label": "X.Self__c", "sf_type": "CustomField"},
|
|
287
|
+
],
|
|
288
|
+
# references first, contains second
|
|
289
|
+
"edges": [
|
|
290
|
+
{"source": "field_x_self__c", "target": "object_x", "relation": "references"},
|
|
291
|
+
{"source": "object_x", "target": "field_x_self__c", "relation": "contains"},
|
|
292
|
+
],
|
|
293
|
+
}
|
|
294
|
+
G = build_from_json(extraction)
|
|
295
|
+
assert G.get_edge_data("object_x", "field_x_self__c")["relation"] == "contains"
|
|
296
|
+
|
|
297
|
+
|
|
216
298
|
# ---------------------------------------------------------------------------
|
|
217
299
|
# _ensure_stub_nodes
|
|
218
300
|
# ---------------------------------------------------------------------------
|
|
@@ -145,3 +145,99 @@ def test_detect_skipped_list(simple_detect_result):
|
|
|
145
145
|
"""Test that skipped list is present."""
|
|
146
146
|
assert "skipped" in simple_detect_result
|
|
147
147
|
assert isinstance(simple_detect_result["skipped"], list)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# .gitignore / .forceignore support (default-on, --include-ignored opt-out)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _make_sfdx_tree(root: Path) -> None:
|
|
156
|
+
"""Create a tiny SFDX tree: one kept class + one queue + a flow."""
|
|
157
|
+
classes = root / "force-app" / "main" / "default" / "classes"
|
|
158
|
+
classes.mkdir(parents=True)
|
|
159
|
+
(classes / "Keep.cls").write_text("public class Keep {}")
|
|
160
|
+
(classes / "Keep.cls-meta.xml").write_text(
|
|
161
|
+
'<?xml version="1.0"?><ApexClass xmlns="http://soap.sforce.com/2006/04/metadata"><apiVersion>59.0</apiVersion></ApexClass>'
|
|
162
|
+
)
|
|
163
|
+
queues = root / "force-app" / "main" / "default" / "queues"
|
|
164
|
+
queues.mkdir(parents=True)
|
|
165
|
+
(queues / "Sales.queue-meta.xml").write_text(
|
|
166
|
+
'<?xml version="1.0"?><Queue xmlns="http://soap.sforce.com/2006/04/metadata"><name>Sales</name></Queue>'
|
|
167
|
+
)
|
|
168
|
+
flows = root / "force-app" / "main" / "default" / "flows"
|
|
169
|
+
flows.mkdir(parents=True)
|
|
170
|
+
(flows / "Deprecated.flow-meta.xml").write_text(
|
|
171
|
+
'<?xml version="1.0"?><Flow xmlns="http://soap.sforce.com/2006/04/metadata"><label>Deprecated</label></Flow>'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_forceignore_skips_files_by_default(tmp_path):
|
|
176
|
+
""".forceignore patterns skip matching files by default, with per-source count."""
|
|
177
|
+
from graphify_sf.detect import detect
|
|
178
|
+
|
|
179
|
+
_make_sfdx_tree(tmp_path)
|
|
180
|
+
(tmp_path / ".forceignore").write_text("**/queues/**\n**/Deprecated.flow-meta.xml\n")
|
|
181
|
+
|
|
182
|
+
res = detect(tmp_path)
|
|
183
|
+
all_files = [f for lst in res["files"].values() for f in lst]
|
|
184
|
+
assert not any("queues" in f for f in all_files), "queue file should be skipped"
|
|
185
|
+
assert not any("Deprecated" in f for f in all_files), "deprecated flow should be skipped"
|
|
186
|
+
assert any("Keep.cls" in f for f in all_files), "kept class must remain"
|
|
187
|
+
assert res["skipped_count"] >= 2
|
|
188
|
+
assert res["skipped_by_source"][".forceignore"] >= 2
|
|
189
|
+
assert res["respect_ignore"] is True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_gitignore_skips_files_by_default(tmp_path):
|
|
193
|
+
""".gitignore patterns skip matching files by default."""
|
|
194
|
+
from graphify_sf.detect import detect
|
|
195
|
+
|
|
196
|
+
_make_sfdx_tree(tmp_path)
|
|
197
|
+
(tmp_path / ".gitignore").write_text("**/Deprecated.flow-meta.xml\n")
|
|
198
|
+
|
|
199
|
+
res = detect(tmp_path)
|
|
200
|
+
all_files = [f for lst in res["files"].values() for f in lst]
|
|
201
|
+
assert not any("Deprecated" in f for f in all_files)
|
|
202
|
+
assert res["skipped_by_source"][".gitignore"] >= 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_include_ignored_scans_everything(tmp_path):
|
|
206
|
+
"""respect_ignore=False (--include-ignored) ignores .gitignore/.forceignore."""
|
|
207
|
+
from graphify_sf.detect import detect
|
|
208
|
+
|
|
209
|
+
_make_sfdx_tree(tmp_path)
|
|
210
|
+
(tmp_path / ".forceignore").write_text("**/queues/**\n**/Deprecated.flow-meta.xml\n")
|
|
211
|
+
|
|
212
|
+
res = detect(tmp_path, respect_ignore=False)
|
|
213
|
+
all_files = [f for lst in res["files"].values() for f in lst]
|
|
214
|
+
assert any("Deprecated" in f for f in all_files), "ignored flow must be included"
|
|
215
|
+
assert res["skipped_count"] == 0
|
|
216
|
+
assert res["respect_ignore"] is False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_ignore_files_discovered_upward_from_subdir(tmp_path):
|
|
220
|
+
"""Scanning a subdir still finds .forceignore at the project root (git-style upward walk)."""
|
|
221
|
+
from graphify_sf.detect import detect
|
|
222
|
+
|
|
223
|
+
_make_sfdx_tree(tmp_path)
|
|
224
|
+
# ignore file at repo root; scan the force-app subdir
|
|
225
|
+
(tmp_path / ".forceignore").write_text("**/Deprecated.flow-meta.xml\n")
|
|
226
|
+
|
|
227
|
+
res = detect(tmp_path / "force-app")
|
|
228
|
+
all_files = [f for lst in res["files"].values() for f in lst]
|
|
229
|
+
assert not any("Deprecated" in f for f in all_files), "root .forceignore must apply when scanning subdir"
|
|
230
|
+
assert res["skipped_by_source"][".forceignore"] >= 1
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_gitignore_negation_semantics(tmp_path):
|
|
234
|
+
"""gitignore negation (!keep) is honored — fnmatch could not do this."""
|
|
235
|
+
from graphify_sf.detect import detect
|
|
236
|
+
|
|
237
|
+
_make_sfdx_tree(tmp_path)
|
|
238
|
+
# ignore all flows, then un-ignore the one we keep
|
|
239
|
+
(tmp_path / ".gitignore").write_text("**/*.flow-meta.xml\n!**/Deprecated.flow-meta.xml\n")
|
|
240
|
+
|
|
241
|
+
res = detect(tmp_path)
|
|
242
|
+
all_files = [f for lst in res["files"].values() for f in lst]
|
|
243
|
+
assert any("Deprecated" in f for f in all_files), "negated pattern must re-include the flow"
|
|
@@ -517,6 +517,126 @@ def test_extract_apex_trigger_fallback_uses_filename(tmp_path):
|
|
|
517
517
|
# ---------------------------------------------------------------------------
|
|
518
518
|
|
|
519
519
|
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# DML variable resolution (Approach A: varName → declared type)
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_extract_apex_dml_lowercase_var_resolves_to_type(tmp_path):
|
|
526
|
+
"""Core fix: `insert c;` where `Case c = new Case()` → dml edge to object_case."""
|
|
527
|
+
from graphify_sf.extract.apex import extract_apex_class
|
|
528
|
+
|
|
529
|
+
cls = tmp_path / "AfterHoursFallback.cls"
|
|
530
|
+
cls.write_text(
|
|
531
|
+
"""\
|
|
532
|
+
public class AfterHoursFallback {
|
|
533
|
+
public void run() {
|
|
534
|
+
Case c = new Case();
|
|
535
|
+
insert c;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
"""
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
result = extract_apex_class(cls)
|
|
542
|
+
dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
|
|
543
|
+
assert len(dml_edges) == 1
|
|
544
|
+
assert "case" in dml_edges[0]["target"].lower()
|
|
545
|
+
assert dml_edges[0]["operation"] == "create"
|
|
546
|
+
assert dml_edges[0]["confidence"] == "INFERRED"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def test_extract_apex_dml_lowercase_var_insert_and_update_two_edges(tmp_path):
|
|
550
|
+
"""Same lowercase variable insert + update → two dml edges (create, update)."""
|
|
551
|
+
from graphify_sf.extract.apex import extract_apex_class
|
|
552
|
+
|
|
553
|
+
cls = tmp_path / "CaseUpdater.cls"
|
|
554
|
+
cls.write_text(
|
|
555
|
+
"""\
|
|
556
|
+
public class CaseUpdater {
|
|
557
|
+
public void run() {
|
|
558
|
+
Case c = new Case();
|
|
559
|
+
insert c;
|
|
560
|
+
update c;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
"""
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
result = extract_apex_class(cls)
|
|
567
|
+
dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
|
|
568
|
+
case_edges = [e for e in dml_edges if "case" in e["target"].lower()]
|
|
569
|
+
assert len(case_edges) == 2
|
|
570
|
+
ops = {e["operation"] for e in case_edges}
|
|
571
|
+
assert ops == {"create", "update"}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_extract_apex_dml_list_generic_resolves_inner_type(tmp_path):
|
|
575
|
+
"""`List<Account> accs = ...; insert accs;` → dml edge to object_account."""
|
|
576
|
+
from graphify_sf.extract.apex import extract_apex_class
|
|
577
|
+
|
|
578
|
+
cls = tmp_path / "AccountBulkWriter.cls"
|
|
579
|
+
cls.write_text(
|
|
580
|
+
"""\
|
|
581
|
+
public class AccountBulkWriter {
|
|
582
|
+
public void run() {
|
|
583
|
+
List<Account> accs = new List<Account>();
|
|
584
|
+
insert accs;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
"""
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
result = extract_apex_class(cls)
|
|
591
|
+
dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
|
|
592
|
+
assert len(dml_edges) == 1
|
|
593
|
+
assert "account" in dml_edges[0]["target"].lower()
|
|
594
|
+
assert dml_edges[0]["operation"] == "create"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_extract_apex_dml_method_param_resolves(tmp_path):
|
|
598
|
+
"""Method parameter `Contact con` → `update con;` produces dml edge to object_contact."""
|
|
599
|
+
from graphify_sf.extract.apex import extract_apex_class
|
|
600
|
+
|
|
601
|
+
cls = tmp_path / "ContactSaver.cls"
|
|
602
|
+
cls.write_text(
|
|
603
|
+
"""\
|
|
604
|
+
public class ContactSaver {
|
|
605
|
+
public void save(Contact con) {
|
|
606
|
+
update con;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
"""
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
result = extract_apex_class(cls)
|
|
613
|
+
dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
|
|
614
|
+
assert len(dml_edges) == 1
|
|
615
|
+
assert "contact" in dml_edges[0]["target"].lower()
|
|
616
|
+
assert dml_edges[0]["operation"] == "update"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def test_extract_apex_dml_unresolvable_var_skipped(tmp_path):
|
|
620
|
+
"""A DML on a variable with no resolvable declaration emits NO dml edge."""
|
|
621
|
+
from graphify_sf.extract.apex import extract_apex_class
|
|
622
|
+
|
|
623
|
+
cls = tmp_path / "MysteryUpdater.cls"
|
|
624
|
+
cls.write_text(
|
|
625
|
+
"""\
|
|
626
|
+
public class MysteryUpdater {
|
|
627
|
+
public void run() {
|
|
628
|
+
update mystery;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
"""
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
result = extract_apex_class(cls)
|
|
635
|
+
dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
|
|
636
|
+
assert len(dml_edges) == 0
|
|
637
|
+
assert not any("mystery" in e.get("target", "").lower() for e in dml_edges)
|
|
638
|
+
|
|
639
|
+
|
|
520
640
|
def test_extract_apex_raw_calls_filters_lowercase_variables(tmp_path):
|
|
521
641
|
"""Local variable method calls (e.g. myObj.doSomething()) are not stored as raw_calls,
|
|
522
642
|
but PascalCase static/utility calls (e.g. AccountService.getInstance()) are kept."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|