graphify-sf 0.3.2__tar.gz → 0.3.4__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.2 → graphify_sf-0.3.4}/PKG-INFO +1 -1
  2. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/apex.py +29 -13
  3. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/flow.py +18 -7
  4. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/PKG-INFO +1 -1
  5. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/pyproject.toml +1 -1
  6. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_apex.py +107 -0
  7. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_flow.py +116 -0
  8. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/LICENSE +0 -0
  9. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/README.md +0 -0
  10. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/__init__.py +0 -0
  11. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/__main__.py +0 -0
  12. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/_bundled_skill.md +0 -0
  13. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/analyze.py +0 -0
  14. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/build.py +0 -0
  15. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/cache.py +0 -0
  16. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/cluster.py +0 -0
  17. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/detect.py +0 -0
  18. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/export.py +0 -0
  19. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/__init__.py +0 -0
  20. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/_ids.py +0 -0
  21. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/agentforce.py +0 -0
  22. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/aura.py +0 -0
  23. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/automation.py +0 -0
  24. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/config.py +0 -0
  25. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/doc.py +0 -0
  26. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/layout.py +0 -0
  27. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/lwc.py +0 -0
  28. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/object.py +0 -0
  29. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/profile.py +0 -0
  30. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/extract/visualforce.py +0 -0
  31. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/llm.py +0 -0
  32. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/report.py +0 -0
  33. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/security.py +0 -0
  34. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/serve.py +0 -0
  35. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/validate.py +0 -0
  36. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf/watch.py +0 -0
  37. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/SOURCES.txt +0 -0
  38. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/dependency_links.txt +0 -0
  39. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/entry_points.txt +0 -0
  40. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/requires.txt +0 -0
  41. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/graphify_sf.egg-info/top_level.txt +0 -0
  42. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/setup.cfg +0 -0
  43. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_analyze.py +0 -0
  44. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_build.py +0 -0
  45. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_cli.py +0 -0
  46. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_cluster.py +0 -0
  47. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_detect.py +0 -0
  48. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_detect_docs.py +0 -0
  49. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_detect_skip_dirs.py +0 -0
  50. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_agentforce.py +0 -0
  51. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_config.py +0 -0
  52. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_doc.py +0 -0
  53. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_lwc.py +0 -0
  54. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_object.py +0 -0
  55. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/tests/test_extract_pipeline.py +0 -0
  56. {graphify_sf-0.3.2 → graphify_sf-0.3.4}/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.2
3
+ Version: 0.3.4
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
@@ -316,26 +316,42 @@ def extract_apex_class(path: Path) -> dict:
316
316
  )
317
317
  )
318
318
 
319
- # DML → dml edges
320
- seen_dml_targets: set[str] = set()
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.
327
+ _DML_VERB_TO_OP = {
328
+ "insert": "create",
329
+ "update": "update",
330
+ "delete": "delete",
331
+ "upsert": "upsert",
332
+ "merge": "merge",
333
+ "undelete": "undelete",
334
+ }
335
+ seen_dml_ops: set[tuple[str, str]] = set()
321
336
  for dm in _DML_RE.finditer(text):
337
+ operation = _DML_VERB_TO_OP[dm.group(1).lower()]
322
338
  obj_var = dm.group(2)
323
339
  # obj_var is a variable name, not necessarily an object API name.
324
340
  # We record it as INFERRED since we can't always resolve the type statically.
325
- if obj_var.lower() not in _APEX_KEYWORDS and obj_var not in seen_dml_targets:
326
- seen_dml_targets.add(obj_var)
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))
327
343
  # Only add the edge if it looks like a type name (capitalized) or known object
328
344
  if obj_var[0].isupper():
