Files
llm/scripts/validate_1c_patch_workspace_semantics.py
T

315 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"(?<![{WORD}])(Процедура|Функция)\s+([А-Яа-яA-Za-z_][{WORD}]*)", re.IGNORECASE)
END_ROUTINE_RE = re.compile(rf"(?<![{WORD}])(КонецПроцедуры|КонецФункции)(?![{WORD}])", re.IGNORECASE)
BLOCK_STARTS = {
"Если": "КонецЕсли",
"Для": "КонецЦикла",
"Пока": "КонецЦикла",
"Попытка": "КонецПопытки",
}
BLOCK_ENDS = {
"КонецЕсли": {"Если"},
"КонецЦикла": {"Для", "Пока"},
"КонецПопытки": {"Попытка"},
}
TOKEN_RE = re.compile(
rf"(?<![{WORD}])(Если|Для|Пока|Попытка|КонецЕсли|КонецЦикла|КонецПопытки)(?![{WORD}])",
re.IGNORECASE,
)
def load_json(path: Path) -> 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())