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.
Files changed (56) hide show
  1. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/PKG-INFO +28 -3
  2. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/README.md +26 -2
  3. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/__main__.py +21 -4
  4. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/build.py +52 -4
  5. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/detect.py +96 -12
  6. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/apex.py +71 -23
  7. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/PKG-INFO +28 -3
  8. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/requires.txt +1 -0
  9. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/pyproject.toml +2 -1
  10. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_build.py +82 -0
  11. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect.py +96 -0
  12. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_apex.py +120 -0
  13. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/LICENSE +0 -0
  14. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/__init__.py +0 -0
  15. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/_bundled_skill.md +0 -0
  16. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/analyze.py +0 -0
  17. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/cache.py +0 -0
  18. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/cluster.py +0 -0
  19. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/export.py +0 -0
  20. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/__init__.py +0 -0
  21. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/_ids.py +0 -0
  22. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/agentforce.py +0 -0
  23. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/aura.py +0 -0
  24. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/automation.py +0 -0
  25. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/config.py +0 -0
  26. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/doc.py +0 -0
  27. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/flow.py +0 -0
  28. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/layout.py +0 -0
  29. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/lwc.py +0 -0
  30. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/object.py +0 -0
  31. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/profile.py +0 -0
  32. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/extract/visualforce.py +0 -0
  33. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/llm.py +0 -0
  34. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/report.py +0 -0
  35. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/security.py +0 -0
  36. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/serve.py +0 -0
  37. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/validate.py +0 -0
  38. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf/watch.py +0 -0
  39. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/SOURCES.txt +0 -0
  40. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/dependency_links.txt +0 -0
  41. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/entry_points.txt +0 -0
  42. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/graphify_sf.egg-info/top_level.txt +0 -0
  43. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/setup.cfg +0 -0
  44. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_analyze.py +0 -0
  45. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_cli.py +0 -0
  46. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_cluster.py +0 -0
  47. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect_docs.py +0 -0
  48. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_detect_skip_dirs.py +0 -0
  49. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_agentforce.py +0 -0
  50. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_config.py +0 -0
  51. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_doc.py +0 -0
  52. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_flow.py +0 -0
  53. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_lwc.py +0 -0
  54. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_object.py +0 -0
  55. {graphify_sf-0.3.4 → graphify_sf-0.3.6}/tests/test_extract_pipeline.py +0 -0
  56. {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.4
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
- Place a `.graphifysfignore` file at your project root to exclude files/directories from scanning (same syntax as `.gitignore` glob patterns):
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
- Place a `.graphifysfignore` file at your project root to exclude files/directories from scanning (same syntax as `.gitignore` glob patterns):
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
- skipped = detection.get("skipped", 0)
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, {skipped} skipped")
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
- key = _norm_label(node.get("label", node.get("id", "")))
347
- if not key:
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 ignore pattern."""
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
- if not _is_ignored(dp, root, ignore_patterns):
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
- if not _is_ignored(dp, root, ignore_patterns):
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
- if _is_ignored(path, root, ignore_patterns):
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. The relation stays "dml" (unchanged) but each edge carries an
320
- # "operation" field derived from the DML verb (insert→create / update→update /
321
- # delete→delete / upsert / merge / undelete) so downstream can distinguish the
322
- # kind of write. Native SF verbs (upsert/merge/undelete) are preserved rather than
323
- # forced into CRUD. confidence stays INFERRED: the DML target is a variable name we
324
- # cannot statically resolve to an object type — that honesty label must not change.
325
- # Dedup is by (obj_var, operation) so a class that both inserts and updates the same
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
- # obj_var is a variable name, not necessarily an object API name.
340
- # We record it as INFERRED since we can't always resolve the type statically.
341
- if obj_var.lower() not in _APEX_KEYWORDS and (obj_var, operation) not in seen_dml_ops:
342
- seen_dml_ops.add((obj_var, operation))
343
- # Only add the edge if it looks like a type name (capitalized) or known object
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
- edge = _make_edge(
346
- class_nid,
347
- object_id(obj_var),
348
- "dml",
349
- "INFERRED",
350
- str_path,
351
- confidence_score=0.7,
352
- )
353
- edge["operation"] = operation
354
- edges.append(edge)
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.4
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
- Place a `.graphifysfignore` file at your project root to exclude files/directories from scanning (same syntax as `.gitignore` glob patterns):
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
@@ -1,6 +1,7 @@
1
1
  networkx>=3.0
2
2
  datasketch>=1.6
3
3
  rapidfuzz>=3.0
4
+ pathspec>=0.12
4
5
  idna>=3.15
5
6
 
6
7
  [all]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphify-sf"
7
- version = "0.3.4"
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