315 lines
13 KiB
Python
315 lines
13 KiB
Python
#!/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())
|