from __future__ import annotations import argparse import json from pathlib import Path from typing import Any XML_KIND_TO_SQL_KIND = { "Catalogs": "Catalog", "Documents": "Document", "Reports": "Report", "DataProcessors": "DataProcessor", "ChartsOfCharacteristicTypes": "ChartOfCharacteristicTypes", "ChartsOfAccounts": "ChartOfAccounts", "ChartsOfCalculationTypes": "ChartOfCalculationTypes", "InformationRegisters": "InformationRegister", "AccumulationRegisters": "AccumulationRegister", "AccountingRegisters": "AccountingRegister", "CalculationRegisters": "CalculationRegister", "BusinessProcesses": "BusinessProcess", "Tasks": "Task", } def read_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def xml_route(item: dict[str, Any]) -> dict[str, Any]: parts = Path(str(item.get("path") or item.get("relative_path") or "")).parts for index, part in enumerate(parts): if part in XML_KIND_TO_SQL_KIND and index + 3 < len(parts): owner_name = parts[index + 1] if parts[index + 2] == "Templates": return { "owner_kind": XML_KIND_TO_SQL_KIND[part], "owner_name": owner_name, "template_name": parts[index + 3], "source": "xml_path", } rel_parts = Path(str(item.get("relative_path") or "")).parts if len(rel_parts) >= 4 and rel_parts[0] == "Templates": return {"template_name": rel_parts[1], "source": "xml_relative_path"} return {"template_name": None, "source": "unknown"} def sql_key(item: dict[str, Any]) -> tuple[str | None, str | None, str | None]: owner = item.get("owner") if isinstance(item.get("owner"), dict) else {} template = item.get("template") if isinstance(item.get("template"), dict) else {} return owner.get("kind"), owner.get("name"), template.get("name") def xml_key(item: dict[str, Any]) -> tuple[str | None, str | None, str | None]: route = item.get("route") if isinstance(item.get("route"), dict) else xml_route(item) return route.get("owner_kind"), route.get("owner_name"), route.get("template_name") def dimension_pair(value: Any) -> tuple[int | None, int | None]: if not isinstance(value, dict): return None, None rows = value.get("rows") columns = value.get("columns") try: parsed_rows = int(rows) if rows is not None else None except (TypeError, ValueError): parsed_rows = None try: parsed_columns = int(columns) if columns is not None else None except (TypeError, ValueError): parsed_columns = None return parsed_rows, parsed_columns def dimension_delta(left: tuple[int | None, int | None], right: tuple[int | None, int | None]) -> dict[str, int | None]: rows = left[0] - right[0] if left[0] is not None and right[0] is not None else None columns = left[1] - right[1] if left[1] is not None and right[1] is not None else None return {"rows": rows, "columns": columns} def format_dimensions(value: Any) -> str: rows, columns = dimension_pair(value) if rows is None and columns is None: return "-" return f"{rows if rows is not None else '-'}x{columns if columns is not None else '-'}" def format_delta(value: dict[str, int | None] | None) -> str: if not isinstance(value, dict): return "-" rows = value.get("rows") columns = value.get("columns") if rows is None and columns is None: return "-" row_text = f"{rows:+d}" if isinstance(rows, int) else "-" column_text = f"{columns:+d}" if isinstance(columns, int) else "-" return f"{row_text}x{column_text}" def sql_count(profile: dict[str, Any], key: str, sample_key: str | None = None) -> int | None: counts = profile.get("counts") if isinstance(profile.get("counts"), dict) else {} value = counts.get(key) if value is not None: try: return int(value) except (TypeError, ValueError): return None if sample_key and isinstance(profile.get(sample_key), list): return len(profile.get(sample_key) or []) return None def merge_record_analysis_counts(profile: dict[str, Any]) -> dict[str, int]: blocks = 0 records = 0 for candidate in profile.get("sample_merge_record_block_candidates") or []: if not isinstance(candidate, dict): continue evidence = candidate.get("evidence") if isinstance(candidate.get("evidence"), dict) else {} analysis = evidence.get("record_analysis") if isinstance(evidence.get("record_analysis"), dict) else {} if analysis.get("schema") != "moxel_numeric_block_records.v1": continue blocks += 1 try: records += int(analysis.get("records_analyzed") or 0) except (TypeError, ValueError): pass return {"blocks": blocks, "records": records} def compare_item(sql_item: dict[str, Any] | None, xml_item: dict[str, Any]) -> dict[str, Any]: route = xml_item.get("route") if isinstance(xml_item.get("route"), dict) else xml_route(xml_item) xml_counts = xml_item.get("counts") if isinstance(xml_item.get("counts"), dict) else {} xml_capacity = dimension_pair(xml_item.get("capacity_dimensions")) xml_used = dimension_pair(xml_item.get("used_dimensions")) result: dict[str, Any] = { "route": route, "xml": { "xml_kind": xml_item.get("xml_kind"), "capacity_dimensions": xml_item.get("capacity_dimensions"), "used_dimensions": xml_item.get("used_dimensions"), "counts": xml_counts, }, "status": "ok", "gaps": [], } if sql_item is None: result["status"] = "missing_sql_profile" result["gaps"].append({"code": "missing_sql_profile", "severity": "error", "message": "No matching SQL template profile was found."}) return result profile = sql_item.get("profile") if isinstance(sql_item.get("profile"), dict) else {} sql_capacity = dimension_pair(profile.get("capacity_dimensions") or profile.get("dimensions")) sql_used = dimension_pair(profile.get("used_dimensions")) used_delta = dimension_delta(sql_used, xml_used) sql_counts = { "cells": sql_count(profile, "cells", None), "cell_coordinate_hints": sql_count(profile, "cell_coordinate_hints", "sample_cell_coordinate_hints"), "cell_style_coordinate_hints": sql_count(profile, "cell_style_coordinate_hints", "sample_style_coordinate_hints"), "parameters": sql_count(profile, "cell_parameters", "sample_cell_parameters"), "text_ids": sql_count(profile, "cell_text_identifiers", "sample_text_identifiers"), "merges": sql_count(profile, "merged_ranges", None), "merge_record_block_candidates": sql_count(profile, "merge_record_block_candidates", "sample_merge_record_block_candidates"), "merge_count_hints": sql_count(profile, "merge_count_hints", "sample_merge_count_hints"), "format_indexes": sql_count(profile, "cell_style_candidates", "sample_style_texts"), } merge_record_analysis = merge_record_analysis_counts(profile) result["sql"] = { "owner": sql_item.get("owner"), "template": sql_item.get("template"), "capacity_dimensions": profile.get("capacity_dimensions") or profile.get("dimensions"), "used_dimensions": profile.get("used_dimensions"), "used_delta": used_delta, "counts": sql_counts, "merge_record_analysis": merge_record_analysis, "capabilities": profile.get("capabilities") or {}, } if sql_capacity != xml_capacity: result["gaps"].append( { "code": "capacity_dimensions_mismatch", "severity": "error", "sql": {"rows": sql_capacity[0], "columns": sql_capacity[1]}, "xml": {"rows": xml_capacity[0], "columns": xml_capacity[1]}, } ) if sql_used == (None, None): result["gaps"].append({"code": "used_dimensions_missing", "severity": "warning", "xml": {"rows": xml_used[0], "columns": xml_used[1]}}) elif sql_used != xml_used: result["gaps"].append( { "code": "used_dimensions_mismatch", "severity": "warning", "sql": {"rows": sql_used[0], "columns": sql_used[1]}, "xml": {"rows": xml_used[0], "columns": xml_used[1]}, } ) checks = [ ("cells", "cells_missing_or_limited"), ("parameters", "parameters_missing_or_limited"), ("merges", "merged_ranges_missing"), ("format_indexes", "format_indexes_missing_or_limited"), ] for key, code in checks: xml_value = xml_counts.get(key) sql_value = sql_counts.get(key) if not xml_value: continue if sql_value in {None, 0}: result["gaps"].append({"code": code, "severity": "warning", "sql": sql_value, "xml": xml_value}) elif int(sql_value) < int(xml_value): result["gaps"].append({"code": code, "severity": "info", "sql": sql_value, "xml": xml_value}) hint_count = int(sql_counts.get("cell_coordinate_hints") or 0) xml_cells = int(xml_counts.get("cells") or 0) if xml_cells and hint_count: result.setdefault("progress", []).append( { "code": "cell_coordinate_hints_available", "sql": hint_count, "xml_cells": xml_cells, "coverage_ratio": round(hint_count / xml_cells, 4), "message": "SQL decoder returned coordinate hints. These are progress evidence, not authoritative decoded cells.", } ) merge_block_count = int(sql_counts.get("merge_record_block_candidates") or 0) merge_count_hint_count = int(sql_counts.get("merge_count_hints") or 0) xml_merges = int(xml_counts.get("merges") or 0) if xml_merges and merge_block_count: result.setdefault("progress", []).append( { "code": "merge_record_blocks_available", "sql": merge_block_count, "xml_merges": xml_merges, "message": "SQL decoder found MOXCEL merge-record block candidates. These are progress evidence, not authoritative merged ranges.", } ) if xml_merges and merge_count_hint_count: result.setdefault("progress", []).append( { "code": "merge_count_hints_available", "sql": merge_count_hint_count, "xml_merges": xml_merges, "message": "SQL decoder found MOXCEL merge-count hints. These confirm merge presence/count slots, not authoritative merged ranges.", } ) if xml_merges and merge_record_analysis["blocks"]: result.setdefault("progress", []).append( { "code": "merge_record_analysis_available", "sql_blocks": merge_record_analysis["blocks"], "sql_records_analyzed": merge_record_analysis["records"], "xml_merges": xml_merges, "message": "SQL decoder returned normalized merge-block record analysis for slot-formula discovery.", } ) if result["gaps"]: result["status"] = "gap" return result def compare(sql_profile: dict[str, Any], xml_profile: dict[str, Any]) -> dict[str, Any]: sql_items = [item for item in sql_profile.get("items") or [] if isinstance(item, dict)] sql_by_key = {sql_key(item): item for item in sql_items} comparisons = [] for xml_item in xml_profile.get("templates") or []: if not isinstance(xml_item, dict) or xml_item.get("xml_kind") != "tabular_document": continue route = xml_route(xml_item) xml_item = {**xml_item, "route": route} key = xml_key(xml_item) sql_item = sql_by_key.get(key) if sql_item is None and key[2]: matches = [item for item in sql_items if sql_key(item)[2] == key[2]] sql_item = matches[0] if len(matches) == 1 else None comparisons.append(compare_item(sql_item, xml_item)) gap_counts: dict[str, int] = {} progress_counts: dict[str, int] = {} for item in comparisons: for gap in item.get("gaps") or []: code = str(gap.get("code") or "unknown") gap_counts[code] = gap_counts.get(code, 0) + 1 for progress in item.get("progress") or []: code = str(progress.get("code") or "unknown") progress_counts[code] = progress_counts.get(code, 0) + 1 return { "schema": "codex_1c_template_sql_xml_profile_compare.v1", "source": "analysis_only_xml_fixture", "sql_profile": sql_profile.get("base_id") or sql_profile.get("schema"), "xml_profile": xml_profile.get("root"), "comparisons": comparisons, "counts": { "xml_tabular_templates": len(comparisons), "matched": sum(1 for item in comparisons if item.get("sql")), "missing_sql_profile": sum(1 for item in comparisons if item.get("status") == "missing_sql_profile"), "with_gaps": sum(1 for item in comparisons if item.get("gaps")), "gap_counts": gap_counts, "progress_counts": progress_counts, }, } def render_markdown(payload: dict[str, Any]) -> str: lines = ["# 1C Template SQL/XML Profile Compare", ""] counts = payload.get("counts") or {} lines.append(f"- XML tabular templates: `{counts.get('xml_tabular_templates')}`") lines.append(f"- Matched SQL profiles: `{counts.get('matched')}`") lines.append(f"- With gaps: `{counts.get('with_gaps')}`") lines.append(f"- Progress signals: `{counts.get('progress_counts') or {}}`") lines.append("") lines.append( "| Owner | Template | Status | SQL capacity | XML capacity | SQL used | XML used | Used delta | SQL cells | Coord hints | Merge blocks | Merge count hints | Merge records analyzed | Gaps | Progress |" ) lines.append("| --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- |") for item in payload.get("comparisons") or []: route = item.get("route") or {} sql = item.get("sql") or {} xml = item.get("xml") or {} sql_counts = sql.get("counts") or {} merge_record_analysis = sql.get("merge_record_analysis") or {} gaps = ", ".join(str(gap.get("code")) for gap in (item.get("gaps") or [])) progress = ", ".join(str(entry.get("code")) for entry in (item.get("progress") or [])) lines.append( f"| `{route.get('owner_kind') or ''}.{route.get('owner_name') or ''}` | " f"`{route.get('template_name') or ''}` | `{item.get('status')}` | " f"`{format_dimensions(sql.get('capacity_dimensions'))}` | " f"`{format_dimensions(xml.get('capacity_dimensions'))}` | " f"`{format_dimensions(sql.get('used_dimensions'))}` | " f"`{format_dimensions(xml.get('used_dimensions'))}` | " f"`{format_delta(sql.get('used_delta'))}` | " f"{sql_counts.get('cells') or 0} | {sql_counts.get('cell_coordinate_hints') or 0} | " f"{sql_counts.get('merge_record_block_candidates') or 0} | {sql_counts.get('merge_count_hints') or 0} | " f"{merge_record_analysis.get('records') or 0} | " f"`{gaps}` | `{progress}` |" ) lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description="Compare SQL-decoded template profiles with XML analysis fixtures.") parser.add_argument("--sql-profile", default="reports/1c-template-baselines/upo_test_tabular_template_profiles.json") parser.add_argument("--xml-profile", required=True) parser.add_argument("--output-json", default="reports/1c-template-baselines/sql-xml-template-profile-compare.json") parser.add_argument("--output-markdown", default="reports/1c-template-baselines/sql-xml-template-profile-compare.md") args = parser.parse_args() payload = compare(read_json(Path(args.sql_profile)), read_json(Path(args.xml_profile))) json_path = Path(args.output_json) md_path = Path(args.output_markdown) json_path.parent.mkdir(parents=True, exist_ok=True) md_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") md_path.write_text(render_markdown(payload), encoding="utf-8") print(json.dumps({"status": "ok", "json": str(json_path), "markdown": str(md_path), "counts": payload["counts"]}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())