graphify-sf 0.4.0__tar.gz → 0.4.2__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 (71) hide show
  1. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/PKG-INFO +35 -31
  2. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/README.md +19 -14
  3. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/__main__.py +137 -54
  4. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/_bundled_skill.md +1 -1
  5. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/cluster.py +52 -15
  6. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/export.py +1 -1
  7. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/__init__.py +40 -3
  8. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/apex.py +170 -18
  9. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/doc.py +99 -1
  10. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/llm.py +124 -39
  11. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/serve.py +222 -36
  12. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/PKG-INFO +35 -31
  13. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/SOURCES.txt +2 -0
  14. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/requires.txt +14 -20
  15. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/pyproject.toml +22 -19
  16. graphify_sf-0.4.2/tests/test_cli.py +817 -0
  17. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_cluster.py +106 -0
  18. graphify_sf-0.4.2/tests/test_export_svg.py +23 -0
  19. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_apex.py +161 -0
  20. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_doc.py +53 -0
  21. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_pipeline.py +103 -0
  22. graphify_sf-0.4.2/tests/test_llm.py +775 -0
  23. graphify_sf-0.4.2/tests/test_llm_sdk_contract.py +155 -0
  24. graphify_sf-0.4.2/tests/test_multigraph.py +560 -0
  25. graphify_sf-0.4.0/tests/test_cli.py +0 -438
  26. graphify_sf-0.4.0/tests/test_llm.py +0 -358
  27. graphify_sf-0.4.0/tests/test_multigraph.py +0 -266
  28. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/LICENSE +0 -0
  29. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/__init__.py +0 -0
  30. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/analyze.py +0 -0
  31. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/build.py +0 -0
  32. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/cache.py +0 -0
  33. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/detect.py +0 -0
  34. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/_ids.py +0 -0
  35. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/agentforce.py +0 -0
  36. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/aura.py +0 -0
  37. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/automation.py +0 -0
  38. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/config.py +0 -0
  39. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/flow.py +0 -0
  40. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/layout.py +0 -0
  41. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/lwc.py +0 -0
  42. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/object.py +0 -0
  43. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/profile.py +0 -0
  44. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/reporting.py +0 -0
  45. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/rules.py +0 -0
  46. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/sharing.py +0 -0
  47. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/extract/visualforce.py +0 -0
  48. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/report.py +0 -0
  49. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/security.py +0 -0
  50. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/validate.py +0 -0
  51. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf/watch.py +0 -0
  52. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/dependency_links.txt +0 -0
  53. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/entry_points.txt +0 -0
  54. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/graphify_sf.egg-info/top_level.txt +0 -0
  55. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/setup.cfg +0 -0
  56. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_analyze.py +0 -0
  57. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_automation_coverage.py +0 -0
  58. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_build.py +0 -0
  59. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_datamodel_coverage.py +0 -0
  60. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect.py +0 -0
  61. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect_docs.py +0 -0
  62. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_detect_skip_dirs.py +0 -0
  63. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_agentforce.py +0 -0
  64. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_config.py +0 -0
  65. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_flow.py +0 -0
  66. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_lwc.py +0 -0
  67. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_extract_object.py +0 -0
  68. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_integration_coverage.py +0 -0
  69. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_reporting_packaging.py +0 -0
  70. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_security_sharing.py +0 -0
  71. {graphify_sf-0.4.0 → graphify_sf-0.4.2}/tests/test_ui_linkage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphify-sf
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -17,55 +17,54 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
22
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
22
23
  Requires-Python: >=3.10
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
26
  Requires-Dist: networkx>=3.0
26
- Requires-Dist: datasketch>=1.6
27
- Requires-Dist: rapidfuzz>=3.0
28
27
  Requires-Dist: pathspec>=0.12
29
28
  Requires-Dist: idna>=3.15
30
29
  Provides-Extra: leiden
31
- Requires-Dist: graspologic; python_version < "3.13" and extra == "leiden"
30
+ Requires-Dist: graspologic-native>=1.2.5; extra == "leiden"
32
31
  Provides-Extra: neo4j
33
32
  Requires-Dist: neo4j; extra == "neo4j"
34
33
  Provides-Extra: svg
35
34
  Requires-Dist: matplotlib; extra == "svg"
35
+ Requires-Dist: scipy; extra == "svg"
36
36
  Provides-Extra: watch
37
37
  Requires-Dist: watchdog>=3.0; extra == "watch"
38
38
  Provides-Extra: mcp
39
- Requires-Dist: mcp>=1.0; extra == "mcp"
40
39
  Provides-Extra: claude
41
40
  Requires-Dist: anthropic>=0.30; extra == "claude"
42
41
  Provides-Extra: kimi
43
- Requires-Dist: openai>=1.0; extra == "kimi"
42
+ Requires-Dist: openai>=1.58; extra == "kimi"
44
43
  Provides-Extra: gemini
45
- Requires-Dist: openai>=1.0; extra == "gemini"
44
+ Requires-Dist: openai>=1.58; extra == "gemini"
46
45
  Provides-Extra: openai
