Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user