Files
llm/scripts/compare_1c_form_sql_xml.py

634 lines
30 KiB
Python

#!/usr/bin/env python3
"""Compare decoded SQL form semantics with exported 1C Form.xml semantics."""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
from parser.form_payload import enrich_form_common_semantic, form_common_semantic # noqa: E402
from parser.form_xml import decode_form_xml # noqa: E402
PROPERTY_NAME_ALIASES = {
"Action": "Действие",
"MainAttribute": "ОсновнойРеквизит",
"AutoEditMode": "АвтоРежимРедактирования",
"HeightInTableRows": "ВысотаВСтрокахТаблицы",
"RowSelectionMode": "РежимВыделенияСтроки",
"HorizontalLinesBWA": "ГоризонтальныеЛинии",
"VerticalLinesBWA": "ВертикальныеЛинии",
"UseAlternationRowColorBWA": "ЧередованиеЦветовСтрок",
"AutoInsertNewRow": "АвтоВставкаНовойСтроки",
"EnableStartDrag": "РазрешитьНачалоПеретаскивания",
"EnableDrag": "РазрешитьПеретаскивание",
"FileDragMode": "РежимПеретаскиванияФайлов",
"CommandBarLocation": "ПоложениеКоманднойПанели",
"DefaultItem": "АктивизироватьПоУмолчанию",
"Autofill": "Автозаполнение",
"AutoMaxWidth": "АвтоМаксимальнаяШирина",
"MaxWidth": "МаксимальнаяШирина",
"MultiLine": "МногострочныйРежим",
"AutoCommandBar": "АвтоКоманднаяПанель",
"SearchStringAddition": "ДополнениеСтрокиПоиска",
"ViewStatusAddition": "ДополнениеСостоянияПросмотра",
"SearchControlAddition": "ДополнениеУправленияПоиском",
"Width": "Ширина",
"Height": "Высота",
"HorizontalStretch": "РастягиватьПоГоризонтали",
"VerticalStretch": "РастягиватьПоВертикали",
"TextColor": "ЦветТекста",
"BackColor": "ЦветФона",
"HorizontalAlign": "ГоризонтальноеПоложениеВГруппе",
"AutoMaxHeight": "АвтоМаксимальнаяВысота",
"MaxHeight": "МаксимальнаяВысота",
"AutoMarkIncomplete": "АвтоОтметкаНезаполненного",
"ToolTipRepresentation": "ОтображениеПодсказки",
"SpinButton": "КнопкаРегулирования",
"Representation": "Отображение",
"DefaultButton": "КнопкаПоУмолчанию",
"OpenButton": "КнопкаОткрытия",
"CreateButton": "КнопкаСоздания",
"ChoiceHistoryOnInput": "ИсторияВыбораПриВводе",
"BorderColor": "ЦветРамки",
"ChangeRowSet": "ИзменятьСоставСтрок",
"ShowInHeader": "ОтображатьВШапке",
"AutoCellHeight": "АвтоВысотаЯчейки",
"SearchStringLocation": "ПоложениеСтрокиПоиска",
"ViewStatusLocation": "ПоложениеСостоянияПросмотра",
"SearchControlLocation": "ПоложениеУправленияПоиском",
"GroupHorizontalAlign": "ГоризонтальноеПоложениеВГруппе",
"GroupVerticalAlign": "ВертикальноеПоложениеВГруппе",
"ShapeRepresentation": "ОтображениеФигуры",
}
def read_json(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a JSON object")
return data
def write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def scalar_text(value: Any) -> str:
if value is True:
return "true"
if value is False:
return "false"
if value is None:
return ""
return str(value).strip()
def comparable(value: Any) -> str:
text = scalar_text(value)
lowered = text.casefold()
if lowered in {"истина", "true", "1"}:
return "true"
if lowered in {"ложь", "false", "0"}:
return "false"
if lowered in {"таблица формы", "таблица", "динамический список"}:
return "table"
if lowered in {"кнопка", "кнопка командной панели", "commandbarbutton"}:
return "button"
if lowered in {"декорация надписи", "labeldecoration"}:
return "label_decoration"
if lowered in {"декорация картинки", "picturedecoration"}:
return "picture_decoration"
if lowered in {"поле переключателя", "radiobuttonfield"}:
return "radio_button_field"
if lowered in {"в дополнительном подменю", "inadditionalsubmenu"}:
return "in_additional_submenu"
if lowered in {"в командной панели", "incommandbar"}:
return "in_command_bar"
if lowered in {"поле", "checkboxfield", "поле флажка"}:
return "field"
if lowered in {"колонка динамического списка", "column", "колонка реквизита"}:
return "column"
if lowered in {"attribute", "реквизит формы"}:
return "attribute"
if lowered in {"event", "событие"}:
return "event"
if lowered in {"command", "команда формы"}:
return "command"
if lowered in {"searchstringaddition", "дополнение строки поиска"}:
return "search_string_addition"
if lowered in {"viewstatusaddition", "дополнение состояния просмотра"}:
return "view_status_addition"
if lowered in {"searchcontroladdition", "дополнение управления поиском"}:
return "search_control_addition"
return lowered
def canonical_property_name(value: Any) -> str:
text = str(value or "").strip()
return PROPERTY_NAME_ALIASES.get(text, text)
def semantic_properties(row: dict[str, Any]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for direct_name, key in (
("Имя", "name"),
("Идентификатор", "id"),
("Заголовок", "title"),
("ПутьКДанным", "path_to_data"),
("Вид", "type_name"),
("Обработчик", "handler"),
):
if key in row and row.get(key) not in {None, ""}:
result[direct_name] = {"name": direct_name, "value": row.get(key), "source": "decoded_direct"}
semantic = row.get("semantic") if isinstance(row.get("semantic"), dict) else {}
for group, props in (semantic.get("groups") or {}).items():
for prop in props or []:
if not isinstance(prop, dict):
continue
name = canonical_property_name(prop.get("name"))
if name and name not in result:
result[name] = {**prop, "group": group}
return result
def iter_sql_rows(form: dict[str, Any]) -> list[dict[str, Any]]:
profile = form.get("profile") if isinstance(form.get("profile"), dict) else {}
rows = []
for section in ("items", "attributes", "parameters", "commands", "tables", "command_bars", "events"):
for row in profile.get(section) or []:
if isinstance(row, dict):
rows.append({**row, "_profile_section": section})
for item in profile.get("items") or []:
if not isinstance(item, dict):
continue
for event in item.get("events") or []:
if not isinstance(event, dict):
continue
event_name = event.get("event_name") or event.get("name")
rows.append(
{
**event,
"name": event_name,
"type_name": "Event",
"_profile_section": "events",
"_event_owner": item.get("name"),
}
)
return rows
def row_key(row: dict[str, Any]) -> tuple[str, str]:
if row.get("id") not in {None, ""}:
return ("id", str(row.get("id")))
return ("name", str(row.get("name") or ""))
def xml_match_keys(xml_item: dict[str, Any]) -> list[tuple[str, str]]:
name = str(xml_item.get("name") or "")
item_id = str(xml_item.get("id") or "")
kind = str(xml_item.get("kind") or "")
keys: list[tuple[str, str]] = []
if kind in {"ExtendedTooltip", "Event", "Column"}:
if name:
keys.append(("name", name.casefold()))
return keys
if kind == "Button":
if name:
keys.append(("name", name.casefold()))
if item_id:
keys.append(("id", item_id))
return keys
if item_id:
keys.append(("id", item_id))
if name:
keys.append(("name", name.casefold()))
return keys
def index_rows(rows: list[dict[str, Any]], *, sections: set[str] | None = None) -> dict[tuple[str, str], dict[str, Any]]:
by_key: dict[tuple[str, str], dict[str, Any]] = {}
by_name: dict[str, dict[str, Any]] = {}
for row in rows:
if sections is not None and str(row.get("_profile_section") or "") not in sections:
continue
key = row_key(row)
if key[1]:
by_key.setdefault(key, row)
name = str(row.get("name") or "")
if name:
by_name.setdefault(name.casefold(), row)
for name, row in by_name.items():
by_key.setdefault(("name", name), row)
return by_key
def xml_sql_sections(xml_item: dict[str, Any]) -> set[str]:
kind = str(xml_item.get("kind") or "")
if kind == "Attribute":
return {"attributes"}
if kind == "Parameter":
return {"parameters"}
if kind == "Column":
return {"items", "tables"}
if kind == "Command":
return {"commands"}
if kind == "Button":
return {"items", "commands"}
if kind in {"CommandBar", "AutoCommandBar"}:
return {"items", "command_bars"}
if kind == "Event":
return {"events"}
return {"items", "commands", "command_bars"}
def xml_profile_from_context_form(form: dict[str, Any], *, max_items: int) -> dict[str, Any] | None:
path_text = str(form.get("form_xml_path") or "")
if not path_text:
structure = form.get("structure") if isinstance(form.get("structure"), dict) else {}
path_text = str(structure.get("form_xml_path") or "")
if not path_text:
return None
path = Path(path_text)
if not path.is_file():
return {"status": "missing_xml_file", "source": {"path": path_text}, "items": []}
profile = decode_form_xml(path, max_items=max_items)
profile["form"]["name"] = form.get("name")
profile["form"]["guid"] = form.get("uuid")
return profile
def matching_sql_form(xml_form: dict[str, Any], sql_forms: list[dict[str, Any]]) -> dict[str, Any] | None:
guid = str((xml_form.get("form") or {}).get("guid") or "").casefold()
name = str((xml_form.get("form") or {}).get("name") or "").casefold()
for form in sql_forms:
if guid and str(form.get("guid") or "").casefold() == guid:
return form
for form in sql_forms:
if name and str(form.get("name") or "").casefold() == name:
return form
return None
def compare_form(sql_form: dict[str, Any] | None, xml_profile: dict[str, Any], *, sample_limit: int) -> dict[str, Any]:
xml_items = [item for item in xml_profile.get("items") or [] if isinstance(item, dict)]
sql_rows = iter_sql_rows(sql_form or {})
sql_any_index = index_rows(sql_rows)
sql_indexes_by_sections: dict[tuple[str, ...], dict[tuple[str, str], dict[str, Any]]] = {}
counts = Counter()
property_route_counts: Counter[tuple[str, str, str, str, str, str]] = Counter()
samples: dict[str, list[dict[str, Any]]] = {
"matched": [],
"missing_sql_item": [],
"xml_only_property": [],
"mismatch": [],
"command_name_match": [],
"command_name_mismatch": [],
"matched_form_property": [],
"xml_only_form_property": [],
"form_mismatch": [],
}
sql_profile = (sql_form or {}).get("profile") if isinstance((sql_form or {}).get("profile"), dict) else {}
sql_form_semantic = sql_profile.get("form_semantic") if isinstance(sql_profile.get("form_semantic"), dict) else None
if sql_form_semantic is None:
sql_form_semantic = form_common_semantic(
[item for item in sql_profile.get("form_parameters") or [] if isinstance(item, dict)],
include_diagnostics=True,
)
enrich_form_common_semantic(
sql_form_semantic,
[item for item in sql_profile.get("items") or [] if isinstance(item, dict)],
)
xml_form = xml_profile.get("form") if isinstance(xml_profile.get("form"), dict) else {}
xml_form_semantic = xml_form.get("semantic") if isinstance(xml_form.get("semantic"), dict) else {}
sql_root_props = semantic_properties({"type_name": "Форма", "semantic": sql_form_semantic})
xml_root_props = semantic_properties({"type_name": "Форма", "semantic": xml_form_semantic})
form_property_routes = []
for name, xml_prop in xml_root_props.items():
sql_prop = sql_root_props.get(name)
if sql_prop is None:
counts["xml_only_form_properties"] += 1
if len(samples["xml_only_form_property"]) < sample_limit:
samples["xml_only_form_property"].append(
{"property": name, "xml_name": xml_prop.get("xml_name"), "xml_value": xml_prop.get("value")}
)
continue
if comparable(sql_prop.get("value")) != comparable(xml_prop.get("value")):
counts["form_mismatches"] += 1
if len(samples["form_mismatch"]) < sample_limit:
samples["form_mismatch"].append(
{
"property": name,
"xml_name": xml_prop.get("xml_name"),
"sql_value": sql_prop.get("value"),
"xml_value": xml_prop.get("value"),
"sql_source": sql_prop.get("source"),
}
)
continue
counts["matched_form_properties"] += 1
if len(samples["matched_form_property"]) < sample_limit:
samples["matched_form_property"].append(
{"property": name, "xml_name": xml_prop.get("xml_name"), "value": xml_prop.get("value")}
)
parameter_indices = sql_prop.get("parameter_indices")
if isinstance(parameter_indices, list) and parameter_indices:
form_property_routes.append(
{
"xml_name": xml_prop.get("xml_name") or name,
"property": name,
"parameter_indices": parameter_indices,
"sql_source": sql_prop.get("source"),
"write_shape": sql_prop.get("write_shape"),
}
)
for xml_item in xml_items:
wanted_sections = xml_sql_sections(xml_item)
section_key = tuple(sorted(wanted_sections))
sql_item_index = sql_indexes_by_sections.setdefault(section_key, index_rows(sql_rows, sections=wanted_sections))
keys = xml_match_keys(xml_item)
sql_row = None
if xml_item.get("kind") == "Column" and xml_item.get("additional_columns_table"):
logical_path = f"{xml_item.get('additional_columns_table')}.{xml_item.get('name')}"
sql_row = next((row for row in sql_rows if comparable(row.get("path_to_data")) == comparable(logical_path)), None)
if sql_row is not None:
counts["logical_column_sql_match"] += 1
if len(samples.setdefault("logical_column_sql_match", [])) < sample_limit:
samples["logical_column_sql_match"].append(
{
"name": xml_item.get("name"),
"id": xml_item.get("id"),
"logical_path": logical_path,
"sql_element": sql_row.get("name"),
"sql_path": sql_row.get("path"),
}
)
if xml_item.get("kind") == "Event" and xml_item.get("owner"):
sql_row = next(
(
row
for row in sql_rows
if str(row.get("_profile_section") or "") == "events"
and str(row.get("_event_owner") or "").casefold() == str(xml_item.get("owner") or "").casefold()
and str(row.get("name") or "").casefold() == str(xml_item.get("name") or "").casefold()
),
None,
)
if sql_row is None and not (xml_item.get("kind") == "Event" and xml_item.get("owner")):
sql_row = next((sql_item_index[key] for key in keys if key in sql_item_index), None)
if sql_row is not None and xml_item.get("kind") == "Column" and xml_item.get("additional_columns_table"):
counts["matched_items"] += 1
if len(samples["matched"]) < sample_limit:
samples["matched"].append(
{
"name": xml_item.get("name"),
"id": xml_item.get("id"),
"sql_path": sql_row.get("path"),
"sql_type": sql_row.get("type_name"),
"xml_kind": xml_item.get("kind_ru"),
"match_by": "additional_columns_data_path",
}
)
continue
if sql_row is None:
fallback = next((sql_any_index[key] for key in keys if key in sql_any_index), None)
if fallback is not None:
counts["non_item_sql_match"] += 1
if len(samples.setdefault("non_item_sql_match", [])) < sample_limit:
samples["non_item_sql_match"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "xml_kind": xml_item.get("kind"), "sql_section": fallback.get("_profile_section"), "sql_type": fallback.get("type_name")})
if sql_row is None:
counts["missing_sql_item"] += 1
if len(samples["missing_sql_item"]) < sample_limit:
samples["missing_sql_item"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "kind": xml_item.get("kind")})
continue
counts["matched_items"] += 1
if len(samples["matched"]) < sample_limit:
samples["matched"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "sql_path": sql_row.get("path"), "sql_type": sql_row.get("type_name"), "xml_kind": xml_item.get("kind_ru")})
sql_props = semantic_properties(sql_row)
xml_props = semantic_properties(xml_item)
sql_command = sql_props.get("ИмяКоманды")
xml_command = xml_props.get("ИмяКоманды")
if sql_command is not None and xml_command is not None:
sql_command_value = sql_command.get("value")
xml_command_value = xml_command.get("value")
command_row = {
"item": xml_item.get("name"),
"id": xml_item.get("id"),
"sql_value": sql_command_value,
"xml_value": xml_command_value,
"sql_path": sql_row.get("path"),
"sql_source": sql_command.get("source"),
}
if comparable(sql_command_value) == comparable(xml_command_value):
counts["command_name_matches"] += 1
if len(samples["command_name_match"]) < sample_limit:
samples["command_name_match"].append(command_row)
else:
counts["command_name_mismatches"] += 1
if len(samples["command_name_mismatch"]) < sample_limit:
samples["command_name_mismatch"].append(command_row)
for name, xml_prop in xml_props.items():
if name == "Идентификатор" and xml_item.get("kind") == "Button" and sql_row.get("_profile_section") == "commands":
continue
xml_value = xml_prop.get("value")
sql_prop = sql_props.get(name)
if sql_prop is None:
counts["xml_only_properties"] += 1
if len(samples["xml_only_property"]) < sample_limit:
samples["xml_only_property"].append({"item": xml_item.get("name"), "id": xml_item.get("id"), "property": name, "xml_value": xml_value, "xml_name": xml_prop.get("xml_name")})
continue
sql_value = sql_prop.get("value")
if comparable(sql_value) != comparable(xml_value):
counts["mismatches"] += 1
if len(samples["mismatch"]) < sample_limit:
samples["mismatch"].append({"item": xml_item.get("name"), "id": xml_item.get("id"), "property": name, "sql_value": sql_value, "xml_value": xml_value, "sql_source": sql_prop.get("source"), "xml_name": xml_prop.get("xml_name")})
else:
counts["matched_properties"] += 1
parameter_index = sql_prop.get("parameter_index")
if parameter_index is not None:
property_route_counts[
(
str(xml_item.get("kind") or ""),
str(xml_prop.get("xml_name") or name),
name,
str(sql_row.get("marker") or ""),
str(parameter_index),
str(sql_prop.get("source") or ""),
)
] += 1
property_routes = [
{
"xml_kind": key[0],
"xml_name": key[1],
"property": key[2],
"sql_marker": key[3] or None,
"parameter_index": int(key[4]) if key[4].lstrip("-").isdigit() else key[4],
"sql_source": key[5] or None,
"matches": match_count,
}
for key, match_count in sorted(property_route_counts.items(), key=lambda item: (-item[1], item[0]))
]
return {
"sql_form": {key: (sql_form or {}).get(key) for key in ("name", "guid", "source") if (sql_form or {}).get(key) is not None},
"xml_form": xml_profile.get("form"),
"xml_source": xml_profile.get("source"),
"counts": dict(counts),
"form_property_routes": form_property_routes,
"property_routes": property_routes,
"samples": samples,
}
def build_report(sql_details: dict[str, Any], xml_context: dict[str, Any], *, sample_limit: int, max_items: int) -> dict[str, Any]:
sql_forms = [form for form in sql_details.get("forms") or [] if isinstance(form, dict)]
comparisons = []
for form in xml_context.get("forms") or []:
if not isinstance(form, dict):
continue
profiles = []
seen_xml_paths: set[str] = set()
xml_profile = xml_profile_from_context_form(form, max_items=max_items)
if xml_profile is not None:
source_path = str((xml_profile.get("source") or {}).get("path") or "").casefold()
if source_path:
seen_xml_paths.add(source_path)
profiles.append(xml_profile)
for overlay in form.get("extension_overlays") or []:
if isinstance(overlay, dict):
overlay_profile = xml_profile_from_context_form(overlay, max_items=max_items)
if overlay_profile is not None:
source_path = str((overlay_profile.get("source") or {}).get("path") or "").casefold()
if source_path and source_path in seen_xml_paths:
continue
if source_path:
seen_xml_paths.add(source_path)
profiles.append(overlay_profile)
for profile in profiles:
comparisons.append(compare_form(matching_sql_form(profile, sql_forms), profile, sample_limit=sample_limit))
totals = Counter()
route_counts: Counter[tuple[str, str, str, str, str, str]] = Counter()
for comparison in comparisons:
totals.update(comparison.get("counts") or {})
for route in comparison.get("property_routes") or []:
route_counts[
(
str(route.get("xml_kind") or ""),
str(route.get("xml_name") or ""),
str(route.get("property") or ""),
str(route.get("sql_marker") or ""),
str(route.get("parameter_index") if route.get("parameter_index") is not None else ""),
str(route.get("sql_source") or ""),
)
] += int(route.get("matches") or 0)
grouped_routes: dict[tuple[str, str, str], list[tuple[tuple[str, str, str, str, str, str], int]]] = {}
for key, match_count in route_counts.items():
grouped_routes.setdefault(key[:3], []).append((key, match_count))
stable_property_routes = []
ambiguous_property_routes = []
for identity, variants in sorted(grouped_routes.items()):
rows = [
{
"xml_kind": key[0],
"xml_name": key[1],
"property": key[2],
"sql_marker": key[3] or None,
"parameter_index": int(key[4]) if key[4].lstrip("-").isdigit() else key[4],
"sql_source": key[5] or None,
"matches": match_count,
}
for key, match_count in sorted(variants, key=lambda item: (-item[1], item[0]))
]
if len(rows) == 1 and rows[0]["matches"] >= 2:
stable_property_routes.append(rows[0])
elif len(rows) > 1:
ambiguous_property_routes.append(
{"xml_kind": identity[0], "xml_name": identity[1], "property": identity[2], "routes": rows}
)
totals["property_routes"] = len(route_counts)
totals["stable_property_routes"] = len(stable_property_routes)
totals["ambiguous_property_routes"] = len(ambiguous_property_routes)
return {
"schema": "onec_form_sql_xml_comparison.v1",
"status": "ok",
"object": sql_details.get("object") or xml_context.get("object"),
"counts": {"forms_compared": len(comparisons), **dict(totals)},
"stable_property_routes": stable_property_routes,
"ambiguous_property_routes": ambiguous_property_routes,
"comparisons": comparisons,
}
def markdown_table_row(values: list[Any]) -> str:
return "| " + " | ".join(str(value).replace("\n", " ") for value in values) + " |"
def render_markdown(report: dict[str, Any]) -> str:
lines = ["# 1C Form SQL/XML Comparison", ""]
obj = report.get("object") or {}
lines.append(f"- Object: `{obj.get('kind')}.{obj.get('name')}`")
counts = report.get("counts") or {}
lines.append(f"- Forms compared: `{counts.get('forms_compared')}`")
lines.append(f"- Matched items: `{counts.get('matched_items', 0)}`")
lines.append(f"- Matched properties: `{counts.get('matched_properties', 0)}`")
lines.append(f"- XML-only properties: `{counts.get('xml_only_properties', 0)}`")
lines.append(f"- Mismatches: `{counts.get('mismatches', 0)}`")
lines.append(f"- CommandName matches: `{counts.get('command_name_matches', 0)}`")
lines.append(f"- CommandName mismatches: `{counts.get('command_name_mismatches', 0)}`")
lines.append("")
for comparison in report.get("comparisons") or []:
xml_form = comparison.get("xml_form") or {}
sql_form = comparison.get("sql_form") or {}
lines.append(f"## {xml_form.get('name') or sql_form.get('name')}")
lines.append("")
lines.append(markdown_table_row(["Metric", "Count"]))
lines.append(markdown_table_row(["---", "---:"]))
for key, value in sorted((comparison.get("counts") or {}).items()):
lines.append(markdown_table_row([key, value]))
for title, key in (("CommandName Matches", "command_name_match"), ("CommandName Mismatches", "command_name_mismatch"), ("XML-Only Properties", "xml_only_property"), ("Mismatches", "mismatch"), ("Missing SQL Items", "missing_sql_item")):
sample = (comparison.get("samples") or {}).get(key) or []
if not sample:
continue
lines.append("")
lines.append(f"### {title}")
lines.append("")
lines.append("```json")
lines.append(json.dumps(sample[:10], ensure_ascii=False, indent=2))
lines.append("```")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="Compare decoded SQL form semantics with Form.xml semantics.")
parser.add_argument("--sql-details", type=Path, required=True)
parser.add_argument("--xml-context", type=Path, required=True)
parser.add_argument("--output-json", type=Path, required=True)
parser.add_argument("--output-markdown", type=Path)
parser.add_argument("--sample-limit", type=int, default=20)
parser.add_argument("--max-items", type=int, default=5000)
args = parser.parse_args()
report = build_report(read_json(args.sql_details), read_json(args.xml_context), sample_limit=args.sample_limit, max_items=args.max_items)
write_json(args.output_json, report)
if args.output_markdown:
args.output_markdown.parent.mkdir(parents=True, exist_ok=True)
args.output_markdown.write_text(render_markdown(report), encoding="utf-8")
print(json.dumps({"schema": "onec_form_sql_xml_comparison_cli_summary.v1", "status": report["status"], "counts": report["counts"], "output_json": str(args.output_json), "output_markdown": str(args.output_markdown) if args.output_markdown else None}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())