#!/usr/bin/env python3 """Validate editable 1C patch workspace files at the BSL/Form.xml level.""" from __future__ import annotations import argparse import json import re import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path from typing import Any from check_1c_patch_workspace_integrity import check_workspace WORD = r"А-Яа-яA-Za-z0-9_" ROUTINE_RE = re.compile(rf"(? dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) def issue(severity: str, code: str, message: str, *, path: Path | str | None = None, line: int | None = None, detail: dict[str, Any] | None = None) -> dict[str, Any]: result: dict[str, Any] = {"severity": severity, "code": code, "message": message} if path is not None: result["path"] = str(path) if line is not None: result["line"] = line if detail: result["detail"] = detail return result def local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] def child_text(element: ET.Element, name: str) -> str | None: for child in element: if local_name(child.tag) == name: return child.text return None def read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8-sig") except UnicodeDecodeError: return path.read_text(encoding="cp1251", errors="replace") def code_segments(line: str, *, in_string: bool) -> tuple[str, bool]: result = [] index = 0 while index < len(line): if in_string: quote = line.find('"', index) if quote < 0: return "".join(result), True if quote + 1 < len(line) and line[quote + 1] == '"': index = quote + 2 continue in_string = False index = quote + 1 continue slash = line.find("//", index) quote = line.find('"', index) if slash >= 0 and (quote < 0 or slash < quote): result.append(line[index:slash]) return "".join(result), False if quote < 0: result.append(line[index:]) return "".join(result), False result.append(line[index:quote]) in_string = True index = quote + 1 return "".join(result), in_string def routine_names(text: str) -> dict[str, list[int]]: names: dict[str, list[int]] = {} in_string = False for number, line in enumerate(text.splitlines(), start=1): code, in_string = code_segments(line, in_string=in_string) for match in ROUTINE_RE.finditer(code): names.setdefault(match.group(2).casefold(), []).append(number) return names def validate_bsl(path: Path) -> tuple[list[dict[str, Any]], dict[str, Any]]: findings: list[dict[str, Any]] = [] text = read_text(path) routines = routine_names(text) for name, lines in routines.items(): if len(lines) > 1: findings.append(issue("error", "duplicate_bsl_routine", f"Duplicate BSL routine: {name}", path=path, line=lines[1], detail={"lines": lines})) routine_stack: list[tuple[str, int]] = [] block_stack: list[tuple[str, int]] = [] in_string = False for number, line in enumerate(text.splitlines(), start=1): code, in_string = code_segments(line, in_string=in_string) if ROUTINE_RE.search(code): routine_stack.append(("routine", number)) for match in END_ROUTINE_RE.finditer(code): if not routine_stack: findings.append(issue("error", "unexpected_routine_end", f"Unexpected {match.group(1)}.", path=path, line=number)) else: routine_stack.pop() for match in TOKEN_RE.finditer(code): token = match.group(1) normalized = next((key for key in (*BLOCK_STARTS.keys(), *BLOCK_ENDS.keys()) if key.casefold() == token.casefold()), token) if normalized in BLOCK_STARTS: block_stack.append((normalized, number)) elif normalized in BLOCK_ENDS: allowed = BLOCK_ENDS[normalized] if not block_stack: findings.append(issue("error", "unexpected_block_end", f"Unexpected {normalized}.", path=path, line=number)) else: start, _start_line = block_stack[-1] if start not in allowed: findings.append(issue("error", "mismatched_bsl_block", f"{normalized} closes {start}.", path=path, line=number)) block_stack.pop() for _kind, line in routine_stack: findings.append(issue("error", "unclosed_bsl_routine", "Unclosed BSL procedure/function.", path=path, line=line)) for kind, line in block_stack: findings.append(issue("error", "unclosed_bsl_block", f"Unclosed BSL block: {kind}.", path=path, line=line)) return findings, { "path": str(path), "routine_count": sum(len(lines) for lines in routines.values()), "unique_routine_count": len(routines), "routines": sorted(routines), } def iter_direct(container: ET.Element, container_name: str, item_name: str) -> list[ET.Element]: result = [] for child in container: if local_name(child.tag) != container_name: continue for item in child: if local_name(item.tag) == item_name: result.append(item) return result def parse_form(path: Path) -> tuple[list[dict[str, Any]], dict[str, Any]]: findings: list[dict[str, Any]] = [] try: root = ET.parse(path).getroot() except ET.ParseError as exc: return [issue("error", "invalid_form_xml", f"Invalid Form.xml: {exc}", path=path)], {"path": str(path), "parse_error": str(exc)} commands = iter_direct(root, "Commands", "Command") command_names = [cmd.attrib.get("name") for cmd in commands if cmd.attrib.get("name")] command_ids = [cmd.attrib.get("id") for cmd in commands if cmd.attrib.get("id")] command_by_name = {name: cmd for name, cmd in zip(command_names, commands) if name} command_by_id = {cmd.attrib.get("id"): cmd for cmd in commands if cmd.attrib.get("id")} for name, count in Counter(command_names).items(): if count > 1: findings.append(issue("error", "duplicate_form_command", f"Duplicate form command: {name}", path=path)) for command_id, count in Counter(command_ids).items(): if count > 1: findings.append(issue("error", "duplicate_form_command_id", f"Duplicate form command id: {command_id}", path=path)) actions: list[str] = [] for command in commands: name = command.attrib.get("name") action = child_text(command, "Action") if action: actions.append(action.strip()) else: findings.append(issue("warning", "form_command_without_action", f"Form command has no action: {name}", path=path, detail={"command": name})) item_count = 0 unresolved_items = [] for element in root.iter(): tag = local_name(element.tag) if tag in {"Button", "ButtonGroup", "Popup", "UsualGroup", "CommandBar", "Table", "TableColumn", "InputField", "CheckBoxField", "LabelDecoration", "PictureDecoration"}: item_count += 1 command_name = child_text(element, "CommandName") if not command_name or command_name == "0": continue if command_name.startswith("Form.StandardCommand.") or command_name.startswith("StandardCommand."): continue command_key = command_name.removeprefix("Form.Command.") if command_key not in command_by_name and command_key not in command_by_id: unresolved_items.append({"item": element.attrib.get("name"), "command_name": command_name}) for unresolved in unresolved_items[:20]: findings.append(issue("error", "unresolved_form_item_command", "Form item references an unknown command.", path=path, detail=unresolved)) return findings, { "path": str(path), "command_count": len(commands), "item_count": item_count, "commands": [{"name": command.attrib.get("name"), "id": command.attrib.get("id"), "action": child_text(command, "Action")} for command in commands], "actions": sorted(set(actions)), "unresolved_item_command_count": len(unresolved_items), } def record_path(workspace: Path, record: dict[str, Any]) -> Path: return workspace / "working" / Path(str(record.get("relative_path") or "")) def expected_form_module_rel(form_rel: str) -> str: rel = form_rel.replace("\\", "/") if rel.endswith("/Ext/Form.xml"): return rel[: -len("/Ext/Form.xml")] + "/Ext/Form/Module.bsl" return "" def validate_workspace(workspace: Path) -> dict[str, Any]: integrity = check_workspace(workspace) findings: list[dict[str, Any]] = [] files: list[dict[str, Any]] = [] if not integrity.get("passed"): findings.append(issue("error", "workspace_integrity_failed", "Workspace integrity failed; semantic validation skipped.", path=workspace)) return build_result(workspace, findings, files, integrity=integrity) manifest = load_json(workspace / "manifest.json") records = manifest.get("files") or [] bsl_by_rel: dict[str, dict[str, Any]] = {} form_files: list[tuple[dict[str, Any], dict[str, Any]]] = [] for record in records: path = record_path(workspace, record) if record.get("kind") == "bsl_module": bsl_findings, summary = validate_bsl(path) findings.extend(bsl_findings) files.append({"relative_path": record.get("relative_path"), "kind": "bsl_module", "summary": summary}) bsl_by_rel[str(record.get("relative_path") or "").replace("\\", "/")] = summary elif record.get("kind") == "form_xml": form_findings, summary = parse_form(path) findings.extend(form_findings) files.append({"relative_path": record.get("relative_path"), "kind": "form_xml", "summary": summary}) form_files.append((record, summary)) for record, form_summary in form_files: module_rel = expected_form_module_rel(str(record.get("relative_path") or "")) if not module_rel: continue module_summary = bsl_by_rel.get(module_rel) if not module_summary: findings.append(issue("warning", "missing_form_module_in_workspace", "Form module is not present in the patch workspace; command handlers were not checked.", detail={"form": record.get("relative_path"), "expected_module": module_rel})) continue routines = {name.casefold() for name in module_summary.get("routines") or []} for action in form_summary.get("actions") or []: if action and action.casefold() not in routines: findings.append(issue("error", "missing_form_command_handler", f"Form command action has no matching module routine: {action}", path=record_path(workspace, record), detail={"module": module_rel, "action": action})) return build_result(workspace, findings, files, integrity=integrity) def build_result(workspace: Path, findings: list[dict[str, Any]], files: list[dict[str, Any]], *, integrity: dict[str, Any]) -> dict[str, Any]: errors = [row for row in findings if row.get("severity") == "error"] warnings = [row for row in findings if row.get("severity") == "warning"] return { "schema": "onec_patch_workspace_semantic_validation.v1", "workspace": str(workspace), "passed": not errors, "integrity": { "schema": integrity.get("schema"), "passed": integrity.get("passed"), "counts": integrity.get("counts"), }, "findings": findings, "files": files, "counts": { "files": len(files), "errors": len(errors), "warnings": len(warnings), }, } def main() -> int: parser = argparse.ArgumentParser(description="Validate BSL/Form.xml semantics in a 1C patch workspace.") parser.add_argument("--workspace", type=Path, required=True) parser.add_argument("--output", type=Path) args = parser.parse_args() result = validate_workspace(args.workspace) output = json.dumps(result, ensure_ascii=False, indent=2) + "\n" if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(output, encoding="utf-8") print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False)) return 0 if result["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())