@salesforce/afv-skills 1.53.0 → 1.55.0

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 (51) hide show
  1. package/package.json +1 -1
  2. package/skills/dx-org-analyze/README.md +310 -0
  3. package/skills/dx-org-analyze/SKILL.md +261 -0
  4. package/skills/dx-org-analyze/references/collection-details.md +88 -0
  5. package/skills/dx-org-analyze/references/report-format.md +67 -0
  6. package/skills/dx-org-analyze/scripts/collect_org_data.py +823 -0
  7. package/skills/dx-org-analyze/scripts/compute_diff.py +1103 -0
  8. package/skills/dx-org-analyze/scripts/introspect_org.py +436 -0
  9. package/skills/dx-org-analyze/tests/README.md +46 -0
  10. package/skills/dx-org-analyze/tests/fixtures/org_a.json +76 -0
  11. package/skills/dx-org-analyze/tests/fixtures/org_b.json +70 -0
  12. package/skills/dx-org-analyze/tests/test_compute_diff.sh +284 -0
  13. package/skills/dx-org-analyze/tests/test_consistency.sh +428 -0
  14. package/skills/experience-design-validate/SKILL.md +163 -0
  15. package/skills/experience-design-validate/references/ai.md +54 -0
  16. package/skills/experience-design-validate/references/components.md +61 -0
  17. package/skills/experience-design-validate/references/craft.md +158 -0
  18. package/skills/experience-design-validate/references/data.md +59 -0
  19. package/skills/experience-design-validate/references/forms-flows.md +57 -0
  20. package/skills/experience-design-validate/references/interaction.md +53 -0
  21. package/skills/experience-design-validate/references/navigation.md +51 -0
  22. package/skills/experience-design-validate/references/performance.md +56 -0
  23. package/skills/experience-design-validate/references/records.md +60 -0
  24. package/skills/experience-design-validate/references/responsive.md +48 -0
  25. package/skills/experience-design-validate/references/scoring-rubric.md +257 -0
  26. package/skills/experience-design-validate/references/state.md +53 -0
  27. package/skills/experience-design-validate/references/trust.md +47 -0
  28. package/skills/experience-design-validate/references/usability.md +35 -0
  29. package/skills/experience-design-validate/references/visual-system.md +183 -0
  30. package/skills/service-agentforce-contact-center-coordinate/SKILL.md +170 -0
  31. package/skills/service-agentforce-contact-center-coordinate/assets/escalation-flow.flow-meta.xml +72 -0
  32. package/skills/service-agentforce-contact-center-coordinate/assets/omni-flow.flow-meta.xml +85 -0
  33. package/skills/service-agentforce-contact-center-coordinate/assets/report-template.md +52 -0
  34. package/skills/service-agentforce-contact-center-coordinate/references/agentforce-prerequisite.md +31 -0
  35. package/skills/service-agentforce-contact-center-coordinate/references/messaging_channel.md +76 -0
  36. package/skills/service-agentforce-contact-center-coordinate/references/number_management_api.md +78 -0
  37. package/skills/service-agentforce-contact-center-coordinate/references/omni-flow-routing.md +74 -0
  38. package/skills/service-agentforce-contact-center-coordinate/references/setup_summary.md +36 -0
  39. package/skills/service-agentforce-contact-center-coordinate/references/verification_and_errors.md +42 -0
  40. package/skills/service-agentforce-contact-center-coordinate/scripts/check-agentforce-prereq.sh +54 -0
  41. package/skills/service-agentforce-contact-center-coordinate/scripts/create-routing-flows.sh +49 -0
  42. package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-agent.sh +73 -0
  43. package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-channel.sh +66 -0
  44. package/skills/service-agentforce-contact-center-coordinate/scripts/fetch-numbers.sh +21 -0
  45. package/skills/service-agentforce-contact-center-coordinate/scripts/lib.sh +46 -0
  46. package/skills/service-agentforce-contact-center-coordinate/scripts/prepare-agent-workdir.sh +21 -0
  47. package/skills/service-agentforce-contact-center-coordinate/scripts/procure-number.sh +24 -0
  48. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-acc-queue.sh +27 -0
  49. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-channel-line.sh +37 -0
  50. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-flow-definition.sh +33 -0
  51. package/skills/service-agentforce-contact-center-coordinate/scripts/verify-number-live.sh +63 -0