329
- edges.append(
330
- _make_edge(
331
- class_nid,
332
- object_id(obj_var),
333
- "dml",
334
- "INFERRED",
335
- str_path,
336
- confidence_score=0.7,
337
- )
345
+ edge = _make_edge(
346
+ class_nid,
347
+ object_id(obj_var),
348
+ "dml",
349
+ "INFERRED",
350
+ str_path,
351
+ confidence_score=0.7,
338
352
  )
353
+ edge["operation"] = operation
354
+ edges.append(edge)
339
355
 
340
356
  # Apex → Flow invocations via Flow.Interview.FlowName
341
357
  seen_flow_invokes: set[str] = set()
@@ -122,15 +122,26 @@ def extract_flow(path: Path) -> dict:
122
122
  if sub_name:
123
123
  edges.append(_make_edge(flow_nid, flow_id(sub_name), "invokes", "EXTRACTED", str_path))
124
124
 
125
- # Object record operations
126
- _record_tags = ("recordLookups", "recordCreates", "recordUpdates", "recordDeletes")
127
- seen_objects: set[str] = set()
128
- for tag in _record_tags:
125
+ # Object record operations. The relation stays "references" (a generic relation
126
+ # reused by many extractors — never change its semantics), but each edge carries an
127
+ # "operation" field (read/create/update/delete) so downstream can distinguish
128
+ # read-only access from writes. Dedup is by (object, operation) so a flow that both
129
+ # reads and updates the same object yields two edges, not one collapsed reference.
130
+ _record_op_tags = {
131
+ "recordLookups": "read",
132
+ "recordCreates": "create",
133
+ "recordUpdates": "update",
134
+ "recordDeletes": "delete",
135
+ }
136
+ seen_record_ops: set[tuple[str, str]] = set()
137
+ for tag, operation in _record_op_tags.items():
129
138
  for el in _find_all(root_el, tag, ns):
130
139
  obj = _find_text(el, "object", ns)
131
- if obj and obj not in seen_objects:
132
- seen_objects.add(obj)
133
- edges.append(_make_edge(flow_nid, object_id(obj), "references", "EXTRACTED", str_path))
140
+ if obj and (obj, operation) not in seen_record_ops:
141
+ seen_record_ops.add((obj, operation))
142
+ edge = _make_edge(flow_nid, object_id(obj), "references", "EXTRACTED", str_path)
143
+ edge["operation"] = operation
144
+ edges.append(edge)
134
145
 
135
146
  # Subflows
136
147
  for subflow in _find_all(root_el, "subflows", ns):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphify-sf
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphify-sf"
7
- version = "0.3.2"
7
+ version = "0.3.4"
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"
@@ -385,6 +385,113 @@ public class LeadCreator {
385
385
  assert e["confidence"] == "INFERRED"
386
386
 
387
387
 
388
+ def test_extract_apex_dml_insert_and_update_same_object_two_edges(tmp_path):
389
+ """A class that both inserts and updates the same object yields TWO dml edges
390
+ (operation=create and operation=update), not one collapsed edge."""
391
+ from graphify_sf.extract.apex import extract_apex_class
392
+
393
+ cls = tmp_path / "AccountWriter.cls"
394
+ cls.write_text(
395
+ """\
396
+ public class AccountWriter {
397
+ public void work() {
398
+ Account Acct = new Account();
399
+ insert Acct;
400
+ update Acct;
401
+ }
402
+ }
403
+ """
404
+ )
405
+
406
+ result = extract_apex_class(cls)
407
+ acct_dml = [e for e in result["edges"] if e.get("relation") == "dml" and "acct" in e["target"].lower()]
408
+ assert len(acct_dml) == 2, "insert+update on same object must not be deduped to one edge"
409
+ ops = {e.get("operation") for e in acct_dml}
410
+ assert ops == {"create", "update"}
411
+ for e in acct_dml:
412
+ assert e["confidence"] == "INFERRED"
413
+
414
+
415
+ def test_extract_apex_dml_pure_update_carries_operation(tmp_path):
416
+ """A bare DML update produces a dml edge with operation='update' and INFERRED confidence."""
417
+ from graphify_sf.extract.apex import extract_apex_class
418
+
419
+ cls = tmp_path / "AccountUpdater.cls"
420
+ cls.write_text(
421
+ """\
422
+ public class AccountUpdater {
423
+ public void touch() {
424
+ update Acct;
425
+ }
426
+ }
427
+ """
428
+ )
429
+
430
+ result = extract_apex_class(cls)
431
+ dml_edges = [e for e in result["edges"] if e.get("relation") == "dml"]
432
+ assert len(dml_edges) == 1
433
+ assert dml_edges[0].get("operation") == "update"
434
+ assert dml_edges[0]["confidence"] == "INFERRED"
435
+
436
+
437
+ def test_extract_apex_dml_native_verbs_preserved(tmp_path):
438
+ """SF-native DML verbs map to their own operation, not forced into CRUD:
439
+ upsert/undelete are preserved; insert->create, delete->delete, update->update.
440
+
441
+ Note: `merge` uses two-object syntax (`merge a b;`) which the single-object DML
442
+ regex deliberately does not capture — that's an existing extractor boundary, out
443
+ of scope for this change (no object-type resolution upgrade)."""
444
+ from graphify_sf.extract.apex import extract_apex_class
445
+
446
+ cls = tmp_path / "AllDml.cls"
447
+ cls.write_text(
448
+ """\
449
+ public class AllDml {
450
+ public void run() {
451
+ insert Acct;
452
+ update Con;
453
+ delete Lead;
454
+ upsert Opp;
455
+ undelete Cse;
456
+ }
457
+ }
458
+ """
459
+ )
460
+
461
+ result = extract_apex_class(cls)
462
+ ops_by_target = {e["target"].lower(): e.get("operation") for e in result["edges"] if e.get("relation") == "dml"}
463
+ assert ops_by_target.get("object_acct") == "create"
464
+ assert ops_by_target.get("object_con") == "update"
465
+ assert ops_by_target.get("object_lead") == "delete"
466
+ assert ops_by_target.get("object_opp") == "upsert"
467
+ assert ops_by_target.get("object_cse") == "undelete"
468
+
469
+
470
+ def test_extract_apex_soql_queries_edge_unaffected(tmp_path):
471
+ """Regression: SOQL still produces a 'queries' edge (EXTRACTED) with no 'operation'
472
+ field — only DML edges gained operation."""
473
+ from graphify_sf.extract.apex import extract_apex_class
474
+
475
+ cls = tmp_path / "AccountReader.cls"
476
+ cls.write_text(
477
+ """\
478
+ public class AccountReader {
479
+ public void read() {
480
+ List<Account> a = [SELECT Id FROM Account];
481
+ update Acct;
482
+ }
483
+ }
484
+ """
485
+ )
486
+
487
+ result = extract_apex_class(cls)
488
+ query_edges = [e for e in result["edges"] if e.get("relation") == "queries"]
489
+ assert len(query_edges) >= 1
490
+ for e in query_edges:
491
+ assert e["confidence"] == "EXTRACTED"
492
+ assert e.get("operation") is None, "queries edges must not carry an operation field"
493
+
494
+
388
495
  # ---------------------------------------------------------------------------
389
496
  # Apex trigger fallback (no regex match → filename)
390
497
  # ---------------------------------------------------------------------------
@@ -324,6 +324,122 @@ def test_extract_flow_action_call_subflow_creates_invokes_edge(tmp_path):
324
324
  assert "subflow" in invokes_edges[0]["target"].lower()
325
325
 
326
326
 
327
+ # ---------------------------------------------------------------------------
328
+ # Flow → Object record operations (read/create/update/delete)
329
+ # ---------------------------------------------------------------------------
330
+
331
+ READ_AND_UPDATE_SAME_OBJECT_XML = f"""\
332
+ <?xml version="1.0" encoding="UTF-8"?>
333
+ <Flow {_NS}>
334
+ <processType>AutoLaunchedFlow</processType>
335
+ <recordLookups>
336
+ <name>GetOpp</name>
337
+ <object>Opportunity</object>
338
+ </recordLookups>
339
+ <recordUpdates>
340
+ <name>UpdateOpp</name>
341
+ <object>Opportunity</object>
342
+ </recordUpdates>
343
+ </Flow>
344
+ """
345
+
346
+ PURE_UPDATE_XML = f"""\
347
+ <?xml version="1.0" encoding="UTF-8"?>
348
+ <Flow {_NS}>
349
+ <processType>AutoLaunchedFlow</processType>
350
+ <recordUpdates>
351
+ <name>UpdateOpp</name>
352
+ <object>Opportunity</object>
353
+ </recordUpdates>
354
+ </Flow>
355
+ """
356
+
357
+ ALL_FOUR_OPS_XML = f"""\
358
+ <?xml version="1.0" encoding="UTF-8"?>
359
+ <Flow {_NS}>
360
+ <processType>AutoLaunchedFlow</processType>
361
+ <recordLookups>
362
+ <name>GetAcc</name>
363
+ <object>Account</object>
364
+ </recordLookups>
365
+ <recordCreates>
366
+ <name>MakeContact</name>
367
+ <object>Contact</object>
368
+ </recordCreates>
369
+ <recordUpdates>
370
+ <name>UpdateAcc</name>
371
+ <object>Account</object>
372
+ </recordUpdates>
373
+ <recordDeletes>
374
+ <name>DeleteLead</name>
375
+ <object>Lead</object>
376
+ </recordDeletes>
377
+ </Flow>
378
+ """
379
+
380
+
381
+ def test_extract_flow_read_and_update_yields_two_edges(tmp_path):
382
+ """A flow that both reads and updates the same object yields TWO edges
383
+ (operation=read and operation=update), not one collapsed reference."""
384
+ from graphify_sf.extract.flow import extract_flow
385
+
386
+ f = tmp_path / "OppReadWrite.flow-meta.xml"
387
+ f.write_text(READ_AND_UPDATE_SAME_OBJECT_XML)
388
+
389
+ result = extract_flow(f)
390
+ opp_edges = [
391
+ e for e in result["edges"] if e.get("relation") == "references" and "opportunity" in e["target"].lower()
392
+ ]
393
+ assert len(opp_edges) == 2, "read+update on same object must not be deduped to one edge"
394
+ ops = {e.get("operation") for e in opp_edges}
395
+ assert ops == {"read", "update"}
396
+
397
+
398
+ def test_extract_flow_pure_update_carries_operation(tmp_path):
399
+ """A recordUpdates element produces a references edge with operation='update'."""
400
+ from graphify_sf.extract.flow import extract_flow
401
+
402
+ f = tmp_path / "OppUpdate.flow-meta.xml"
403
+ f.write_text(PURE_UPDATE_XML)
404
+
405
+ result = extract_flow(f)
406
+ ref_edges = [e for e in result["edges"] if e.get("relation") == "references"]
407
+ assert len(ref_edges) == 1
408
+ assert ref_edges[0].get("operation") == "update"
409
+ assert "opportunity" in ref_edges[0]["target"].lower()
410
+
411
+
412
+ def test_extract_flow_record_ops_relation_unchanged(tmp_path):
413
+ """Regression: record-op edges keep relation='references' (semantics unchanged);
414
+ only the new 'operation' field distinguishes them."""
415
+ from graphify_sf.extract.flow import extract_flow
416
+
417
+ f = tmp_path / "OppReadWrite.flow-meta.xml"
418
+ f.write_text(READ_AND_UPDATE_SAME_OBJECT_XML)
419
+
420
+ result = extract_flow(f)
421
+ record_op_edges = [e for e in result["edges"] if e.get("operation") is not None]
422
+ assert record_op_edges, "expected at least one record-op edge"
423
+ for e in record_op_edges:
424
+ assert e["relation"] == "references"
425
+ assert e["confidence"] == "EXTRACTED"
426
+
427
+
428
+ def test_extract_flow_all_four_operations(tmp_path):
429
+ """All four record-op tags map to their operation: read/create/update/delete."""
430
+ from graphify_sf.extract.flow import extract_flow
431
+
432
+ f = tmp_path / "AllOps.flow-meta.xml"
433
+ f.write_text(ALL_FOUR_OPS_XML)
434
+
435
+ result = extract_flow(f)
436
+ by_op = {e.get("operation"): e["target"].lower() for e in result["edges"] if e.get("operation") is not None}
437
+ assert by_op.get("read") and "account" in by_op["read"]
438
+ assert by_op.get("create") and "contact" in by_op["create"]
439
+ assert by_op.get("update") and "account" in by_op["update"]
440
+ assert by_op.get("delete") and "lead" in by_op["delete"]
441
+
442
+
327
443
  # ---------------------------------------------------------------------------
328
444
  # Flow child element nodes (Decision, Screen, Loop, Assignment)
329
445
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes