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,314 @@
#!/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())