@@ -0,0 +1,1103 @@
1
+ #!/usr/bin/env python3
2
+ """Compute diff between two org data files and produce a report.
3
+
4
+ Takes two JSON files (output of collect_org_data.py) and outputs:
5
+ - A JSON file with structured diff data
6
+ - A markdown report with drift score, summary, and details
7
+
8
+ Usage:
9
+ compute_diff.py --org-a org_a.json --org-b org_b.json --output /tmp/diff
10
+ compute_diff.py --org-a org_a.json --org-b org_b.json --output /tmp/diff --format json
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from datetime import date
18
+
19
+ # ── Drift score weights ──────────────────────────────────────────────
20
+
21
+ WEIGHT_METADATA = 0.6
22
+ WEIGHT_PERMISSION = 0.3
23
+ WEIGHT_PROFILE = 0.1
24
+
25
+ DRIFT_LEVELS = [
26
+ (10, "LOW"),
27
+ (30, "MODERATE"),
28
+ (60, "HIGH"),
29
+ (100, "CRITICAL"),
30
+ ]
31
+
32
+ # ── Permission categories ────────────────────────────────────────────
33
+
34
+ PERM_CATEGORIES = [
35
+ ("Commerce", ["commerce", "b2b", "b2c", "order", "cart", "store", "checkout"]),
36
+ ("Platform/API", ["platform", "api", "rest", "soap", "bulk", "streaming", "connect", "metadata"]),
37
+ ("Security", ["security", "auth", "login", "mfa", "sso", "encryption", "certificate", "session", "password", "oauth"]),
38
+ ("Einstein/AI", ["einstein", "ai", "predict", "ml", "copilot", "agent"]),
39
+ ("Service", ["service", "case", "knowledge", "chat", "messaging", "omni", "entitlement"]),
40
+ ("Data", ["data", "record", "report", "dashboard", "analytics", "tableau"]),
41
+ ("Trialforce", ["trial", "trialforce", "tso", "tmo", "signup", "template", "edition"]),
42
+ ("Sales", ["sales", "opportunity", "lead", "forecast", "quote", "contract"]),
43
+ ("Work/HR", ["work", "hr", "employee", "shift", "wellness"]),
44
+ ]
45
+
46
+ # ── Org value categories ─────────────────────────────────────────────
47
+
48
+ VALUE_CATEGORIES = [
49
+ ("Identity", ["name", "edition", "org", "domain", "id", "type", "instance", "locale"]),
50
+ ("Storage", ["storage", "file", "size", "space", "quota"]),
51
+ ("Feature Limits", ["max", "limit", "count", "num", "enable"]),
52
+ ("API", ["api", "call", "request", "bandwidth"]),
53
+ ]
54
+
55
+
56
+ def classify(name, categories, default="Other"):
57
+ lower = name.lower()
58
+ for label, keywords in categories:
59
+ if any(k in lower for k in keywords):
60
+ return label
61
+ return default
62
+
63
+
64
+ def drift_level(score):
65
+ for threshold, level in DRIFT_LEVELS:
66
+ if score <= threshold:
67
+ return level
68
+ return "CRITICAL"
69
+
70
+
71
+ def safe_pct(num, denom):
72
+ return (num / denom * 100) if denom > 0 else 0.0
73
+
74
+
75
+ # ── Core diff logic ──────────────────────────────────────────────────
76
+
77
+ def diff_metadata(a_data, b_data):
78
+ a_components = a_data.get("metadata_components", {})
79
+ b_components = b_data.get("metadata_components", {})
80
+ all_types = sorted(set(list(a_components.keys()) + list(b_components.keys())))
81
+
82
+ stats = []
83
+ for mtype in all_types:
84
+ a_items = a_components.get(mtype, [])
85
+ b_items = b_components.get(mtype, [])
86
+
87
+ a_names = {c["fullName"] for c in a_items if not c.get("namespace")}
88
+ b_names = {c["fullName"] for c in b_items if not c.get("namespace")}
89
+
90
+ if not a_names and not b_names:
91
+ continue
92
+
93
+ shared = a_names & b_names
94
+ only_a = a_names - b_names
95
+ only_b = b_names - a_names
96
+
97
+ a_only_sorted = sorted(only_a)
98
+ b_only_sorted = sorted(only_b)
99
+ shared_sorted = sorted(shared)
100
+ stats.append({
101
+ "type": mtype,
102
+ "a_count": len(a_names),
103
+ "b_count": len(b_names),
104
+ "shared": len(shared),
105
+ "a_only": len(only_a),
106
+ "b_only": len(only_b),
107
+ "identical": len(shared),
108
+ "different": 0,
109
+ "a_only_names": a_only_sorted,
110
+ "b_only_names": b_only_sorted,
111
+ "shared_names": shared_sorted,
112
+ })
113
+
114
+ return stats
115
+
116
+
117
+ def _perm_value(v):
118
+ """Extract the raw value string from a permission entry."""
119
+ return str(v.get("value", v) if isinstance(v, dict) else v)
120
+
121
+
122
+ def _is_enabled(v):
123
+ return _perm_value(v).lower() in ("true", "enabled", "yes")
124
+
125
+
126
+ def diff_permissions(a_data, b_data):
127
+ a_perms = a_data.get("permissions", {})
128
+ b_perms = b_data.get("permissions", {})
129
+
130
+ a_enabled = {k for k, v in a_perms.items() if _is_enabled(v)}
131
+ b_enabled = {k for k, v in b_perms.items() if _is_enabled(v)}
132
+
133
+ only_a = sorted(a_enabled - b_enabled)
134
+ only_b = sorted(b_enabled - a_enabled)
135
+ shared = a_enabled & b_enabled
136
+
137
+ a_cats = {}
138
+ for p in only_a:
139
+ a_cats.setdefault(classify(p, PERM_CATEGORIES), []).append(p)
140
+
141
+ b_cats = {}
142
+ for p in only_b:
143
+ b_cats.setdefault(classify(p, PERM_CATEGORIES), []).append(p)
144
+
145
+ value_diffs = []
146
+ all_keys = sorted(set(a_perms.keys()) | set(b_perms.keys()))
147
+ for k in all_keys:
148
+ if k in a_enabled or k in b_enabled:
149
+ continue
150
+ a_val = _perm_value(a_perms[k]) if k in a_perms else "(not set)"
151
+ b_val = _perm_value(b_perms[k]) if k in b_perms else "(not set)"
152
+ if a_val != b_val:
153
+ value_diffs.append({"name": k, "a": a_val, "b": b_val})
154
+
155
+ return {
156
+ "a_only": only_a,
157
+ "b_only": only_b,
158
+ "shared_count": len(shared),
159
+ "a_cats": a_cats,
160
+ "b_cats": b_cats,
161
+ "value_diffs": value_diffs,
162
+ }
163
+
164
+
165
+ def diff_org_values(a_data, b_data):
166
+ a_vals = a_data.get("org_values", {})
167
+ b_vals = b_data.get("org_values", {})
168
+
169
+ all_keys = sorted(set(a_vals.keys()) | set(b_vals.keys()))
170
+ diffs = []
171
+ for k in all_keys:
172
+ av = a_vals.get(k, "(not set)")
173
+ bv = b_vals.get(k, "(not set)")
174
+ if av != bv:
175
+ diffs.append({"name": k, "a": av, "b": bv})
176
+
177
+ cats = {}
178
+ for d in diffs:
179
+ cats.setdefault(classify(d["name"], VALUE_CATEGORIES), []).append(d)
180
+
181
+ return {"total": len(all_keys), "diffs": diffs, "cats": cats}
182
+
183
+
184
+ def _pkg_key(p):
185
+ """Unique key for a package: namespace if present, otherwise name."""
186
+ ns = p.get("namespace") or ""
187
+ name = p.get("name", "")
188
+ if ns:
189
+ return ns
190
+ return f"::{name}"
191
+
192
+
193
+ def diff_packages(a_data, b_data):
194
+ a_pkgs = {_pkg_key(p): p for p in a_data.get("installed_packages", [])}
195
+ b_pkgs = {_pkg_key(p): p for p in b_data.get("installed_packages", [])}
196
+
197
+ only_a = [a_pkgs[ns] for ns in sorted(set(a_pkgs) - set(b_pkgs), key=str)]
198
+ only_b = [b_pkgs[ns] for ns in sorted(set(b_pkgs) - set(a_pkgs), key=str)]
199
+ shared_ns = sorted(set(a_pkgs) & set(b_pkgs), key=str)
200
+ version_mismatches = [
201
+ {"namespace": ns, "name": a_pkgs[ns]["name"],
202
+ "a_version": a_pkgs[ns]["version"], "b_version": b_pkgs[ns]["version"]}
203
+ for ns in shared_ns if a_pkgs[ns]["version"] != b_pkgs[ns]["version"]
204
+ ]
205
+ identical = [ns for ns in shared_ns if a_pkgs[ns]["version"] == b_pkgs[ns]["version"]]
206
+
207
+ return {
208
+ "a_only": only_a,
209
+ "b_only": only_b,
210
+ "version_mismatches": version_mismatches,
211
+ "identical": [a_pkgs[ns] for ns in identical],
212
+ "a_total": len(a_pkgs),
213
+ "b_total": len(b_pkgs),
214
+ }
215
+
216
+
217
+ def diff_licenses(a_data, b_data):
218
+ result = {}
219
+ for ltype in ["user", "permission_set", "package"]:
220
+ a_lics = a_data.get("licenses", {}).get(ltype, [])
221
+ b_lics = b_data.get("licenses", {}).get(ltype, [])
222
+
223
+ key_field = "Name" if ltype != "package" else "NamespacePrefix"
224
+ if ltype == "permission_set":
225
+ key_field = "DeveloperName"
226
+
227
+ a_map = {lic.get(key_field, ""): lic for lic in a_lics}
228
+ b_map = {lic.get(key_field, ""): lic for lic in b_lics}
229
+
230
+ only_a = [a_map[k] for k in sorted(set(a_map) - set(b_map))]
231
+ only_b = [b_map[k] for k in sorted(set(b_map) - set(a_map))]
232
+ diffs = []
233
+ for k in sorted(set(a_map) & set(b_map)):
234
+ a_tl = a_map[k].get("TotalLicenses")
235
+ a_total = a_tl if a_tl is not None else a_map[k].get("AllowedLicenses", 0)
236
+ b_tl = b_map[k].get("TotalLicenses")
237
+ b_total = b_tl if b_tl is not None else b_map[k].get("AllowedLicenses", 0)
238
+ if a_total != b_total:
239
+ diffs.append({"name": k, "a_total": a_total, "b_total": b_total})
240
+
241
+ result[ltype] = {"a_only": only_a, "b_only": only_b, "diffs": diffs,
242
+ "a_count": len(a_lics), "b_count": len(b_lics)}
243
+ return result
244
+
245
+
246
+ def diff_system_permissions(a_data, b_data):
247
+ """Diff system permissions (PermissionsXxx on PermissionSet) between orgs."""
248
+ a_sp = a_data.get("system_permissions", {})
249
+ b_sp = b_data.get("system_permissions", {})
250
+ a_sets = {ps.get("label") or ps["name"]: ps for ps in a_sp.get("permission_sets", [])}
251
+ b_sets = {ps.get("label") or ps["name"]: ps for ps in b_sp.get("permission_sets", [])}
252
+
253
+ only_a_sets = sorted(set(a_sets) - set(b_sets))
254
+ only_b_sets = sorted(set(b_sets) - set(a_sets))
255
+ shared_sets = sorted(set(a_sets) & set(b_sets))
256
+
257
+ per_set_diffs = []
258
+ for label in shared_sets:
259
+ a_perms = a_sets[label].get("permissions", {})
260
+ b_perms = b_sets[label].get("permissions", {})
261
+ a_enabled = set(a_perms.keys())
262
+ b_enabled = set(b_perms.keys())
263
+ a_only = sorted(a_enabled - b_enabled)
264
+ b_only = sorted(b_enabled - a_enabled)
265
+ if a_only or b_only:
266
+ per_set_diffs.append({
267
+ "name": label,
268
+ "is_profile": a_sets[label].get("is_profile", False),
269
+ "a_only": a_only,
270
+ "b_only": b_only,
271
+ })
272
+
273
+ # Aggregate: across all sets, which permissions are enabled more/less
274
+ all_a_perms = set()
275
+ all_b_perms = set()
276
+ for ps in a_sp.get("permission_sets", []):
277
+ all_a_perms.update(ps.get("permissions", {}).keys())
278
+ for ps in b_sp.get("permission_sets", []):
279
+ all_b_perms.update(ps.get("permissions", {}).keys())
280
+
281
+ return {
282
+ "a_only_sets": only_a_sets,
283
+ "b_only_sets": only_b_sets,
284
+ "shared_sets": len(shared_sets),
285
+ "per_set_diffs": per_set_diffs,
286
+ "fields_discovered": max(len(a_sp.get("fields_discovered", [])), len(b_sp.get("fields_discovered", []))),
287
+ "a_unique_perms": sorted(all_a_perms - all_b_perms),
288
+ "b_unique_perms": sorted(all_b_perms - all_a_perms),
289
+ }
290
+
291
+
292
+ def diff_package_components(a_data, b_data):
293
+ """Extract all packaged metadata components, grouped by namespace."""
294
+ # Build namespace → package name map from installed packages in both orgs
295
+ pkg_name_map = {}
296
+ for p in a_data.get("installed_packages", []) + b_data.get("installed_packages", []):
297
+ ns = p.get("namespace") or p.get("NamespacePrefix")
298
+ if ns and ns != "None":
299
+ pkg_name_map[ns] = p.get("name", ns)
300
+
301
+ # Only include namespaces that belong to an actual installed package
302
+ all_ns = set(pkg_name_map.keys())
303
+
304
+ namespaced = {}
305
+ for ns in sorted(all_ns):
306
+ a_by_type = {}
307
+ b_by_type = {}
308
+ for mtype, items in a_data.get("metadata_components", {}).items():
309
+ names = sorted(c["fullName"] for c in items if c.get("namespace") == ns)
310
+ if names:
311
+ a_by_type[mtype] = names
312
+ for mtype, items in b_data.get("metadata_components", {}).items():
313
+ names = sorted(c["fullName"] for c in items if c.get("namespace") == ns)
314
+ if names:
315
+ b_by_type[mtype] = names
316
+
317
+ if not a_by_type and not b_by_type:
318
+ continue
319
+
320
+ all_types = sorted(set(list(a_by_type.keys()) + list(b_by_type.keys())))
321
+ types_detail = []
322
+ for mtype in all_types:
323
+ a_names = set(a_by_type.get(mtype, []))
324
+ b_names = set(b_by_type.get(mtype, []))
325
+ types_detail.append({
326
+ "type": mtype,
327
+ "a_count": len(a_names),
328
+ "b_count": len(b_names),
329
+ "shared": len(a_names & b_names),
330
+ "shared_names": sorted(a_names & b_names),
331
+ "a_only": sorted(a_names - b_names),
332
+ "b_only": sorted(b_names - a_names),
333
+ "a_only_count": len(a_names - b_names),
334
+ "b_only_count": len(b_names - a_names),
335
+ })
336
+
337
+ namespaced[ns] = {
338
+ "package_name": pkg_name_map.get(ns, ns),
339
+ "types": types_detail,
340
+ "a_total": sum(t["a_count"] for t in types_detail),
341
+ "b_total": sum(t["b_count"] for t in types_detail),
342
+ }
343
+
344
+ # Non-namespaced packages — use installed_components data if available
345
+ no_ns_pkgs = []
346
+ for p in a_data.get("installed_packages", []) + b_data.get("installed_packages", []):
347
+ ns = p.get("namespace") or p.get("NamespacePrefix")
348
+ if not ns or ns == "None":
349
+ name = p.get("name", "?")
350
+ if name not in [x["name"] for x in no_ns_pkgs]:
351
+ no_ns_pkgs.append({"name": name, "version": p.get("version", "?")})
352
+
353
+ # Build per-org installed component breakdown for non-namespaced packages
354
+ a_installed = a_data.get("installed_components", {})
355
+ b_installed = b_data.get("installed_components", {})
356
+ all_inst_types = sorted(set(list(a_installed.keys()) + list(b_installed.keys())))
357
+ installed_by_type = []
358
+ for mtype in all_inst_types:
359
+ a_names = {c.get("name", "") for c in a_installed.get(mtype, [])} - {""}
360
+ b_names = {c.get("name", "") for c in b_installed.get(mtype, [])} - {""}
361
+ installed_by_type.append({
362
+ "type": mtype,
363
+ "a_count": len(a_names),
364
+ "b_count": len(b_names),
365
+ "shared": len(a_names & b_names),
366
+ "shared_names": sorted(a_names & b_names),
367
+ "a_only": sorted(a_names - b_names),
368
+ "b_only": sorted(b_names - a_names),
369
+ "a_only_count": len(a_names - b_names),
370
+ "b_only_count": len(b_names - a_names),
371
+ })
372
+
373
+ return {
374
+ "namespaced": namespaced,
375
+ "non_namespaced": no_ns_pkgs,
376
+ "installed_by_type": installed_by_type,
377
+ }
378
+
379
+
380
+ def diff_deep_data(a_data, b_data):
381
+ a_deep = a_data.get("deep_data", {})
382
+ b_deep = b_data.get("deep_data", {})
383
+ result = {}
384
+
385
+ # Apex classes — compare body hash, API version, LOC for shared classes
386
+ a_apex = {c["name"]: c for c in a_deep.get("apex_classes", [])}
387
+ b_apex = {c["name"]: c for c in b_deep.get("apex_classes", [])}
388
+ shared_apex = sorted(set(a_apex) & set(b_apex))
389
+ apex_diffs = []
390
+ for name in shared_apex:
391
+ a, b = a_apex[name], b_apex[name]
392
+ diffs = {}
393
+ if a.get("bodyHash") != b.get("bodyHash") and a.get("bodyHash") and b.get("bodyHash"):
394
+ diffs["body"] = "different"
395
+ if a.get("apiVersion") != b.get("apiVersion"):
396
+ diffs["apiVersion"] = {"a": a.get("apiVersion"), "b": b.get("apiVersion")}
397
+ if a.get("loc") != b.get("loc"):
398
+ diffs["loc"] = {"a": a.get("loc"), "b": b.get("loc")}
399
+ if diffs:
400
+ apex_diffs.append({"name": name, **diffs})
401
+ result["apex_classes"] = {"shared": len(shared_apex), "different": len(apex_diffs), "diffs": apex_diffs}
402
+
403
+ # Apex triggers — same logic
404
+ a_trig = {c["name"]: c for c in a_deep.get("apex_triggers", [])}
405
+ b_trig = {c["name"]: c for c in b_deep.get("apex_triggers", [])}
406
+ shared_trig = sorted(set(a_trig) & set(b_trig))
407
+ trig_diffs = []
408
+ for name in shared_trig:
409
+ a, b = a_trig[name], b_trig[name]
410
+ if a.get("bodyHash") != b.get("bodyHash") and a.get("bodyHash") and b.get("bodyHash"):
411
+ trig_diffs.append({"name": name, "body": "different"})
412
+ result["apex_triggers"] = {"shared": len(shared_trig), "different": len(trig_diffs), "diffs": trig_diffs}
413
+
414
+ # Custom fields — group by object, compare field sets
415
+ a_fields = {}
416
+ for f in a_deep.get("custom_fields", []):
417
+ a_fields.setdefault(f["object"], {})[f["name"]] = f
418
+ b_fields = {}
419
+ for f in b_deep.get("custom_fields", []):
420
+ b_fields.setdefault(f["object"], {})[f["name"]] = f
421
+
422
+ field_diffs = []
423
+ shared_objects = sorted(set(a_fields) & set(b_fields))
424
+ for obj in shared_objects:
425
+ af, bf = a_fields[obj], b_fields[obj]
426
+ only_a = sorted(set(af) - set(bf))
427
+ only_b = sorted(set(bf) - set(af))
428
+ type_diffs = []
429
+ for fname in sorted(set(af) & set(bf)):
430
+ if af[fname].get("type") != bf[fname].get("type"):
431
+ type_diffs.append({"field": fname, "a_type": af[fname].get("type"), "b_type": bf[fname].get("type")})
432
+ if only_a or only_b or type_diffs:
433
+ field_diffs.append({"object": obj, "a_only": only_a, "b_only": only_b, "type_diffs": type_diffs})
434
+ result["custom_fields"] = {"objects_compared": len(shared_objects), "objects_with_diffs": len(field_diffs), "diffs": field_diffs}
435
+
436
+ # Validation rules — compare active state and error message
437
+ a_vr = {f"{v['object']}.{v['name']}": v for v in a_deep.get("validation_rules", [])}
438
+ b_vr = {f"{v['object']}.{v['name']}": v for v in b_deep.get("validation_rules", [])}
439
+ vr_diffs = []
440
+ for key in sorted(set(a_vr) & set(b_vr)):
441
+ a, b = a_vr[key], b_vr[key]
442
+ if a.get("active") != b.get("active"):
443
+ vr_diffs.append({"name": key, "a_active": a["active"], "b_active": b["active"]})
444
+ result["validation_rules"] = {
445
+ "a_only": sorted(set(a_vr) - set(b_vr)),
446
+ "b_only": sorted(set(b_vr) - set(a_vr)),
447
+ "active_diffs": vr_diffs,
448
+ }
449
+
450
+ # Flows — compare active version, process type
451
+ a_flows = {f["name"]: f for f in a_deep.get("flows", [])}
452
+ b_flows = {f["name"]: f for f in b_deep.get("flows", [])}
453
+ flow_diffs = []
454
+ for name in sorted(set(a_flows) & set(b_flows)):
455
+ a, b = a_flows[name], b_flows[name]
456
+ diffs = {}
457
+ if a.get("processType") != b.get("processType"):
458
+ diffs["processType"] = {"a": a.get("processType"), "b": b.get("processType")}
459
+ if a.get("apiVersion") != b.get("apiVersion"):
460
+ diffs["apiVersion"] = {"a": a.get("apiVersion"), "b": b.get("apiVersion")}
461
+ if diffs:
462
+ flow_diffs.append({"name": name, **diffs})
463
+ result["flows"] = {"shared": len(set(a_flows) & set(b_flows)), "different": len(flow_diffs), "diffs": flow_diffs}
464
+
465
+ # Named credentials — compare endpoints
466
+ a_nc = {c["name"]: c for c in a_deep.get("named_credentials", [])}
467
+ b_nc = {c["name"]: c for c in b_deep.get("named_credentials", [])}
468
+ nc_diffs = []
469
+ for name in sorted(set(a_nc) & set(b_nc)):
470
+ if a_nc[name].get("endpoint") != b_nc[name].get("endpoint"):
471
+ nc_diffs.append({"name": name, "a_endpoint": a_nc[name].get("endpoint", ""), "b_endpoint": b_nc[name].get("endpoint", "")})
472
+ result["named_credentials"] = {
473
+ "a_only": sorted(set(a_nc) - set(b_nc)),
474
+ "b_only": sorted(set(b_nc) - set(a_nc)),
475
+ "endpoint_diffs": nc_diffs,
476
+ }
477
+
478
+ return result
479
+
480
+
481
+ def enrich_metadata_with_deep_diffs(meta_stats, deep_diff):
482
+ """Patch metadata stats with content-level difference counts from deep data.
483
+
484
+ Splits 'shared' into 'identical' (same content) and 'different' (shared name,
485
+ different content) using body hash / API version / active state comparisons
486
+ already computed by diff_deep_data.
487
+ """
488
+ diff_names_by_type = {}
489
+
490
+ for d in deep_diff.get("apex_classes", {}).get("diffs", []):
491
+ diff_names_by_type.setdefault("ApexClass", set()).add(d["name"])
492
+ for d in deep_diff.get("apex_triggers", {}).get("diffs", []):
493
+ diff_names_by_type.setdefault("ApexTrigger", set()).add(d["name"])
494
+ for d in deep_diff.get("flows", {}).get("diffs", []):
495
+ diff_names_by_type.setdefault("Flow", set()).add(d["name"])
496
+ for d in deep_diff.get("validation_rules", {}).get("active_diffs", []):
497
+ diff_names_by_type.setdefault("ValidationRule", set()).add(d["name"])
498
+ for d in deep_diff.get("custom_fields", {}).get("diffs", []):
499
+ diff_names_by_type.setdefault("CustomObject", set()).add(d["object"])
500
+ for d in deep_diff.get("named_credentials", {}).get("endpoint_diffs", []):
501
+ diff_names_by_type.setdefault("NamedCredential", set()).add(d["name"])
502
+
503
+ for stat in meta_stats:
504
+ mtype = stat["type"]
505
+ diff_set = diff_names_by_type.get(mtype, set())
506
+ if diff_set:
507
+ shared_names = set(stat.get("shared_names", []))
508
+ different_count = len(diff_set & shared_names) if shared_names else min(len(diff_set), stat["shared"])
509
+ stat["different"] = different_count
510
+ stat["identical"] = stat["shared"] - different_count
511
+ else:
512
+ stat["identical"] = stat["shared"]
513
+
514
+ def compute_drift(meta_stats, perm_diff, a_data, b_data):
515
+ total_a_only = sum(s["a_only"] for s in meta_stats)
516
+ total_b_only = sum(s["b_only"] for s in meta_stats)
517
+ total_shared = sum(s["shared"] for s in meta_stats)
518
+ total_different = sum(s["different"] for s in meta_stats)
519
+ total_unique = total_a_only + total_b_only + total_shared
520
+
521
+ metadata_drift = safe_pct(total_a_only + total_b_only + total_different, total_unique)
522
+ perm_denom = len(perm_diff["a_only"]) + len(perm_diff["b_only"]) + perm_diff["shared_count"]
523
+ perm_drift = safe_pct(len(perm_diff["a_only"]) + len(perm_diff["b_only"]), perm_denom)
524
+ # Floor: any real permission difference should register as at least 1% so it
525
+ # isn't silently swallowed by rounding when shared_enabled >> diff count.
526
+ if 0 < perm_drift < 1:
527
+ perm_drift = 1.0
528
+
529
+ a_profiles = {s for s in _get_names(a_data, "Profile")}
530
+ b_profiles = {s for s in _get_names(b_data, "Profile")}
531
+ prof_unique = len((a_profiles - b_profiles) | (b_profiles - a_profiles)) + len(a_profiles & b_profiles)
532
+ prof_diff_count = len(a_profiles - b_profiles) + len(b_profiles - a_profiles)
533
+ prof_drift = safe_pct(prof_diff_count, prof_unique)
534
+
535
+ overall = (WEIGHT_METADATA * metadata_drift +
536
+ WEIGHT_PERMISSION * perm_drift +
537
+ WEIGHT_PROFILE * prof_drift)
538
+
539
+ return {
540
+ "metadata": round(metadata_drift, 1),
541
+ "permission": round(perm_drift, 1),
542
+ "profile": round(prof_drift, 1),
543
+ "overall": round(overall, 1),
544
+ "level": drift_level(overall),
545
+ "totals": {
546
+ "a_only": total_a_only, "b_only": total_b_only,
547
+ "shared": total_shared, "different": total_different,
548
+ "unique": total_unique,
549
+ },
550
+ }
551
+
552
+
553
+ def _get_names(org_data, mtype):
554
+ items = org_data.get("metadata_components", {}).get(mtype, [])
555
+ return {c["fullName"] for c in items if not c.get("namespace")}
556
+
557
+
558
+ # ── Markdown report ──────────────────────────────────────────────────
559
+
560
+ def render_markdown(a_data, b_data, meta_stats, perm_diff, val_diff, pkg_diff, lic_diff, sys_perm_diff, pkg_components, deep_diff, drift, label_a, label_b, show_shared=False):
561
+ lines = []
562
+
563
+ def w(s=""):
564
+ lines.append(s)
565
+
566
+ d = drift
567
+ a_id = a_data.get("org_id", "?")
568
+ b_id = b_data.get("org_id", "?")
569
+
570
+ # 8a — Header + Drift Score
571
+ w(f"# Org Comparison Report")
572
+ w()
573
+ a_name = a_data.get("org_name", "?")
574
+ b_name = b_data.get("org_name", "?")
575
+ a_edition = a_data.get("org_edition", "?")
576
+ b_edition = b_data.get("org_edition", "?")
577
+ w(f"| | {label_a} | {label_b} |")
578
+ w(f"|--|--|--|")
579
+ w(f"| **Org ID** | {a_id} | {b_id} |")
580
+ w(f"| **Name** | {a_name} | {b_name} |")
581
+ w(f"| **Edition** | {a_edition} | {b_edition} |")
582
+ w()
583
+ w(f"**Date:** {date.today().isoformat()}")
584
+ types_discovered = len(a_data.get("metadata_types", []))
585
+ if types_discovered:
586
+ w(f"**Discovery method:** `describeMetadata` ({types_discovered} types discovered)")
587
+ w()
588
+ w("---")
589
+ w()
590
+ w(f"## Drift Score: {d['overall']:.0f}% — {d['level']}")
591
+ w()
592
+ w("| Dimension | Score | Weight | Contribution |")
593
+ w("|-----------|-------|--------|--------------|")
594
+ w(f"| Metadata Components | {d['metadata']:.0f}% | 60% | {d['metadata'] * 0.6:.0f}% |")
595
+ w(f"| Org Permissions | {d['permission']:.0f}% | 30% | {d['permission'] * 0.3:.0f}% |")
596
+ w(f"| Profiles | {d['profile']:.0f}% | 10% | {d['profile'] * 0.1:.0f}% |")
597
+ w(f"| **Overall** | | | **{d['overall']:.0f}%** |")
598
+ w()
599
+
600
+ # 8b — Summary Statistics
601
+ w("---")
602
+ w()
603
+ w("## Summary Statistics")
604
+ w()
605
+ w("| Category | {a} | {b} | Shared | {a} Only | {b} Only | Identical | Different |".format(a=label_a, b=label_b))
606
+ w("|----------|-----|-----|--------|----------|----------|-----------|-----------|")
607
+ for s in meta_stats:
608
+ w(f"| {s['type']} | {s['a_count']} | {s['b_count']} | {s['shared']} | {s['a_only']} | {s['b_only']} | {s['identical']} | {s['different']} |")
609
+ t = d["totals"]
610
+ total_a = sum(s["a_count"] for s in meta_stats)
611
+ total_b = sum(s["b_count"] for s in meta_stats)
612
+ total_identical = sum(s["identical"] for s in meta_stats)
613
+ w(f"| **Total** | **{total_a}** | **{total_b}** | **{t['shared']}** | **{t['a_only']}** | **{t['b_only']}** | **{total_identical}** | **{t['different']}** |")
614
+ w()
615
+ w("### Key Metrics")
616
+ if types_discovered:
617
+ types_with = len([s for s in meta_stats if s["a_count"] + s["b_count"] > 0])
618
+ w(f"- **Metadata types discovered:** {types_discovered}, {types_with} with components")
619
+ w(f"- **Total unique components:** {t['unique']}")
620
+ w(f"- **Only in {label_a}:** {t['a_only']} ({safe_pct(t['a_only'], t['unique']):.0f}%)")
621
+ w(f"- **Only in {label_b}:** {t['b_only']} ({safe_pct(t['b_only'], t['unique']):.0f}%)")
622
+ w(f"- **In both:** {t['shared']} ({safe_pct(t['shared'], t['unique']):.0f}%)")
623
+ w(f"- **Org Permissions:** {len(perm_diff['a_only'])} {label_a}-only, {len(perm_diff['b_only'])} {label_b}-only, {perm_diff['shared_count']} shared")
624
+ w(f"- **Org Values:** {len(val_diff['diffs'])} of {val_diff['total']} differ")
625
+ w(f"- **Installed Packages:** {pkg_diff['a_total']} in {label_a}, {pkg_diff['b_total']} in {label_b} ({len(pkg_diff['version_mismatches'])} version mismatches)")
626
+ w()
627
+
628
+ # 8c — Detailed Differences
629
+ w("---")
630
+ w()
631
+ w("## Detailed Differences")
632
+ w()
633
+
634
+ # Metadata by type
635
+ w("### Metadata Components by Type")
636
+ for s in meta_stats:
637
+ if s["a_only"] == 0 and s["b_only"] == 0 and not show_shared:
638
+ continue
639
+ w(f"\n#### {s['type']} ({s['shared']} shared, {s['a_only']} {label_a}-only, {s['b_only']} {label_b}-only)")
640
+ w()
641
+ if s["a_only_names"]:
642
+ shown = s["a_only_names"]
643
+ w(f"**{label_a}-only ({s['a_only']}):** " + ", ".join(f"`{n}`" for n in shown))
644
+ w()
645
+ if s["b_only_names"]:
646
+ shown = s["b_only_names"]
647
+ w(f"**{label_b}-only ({s['b_only']}):** " + ", ".join(f"`{n}`" for n in shown))
648
+ w()
649
+ if show_shared and s["shared_names"]:
650
+ shown = s["shared_names"]
651
+ w(f"**Shared ({s['shared']}):** " + ", ".join(f"`{n}`" for n in shown))
652
+ w()
653
+
654
+ # Profiles
655
+ a_prof = _get_names(a_data, "Profile")
656
+ b_prof = _get_names(b_data, "Profile")
657
+ if a_prof or b_prof:
658
+ w("\n### Profiles")
659
+ w()
660
+ only_a_p = sorted(a_prof - b_prof)
661
+ only_b_p = sorted(b_prof - a_prof)
662
+ shared_p = sorted(a_prof & b_prof)
663
+ if only_a_p:
664
+ w(f"**{label_a}-only ({len(only_a_p)}):** " + ", ".join(only_a_p))
665
+ if only_b_p:
666
+ w(f"\n**{label_b}-only ({len(only_b_p)}):** " + ", ".join(only_b_p))
667
+ if shared_p:
668
+ w(f"\n**Shared ({len(shared_p)}):** " + ", ".join(shared_p))
669
+ w()
670
+
671
+ # Installed Packages
672
+ if pkg_diff["a_total"] + pkg_diff["b_total"] > 0:
673
+ w("\n### Installed Packages")
674
+ w()
675
+ if pkg_diff["a_only"]:
676
+ w(f"**{label_a}-only ({len(pkg_diff['a_only'])}):**")
677
+ w()
678
+ w(f"| Package | Namespace | Version |")
679
+ w(f"|---------|-----------|---------|")
680
+ for p in pkg_diff["a_only"]:
681
+ w(f"| {p['name']} | {p.get('namespace') or '—'} | {p['version']} |")
682
+ w()
683
+ if pkg_diff["b_only"]:
684
+ w(f"**{label_b}-only ({len(pkg_diff['b_only'])}):**")
685
+ w()
686
+ w(f"| Package | Namespace | Version |")
687
+ w(f"|---------|-----------|---------|")
688
+ for p in pkg_diff["b_only"]:
689
+ w(f"| {p['name']} | {p.get('namespace') or '—'} | {p['version']} |")
690
+ w()
691
+ if pkg_diff["version_mismatches"]:
692
+ w(f"**Version mismatches ({len(pkg_diff['version_mismatches'])}):**")
693
+ w()
694
+ w(f"| Package | Namespace | {label_a} | {label_b} |")
695
+ w(f"|---------|-----------|-----|-----|")
696
+ for p in pkg_diff["version_mismatches"]:
697
+ w(f"| {p['name']} | {p.get('namespace') or '—'} | {p['a_version']} | {p['b_version']} |")
698
+ w()
699
+ if pkg_diff["identical"]:
700
+ w(f"**Identical ({len(pkg_diff['identical'])}):** " +
701
+ ", ".join(f"{p['name']} ({p.get('namespace') or '—'} {p['version']})" for p in pkg_diff["identical"]))
702
+ w()
703
+
704
+ # Package Components
705
+ ns_data = pkg_components.get("namespaced", {})
706
+ no_ns = pkg_components.get("non_namespaced", [])
707
+ inst_by_type = pkg_components.get("installed_by_type", [])
708
+ if ns_data or no_ns:
709
+ w("\n### Package Components")
710
+ w()
711
+ if no_ns:
712
+ pkg_list = ", ".join(f"{p['name']} v{p['version']}" for p in no_ns)
713
+ if inst_by_type:
714
+ a_tot = sum(t["a_count"] for t in inst_by_type)
715
+ b_tot = sum(t["b_count"] for t in inst_by_type)
716
+ w(f"#### Non-namespaced packages ({a_tot} in {label_a}, {b_tot} in {label_b})")
717
+ w()
718
+ w(f"Packages: {pkg_list}")
719
+ w()
720
+ w(f"| Type | {label_a} | {label_b} | Shared | {label_a} Only | {label_b} Only |")
721
+ w(f"|------|-----|-----|--------|----------|----------|")
722
+ for t in inst_by_type:
723
+ w(f"| {t['type']} | {t['a_count']} | {t['b_count']} | {t['shared']} | {t['a_only_count']} | {t['b_only_count']} |")
724
+ w()
725
+ for t in inst_by_type:
726
+ if not t["a_only"] and not t["b_only"] and not (show_shared and t.get("shared_names")):
727
+ continue
728
+ w(f"**{t['type']}:**")
729
+ if t["a_only"]:
730
+ shown = t["a_only"]
731
+ w(f" {label_a}-only: " + ", ".join(f"`{n}`" for n in shown))
732
+ if t["b_only"]:
733
+ shown = t["b_only"]
734
+ w(f" {label_b}-only: " + ", ".join(f"`{n}`" for n in shown))
735
+ if show_shared and t.get("shared_names"):
736
+ shown = t["shared_names"]
737
+ w(f" Shared: " + ", ".join(f"`{n}`" for n in shown))
738
+ w()
739
+ else:
740
+ w(f"**Non-namespaced packages** (no ManageableState data — re-collect to see components):")
741
+ w()
742
+ for p in no_ns:
743
+ w(f"- {p['name']} v{p['version']}")
744
+ w()
745
+ for ns, info in ns_data.items():
746
+ pkg_name = info["package_name"]
747
+ a_tot = info["a_total"]
748
+ b_tot = info["b_total"]
749
+ w(f"#### `{ns}` — {pkg_name} ({a_tot} in {label_a}, {b_tot} in {label_b})")
750
+ w()
751
+ w(f"| Type | {label_a} | {label_b} | Shared | {label_a} Only | {label_b} Only |")
752
+ w(f"|------|-----|-----|--------|----------|----------|")
753
+ for t in info["types"]:
754
+ w(f"| {t['type']} | {t['a_count']} | {t['b_count']} | {t['shared']} | {t['a_only_count']} | {t['b_only_count']} |")
755
+ w()
756
+ for t in info["types"]:
757
+ if not t["a_only"] and not t["b_only"] and not (show_shared and t.get("shared_names")):
758
+ continue
759
+ w(f"**{t['type']}:**")
760
+ if t["a_only"]:
761
+ shown = t["a_only"]
762
+ w(f" {label_a}-only: " + ", ".join(f"`{n}`" for n in shown))
763
+ if t["b_only"]:
764
+ shown = t["b_only"]
765
+ w(f" {label_b}-only: " + ", ".join(f"`{n}`" for n in shown))
766
+ if show_shared and t.get("shared_names"):
767
+ shown = t["shared_names"]
768
+ w(f" Shared: " + ", ".join(f"`{n}`" for n in shown))
769
+ w()
770
+
771
+ # Permissions
772
+ w("\n### Org Permissions")
773
+ w()
774
+ w(f"**Shared enabled:** {perm_diff['shared_count']}")
775
+ w()
776
+ for side_label, cats in [(f"{label_a}-Only", perm_diff["a_cats"]), (f"{label_b}-Only", perm_diff["b_cats"])]:
777
+ total_side = sum(len(v) for v in cats.values())
778
+ if total_side == 0:
779
+ continue
780
+ w(f"**{side_label} ({total_side} total):**")
781
+ w()
782
+ for cat in sorted(cats.keys()):
783
+ items = cats[cat]
784
+ shown = items
785
+ w(f"*{cat}* ({len(items)}): " + ", ".join(f"`{p}`" for p in shown))
786
+ w()
787
+
788
+ if perm_diff.get("value_diffs"):
789
+ w(f"**Settings with different values ({len(perm_diff['value_diffs'])}):**")
790
+ w()
791
+ w(f"| Setting | {label_a} | {label_b} |")
792
+ w("|---------|--------|--------|")
793
+ for vd in perm_diff["value_diffs"]:
794
+ w(f"| `{vd['name']}` | {vd['a']} | {vd['b']} |")
795
+ w()
796
+
797
+ # System Permissions (PermissionsXxx on PermissionSet)
798
+ if sys_perm_diff.get("per_set_diffs") or sys_perm_diff.get("a_only_sets") or sys_perm_diff.get("b_only_sets"):
799
+ w("\n### System Permissions (PermissionSet)")
800
+ w()
801
+ w(f"**Fields discovered:** {sys_perm_diff.get('fields_discovered', 0)}")
802
+ w(f"**Permission sets compared:** {sys_perm_diff.get('shared_sets', 0)} shared, "
803
+ f"{len(sys_perm_diff.get('a_only_sets', []))} {label_a}-only, "
804
+ f"{len(sys_perm_diff.get('b_only_sets', []))} {label_b}-only")
805
+ w()
806
+
807
+ if sys_perm_diff.get("a_only_sets"):
808
+ w(f"**{label_a}-only permission sets:** " + ", ".join(f"`{s}`" for s in sys_perm_diff["a_only_sets"]))
809
+ w()
810
+ if sys_perm_diff.get("b_only_sets"):
811
+ w(f"**{label_b}-only permission sets:** " + ", ".join(f"`{s}`" for s in sys_perm_diff["b_only_sets"]))
812
+ w()
813
+
814
+ if sys_perm_diff.get("per_set_diffs"):
815
+ w(f"**Permission differences by set ({len(sys_perm_diff['per_set_diffs'])} sets differ):**")
816
+ w()
817
+ for ps_diff in sys_perm_diff["per_set_diffs"]:
818
+ kind = "Profile" if ps_diff.get("is_profile") else "PermSet"
819
+ w(f"*{ps_diff['name']}* ({kind}):")
820
+ if ps_diff["a_only"]:
821
+ shown = ps_diff["a_only"]
822
+ w(f" {label_a}-only: " + ", ".join(f"`{p}`" for p in shown))
823
+ if ps_diff["b_only"]:
824
+ shown = ps_diff["b_only"]
825
+ w(f" {label_b}-only: " + ", ".join(f"`{p}`" for p in shown))
826
+ w()
827
+
828
+ if sys_perm_diff.get("a_unique_perms") or sys_perm_diff.get("b_unique_perms"):
829
+ w(f"**Permissions used in only one org:**")
830
+ w()
831
+ if sys_perm_diff.get("a_unique_perms"):
832
+ shown = sys_perm_diff["a_unique_perms"]
833
+ w(f" {label_a}-only ({len(sys_perm_diff['a_unique_perms'])}): " +
834
+ ", ".join(f"`{p}`" for p in shown))
835
+ if sys_perm_diff.get("b_unique_perms"):
836
+ shown = sys_perm_diff["b_unique_perms"]
837
+ w(f" {label_b}-only ({len(sys_perm_diff['b_unique_perms'])}): " +
838
+ ", ".join(f"`{p}`" for p in shown))
839
+ w()
840
+
841
+ # Org Values
842
+ w("\n### Org Values & Limits")
843
+ w()
844
+ for cat_name in ["Identity", "Storage", "Feature Limits", "API", "Other"]:
845
+ items = val_diff["cats"].get(cat_name, [])
846
+ if not items:
847
+ continue
848
+ w(f"**{cat_name}** ({len(items)} {'difference' if len(items) == 1 else 'differences'}):")
849
+ w()
850
+ w(f"| Setting | {label_a} | {label_b} |")
851
+ w("|---------|-----|-----|")
852
+ for d_item in items:
853
+ w(f"| {d_item['name']} | {str(d_item['a'])} | {str(d_item['b'])} |")
854
+ w()
855
+
856
+ # Deep Data Diff
857
+ if deep_diff:
858
+ has_deep = any([
859
+ deep_diff.get("apex_classes", {}).get("different", 0),
860
+ deep_diff.get("apex_triggers", {}).get("different", 0),
861
+ deep_diff.get("custom_fields", {}).get("objects_with_diffs", 0),
862
+ deep_diff.get("validation_rules", {}).get("a_only") or deep_diff.get("validation_rules", {}).get("active_diffs"),
863
+ deep_diff.get("flows", {}).get("different", 0),
864
+ deep_diff.get("named_credentials", {}).get("endpoint_diffs"),
865
+ ])
866
+ if has_deep:
867
+ w("\n### Deep Diff (Content-Level)")
868
+ w()
869
+
870
+ # Apex body diffs
871
+ apex_d = deep_diff.get("apex_classes", {})
872
+ if apex_d.get("different"):
873
+ w(f"**Apex Classes:** {apex_d['shared']} shared, {apex_d['different']} with code differences")
874
+ w()
875
+ w(f"| Class | Difference |")
876
+ w(f"|-------|-----------|")
877
+ for d in apex_d["diffs"]:
878
+ parts = []
879
+ if d.get("body") == "different":
880
+ parts.append("body differs")
881
+ if d.get("apiVersion"):
882
+ parts.append(f"API {d['apiVersion']['a']}→{d['apiVersion']['b']}")
883
+ if d.get("loc"):
884
+ parts.append(f"LOC {d['loc']['a']}→{d['loc']['b']}")
885
+ w(f"| {d['name']} | {', '.join(parts)} |")
886
+ w()
887
+
888
+ # Trigger diffs
889
+ trig_d = deep_diff.get("apex_triggers", {})
890
+ if trig_d.get("different"):
891
+ w(f"**Apex Triggers:** {trig_d['different']} with body differences")
892
+ w()
893
+
894
+ # Custom field diffs
895
+ cf_d = deep_diff.get("custom_fields", {})
896
+ if cf_d.get("objects_with_diffs"):
897
+ w(f"**Custom Fields:** {cf_d['objects_compared']} objects compared, {cf_d['objects_with_diffs']} with differences")
898
+ w()
899
+ for obj_diff in cf_d["diffs"]:
900
+ w(f"*{obj_diff['object']}:*")
901
+ if obj_diff["a_only"]:
902
+ w(f" {label_a}-only fields: " + ", ".join(f"`{f}`" for f in obj_diff["a_only"]))
903
+ if obj_diff["b_only"]:
904
+ w(f" {label_b}-only fields: " + ", ".join(f"`{f}`" for f in obj_diff["b_only"]))
905
+ if obj_diff["type_diffs"]:
906
+ for td in obj_diff["type_diffs"]:
907
+ w(f" `{td['field']}`: type {td['a_type']} → {td['b_type']}")
908
+ w()
909
+
910
+ # Validation rule diffs
911
+ vr_d = deep_diff.get("validation_rules", {})
912
+ if vr_d.get("a_only") or vr_d.get("b_only") or vr_d.get("active_diffs"):
913
+ w(f"**Validation Rules:**")
914
+ if vr_d.get("a_only"):
915
+ w(f" {label_a}-only: " + ", ".join(f"`{v}`" for v in vr_d["a_only"]))
916
+ if vr_d.get("b_only"):
917
+ w(f" {label_b}-only: " + ", ".join(f"`{v}`" for v in vr_d["b_only"]))
918
+ if vr_d.get("active_diffs"):
919
+ for d in vr_d["active_diffs"]:
920
+ w(f" `{d['name']}`: active {d['a_active']}→{d['b_active']}")
921
+ w()
922
+
923
+ # Flow diffs
924
+ fl_d = deep_diff.get("flows", {})
925
+ if fl_d.get("different"):
926
+ w(f"**Flows:** {fl_d['shared']} shared, {fl_d['different']} with differences")
927
+ w()
928
+
929
+ # Named credential diffs
930
+ nc_d = deep_diff.get("named_credentials", {})
931
+ if nc_d.get("a_only") or nc_d.get("b_only") or nc_d.get("endpoint_diffs"):
932
+ w(f"**Named Credentials:**")
933
+ if nc_d.get("a_only"):
934
+ w(f" {label_a}-only: " + ", ".join(nc_d["a_only"]))
935
+ if nc_d.get("b_only"):
936
+ w(f" {label_b}-only: " + ", ".join(nc_d["b_only"]))
937
+ if nc_d.get("endpoint_diffs"):
938
+ for d in nc_d["endpoint_diffs"]:
939
+ w(f" `{d['name']}`: {d['a_endpoint']} → {d['b_endpoint']}")
940
+ w()
941
+
942
+ # Notes
943
+ # Licenses
944
+ has_lic_diffs = any(
945
+ lic_diff[lt]["a_only"] or lic_diff[lt]["b_only"] or lic_diff[lt]["diffs"]
946
+ for lt in lic_diff
947
+ )
948
+ if has_lic_diffs:
949
+ w("\n### Licenses")
950
+ w()
951
+ lic_labels = {"user": "User Licenses", "permission_set": "Permission Set Licenses", "package": "Package Licenses"}
952
+ for ltype, label in lic_labels.items():
953
+ ld = lic_diff[ltype]
954
+ if not ld["a_only"] and not ld["b_only"] and not ld["diffs"]:
955
+ continue
956
+ w(f"**{label}:**")
957
+ w()
958
+ if ld["diffs"]:
959
+ w(f"| License | {label_a} Total | {label_b} Total |")
960
+ w("|---------|-----|-----|")
961
+ def _fmt_lic(v):
962
+ return "-1 (unlimited)" if v == -1 else str(v)
963
+ for d in ld["diffs"]:
964
+ w(f"| {d['name']} | {_fmt_lic(d['a_total'])} | {_fmt_lic(d['b_total'])} |")
965
+ w()
966
+ if ld["a_only"]:
967
+ names = [l.get("Name") or l.get("DeveloperName") or l.get("NamespacePrefix", "?") for l in ld["a_only"]]
968
+ w(f"**{label_a}-only ({len(names)}):** " + ", ".join(names))
969
+ w()
970
+ if ld["b_only"]:
971
+ names = [l.get("Name") or l.get("DeveloperName") or l.get("NamespacePrefix", "?") for l in ld["b_only"]]
972
+ w(f"**{label_b}-only ({len(names)}):** " + ", ".join(names))
973
+ w()
974
+
975
+ w("---")
976
+ w()
977
+ w("## Notes")
978
+ w()
979
+ w("- **Namespaced (managed-package) components** are excluded from the summary table and listed separately under Package Components.")
980
+
981
+ a_errors = a_data.get("errors", [])
982
+ b_errors = b_data.get("errors", [])
983
+ if a_errors or b_errors:
984
+ w(f"- **Collection warnings:** {len(a_errors)} on {label_a}, {len(b_errors)} on {label_b}")
985
+
986
+ w()
987
+ w("*Report generated by dx-org-analyze. Read-only — no changes were made to either org.*")
988
+
989
+ return "\n".join(lines)
990
+
991
+
992
+ # ── Data loader (split directory or single JSON) ────────────────────
993
+
994
+ def load_org_data(path):
995
+ """Load org data from a split directory or a single JSON file.
996
+
997
+ Split layout (directory with manifest.json):
998
+ path/manifest.json — lists category files
999
+ path/identity.json — org_id, org_name, org_edition, errors
1000
+ path/metadata.json — metadata_types, metadata_components
1001
+ path/permissions.json — permissions
1002
+ path/org_values.json — org_values
1003
+ path/packages.json — installed_packages
1004
+ path/licenses.json — licenses
1005
+ path/system_permissions.json
1006
+ path/deep_data.json
1007
+ path/combined.json — backward-compat single file
1008
+
1009
+ Single file: a JSON file with all keys at the top level.
1010
+ """
1011
+ if os.path.isdir(path):
1012
+ combined = os.path.join(path, "combined.json")
1013
+ if os.path.exists(combined):
1014
+ with open(combined) as f:
1015
+ return json.load(f)
1016
+ manifest_path = os.path.join(path, "manifest.json")
1017
+ if not os.path.exists(manifest_path):
1018
+ raise FileNotFoundError(f"Directory {path} has no manifest.json or combined.json")
1019
+ with open(manifest_path) as f:
1020
+ manifest = json.load(f)
1021
+ data = {}
1022
+ for cat, fname in manifest.get("files", {}).items():
1023
+ fpath = os.path.join(path, fname)
1024
+ with open(fpath) as f:
1025
+ chunk = json.load(f)
1026
+ if isinstance(chunk, dict):
1027
+ data.update(chunk)
1028
+ else:
1029
+ data[cat] = chunk
1030
+ return data
1031
+
1032
+ with open(path) as f:
1033
+ return json.load(f)
1034
+
1035
+
1036
+ # ── Main ─────────────────────────────────────────────────────────────
1037
+
1038
+ def main():
1039
+ parser = argparse.ArgumentParser(description="Compute diff between two Salesforce org data files.")
1040
+ parser.add_argument("--org-a", required=True, help="Path to org A data directory or JSON file (from collect_org_data.py)")
1041
+ parser.add_argument("--org-b", required=True, help="Path to org B data directory or JSON file (from collect_org_data.py)")
1042
+ parser.add_argument("--output", required=True, help="Output path prefix (writes .json and/or .md)")
1043
+ parser.add_argument("--format", choices=["markdown", "json", "both"], default="both",
1044
+ help="Output format (default: both)")
1045
+ parser.add_argument("--org-a-label", default="Source", help="Display name for org A (source/reference)")
1046
+ parser.add_argument("--org-b-label", default="Target", help="Display name for org B (target to compare)")
1047
+ parser.add_argument("--show-shared", action="store_true",
1048
+ help="Include shared components in the detailed breakdown")
1049
+ args = parser.parse_args()
1050
+
1051
+ try:
1052
+ a_data = load_org_data(args.org_a)
1053
+ b_data = load_org_data(args.org_b)
1054
+ except FileNotFoundError as e:
1055
+ print(f"Error: {e}", file=sys.stderr)
1056
+ sys.exit(1)
1057
+ except json.JSONDecodeError as e:
1058
+ print(f"Error: invalid JSON — {e}", file=sys.stderr)
1059
+ sys.exit(1)
1060
+
1061
+ meta_stats = diff_metadata(a_data, b_data)
1062
+ perm_diff = diff_permissions(a_data, b_data)
1063
+ val_diff = diff_org_values(a_data, b_data)
1064
+ pkg_diff = diff_packages(a_data, b_data)
1065
+ lic_diff = diff_licenses(a_data, b_data)
1066
+ sys_perm_diff = diff_system_permissions(a_data, b_data)
1067
+ deep_diff = diff_deep_data(a_data, b_data)
1068
+ enrich_metadata_with_deep_diffs(meta_stats, deep_diff)
1069
+ pkg_components = diff_package_components(a_data, b_data)
1070
+ drift = compute_drift(meta_stats, perm_diff, a_data, b_data)
1071
+
1072
+ result = {
1073
+ "metadata_stats": meta_stats,
1074
+ "permissions": perm_diff,
1075
+ "org_values": val_diff,
1076
+ "installed_packages": pkg_diff,
1077
+ "licenses": lic_diff,
1078
+ "system_permissions": sys_perm_diff,
1079
+ "package_components": pkg_components,
1080
+ "deep_data": deep_diff,
1081
+ "drift": drift,
1082
+ }
1083
+
1084
+ if args.format in ("json", "both"):
1085
+ out_json = args.output + ".json"
1086
+ with open(out_json, "w") as f:
1087
+ json.dump(result, f, indent=2, default=str)
1088
+ print(f"Wrote {out_json}", file=sys.stderr)
1089
+
1090
+ if args.format in ("markdown", "both"):
1091
+ md = render_markdown(a_data, b_data, meta_stats, perm_diff, val_diff, pkg_diff, lic_diff, sys_perm_diff, pkg_components, deep_diff, drift,
1092
+ args.org_a_label, args.org_b_label, show_shared=args.show_shared)
1093
+ out_md = args.output + ".md"
1094
+ with open(out_md, "w") as f:
1095
+ f.write(md)
1096
+ print(f"Wrote {out_md}", file=sys.stderr)
1097
+ print(md)
1098
+
1099
+ sys.exit(0)
1100
+
1101
+
1102
+ if __name__ == "__main__":
1103
+ main()