47
- Requires-Dist: openai>=1.0; extra == "openai"
46
+ Requires-Dist: openai>=1.58; extra == "openai"
48
47
  Provides-Extra: bedrock
49
- Requires-Dist: boto3>=1.28; extra == "bedrock"
48
+ Requires-Dist: boto3>=1.34.116; extra == "bedrock"
50
49
  Provides-Extra: ollama
51
- Requires-Dist: openai>=1.0; extra == "ollama"
50
+ Requires-Dist: openai>=1.58; extra == "ollama"
52
51
  Provides-Extra: llm
53
- Requires-Dist: openai>=1.0; extra == "llm"
52
+ Requires-Dist: openai>=1.58; extra == "llm"
54
53
  Requires-Dist: anthropic>=0.30; extra == "llm"
55
54
  Provides-Extra: docs
56
- Requires-Dist: pypdf; extra == "docs"
55
+ Requires-Dist: pypdf>=6.19; extra == "docs"
57
56
  Requires-Dist: python-docx; extra == "docs"
58
57
  Requires-Dist: openpyxl; extra == "docs"
59
58
  Provides-Extra: all
60
- Requires-Dist: graspologic; python_version < "3.13" and extra == "all"
59
+ Requires-Dist: graspologic-native>=1.2.5; extra == "all"
61
60
  Requires-Dist: neo4j; extra == "all"
62
61
  Requires-Dist: matplotlib; extra == "all"
62
+ Requires-Dist: scipy; extra == "all"
63
63
  Requires-Dist: watchdog>=3.0; extra == "all"
64
- Requires-Dist: mcp>=1.0; extra == "all"
65
- Requires-Dist: openai>=1.0; extra == "all"
64
+ Requires-Dist: openai>=1.58; extra == "all"
66
65
  Requires-Dist: anthropic>=0.30; extra == "all"
67
- Requires-Dist: boto3>=1.28; extra == "all"
68
- Requires-Dist: pypdf; extra == "all"
66
+ Requires-Dist: boto3>=1.34.116; extra == "all"
67
+ Requires-Dist: pypdf>=6.19; extra == "all"
69
68
  Requires-Dist: python-docx; extra == "all"
70
69
  Requires-Dist: openpyxl; extra == "all"
71
70
  Provides-Extra: dev
@@ -301,15 +300,17 @@ The `--backend` flag adds an AI-powered extraction layer on top of the static XM
301
300
 
302
301
  ### Supported Backends
303
302
 
304
- | Backend | Flag | API Key env var | Estimated cost (1k files) |
305
- |---------|------|-----------------|--------------------------|
306
- | Claude | `--backend claude` | `ANTHROPIC_API_KEY` | ~$0.50 |
307
- | Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | ~$0.05 |
308
- | Kimi K2 | `--backend kimi` | `MOONSHOT_API_KEY` | ~$0.30 |
309
- | OpenAI | `--backend openai` | `OPENAI_API_KEY` | ~$0.20 |
310
- | AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | ~$0.50 |
311
- | Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | free |
312
- | Auto-detect | `--backend auto` | _(first found)_ | varies |
303
+ | Backend | Flag | API Key env var | Default model | Price per 1M tokens (in / out) |
304
+ |---------|------|-----------------|---------------|--------------------------------|
305
+ | Claude | `--backend claude` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` | $3.00 / $15.00 |
306
+ | Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-3.6-flash` | $0.75 / $3.75 |
307
+ | Kimi | `--backend kimi` | `MOONSHOT_API_KEY` | `kimi-k3` | $3.00 / $15.00 |
308
+ | OpenAI | `--backend openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | $0.40 / $1.60 |
309
+ | AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | `us.anthropic.claude-sonnet-4-6` | $3.30 / $16.50 |
310
+ | Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | `qwen2.5-coder:7b` | free |
311
+ | Auto-detect | `--backend auto` | _(first found)_ | — | varies |
312
+
313
+ Prices mirror `BACKENDS` in `graphify_sf/llm.py`, which the CLI also uses for its cost estimates. The estimates assume the default model, even if you override it. Gemini 3.x and Kimi K3 always reason, and their thinking tokens are billed as output. The Bedrock default is a US geo inference profile: outside US regions, set `GRAPHIFY_SF_BEDROCK_MODEL` (e.g. `eu.anthropic.claude-sonnet-4-6` or `global.anthropic.claude-sonnet-4-6`). Gemini, Kimi, OpenAI and Bedrock defaults can be overridden with the model variables below (Ollama via `OLLAMA_MODEL`). The Claude model is fixed, and the CLI has no `--model` flag.
313
314
 
314
315
  ### Install the SDK
315
316
 
@@ -333,12 +334,13 @@ pip install graphify-sf[llm]
333
334
  |----------|-------------|
334
335
  | `ANTHROPIC_API_KEY` | Claude API key |
335
336
  | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API key |
336
- | `MOONSHOT_API_KEY` | Kimi K2 API key |
337
+ | `MOONSHOT_API_KEY` | Kimi API key |
337
338
  | `OPENAI_API_KEY` | OpenAI API key |
338
339
  | `AWS_PROFILE` / `AWS_REGION` | AWS credentials for Bedrock |
339
340
  | `OLLAMA_BASE_URL` | Ollama server URL (default: `http://localhost:11434/v1`) |
