@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.
- package/package.json +1 -1
- package/skills/dx-org-analyze/README.md +310 -0
- package/skills/dx-org-analyze/SKILL.md +261 -0
- package/skills/dx-org-analyze/references/collection-details.md +88 -0
- package/skills/dx-org-analyze/references/report-format.md +67 -0
- package/skills/dx-org-analyze/scripts/collect_org_data.py +823 -0
- package/skills/dx-org-analyze/scripts/compute_diff.py +1103 -0
- package/skills/dx-org-analyze/scripts/introspect_org.py +436 -0
- package/skills/dx-org-analyze/tests/README.md +46 -0
- package/skills/dx-org-analyze/tests/fixtures/org_a.json +76 -0
- package/skills/dx-org-analyze/tests/fixtures/org_b.json +70 -0
- package/skills/dx-org-analyze/tests/test_compute_diff.sh +284 -0
- package/skills/dx-org-analyze/tests/test_consistency.sh +428 -0
- package/skills/experience-design-validate/SKILL.md +163 -0
- package/skills/experience-design-validate/references/ai.md +54 -0
- package/skills/experience-design-validate/references/components.md +61 -0
- package/skills/experience-design-validate/references/craft.md +158 -0
- package/skills/experience-design-validate/references/data.md +59 -0
- package/skills/experience-design-validate/references/forms-flows.md +57 -0
- package/skills/experience-design-validate/references/interaction.md +53 -0
- package/skills/experience-design-validate/references/navigation.md +51 -0
- package/skills/experience-design-validate/references/performance.md +56 -0
- package/skills/experience-design-validate/references/records.md +60 -0
- package/skills/experience-design-validate/references/responsive.md +48 -0
- package/skills/experience-design-validate/references/scoring-rubric.md +257 -0
- package/skills/experience-design-validate/references/state.md +53 -0
- package/skills/experience-design-validate/references/trust.md +47 -0
- package/skills/experience-design-validate/references/usability.md +35 -0
- package/skills/experience-design-validate/references/visual-system.md +183 -0
- package/skills/service-agentforce-contact-center-coordinate/SKILL.md +170 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/escalation-flow.flow-meta.xml +72 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/omni-flow.flow-meta.xml +85 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/report-template.md +52 -0
- package/skills/service-agentforce-contact-center-coordinate/references/agentforce-prerequisite.md +31 -0
- package/skills/service-agentforce-contact-center-coordinate/references/messaging_channel.md +76 -0
- package/skills/service-agentforce-contact-center-coordinate/references/number_management_api.md +78 -0
- package/skills/service-agentforce-contact-center-coordinate/references/omni-flow-routing.md +74 -0
- package/skills/service-agentforce-contact-center-coordinate/references/setup_summary.md +36 -0
- package/skills/service-agentforce-contact-center-coordinate/references/verification_and_errors.md +42 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/check-agentforce-prereq.sh +54 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-routing-flows.sh +49 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-agent.sh +73 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-channel.sh +66 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/fetch-numbers.sh +21 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/lib.sh +46 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/prepare-agent-workdir.sh +21 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/procure-number.sh +24 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-acc-queue.sh +27 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-channel-line.sh +37 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-flow-definition.sh +33 -0
- 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()
|