Initial SQL-only 1C adapter baseline

This commit is contained in:
2026-07-22 03:03:47 +03:00
commit e2503b77e7
545 changed files with 184711 additions and 0 deletions
@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Analyze saved-state object changes beyond storage bytes."""
from __future__ import annotations
import argparse
import difflib
import importlib.util
import json
import re
import sys
from pathlib import Path
from typing import Any
WORD_RE = re.compile(r"[\wА-Яа-яЁё]{3,}", re.UNICODE)
BASE64ISH_RE = re.compile(r"^[A-Za-z0-9+/=_-]{24,}$")
REPO_ROOT = Path(__file__).resolve().parents[1]
def load_payload_to_text():
module_path = REPO_ROOT / "plugins" / "1c" / "parser" / "payload.py"
spec = importlib.util.spec_from_file_location("onec_payload", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load payload parser: {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module.payload_to_text
payload_to_text = load_payload_to_text()
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8-sig"))
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 safe_join(root: Path, file_name: str) -> Path:
relative = Path(file_name.replace("\\", "/"))
if relative.is_absolute() or ".." in relative.parts or not str(relative):
raise ValueError(file_name)
return root / relative
def words(text: str) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for match in WORD_RE.finditer(text):
value = match.group(0)
key = value.casefold()
if key not in seen:
result.append(value)
seen.add(key)
return result
def semantic_words(values: list[str], *, limit: int = 40) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
if not value:
continue
if BASE64ISH_RE.match(value):
continue
if value.isdigit():
continue
if len(value) > 80:
continue
has_cyrillic = any("А" <= char <= "я" or char in "Ёё" for char in value)
has_1c_shape = any(marker in value for marker in ("Форма", "Команда", "Реквизит", "Модуль", "Область", "Процедура", "Функция"))
if not has_cyrillic and not has_1c_shape:
continue
key = value.casefold()
if key in seen:
continue
result.append(value)
seen.add(key)
if len(result) >= limit:
break
return result
def classify_payload_part(item: dict[str, Any], file_name: str) -> str:
kind = str(item.get("kind") or "")
suffix = ""
if "." in file_name:
suffix = file_name.rsplit(".", 1)[1]
if kind in {"CommonModule", "ObjectModule", "ManagerModule"} and suffix == "0":
return "bsl_module_text"
if kind == "Form" and not suffix:
return "form_descriptor"
if kind == "Form" and suffix == "0":
return "form_body"
if suffix == "0":
return "primary_payload"
return "metadata_payload"
def textish_from_payload(decoded: dict[str, Any]) -> tuple[str | None, str | None]:
text = decoded.get("text")
if text:
return text, str(decoded.get("encoding") or "")
payload = decoded.get("payload")
if not isinstance(payload, bytes):
return None, None
candidates: list[tuple[str, str, int]] = []
for encoding in ("utf-8-sig", "utf-8", "cp1251", "utf-16-le"):
try:
candidate = payload.decode(encoding, errors="ignore").replace("\x00", "").replace("\ufeff", "")
except Exception:
continue
word_count = len(words(candidate))
cyrillic_count = sum(1 for char in candidate if "А" <= char <= "я" or char in "Ёё")
known_1c_terms = sum(
candidate.count(term)
for term in ("Процедура", "Функция", "Конец", "Если", "Тогда", "Область", "Перем", "Экспорт", "пример")
)
mojibake_penalty = candidate.count("Р") * 8 + candidate.count("С") * 4
score = word_count * 5 + cyrillic_count + known_1c_terms * 500 - mojibake_penalty
if word_count:
candidates.append((candidate, f"{encoding}:lossy", score))
if not candidates:
return None, None
candidates.sort(key=lambda item: item[2], reverse=True)
return candidates[0][0], candidates[0][1]
def common_edges(left: str, right: str) -> tuple[int, int]:
prefix = 0
for a, b in zip(left, right):
if a != b:
break
prefix += 1
suffix = 0
left_tail = left[prefix:]
right_tail = right[prefix:]
for a, b in zip(reversed(left_tail), reversed(right_tail)):
if a != b:
break
suffix += 1
return prefix, suffix
def text_window(text: str, center: int, size: int = 500) -> str:
start = max(center - size // 2, 0)
end = min(center + size // 2, len(text))
return text[start:end].replace("\x00", "")
def line_diff(left: str, right: str, *, limit: int) -> list[str]:
left_lines = left.splitlines()
right_lines = right.splitlines()
diff = list(difflib.unified_diff(left_lines, right_lines, fromfile="active", tofile="saved", lineterm=""))
if len(diff) > limit:
return [*diff[:limit], f"... truncated {len(diff) - limit} lines ..."]
return diff
def analyze_payload(active_path: Path | None, saved_path: Path) -> dict[str, Any]:
saved_raw = saved_path.read_bytes()
saved_decoded = payload_to_text(saved_raw)
active_decoded: dict[str, Any] | None = None
if active_path and active_path.exists():
active_decoded = payload_to_text(active_path.read_bytes())
result: dict[str, Any] = {
"saved_path": str(saved_path),
"active_path": str(active_path) if active_path else None,
"saved": {
"raw_bytes": saved_decoded.get("raw_bytes"),
"payload_bytes": saved_decoded.get("payload_bytes"),
"compression": saved_decoded.get("compression"),
"encoding": saved_decoded.get("encoding"),
},
"active": None,
"text_comparable": False,
}
if active_decoded:
result["active"] = {
"raw_bytes": active_decoded.get("raw_bytes"),
"payload_bytes": active_decoded.get("payload_bytes"),
"compression": active_decoded.get("compression"),
"encoding": active_decoded.get("encoding"),
}
saved_text, saved_text_mode = textish_from_payload(saved_decoded)
active_text, active_text_mode = textish_from_payload(active_decoded) if active_decoded else (None, None)
result["saved"]["text_mode"] = saved_text_mode
if result["active"] is not None:
result["active"]["text_mode"] = active_text_mode
if saved_text is None:
result["summary"] = "Saved payload is not text-decodable."
return result
result["saved_strings_sample"] = words(saved_text)[:80]
if active_text is None:
result["summary"] = "Saved text payload has no active counterpart."
result["text_comparable"] = False
result["saved_text_sample"] = text_window(saved_text, 0)
return result
result["text_comparable"] = True
prefix, suffix = common_edges(active_text, saved_text)
active_words = {value.casefold(): value for value in words(active_text)}
saved_words = {value.casefold(): value for value in words(saved_text)}
added_keys = [key for key in saved_words if key not in active_words]
removed_keys = [key for key in active_words if key not in saved_words]
result["text_diff"] = {
"active_chars": len(active_text),
"saved_chars": len(saved_text),
"delta_chars": len(saved_text) - len(active_text),
"common_prefix_chars": prefix,
"common_suffix_chars": suffix,
"added_words": [saved_words[key] for key in added_keys[:80]],
"removed_words": [active_words[key] for key in removed_keys[:80]],
"active_window": text_window(active_text, prefix),
"saved_window": text_window(saved_text, prefix),
"unified_diff": line_diff(active_text, saved_text, limit=120),
}
result["semantic_hints"] = {
"added_terms": semantic_words(result["text_diff"]["added_words"]),
"removed_terms": semantic_words(result["text_diff"]["removed_words"]),
}
result["summary"] = "Text payload differs." if active_text != saved_text else "Text payload matches."
return result
def storage_root(saved_table: str, active_table: str, roots: dict[str, Path]) -> tuple[Path | None, Path | None]:
saved_root = roots.get(saved_table)
active_root = roots.get(active_table)
return saved_root, active_root
def build_extension_cas_map(summary_path: Path | None) -> dict[tuple[str, str], str]:
if not summary_path or not summary_path.exists():
return {}
data = load_json(summary_path)
result: dict[tuple[str, str], str] = {}
for extension in data.get("extensions") or []:
extension_name = str(extension.get("extension_name") or "")
for obj in extension.get("sample_objects") or []:
for part in obj.get("parts") or []:
object_id = str(part.get("object_id") or "").casefold()
cas_key = str(part.get("cas_key") or "")
if extension_name and object_id and cas_key:
result[(extension_name.casefold(), object_id)] = cas_key
return result
def extension_object_id_from_saved_file(file_name: str) -> str | None:
if "__" not in file_name:
return None
object_id = file_name.split("__", 1)[1]
if object_id == "configinfo":
return None
return object_id.casefold()
def active_extension_path(item: dict[str, Any], file_name: str, extension_cas_map: dict[tuple[str, str], str], config_cas_all_dir: Path | None) -> tuple[Path | None, str | None]:
if not config_cas_all_dir:
return None, None
extension = str(item.get("extension") or "").casefold()
object_id = extension_object_id_from_saved_file(file_name)
if not extension or not object_id:
return None, None
cas_key = extension_cas_map.get((extension, object_id))
if not cas_key:
return None, None
path = config_cas_all_dir / cas_key
return (path if path.exists() else None), cas_key
def analyze(comparison: dict[str, Any], roots: dict[str, Path], *, extension_manifest_summary: Path | None = None, config_cas_all_dir: Path | None = None) -> dict[str, Any]:
extension_cas_map = build_extension_cas_map(extension_manifest_summary)
objects = []
for item in comparison.get("object_changes") or []:
details = []
for storage in item.get("storage") or []:
saved_root, active_root = storage_root(str(storage.get("saved_table")), str(storage.get("active_table")), roots)
file_name = str(storage.get("file_name") or "")
if not saved_root:
details.append({"file_name": file_name, "error": f"Missing saved root for {storage.get('saved_table')}"})
continue
try:
saved_path = safe_join(saved_root, file_name)
active_path = safe_join(active_root, file_name) if active_root else None
except ValueError:
details.append({"file_name": file_name, "error": "Unsafe storage file name."})
continue
if not saved_path.exists():
details.append({"file_name": file_name, "error": f"Saved payload file is missing: {saved_path}"})
continue
active_cas_key = None
if not (active_path and active_path.exists()) and storage.get("saved_table") == "ConfigCASSave":
active_path, active_cas_key = active_extension_path(item, file_name, extension_cas_map, config_cas_all_dir)
payload_detail = analyze_payload(active_path if active_path and active_path.exists() else None, saved_path)
details.append({
"file_name": file_name,
"payload_role": classify_payload_part(item, file_name),
"saved_table": storage.get("saved_table"),
"active_table": storage.get("active_table"),
"active_exists": storage.get("active_exists"),
"active_cas_key": active_cas_key,
"payload": payload_detail,
})
objects.append({
"full_name": item.get("full_name"),
"layer": item.get("layer"),
"extension": item.get("extension"),
"kind": item.get("kind"),
"kind_ru": item.get("kind_ru"),
"name": item.get("name"),
"synonym": item.get("synonym"),
"change_state": item.get("change_state"),
"details": details,
})
return {
"schema": "onec_saved_state_object_detail.v1",
"source_schema": comparison.get("schema"),
"database": comparison.get("database"),
"view": comparison.get("view"),
"object_details": objects,
"counts": {
"objects": len(objects),
"details": sum(len(item.get("details") or []) for item in objects),
},
"safety": {
"read_only": True,
"sql_write_performed": False,
"public_terms_are_1c_objects": True,
},
"active_extension_resolution": {
"extension_manifest_summary": str(extension_manifest_summary) if extension_manifest_summary else None,
"config_cas_all_dir": str(config_cas_all_dir) if config_cas_all_dir else None,
"mapped_parts": len(extension_cas_map),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Analyze saved-state object changes beyond storage bytes.")
parser.add_argument("--comparison", type=Path, required=True)
parser.add_argument("--config-save-dir", type=Path, required=True)
parser.add_argument("--config-dir", type=Path, required=True)
parser.add_argument("--config-cas-save-dir", type=Path, required=True)
parser.add_argument("--config-cas-dir", type=Path, required=True)
parser.add_argument("--extension-manifest-summary", type=Path)
parser.add_argument("--config-cas-all-dir", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
roots = {
"ConfigSave": args.config_save_dir,
"Config": args.config_dir,
"ConfigCASSave": args.config_cas_save_dir,
"ConfigCAS": args.config_cas_dir,
}
result = analyze(
load_json(args.comparison),
roots,
extension_manifest_summary=args.extension_manifest_summary,
config_cas_all_dir=args.config_cas_all_dir,
)
if args.output:
write_json(args.output, result)
print(json.dumps({"output": str(args.output) if args.output else None, "schema": result["schema"], "counts": result["counts"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())