340
341
  | `OLLAMA_MODEL` | Ollama model name (default: `qwen2.5-coder:7b`) |
341
342
  | `GRAPHIFY_SF_GEMINI_MODEL` | Override Gemini model |
343
+ | `GRAPHIFY_SF_KIMI_MODEL` | Override Kimi model (e.g. `kimi-k2.6`; K2.x runs with thinking disabled) |
342
344
  | `GRAPHIFY_SF_OPENAI_MODEL` | Override OpenAI model |
343
345
  | `GRAPHIFY_SF_BEDROCK_MODEL` | Override Bedrock model |
344
346
  | `GRAPHIFY_SF_MAX_OUTPUT_TOKENS` | Override max output tokens per LLM call |
@@ -572,6 +574,7 @@ graphify-sf serve --graph path/to/graph.json
572
574
  | `god_nodes` | Highest-degree nodes — the most central metadata in the org |
573
575
  | `list_communities` | All communities with their labels and member counts |
574
576
  | `get_community` | All members of a specific community (by id or label) |
577
+ | `bfs_impact` | Bounded, direction-aware impact traversal (`forward`/`reverse`/`both`, `max_depth`, `relation_filter`, EXTRACTED-only unless `include_inferred`) |
575
578
 
576
579
  **Available resources** (via `resources/list` and `resources/read`):
577
580
 
