#!/usr/bin/env python3 """Compare two metadata exporter outputs and report meaningful differences.""" from __future__ import annotations import argparse import json import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any IGNORED_FILES = {".export-metadata.json"} HIGH_IMPACT_OBJECTS = {"Account", "Contact", "Lead", "Opportunity", "Order", "Revenue__c"} HIGH_IMPACT_KINDS = {"validation_rule", "flow_definition", "permission", "page_layout"} @dataclass class Change: path: str kind: str layer_hint: str summary: str details: list[str] = field(default_factory=list) @dataclass class DiffResult: added_files: list[str] = field(default_factory=list) removed_files: list[str] = field(default_factory=list) changes: list[Change] = field(default_factory=list) @property def has_diff(self) -> bool: return bool(self.added_files or self.removed_files or self.changes) def reconcile_groups(payload: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: missing_only = payload.get("missing_only", []) extra_only = payload.get("extra_only", []) both = payload.get("both_missing_and_extra", payload.get("both", [])) if not isinstance(missing_only, list): missing_only = [] if not isinstance(extra_only, list): extra_only = [] if not isinstance(both, list): both = [] return missing_only, extra_only, both def load_reconcile_summary(path: str | None) -> dict[str, Any] | None: if not path: return None payload = load_json(Path(path).expanduser().resolve()) if not isinstance(payload, dict): raise ValueError("reconcile summary must be a JSON object") return payload def reconcile_has_mismatch(payload: dict[str, Any] | None) -> bool: if not payload: return False status = str(payload.get("status") or "").strip().lower() if status and status != "ok": return True missing_only, extra_only, both = reconcile_groups(payload) return bool(missing_only or extra_only or both) def reconcile_totals(payload: dict[str, Any] | None) -> dict[str, int]: if not payload: return {"missing_only": 0, "extra_only": 0, "mixed": 0, "mismatch_count": 0} missing_only, extra_only, both = reconcile_groups(payload) return { "missing_only": len(missing_only), "extra_only": len(extra_only), "mixed": len(both), "mismatch_count": int(payload.get("mismatch_count") or (len(missing_only) + len(extra_only) + len(both))), } def reconcile_row_text(row: dict[str, Any]) -> str: object_name = str(row.get("object") or "").strip() or "unknown" priority = str(row.get("priority") or "").strip() or "P?" missing = int(row.get("missing_ids_count") or 0) extra = int(row.get("unexpected_extra_ids_count") or 0) parts = [object_name, f"priority={priority}"] if missing: parts.append(f"missing={missing}") if extra: parts.append(f"extra={extra}") return ", ".join(parts) def top_reconcile_rows(rows: list[dict[str, Any]], limit: int = 10) -> list[dict[str, Any]]: def sort_key(row: dict[str, Any]) -> tuple[int, int, int, str]: priority = str(row.get("priority") or "P9").strip().upper() if priority.startswith("P") and priority[1:].isdigit(): priority_num = int(priority[1:]) else: priority_num = 9 volume = int(row.get("missing_ids_count") or 0) + int(row.get("unexpected_extra_ids_count") or 0) return (priority_num, -volume, -int(row.get("missing_ids_count") or 0), str(row.get("object") or "")) return sorted([row for row in rows if isinstance(row, dict)], key=sort_key)[:limit] def extract_object_name(path: str) -> str: if path.startswith("objects/") and path.endswith(".json"): return path.removeprefix("objects/").removesuffix(".json") if path.startswith("layouts/"): stem = Path(path).stem if "_" in stem: return stem.split("_", 1)[0] return stem return "" def is_high_impact_path(path: str, kind: str) -> bool: object_name = extract_object_name(path) return kind in HIGH_IMPACT_KINDS or object_name in HIGH_IMPACT_OBJECTS def build_high_impact_summary( result: DiffResult, left_index: dict[str, dict[str, Any]], right_index: dict[str, dict[str, Any]], ) -> dict[str, Any]: conflicts: list[dict[str, str]] = [] high_impact: list[dict[str, str]] = [] for change in result.changes: row = { "path": change.path, "kind": change.kind, "layerHint": change.layer_hint, "diffType": "changed", } conflicts.append(row) if is_high_impact_path(change.path, change.kind): high_impact.append(row) for diff_type, paths in (("added", result.added_files), ("removed", result.removed_files)): for path in paths: kind, layer = component_meta(path, left_index, right_index) row = { "path": path, "kind": kind, "layerHint": layer, "diffType": diff_type, } if is_high_impact_path(path, kind): high_impact.append(row) return { "conflictCount": len(conflicts), "highImpactCount": len(high_impact), "conflicts": conflicts[:20], "highImpact": high_impact[:20], } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Compare two metadata export directories.") parser.add_argument("--left", required=True, help="Left export directory.") parser.add_argument("--right", required=True, help="Right export directory.") parser.add_argument("--left-label", default="", help="Stable display label for the left directory.") parser.add_argument("--right-label", default="", help="Stable display label for the right directory.") parser.add_argument( "--reconcile-summary", help="Optional Salesforce-vs-TiDB reconcile summary JSON to surface live data drift not visible in metadata exports.", ) parser.add_argument("--output", help="Output report file.") parser.add_argument("--format", choices=["text", "json"], default="text", help="Report format.") return parser.parse_args() def load_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def normalize(value: Any) -> Any: if isinstance(value, dict): return {key: normalize(val) for key, val in sorted(value.items())} if isinstance(value, list): normalized = [normalize(item) for item in value] if all(isinstance(item, dict) for item in normalized): return sorted(normalized, key=business_sort_key) return normalized return value def business_sort_key(item: dict[str, Any]) -> tuple: for key in ( "apiName", "objectApiName", "name", "fullName", "layoutName", "developerName", "masterLabel", "Id", ): if key in item: return (str(item.get(key, "")), json.dumps(item, sort_keys=True, ensure_ascii=False)) return (json.dumps(item, sort_keys=True, ensure_ascii=False),) def collect_files(root: Path) -> dict[str, Path]: files: dict[str, Path] = {} for path in root.rglob("*.json"): rel = str(path.relative_to(root)) if rel in IGNORED_FILES: continue files[rel] = path index_path = root / ".component-index.json" if index_path.exists(): files[".component-index.json"] = index_path return files def load_component_index(root: Path) -> dict[str, dict[str, Any]]: path = root / ".component-index.json" if not path.exists(): return {} try: rows = load_json(path) except json.JSONDecodeError: return {} by_path: dict[str, dict[str, Any]] = {} if isinstance(rows, list): for row in rows: if isinstance(row, dict) and "path" in row: by_path[str(row["path"])] = row return by_path def component_meta(path: str, left_index: dict[str, dict[str, Any]], right_index: dict[str, dict[str, Any]]) -> tuple[str, str]: meta = left_index.get(path) or right_index.get(path) or {} return str(meta.get("kind") or infer_kind(path)), str(meta.get("layerHint") or "unknown") def infer_kind(path: str) -> str: if path.startswith("objects/"): return "object_spec" if path.startswith("layouts/"): return "page_layout" if path.startswith("record_types/"): return "record_type" if path.startswith("validation/"): return "validation_rule" if path.startswith("permissions/"): return "permission" if path.startswith("report_types/"): return "report_type" if path.startswith("relationships/"): return "relationship" if path.startswith("field_mappings/"): return "field_mapping" if path.startswith("flow_definitions/"): return "flow_definition" return "json" def compare_keyed_collection( left_items: list[dict[str, Any]], right_items: list[dict[str, Any]], key_fn, label: str, ) -> list[str]: left_map = {key_fn(item): normalize(item) for item in left_items} right_map = {key_fn(item): normalize(item) for item in right_items} details: list[str] = [] for key in sorted(left_map.keys() - right_map.keys()): details.append(f"{label} removed: {key}") for key in sorted(right_map.keys() - left_map.keys()): details.append(f"{label} added: {key}") for key in sorted(left_map.keys() & right_map.keys()): if left_map[key] != right_map[key]: details.append(f"{label} changed: {key}") return details def compare_layout(left: dict[str, Any], right: dict[str, Any]) -> list[str]: details: list[str] = [] for attr in ("objectApiName", "layoutName", "type", "isDefault", "source"): if normalize(left.get(attr)) != normalize(right.get(attr)): details.append(f"layout attr changed: {attr}") left_sections = {section_key(section): normalize(section) for section in left.get("sections", [])} right_sections = {section_key(section): normalize(section) for section in right.get("sections", [])} for key in sorted(left_sections.keys() - right_sections.keys()): details.append(f"section removed: {key}") for key in sorted(right_sections.keys() - left_sections.keys()): details.append(f"section added: {key}") for key in sorted(left_sections.keys() & right_sections.keys()): left_section = left_sections[key] right_section = right_sections[key] if left_section == right_section: continue left_fields = list(left_section.get("fields", [])) right_fields = list(right_section.get("fields", [])) for field_name in sorted(set(left_fields) - set(right_fields)): details.append(f"section {key} field removed: {field_name}") for field_name in sorted(set(right_fields) - set(left_fields)): details.append(f"section {key} field added: {field_name}") if left_fields == right_fields and left_section != right_section: details.append(f"section changed: {key}") details.extend( compare_keyed_collection( list(left.get("relatedLists", [])), list(right.get("relatedLists", [])), lambda item: f"{item.get('objectApiName')}:{item.get('lookupField')}:{item.get('label')}", "relatedList", ) ) details.extend( compare_keyed_collection( list(left.get("recordPanels", [])), list(right.get("recordPanels", [])), lambda item: f"{item.get('rendererId')}:{item.get('tabId')}:{item.get('tabLabel')}", "recordPanel", ) ) return details def section_key(section: dict[str, Any]) -> str: return str(section.get("id") or section.get("label") or json.dumps(section, sort_keys=True, ensure_ascii=False)) def compare_record_types(left: dict[str, Any], right: dict[str, Any]) -> list[str]: left_rows = left.get("recordTypes", []) right_rows = right.get("recordTypes", []) left_map = {row["objectApiName"]: normalize(row.get("types", [])) for row in left_rows} right_map = {row["objectApiName"]: normalize(row.get("types", [])) for row in right_rows} details: list[str] = [] for object_name in sorted(left_map.keys() - right_map.keys()): details.append(f"recordType object removed: {object_name}") for object_name in sorted(right_map.keys() - left_map.keys()): details.append(f"recordType object added: {object_name}") for object_name in sorted(left_map.keys() & right_map.keys()): if left_map[object_name] != right_map[object_name]: left_types = {row.get("name"): row for row in left_map[object_name]} right_types = {row.get("name"): row for row in right_map[object_name]} for type_name in sorted(left_types.keys() - right_types.keys()): details.append(f"recordType removed: {object_name}/{type_name}") for type_name in sorted(right_types.keys() - left_types.keys()): details.append(f"recordType added: {object_name}/{type_name}") for type_name in sorted(left_types.keys() & right_types.keys()): if left_types[type_name] != right_types[type_name]: details.append(f"recordType changed: {object_name}/{type_name}") return details def compare_wrapped_list(left: dict[str, Any], right: dict[str, Any], key: str, label: str, key_fn) -> list[str]: return compare_keyed_collection(list(left.get(key, [])), list(right.get(key, [])), key_fn, label) def compare_json_file(path: str, left_data: Any, right_data: Any) -> list[str]: if path.startswith("layouts/"): return compare_layout(left_data, right_data) if path == "record_types/record_types.json": return compare_record_types(left_data, right_data) if path == "validation/standard_validation_rules.json": return compare_wrapped_list(left_data, right_data, "validationRules", "validationRule", lambda item: f"{item.get('objectApiName')}:{item.get('name')}") if path == "report_types/standard_report_types.json": return compare_wrapped_list(left_data, right_data, "reportTypes", "reportType", lambda item: str(item.get("fullName"))) if path == "flow_definitions/flow_definitions.json": return compare_wrapped_list(left_data, right_data, "flowDefinitions", "flowDefinition", lambda item: str(item.get("developerName"))) if path == "relationships/standard_relationships.json": return compare_wrapped_list(left_data, right_data, "relationships", "relationship", lambda item: f"{item.get('ChildObjectApiName')}:{item.get('FieldApiName')}") if path == "field_mappings/object_field_mappings.json": return compare_wrapped_list( left_data, right_data, "fieldMappings", "fieldMapping", lambda item: f"{item.get('TargetObject')}:{item.get('MappingActionType')}:{item.get('TargetFieldAPIName')}", ) if path == "permissions/crm_standard_permissions.json": return compare_keyed_collection(list(left_data), list(right_data), lambda item: str(item.get("apiName")), "permission") if path.startswith("objects/"): return compare_keyed_collection(list(left_data), list(right_data), lambda item: str(item.get("apiName")), "object") if normalize(left_data) != normalize(right_data): return ["json changed"] return [] def compare_exports(left_dir: Path, right_dir: Path) -> DiffResult: left_files = collect_files(left_dir) right_files = collect_files(right_dir) left_index = load_component_index(left_dir) right_index = load_component_index(right_dir) result = DiffResult() left_paths = set(left_files.keys()) right_paths = set(right_files.keys()) result.added_files = sorted(right_paths - left_paths) result.removed_files = sorted(left_paths - right_paths) for path in sorted((left_paths & right_paths) - {".component-index.json"}): left_data = load_json(left_files[path]) right_data = load_json(right_files[path]) details = compare_json_file(path, left_data, right_data) if details: kind, layer = component_meta(path, left_index, right_index) result.changes.append( Change( path=path, kind=kind, layer_hint=layer, summary=f"{kind} changed", details=details, ) ) return result def summarize_counts(paths: list[str], changes: list[Change], left_index: dict[str, dict[str, Any]], right_index: dict[str, dict[str, Any]]) -> dict[str, int]: counts: dict[str, int] = {} for path in paths: _kind, layer = component_meta(path, left_index, right_index) counts[layer] = counts.get(layer, 0) + 1 for change in changes: counts[change.layer_hint] = counts.get(change.layer_hint, 0) + 1 return counts def format_text_report( result: DiffResult, left_dir: Path, right_dir: Path, left_label: str = "", right_label: str = "", reconcile_summary: dict[str, Any] | None = None, ) -> str: left_index = load_component_index(left_dir) right_index = load_component_index(right_dir) left_display = left_label or str(left_dir) right_display = right_label or str(right_dir) lines = [ "# Metadata Diff Report", "", f"Left: {left_display}", f"Right: {right_display}", "", ] if not result.has_diff: lines.append("No differences found.") return "\n".join(lines) high_impact_summary = build_high_impact_summary(result, left_index, right_index) lines.append("## High Impact Summary") lines.append(f"- conflicts: {high_impact_summary['conflictCount']}") lines.append(f"- high impact diff rows: {high_impact_summary['highImpactCount']}") if high_impact_summary["conflicts"]: lines.append("") lines.append("### Conflict First") for row in high_impact_summary["conflicts"]: lines.append(f"- {row['path']} [{row['kind']}, {row['layerHint']}]") if high_impact_summary["highImpact"]: lines.append("") lines.append("### High Impact Paths") for row in high_impact_summary["highImpact"]: lines.append(f"- {row['diffType']}: {row['path']} [{row['kind']}, {row['layerHint']}]") lines.append("") layer_counts = summarize_counts(result.added_files + result.removed_files, result.changes, left_index, right_index) if layer_counts: lines.append("## Layer Hint Summary") for layer, count in sorted(layer_counts.items()): lines.append(f"- {layer}: {count}") lines.append("") l2_relevant = [change for change in result.changes if change.layer_hint == "likely_l2_admin"] l2_relevant_paths = [] for path in result.added_files + result.removed_files: _kind, layer = component_meta(path, left_index, right_index) if layer == "likely_l2_admin": l2_relevant_paths.append(path) if l2_relevant or l2_relevant_paths: lines.append("## Likely L2 / Admin Differences") for path in l2_relevant_paths: lines.append(f"- file changed at L2 hint: {path}") for change in l2_relevant: lines.append(f"- {change.path}: {change.summary}") lines.append("") if result.added_files: lines.append("## Files Only In Right") for path in result.added_files: kind, layer = component_meta(path, left_index, right_index) lines.append(f"- {path} [{kind}, {layer}]") lines.append("") if result.removed_files: lines.append("## Files Only In Left") for path in result.removed_files: kind, layer = component_meta(path, left_index, right_index) lines.append(f"- {path} [{kind}, {layer}]") lines.append("") if result.changes: lines.append("## Changed Components") for change in result.changes: lines.append(f"- {change.path} [{change.kind}, {change.layer_hint}]") for detail in change.details[:25]: lines.append(f" {detail}") if len(change.details) > 25: lines.append(f" ... {len(change.details) - 25} more") lines.append("") if reconcile_summary: totals = reconcile_totals(reconcile_summary) missing_only, extra_only, both = reconcile_groups(reconcile_summary) lines.append("## Live Salesforce/TiDB Data Drift") lines.append(f"- status: {reconcile_summary.get('status', '')}") lines.append(f"- source_mode: {reconcile_summary.get('source_mode', '')}") lines.append(f"- source_org: {reconcile_summary.get('source_org', '')}") lines.append(f"- mismatch_objects: {totals['mismatch_count']}") lines.append(f"- missing_only_objects: {totals['missing_only']}") lines.append(f"- extra_only_objects: {totals['extra_only']}") lines.append(f"- mixed_drift_objects: {totals['mixed']}") lines.append("") if missing_only: lines.append("### Missing-Only Objects") for row in top_reconcile_rows(missing_only): lines.append(f"- {reconcile_row_text(row)}") lines.append("") if extra_only: lines.append("### Extra-Only Objects") for row in top_reconcile_rows(extra_only): lines.append(f"- {reconcile_row_text(row)}") lines.append("") if both: lines.append("### Mixed Drift Objects") for row in top_reconcile_rows(both): lines.append(f"- {reconcile_row_text(row)}") lines.append("") lines.append("---") lines.append("## Summary") lines.append(f"- files only in right: {len(result.added_files)}") lines.append(f"- files only in left: {len(result.removed_files)}") lines.append(f"- changed components: {len(result.changes)}") if reconcile_summary: totals = reconcile_totals(reconcile_summary) lines.append(f"- live data drift objects: {totals['mismatch_count']}") return "\n".join(lines) def format_json_report( result: DiffResult, left_dir: Path, right_dir: Path, left_label: str = "", right_label: str = "", reconcile_summary: dict[str, Any] | None = None, ) -> str: left_index = load_component_index(left_dir) right_index = load_component_index(right_dir) high_impact_summary = build_high_impact_summary(result, left_index, right_index) reconcile_totals_payload = reconcile_totals(reconcile_summary) if reconcile_summary else None return json.dumps( { "left": left_label or str(left_dir), "right": right_label or str(right_dir), "added_files": [ { "path": path, "kind": component_meta(path, left_index, right_index)[0], "layerHint": component_meta(path, left_index, right_index)[1], } for path in result.added_files ], "removed_files": [ { "path": path, "kind": component_meta(path, left_index, right_index)[0], "layerHint": component_meta(path, left_index, right_index)[1], } for path in result.removed_files ], "changes": [ { "path": change.path, "kind": change.kind, "layerHint": change.layer_hint, "summary": change.summary, "details": change.details, } for change in result.changes ], "high_impact_summary": high_impact_summary, "has_diff": result.has_diff, "has_reconcile_mismatch": reconcile_has_mismatch(reconcile_summary), "has_any_issue": result.has_diff or reconcile_has_mismatch(reconcile_summary), "reconcile_summary": reconcile_summary, "reconcile_totals": reconcile_totals_payload, }, indent=2, ensure_ascii=False, ) def main() -> int: args = parse_args() left_dir = Path(args.left).expanduser().resolve() right_dir = Path(args.right).expanduser().resolve() for directory in (left_dir, right_dir): if not directory.exists(): print(f"error: directory does not exist: {directory}", file=sys.stderr) return 1 result = compare_exports(left_dir, right_dir) reconcile_summary = load_reconcile_summary(args.reconcile_summary) report = ( format_json_report(result, left_dir, right_dir, args.left_label, args.right_label, reconcile_summary) if args.format == "json" else format_text_report(result, left_dir, right_dir, args.left_label, args.right_label, reconcile_summary) ) if args.output: Path(args.output).write_text(report, encoding="utf-8") print(f"report written to {args.output}") else: print(report) has_issue = result.has_diff or reconcile_has_mismatch(reconcile_summary) return 0 if not has_issue else 1 if __name__ == "__main__": raise SystemExit(main())