@@ -742,7 +745,7 @@ force-app/archived/**
742
745
  ## Optional Extras
743
746
 
744
747
  ```bash
745
- # Better community detection (Leiden algorithm, Python < 3.13 only)
748
+ # Better community detection (Leiden algorithm via graspologic-native)
746
749
  pip install graphify-sf[leiden]
747
750
 
748
751
  # Neo4j direct push support
@@ -754,7 +757,8 @@ pip install graphify-sf[svg]
754
757
  # Watchdog for efficient file-system events in watch mode
755
758
  pip install graphify-sf[watch]
756
759
 
757
- # MCP SDK (reserved for future native MCP SDK adoption)
760
+ # MCP server — no extra needed (`graphify-sf serve` is self-contained);
761
+ # kept as an empty alias so existing install commands still work
758
762
  pip install graphify-sf[mcp]
759
763
 
760
764
  # LLM semantic extraction — Claude
@@ -782,7 +786,7 @@ pip install graphify-sf[all]
782
786
 
783
787
  - Python 3.10+
784
788
  - No Salesforce CLI, no org connection, no API keys
785
- - Core dependencies: `networkx`, `datasketch`, `rapidfuzz`
789
+ - Core dependencies: `networkx`, `pathspec`, `idna`
786
790
 
787
791
  ---
788
792
 
@@ -827,7 +831,7 @@ graphify_sf/
827
831
  ├── export.py All export formats — HTML, SVG, Obsidian, GraphML, Cypher, wiki, ...
828
832
  ├── llm.py LLM semantic extraction — 6 backends, SF-specific prompt, adaptive retry
829
833
  ├── watch.py File-system watcher — fcntl rebuild lock, resource limits, callflow regen
830
- ├── serve.py MCP stdio server — 8 tools, 6 resources, blank-stdin filter
834
+ ├── serve.py MCP stdio server — 9 tools, 6 resources, blank-stdin filter
831
835
  ├── cache.py Per-file extraction cache — SHA-256 keyed, atomic writes
832
836
  ├── validate.py Extraction schema validation — catches bad extractors before graph assembly
833
837
  ├── security.py Input sanitization — sanitize_label, validate_graph_path
@@ -225,15 +225,17 @@ The `--backend` flag adds an AI-powered extraction layer on top of the static XM
225
225
 
226
226
  ### Supported Backends
227
227
 
228
- | Backend | Flag | API Key env var | Estimated cost (1k files) |
229
- |---------|------|-----------------|--------------------------|
230
- | Claude | `--backend claude` | `ANTHROPIC_API_KEY` | ~$0.50 |
231
- | Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | ~$0.05 |
232
- | Kimi K2 | `--backend kimi` | `MOONSHOT_API_KEY` | ~$0.30 |
233
- | OpenAI | `--backend openai` | `OPENAI_API_KEY` | ~$0.20 |
234
- | AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | ~$0.50 |
235
- | Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | free |
236
- | Auto-detect | `--backend auto` | _(first found)_ | varies |
228
+ | Backend | Flag | API Key env var | Default model | Price per 1M tokens (in / out) |
229
+ |---------|------|-----------------|---------------|--------------------------------|
230
+ | Claude | `--backend claude` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` | $3.00 / $15.00 |
231
+ | Gemini | `--backend gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-3.6-flash` | $0.75 / $3.75 |
232
+ | Kimi | `--backend kimi` | `MOONSHOT_API_KEY` | `kimi-k3` | $3.00 / $15.00 |
233
+ | OpenAI | `--backend openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | $0.40 / $1.60 |
234
+ | AWS Bedrock | `--backend bedrock` | `AWS_PROFILE` / `AWS_REGION` | `us.anthropic.claude-sonnet-4-6` | $3.30 / $16.50 |
235
+ | Ollama (local) | `--backend ollama` | `OLLAMA_BASE_URL` | `qwen2.5-coder:7b` | free |
236
+ | Auto-detect | `--backend auto` | _(first found)_ | — | varies |
237
+
238
+ Prices mirror `BACKENDS` in `graphify_sf/llm.py`, which the CLI also uses for its cost estimates. The estimates assume the default model, even if you override it. Gemini 3.x and Kimi K3 always reason, and their thinking tokens are billed as output. The Bedrock default is a US geo inference profile: outside US regions, set `GRAPHIFY_SF_BEDROCK_MODEL` (e.g. `eu.anthropic.claude-sonnet-4-6` or `global.anthropic.claude-sonnet-4-6`). Gemini, Kimi, OpenAI and Bedrock defaults can be overridden with the model variables below (Ollama via `OLLAMA_MODEL`). The Claude model is fixed, and the CLI has no `--model` flag.
237
239
 
238
240
  ### Install the SDK
239
241
 
@@ -257,12 +259,13 @@ pip install graphify-sf[llm]
257
259
  |----------|-------------|
258
260
  | `ANTHROPIC_API_KEY` | Claude API key |
259
261
  | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API key |
260
- | `MOONSHOT_API_KEY` | Kimi K2 API key |
262
+ | `MOONSHOT_API_KEY` | Kimi API key |
261
263
  | `OPENAI_API_KEY` | OpenAI API key |
262
264
  | `AWS_PROFILE` / `AWS_REGION` | AWS credentials for Bedrock |
263
265
  | `OLLAMA_BASE_URL` | Ollama server URL (default: `http://localhost:11434/v1`) |
264
266
  | `OLLAMA_MODEL` | Ollama model name (default: `qwen2.5-coder:7b`) |
265
267
  | `GRAPHIFY_SF_GEMINI_MODEL` | Override Gemini model |
268
+ | `GRAPHIFY_SF_KIMI_MODEL` | Override Kimi model (e.g. `kimi-k2.6`; K2.x runs with thinking disabled) |
266
269
  | `GRAPHIFY_SF_OPENAI_MODEL` | Override OpenAI model |
267
270
  | `GRAPHIFY_SF_BEDROCK_MODEL` | Override Bedrock model |
268
271
  | `GRAPHIFY_SF_MAX_OUTPUT_TOKENS` | Override max output tokens per LLM call |
@@ -496,6 +499,7 @@ graphify-sf serve --graph path/to/graph.json
496
499
  | `god_nodes` | Highest-degree nodes — the most central metadata in the org |
497
500
  | `list_communities` | All communities with their labels and member counts |
498
501
  | `get_community` | All members of a specific community (by id or label) |
502
+ | `bfs_impact` | Bounded, direction-aware impact traversal (`forward`/`reverse`/`both`, `max_depth`, `relation_filter`, EXTRACTED-only unless `include_inferred`) |
499
503
 
500
504
  **Available resources** (via `resources/list` and `resources/read`):
501
505
 
@@ -666,7 +670,7 @@ force-app/archived/**
666
670
  ## Optional Extras
667
671
 
668
672
  ```bash
669
- # Better community detection (Leiden algorithm, Python < 3.13 only)
673
+ # Better community detection (Leiden algorithm via graspologic-native)
670
674
  pip install graphify-sf[leiden]
671
675
 
672
676
  # Neo4j direct push support
@@ -678,7 +682,8 @@ pip install graphify-sf[svg]
678
682
  # Watchdog for efficient file-system events in watch mode
679
683
  pip install graphify-sf[watch]
680
684
 
681
- # MCP SDK (reserved for future native MCP SDK adoption)
685
+ # MCP server — no extra needed (`graphify-sf serve` is self-contained);
686
+ # kept as an empty alias so existing install commands still work
682
687
  pip install graphify-sf[mcp]
683
688
 
684
689
  # LLM semantic extraction — Claude
@@ -706,7 +711,7 @@ pip install graphify-sf[all]
706
711
 
707
712
  - Python 3.10+
708
713
  - No Salesforce CLI, no org connection, no API keys
709
- - Core dependencies: `networkx`, `datasketch`, `rapidfuzz`
714
+ - Core dependencies: `networkx`, `pathspec`, `idna`
710
715
 
711
716
  ---
712
717
 
@@ -751,7 +756,7 @@ graphify_sf/
751
756
  ├── export.py All export formats — HTML, SVG, Obsidian, GraphML, Cypher, wiki, ...
752
757
  ├── llm.py LLM semantic extraction — 6 backends, SF-specific prompt, adaptive retry
753
758
  ├── watch.py File-system watcher — fcntl rebuild lock, resource limits, callflow regen
754
- ├── serve.py MCP stdio server — 8 tools, 6 resources, blank-stdin filter
759
+ ├── serve.py MCP stdio server — 9 tools, 6 resources, blank-stdin filter
755
760
  ├── cache.py Per-file extraction cache — SHA-256 keyed, atomic writes
756
761
  ├── validate.py Extraction schema validation — catches bad extractors before graph assembly
757
762
  ├── security.py Input sanitization — sanitize_label, validate_graph_path
@@ -37,6 +37,78 @@ def _load_graph_from_json(path: Path):
37
37
  return _jg.node_link_graph(raw), raw
38
38
 
39
39
 
40
+ def _load_graph_json_raw(path: Path) -> dict:
41
+ """Load a graph.json's raw ``nodes``/``links`` lists (no NetworkX collapse).
42
+
43
+ Returns ``{"nodes": [...], "links": [...]}`` — empty lists if the file is
44
+ missing or unreadable. The merge paths use this instead of a ``nx.Graph()``
45
+ union so same-direction parallel edges survive until the multigraph build.
46
+ """
47
+ if not path.exists():
48
+ return {"nodes": [], "links": []}
49
+ try:
50
+ raw = json.loads(path.read_text(encoding="utf-8"))
51
+ if "links" not in raw and "edges" in raw:
52
+ raw["links"] = raw["edges"]
53
+ return raw
54
+ except Exception:
55
+ return {"nodes": [], "links": []}
56
+
57
+
58
+ _UNION_EDGE_IGNORED_FIELDS = frozenset({"key", "source_file"})
59
+
60
+
61
+ def _union_edge_identity(edge: dict) -> str:
62
+ """Canonical dedup identity for a link during union.
63
+
64
+ Hashes the full edge dict minus the per-graph fields that legitimately vary
65
+ across inputs (``key`` — each source graph numbers its own parallel edges
66
+ from 0; ``source_file`` — an absolute path unique to the machine that built
67
+ that graph). Every other field participates, so edges that differ only in
68
+ ``operation`` (e.g. ``dml`` create vs. update on the same source→target
69
+ pair, which ``extract/apex.py`` and ``extract/flow.py`` deliberately emit as
70
+ distinct edges) are kept apart, while genuinely-identical duplicates (the
71
+ common base/ours/theirs 3-way case) still collapse to one.
72
+ """
73
+ identity = {k: v for k, v in edge.items() if k not in _UNION_EDGE_IGNORED_FIELDS}
74
+ return json.dumps(identity, sort_keys=True, default=str)
75
+
76
+
77
+ def _union_graph_json(datas: list[dict]) -> tuple[list[dict], list[dict]]:
78
+ """Union raw graph.json node/link lists across multiple graphs.
79
+
80
+ Nodes are deduped by ``id`` (first occurrence wins). Links are unioned and
81
+ deduped on a canonical hash of the full edge dict (minus the per-graph
82
+ ``key``/``source_file`` fields), so distinct parallel edges are ALL
83
+ preserved — both those differing in ``relation`` (e.g. ``queries`` + ``dml``
84
+ on the same pair) and those differing only in ``operation`` (e.g. two
85
+ ``dml`` edges, one create + one update, on the same pair). A plain
86
+ ``nx.Graph()`` union would collapse the former, and a coarse
87
+ ``(source, target, relation)`` key would collapse the latter, before the
88
+ multigraph build ever runs. Genuinely-identical duplicate edges (the
89
+ base/ours/theirs 3-way merge case) still coalesce to one.
90
+ """
91
+ all_nodes: dict[str, dict] = {}
92
+ all_edges: list[dict] = []
93
+ seen_edges: set[str] = set()
94
+ for data in datas:
95
+ for node in data.get("nodes", []):
96
+ nid = node.get("id", "")
97
+ if nid and nid not in all_nodes:
98
+ all_nodes[nid] = node
99
+ links_key = "links" if "links" in data else "edges"
100
+ for edge in data.get(links_key, []):
101
+ identity = _union_edge_identity(edge)
102
+ if identity not in seen_edges:
103
+ seen_edges.add(identity)
104
+ # Drop the exported per-graph "key" — each source graph numbers its
105
+ # own parallel edges from 0, so two distinct relations on the same
106
+ # pair both arrive as key=0 and would collapse on rebuild. Stripping
107
+ # it lets the MultiDiGraph assign fresh keys per parallel edge.
108
+ all_edges.append({k: v for k, v in edge.items() if k != "key"})
109
+ return list(all_nodes.values()), all_edges
110
+
111
+
40
112
  def _derive_community_labels(G, communities: dict) -> dict[int, str]:
41
113
  """Derive community label from the highest-degree non-method node."""
42
114
  labels: dict[int, str] = {}
@@ -184,6 +256,7 @@ def _run_pipeline(
184
256
  backend: str | None = None,
185
257
  token_budget: int = 40_000,
186
258
  respect_ignore: bool = True,
259
+ verbose: bool = False,
187
260
  ) -> None:
188
261
  """Full pipeline: detect → extract → [LLM] → build → cluster → report → export."""
189
262
  from graphify_sf.analyze import god_nodes, suggest_questions, surprising_connections
@@ -241,7 +314,9 @@ def _run_pipeline(
241
314
  _run_llm_extraction(target, detection, extraction, backend, token_budget, token_cost)
242
315
 
243
316
  # Dedup by label
317
+ n_extracted = len(extraction["nodes"])
244
318
  nodes, edges = deduplicate_by_label(extraction["nodes"], extraction["edges"])
319
+ n_deduped = len(nodes)
245
320
 
246
321
  new_extraction = {"nodes": nodes, "edges": edges}
247
322
 
@@ -254,6 +329,34 @@ def _run_pipeline(
254
329
 
255
330
  print(f"[graphify-sf] graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
256
331
 
332
+ if verbose:
333
+ # Honest node-drop accounting (closes the unanswered BUGS_FOUND question:
334
+ # "N nodes dropped between extract and build — dedup or dangling pruning?").
335
+ # Real pipeline stages, verified against build.py:
336
+ # * deduplicate_by_label MERGES same-(sf_type,label) nodes → removes nodes
337
+ # * build_from_json ADDS stub nodes for prefix-matched missing edge
338
+ # endpoints, and PRUNES dangling EDGES (edges to still-absent nodes) —
339
+ # it removes edges, never nodes.
340
+ # "Build-added stubs" are counted as final node ids absent from the deduped
341
+ # set (NOT the `stub` attribute, which the extractors also set on stubs they
342
+ # inject before dedup). For a fresh build: final == deduped + build_stubs.
343
+ n_final = G.number_of_nodes()
344
+ deduped_ids = {n["id"] for n in nodes if isinstance(n, dict) and "id" in n}
345
+ # In incremental mode G is merged with the ENTIRE prior graph.json, so nodes
346
+ # retained unchanged from that prior run are absent from this run's own
347
+ # deduped_ids and would be miscounted as build-injected stubs. Gate stub
348
+ # reporting to the fresh-build path, same as n_pruned_edges below.
349
+ n_build_stubs = sum(1 for nid in G.nodes() if nid not in deduped_ids) if not incremental else None
350
+ stub_str = "n/a (incremental merge)" if n_build_stubs is None else f"+{n_build_stubs} stub"
351
+ n_pruned_edges = max(0, len(edges) - G.number_of_edges()) if not incremental else None
352
+ pruned_str = "n/a (incremental merge)" if n_pruned_edges is None else str(n_pruned_edges)
353
+ print(
354
+ f"[graphify-sf] node accounting: {n_extracted} extracted "
355
+ f"→ {n_deduped} deduped-by-label ({n_extracted - n_deduped} merged) "
356
+ f"→ {stub_str} → {n_final} final nodes "
357
+ f"({pruned_str} dangling edge(s) pruned)"
358
+ )
359
+
257
360
  if G.number_of_nodes() == 0:
258
361
  print("[graphify-sf] error: graph is empty after extraction", file=sys.stderr)
259
362
  sys.exit(1)
@@ -500,15 +603,20 @@ def _cmd_path(source_label: str, target_label: str, graph_path: Path) -> None:
500
603
  print(f"No path found between '{source_label}' and '{target_label}'.")
501
604
  sys.exit(0)
502
605
 
503
- from graphify_sf.build import edge_data
606
+ from graphify_sf.build import edge_datas
504
607
 
505
608
  hops = len(path_nodes) - 1
506
609
  segments = []
507
610
  for i in range(len(path_nodes) - 1):
508
611
  u, v = path_nodes[i], path_nodes[i + 1]
509
- edata = edge_data(G, u, v)
510
- rel = edata.get("relation", "")
511
- conf = edata.get("confidence", "")
612
+ # MultiDiGraph: a hop may carry several parallel edges (e.g. queries +
613
+ # dml). Show every relation on the hop, not an arbitrary first one.
614
+ edatas = edge_datas(G, u, v)
615
+ rels = [ed.get("relation", "") for ed in edatas] or [""]
616
+ confs = [ed.get("confidence", "") for ed in edatas] or [""]
617
+ rel = "/".join(r for r in rels if r) or (rels[0] if rels else "")
618
+ conf_parts = [c for c in confs if c]
619
+ conf = "/".join(dict.fromkeys(conf_parts)) if conf_parts else ""
512
620
  conf_str = f" [{conf}]" if conf else ""
513
621
  if i == 0:
514
622
  segments.append(G.nodes[u].get("label", u))
@@ -733,23 +841,23 @@ def _cmd_merge_graphs(graph_paths: list[Path], out_path: Path, *, no_viz: bool =
733
841
  print(f"error: graph not found: {gp}", file=sys.stderr)
734
842
  sys.exit(1)
735
843
 
736
- # Load all graphs
737
- import networkx as nx
738
-
739
- merged = nx.Graph()
844
+ # Load all graphs as raw JSON node/link lists and union them WITHOUT going
845
+ # through a plain nx.Graph(), which would collapse same-direction parallel
846
+ # edges before the multigraph-aware build ever runs. Mirrors the edge-safe
847
+ # union already used by _cmd_merge_driver.
848
+ raw_datas = []
740
849
  for gp in graph_paths:
741
850
  print(f"[graphify-sf] loading {gp}...")
742
- G_i, raw_i = _load_graph_from_json(gp)
743
- print(f" {G_i.number_of_nodes()} nodes, {G_i.number_of_edges()} edges")
744
- merged.update(G_i)
851
+ raw_i = _load_graph_json_raw(gp)
852
+ print(f" {len(raw_i.get('nodes', []))} nodes, {len(raw_i.get('links', raw_i.get('edges', [])))} edges")
853
+ raw_datas.append(raw_i)
745
854
 
746
- print(f"[graphify-sf] merged: {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges (before dedup)")
855
+ nodes_data, edges_data = _union_graph_json(raw_datas)
856
+ print(f"[graphify-sf] merged: {len(nodes_data)} nodes, {len(edges_data)} edges (before dedup)")
747
857
 
748
- # Dedup by label
749
- nodes_data = [{"id": nid, **data} for nid, data in merged.nodes(data=True)]
750
- edges_data = [{"source": u, "target": v, **data} for u, v, data in merged.edges(data=True)]
858
+ # Dedup by label, then build the multigraph so parallel edges survive.
751
859
  nodes_dedup, edges_dedup = deduplicate_by_label(nodes_data, edges_data)
752
- G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup})
860
+ G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup}, multigraph=True)
753
861
 
754
862
  print(f"[graphify-sf] after dedup: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
755
863
 
@@ -823,44 +931,13 @@ def _cmd_merge_driver(base: Path, ours: Path, theirs: Path) -> None:
823
931
  from graphify_sf.cluster import cluster
824
932
  from graphify_sf.export import to_json
825
933
 
826
- def _load(p: Path):
827
- if not p.exists():
828
- return {"nodes": [], "links": []}
829
- try:
830
- raw = json.loads(p.read_text(encoding="utf-8"))
831
- if "links" not in raw and "edges" in raw:
832
- raw["links"] = raw["edges"]
833
- return raw
834
- except Exception:
835
- return {"nodes": [], "links": []}
836
-
837
- base_data = _load(base)
838
- our_data = _load(ours)
839
- their_data = _load(theirs)
840
-
841
- # Union all nodes and edges by id
842
- all_nodes: dict[str, dict] = {}
843
- all_edges: list[dict] = []
844
- seen_edges: set[tuple] = set()
845
-
846
- for data in (base_data, our_data, their_data):
847
- for node in data.get("nodes", []):
848
- nid = node.get("id", "")
849
- if nid and nid not in all_nodes:
850
- all_nodes[nid] = node
851
- links_key = "links" if "links" in data else "edges"
852
- for edge in data.get(links_key, []):
853
- src = edge.get("source", "")
854
- tgt = edge.get("target", "")
855
- rel = edge.get("relation", "")
856
- key = (src, tgt, rel)
857
- if key not in seen_edges:
858
- seen_edges.add(key)
859
- all_edges.append(edge)
860
-
861
- extraction = {"nodes": list(all_nodes.values()), "edges": all_edges}
862
- nodes_dedup, edges_dedup = deduplicate_by_label(extraction["nodes"], extraction["edges"])
863
- G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup})
934
+ # Union raw node/link lists (dedup nodes by id, links by (src,tgt,rel)) so
935
+ # parallel edges survive; see _union_graph_json.
936
+ all_nodes, all_edges = _union_graph_json(
937
+ [_load_graph_json_raw(base), _load_graph_json_raw(ours), _load_graph_json_raw(theirs)]
938
+ )
939
+ nodes_dedup, edges_dedup = deduplicate_by_label(all_nodes, all_edges)
940
+ G = build_from_json({"nodes": nodes_dedup, "edges": edges_dedup}, multigraph=True)
864
941
  communities = cluster(G)
865
942
  to_json(G, communities, str(ours), force=True)
866
943
  print(f"[graphify-sf] merge-driver: merged {G.number_of_nodes()} nodes, {G.number_of_edges()} edges → {ours}")
@@ -1578,6 +1655,7 @@ def main() -> None:
1578
1655
  print(" --update incremental update (re-extract changed files)")
1579
1656
  print(" --directed build directed graph")
1580
1657
  print(" --no-viz skip graph.html generation")
1658
+ print(" --verbose, -v print node-drop accounting (extract→dedup→build)")
1581
1659
  print(" --force overwrite graph.json even if new graph is smaller")
1582
1660
  print(" --include-ignored scan files matched by .gitignore/.forceignore")
1583
1661
  print(" (default: those files are skipped)")
@@ -1947,6 +2025,7 @@ def main() -> None:
1947
2025
  backend: str | None = None
1948
2026
  token_budget = 40_000
1949
2027
  respect_ignore = True
2028
+ verbose = False
1950
2029
 
1951
2030
  i = 1
1952
2031
  while i < len(args):
@@ -1966,6 +2045,9 @@ def main() -> None:
1966
2045
  elif a == "--no-viz":
1967
2046
  no_viz = True
1968
2047
  i += 1
2048
+ elif a in ("--verbose", "-v"):
2049
+ verbose = True
2050
+ i += 1
1969
2051
  elif a == "--force":
1970
2052
  force = True
1971
2053
  i += 1
@@ -2005,6 +2087,7 @@ def main() -> None:
2005
2087
  backend=backend,
2006
2088
  token_budget=token_budget,
2007
2089
  respect_ignore=respect_ignore,
2090
+ verbose=verbose,
2008
2091
  )
2009
2092
  return
2010
2093
 
@@ -20,7 +20,7 @@ Turn any Salesforce SFDX project into a navigable knowledge graph with community
20
20
  /graphify-sf <path> --force # overwrite graph.json even if new graph is smaller
21
21
  /graphify-sf <path> --max-workers N # parallel extraction worker count
22
22
  /graphify-sf <path> --backend claude # add AI semantic layer (business rules, dead metadata)
23
- /graphify-sf <path> --backend gemini # Gemini backend (cheapest, ~$0.05/1k files)
23
+ /graphify-sf <path> --backend gemini # Gemini backend
24
24
  /graphify-sf <path> --backend auto # auto-detect backend from available API keys
25
25
  /graphify-sf <path> --backend claude --token-budget 20000 # smaller chunks for large orgs
26
26
  /graphify-sf cluster-only <path> # rerun clustering on existing graph
@@ -1,10 +1,10 @@
1
1
  """Community detection on NetworkX graphs.
2
2
 
3
- Uses Leiden (graspologic) if available, falls back to Louvain (networkx).
3
+ Uses Leiden (graspologic-native) if available, falls back to Louvain (networkx).
4
4
  Splits oversized communities. Returns cohesion scores.
5
5
 
6
- Adapted from Graphify's cluster.py — logic is identical; just updated
7
- module name in docstrings.
6
+ Adapted from Graphify's cluster.py; the Leiden path has since diverged
7
+ (graspologic-native engine, parallel-edge aggregation, deterministic labels).
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -22,23 +22,60 @@ def _suppress_output():
22
22
  return contextlib.redirect_stdout(io.StringIO())
23
23
 
24
24
 
25
+ def _leiden_edge_list(G: nx.Graph) -> list[tuple[str, str, float]]:
26
+ """Collapse G into one weighted edge per unordered node pair, keyed by str ids.
27
+
28
+ graspologic-native keeps only the first weight it sees for a pair (later
29
+ duplicates, including the reversed pair, are dropped), so parallel edges are
30
+ summed here — the same treatment Louvain gives multigraphs. Self-loops are
31
+ skipped. Sorted, so the result never depends on G's iteration order (which,
32
+ for small networkx subgraph views, follows Python's randomized str hashing).
33
+ """
34
+ weights: dict[tuple[str, str], float] = {}
35
+ for u, v, w in G.edges(data="weight", default=1.0):
36
+ su, sv = str(u), str(v)
37
+ if su == sv:
38
+ continue
39
+ pair = (su, sv) if su < sv else (sv, su)
40
+ weights[pair] = weights.get(pair, 0.0) + float(w)
41
+ return sorted((a, b, w) for (a, b), w in weights.items())
42
+
43
+
25
44
  def _partition(G: nx.Graph) -> dict[str, int]:
26
45
  """Run community detection. Returns {node_id: community_id}.
27
46
 
28
- Tries Leiden (graspologic) first — best quality.
29
- Falls back to Louvain (built into networkx) if graspologic is not installed.
47
+ Tries Leiden (graspologic-native) first — best quality.
48
+ Falls back to Louvain (built into networkx) if graspologic-native is not installed.
30
49
  """
31
50
  try:
32
- from graspologic.partition import leiden
33
-
34
- old_stderr = sys.stderr
35
- try:
36
- sys.stderr = io.StringIO()
37
- with _suppress_output():
38
- result = leiden(G)
39
- finally:
40
- sys.stderr = old_stderr
41
- return result
51
+ from graspologic_native import leiden
52
+
53
+ edges = _leiden_edge_list(G)
54
+ native: dict[str, int] = {}
55
+ if edges:
56
+ old_stderr = sys.stderr
57
+ try:
58
+ sys.stderr = io.StringIO()
59
+ with _suppress_output():
60
+ _, native = leiden(
61
+ edges=edges,
62
+ resolution=1.0,
63
+ randomness=0.001,
64
+ iterations=1,
65
+ use_modularity=True,
66
+ seed=42,
67
+ trials=1,
68
+ )
69
+ finally:
70
+ sys.stderr = old_stderr
71
+ # Relabel in sorted-id order: the engine's own labels (and dict order) vary
72
+ # between runs even with a fixed seed, which would reshuffle cluster()'s
73
+ # size-tie ordering. Nodes whose only edges are self-loops never reach the
74
+ # engine and get a community of their own.
75
+ labels: dict[object, int] = {}
76
+ return {
77
+ n: labels.setdefault(native.get(str(n), ("self-loop", n)), len(labels)) for n in sorted(G.nodes(), key=str)
78
+ }
42
79
  except ImportError:
43
80
  pass
44
81
 
@@ -1025,7 +1025,7 @@ def to_svg(
1025
1025
  ) -> None:
1026
1026
  """Export the graph as an SVG image using matplotlib + networkx layout.
1027
1027
 
1028
- Requires: pip install graphify-sf[svg] (installs matplotlib)
1028
+ Requires: pip install graphify-sf[svg] (installs matplotlib + scipy)
1029
1029
 
1030
1030
  Args:
1031
1031
  G: NetworkX graph.