Files
cc-1c-skills/.augment/skills/cfe-patch-method/scripts/cfe-patch-method.py
T

1449 lines
65 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
# cfe-patch-method v2.12 — Source-aware method interceptor for 1C extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
TYPE_DIR_MAP = {
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
"ExchangePlan": "ExchangePlans", "ChartOfAccounts": "ChartsOfAccounts",
"ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
"ChartOfCalculationTypes": "ChartsOfCalculationTypes",
"BusinessProcess": "BusinessProcesses", "Task": "Tasks",
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
# Прощающий ввод: имя каталога принимается наравне с именем типа (Catalogs.X ≡ Catalog.X) —
# PS1-порт так умел с самого начала, PY отставал.
"Catalogs": "Catalogs",
"Documents": "Documents",
"Enums": "Enums",
"CommonModules": "CommonModules",
"Reports": "Reports",
"DataProcessors": "DataProcessors",
"ExchangePlans": "ExchangePlans",
"ChartsOfAccounts": "ChartsOfAccounts",
"ChartsOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
"ChartsOfCalculationTypes": "ChartsOfCalculationTypes",
"BusinessProcesses": "BusinessProcesses",
"Tasks": "Tasks",
"InformationRegisters": "InformationRegisters",
"AccumulationRegisters": "AccumulationRegisters",
"AccountingRegisters": "AccountingRegisters",
"CalculationRegisters": "CalculationRegisters",
}
# accept plural forms too
for _v in list(TYPE_DIR_MAP.values()):
TYPE_DIR_MAP[_v] = _v
# InterceptorType -> Russian decorator keyword
DECORATOR_MAP = {
"Before": "Перед", "After": "После", "Instead": "Вместо",
"ModificationAndControl": "ИзменениеИКонтроль",
}
# InterceptorType -> keyword table key (emission goes through the table, the map above stays
# the normalized value the logic compares against)
DECORATOR_KEY = {
"Before": "Before", "After": "After", "Instead": "Around",
"ModificationAndControl": "Control",
}
# Built-in language keywords in both spellings. The platform accepts either one in any module
# (pairs taken from the platform string tables), so a module written in English is ordinary
# source, not a broken one: we must read both and emit the spelling we read.
def bsl_keywords():
return {
"ru": {
"Async": "Асинх", "Proc": "Процедура", "EndProc": "КонецПроцедуры",
"Func": "Функция", "EndFunc": "КонецФункции", "Val": "Знач",
"Region": "Область", "EndRegion": "КонецОбласти",
"If": "Если", "Then": "Тогда", "ElsIf": "ИначеЕсли", "Else": "Иначе", "EndIf": "КонецЕсли",
# Platform spells the negation "Не"; we emit "НЕ" as this skill always has —
# the language is case-insensitive, and the lower case would churn every snapshot.
"And": "И", "Or": "Или", "Not": "НЕ",
"Insert": "Вставка", "EndInsert": "КонецВставки", "Delete": "Удаление", "EndDelete": "КонецУдаления",
"Before": "Перед", "After": "После", "Around": "Вместо", "Control": "ИзменениеИКонтроль",
"Proceed": "ПродолжитьВызов", "Return": "Возврат",
# Not a keyword: name of the local the generated Instead-stub declares. Lives here so
# that the language of emitted text is decided in exactly one place.
"ResultVar": "Результат",
"Directives": ["НаКлиенте", "НаСервере", "НаСервереБезКонтекста", "НаКлиентеНаСервереБезКонтекста", "НаКлиентеНаСервере"],
},
"en": {
"Async": "Async", "Proc": "Procedure", "EndProc": "EndProcedure",
"Func": "Function", "EndFunc": "EndFunction", "Val": "Val",
"Region": "Region", "EndRegion": "EndRegion",
"If": "If", "Then": "Then", "ElsIf": "ElsIf", "Else": "Else", "EndIf": "EndIf",
"And": "And", "Or": "Or", "Not": "Not",
"Insert": "Insert", "EndInsert": "EndInsert", "Delete": "Delete", "EndDelete": "EndDelete",
"Before": "Before", "After": "After", "Around": "Around", "Control": "ChangeAndValidate",
"Proceed": "ProceedWithCall", "Return": "Return",
"ResultVar": "Result",
"Directives": ["AtClient", "AtServer", "AtServerNoContext", "AtClientAtServerNoContext", "AtClientAtServer"],
},
}
BSL_KW = bsl_keywords()
# Regex alternation for a keyword in both spellings
def kw_alt(key):
return '(?:' + BSL_KW["ru"][key] + '|' + BSL_KW["en"][key] + ')'
# Spelling language of a recognized keyword. English is the discriminator: a Russian keyword
# never equals an English one, so anything else is 'ru'.
def bsl_lang(word):
w = word.strip().lower()
for k in ("Async", "Proc", "EndProc", "Func", "EndFunc", "Region", "If", "Insert", "Delete",
"Before", "After", "Around", "Control"):
if BSL_KW["en"][k].lower() == w:
return "en"
return "ru"
# Edit marker kind at the start of a line: Insert/Delete/EndInsert/EndDelete, None if none.
# The marker owns the start of the line only — a trailing comment does not bother the platform
# and must not bother us either.
def bsl_marker_kind(line):
kw = BSL_KW
for k in ("EndInsert", "EndDelete", "Insert", "Delete"):
if re.match(r'^\s*#(?:' + kw["ru"][k] + '|' + kw["en"][k] + r')\b', line, re.IGNORECASE):
return k
return None
CONTEXT_RE = re.compile(
r'^&(' + '|'.join(BSL_KW["ru"]["Directives"] + BSL_KW["en"]["Directives"]) + r')\s*$',
re.IGNORECASE
)
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
def detect_format_version(d):
while d:
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
ext_path = d + ".xml"
if os.path.isfile(ext_path):
with open(ext_path, "r", encoding="utf-8-sig") as f:
ext_head = f.read(2000)
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
if m:
return m.group(1)
cfg_path = os.path.join(d, "Configuration.xml")
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8-sig") as f:
head = f.read(2000)
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
if m:
return m.group(1)
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return "2.17"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def build_property_state_xml(property_name, indent):
return "\n".join([
f"{indent}<xr:PropertyState>",
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
f"{indent}\t<xr:State>Extended</xr:State>",
f"{indent}</xr:PropertyState>",
])
def set_property_state_flag(obj_file, property_name, format_version):
if format_rank(format_version) < 219:
return
if not os.path.isfile(obj_file):
return
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
text = fh.read()
nl = "\r\n" if "\r\n" in text else "\n"
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
if empty and (not opened or empty.start() < opened.start()):
ind = empty.group(1)
block = build_property_state_xml(property_name, ind + "\t")
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
text = text[:empty.start()] + replacement + text[empty.end():]
elif opened:
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
return
ind = opened.group(1)
block = build_property_state_xml(property_name, ind + "\t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
close_at = opened.end() - len("</InternalInfo>") - len(ind)
text = text[:close_at] + block + nl + text[close_at:]
else:
return
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
fh.write(text)
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
def get_module_flag_target(rel_parts, ext_root):
if len(rel_parts) != 4 or rel_parts[2] != "Ext":
return None
prop = os.path.splitext(rel_parts[3])[0]
return {
"file": os.path.join(ext_root, rel_parts[0], f"{rel_parts[1]}.xml"),
"property": prop,
}
def get_module_rel_path(module_path):
parts = module_path.split(".")
if len(parts) < 2:
raise ValueError(
"Invalid ModulePath format: %s. Expected: Type.Name.Module, "
"Type.Name.Form.FormName or CommonModule.Name" % module_path
)
obj_type = parts[0]
obj_name = parts[1]
if obj_type not in TYPE_DIR_MAP:
raise ValueError("Unknown object type: %s" % obj_type)
dir_name = TYPE_DIR_MAP[obj_type]
if obj_type == "CommonModule":
return [dir_name, obj_name, "Ext", "Module.bsl"]
if len(parts) >= 4 and parts[2] == "Form":
form_name = parts[3]
return [dir_name, obj_name, "Forms", form_name, "Ext", "Form", "Module.bsl"]
if len(parts) >= 3:
module_name = parts[2]
module_file_map = {
"ObjectModule": "ObjectModule.bsl",
"ManagerModule": "ManagerModule.bsl",
"RecordSetModule": "RecordSetModule.bsl",
"CommandModule": "CommandModule.bsl",
"ValueManagerModule": "ValueManagerModule.bsl",
}
return [dir_name, obj_name, "Ext", module_file_map.get(module_name, module_name + ".bsl")]
raise ValueError("Invalid ModulePath format: %s" % module_path)
def get_rel_parts_from_file_path(path):
segs = [s for s in path.replace("\\", "/").split("/") if s]
anchors = set(TYPE_DIR_MAP.values())
for i in range(len(segs)):
if segs[i] in anchors:
return segs[i:]
return None
def read_lines(path):
with open(path, "r", encoding="utf-8-sig", newline="") as f:
text = f.read()
text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
if lines and lines[-1] == "":
lines.pop()
return lines
def write_bsl(path, lines):
text = "\r\n".join(lines) + "\r\n"
with open(path, "w", encoding="utf-8-sig", newline="") as f:
f.write(text)
def split_top_level(text, sep=","):
result = []
depth = 0
in_str = False
buf = []
for ch in text:
if in_str:
buf.append(ch)
if ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
buf.append(ch)
continue
if ch == '(':
depth += 1
buf.append(ch)
continue
if ch == ')':
depth -= 1
buf.append(ch)
continue
if ch == sep and depth == 0:
result.append("".join(buf))
buf = []
continue
buf.append(ch)
result.append("".join(buf))
return result
def is_context_directive(trimmed):
return bool(CONTEXT_RE.match(trimmed))
def read_signature(lines, start_idx):
"""Return (params_text, end_line_idx) or None."""
depth = 0
in_str = False
open_found = False
params = []
for li in range(start_idx, len(lines)):
line = lines[li]
for ch in line:
if in_str:
if open_found:
params.append(ch)
if ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
if open_found:
params.append(ch)
continue
if ch == '(':
depth += 1
if not open_found:
open_found = True
else:
params.append(ch)
continue
if ch == ')':
depth -= 1
if depth == 0:
return ("".join(params), li)
params.append(ch)
continue
if open_found:
params.append(ch)
if open_found and depth >= 1:
params.append("\r\n")
return None
def effective_condition(frame, lang):
kw_not = BSL_KW[lang]["Not"]
kw_and = BSL_KW[lang]["And"]
conds = frame["conds"]
n = len(conds)
if frame["in_else"]:
return (" %s " % kw_and).join("%s (%s)" % (kw_not, c) for c in conds)
if n == 1:
return conds[0]
parts = ["%s (%s)" % (kw_not, conds[j]) for j in range(n - 1)]
parts.append(conds[n - 1])
return (" %s " % kw_and).join(parts)
def get_enclosing_chain(lines, target_idx, lang):
re_region = re.compile(r'^#' + kw_alt("Region") + r'\s+(\S+)', re.IGNORECASE)
re_end_region = re.compile(r'^#' + kw_alt("EndRegion"), re.IGNORECASE)
re_if = re.compile(r'^#' + kw_alt("If") + r'\s+(.+?)\s+' + kw_alt("Then"), re.IGNORECASE)
re_elsif = re.compile(r'^#' + kw_alt("ElsIf") + r'\s+(.+?)\s+' + kw_alt("Then"), re.IGNORECASE)
re_else = re.compile(r'^#' + kw_alt("Else") + r'(\s|$)', re.IGNORECASE)
re_endif = re.compile(r'^#' + kw_alt("EndIf"), re.IGNORECASE)
stack = []
for i in range(target_idx):
t = lines[i].strip()
m = re_region.match(t)
if m:
stack.append({"kind": "region", "name": m.group(1)})
continue
if re_end_region.match(t):
for k in range(len(stack) - 1, -1, -1):
if stack[k]["kind"] == "region":
del stack[k]
break
continue
m = re_if.match(t)
if m:
stack.append({"kind": "if", "conds": [m.group(1).strip()], "in_else": False})
continue
m = re_elsif.match(t)
if m:
for k in range(len(stack) - 1, -1, -1):
if stack[k]["kind"] == "if":
stack[k]["conds"].append(m.group(1).strip())
stack[k]["in_else"] = False
break
continue
if re_else.match(t):
for k in range(len(stack) - 1, -1, -1):
if stack[k]["kind"] == "if":
stack[k]["in_else"] = True
break
continue
if re_endif.match(t):
for k in range(len(stack) - 1, -1, -1):
if stack[k]["kind"] == "if":
del stack[k]
break
continue
chain = []
for f in stack:
if f["kind"] == "region":
chain.append({"kind": "region", "name": f["name"]})
else:
chain.append({"kind": "if", "cond": effective_condition(f, lang)})
return chain
def extract_method(lines, method_name):
decl_re = re.compile(
r'^\s*(' + kw_alt("Async") + r'\s+)?(' + kw_alt("Proc") + '|' + kw_alt("Func") + r')\s+('
+ re.escape(method_name) + r')\s*\(',
re.IGNORECASE,
)
for i in range(len(lines)):
m = decl_re.match(lines[i])
if not m:
continue
is_async = bool(m.group(1))
keyword = m.group(2)
canonical = m.group(3)
lang = bsl_lang(keyword)
is_function = keyword.lower() in (BSL_KW["ru"]["Func"].lower(), BSL_KW["en"]["Func"].lower())
sig = read_signature(lines, i)
if not sig:
raise ValueError("Не удалось разобрать сигнатуру метода '%s'" % method_name)
params_text, sig_end = sig
param_names = []
if params_text.strip():
for seg in split_top_level(params_text):
s = re.sub(r'^' + kw_alt("Val") + r'\s+', '', seg.strip(), flags=re.IGNORECASE)
mm = re.match(r'^([\w]+)', s)
if mm:
param_names.append(mm.group(1))
end_re = re.compile(r'^\s*' + (kw_alt("EndFunc") if is_function else kw_alt("EndProc")) + r'\b',
re.IGNORECASE)
body_start = sig_end + 1
body_end = -1
for j in range(body_start, len(lines)):
if end_re.match(lines[j]):
body_end = j
break
if body_end < 0:
raise ValueError("Не найден конец метода '%s'" % method_name)
body_lines = lines[body_start:body_end]
context = ""
if i >= 1:
prev = lines[i - 1].strip()
if is_context_directive(prev):
context = prev
chain = get_enclosing_chain(lines, i, lang)
return {
"canonical": canonical,
"lang": lang,
"is_function": is_function,
"is_async": is_async,
"params_text": params_text,
"param_names": param_names,
"context": context,
"body_lines": body_lines,
"chain": chain,
"decl_idx": i,
"sig_end_idx": sig_end,
"body_end_idx": body_end,
}
return None
# Parse interceptor annotations present in a module. Type is normalized to the Russian spelling
# so everything downstream stays single-language; raw keeps what the file actually says.
def get_interceptors(lines):
kw = BSL_KW
keys = ("Before", "After", "Control", "Around")
alt = []
norm = {}
for k in keys:
alt.append(kw["ru"][k])
alt.append(kw["en"][k])
norm[kw["ru"][k].lower()] = kw["ru"][k]
norm[kw["en"][k].lower()] = kw["ru"][k]
pat = re.compile(r'^&(' + '|'.join(alt) + r')\("([^"]+)"\)', re.IGNORECASE)
result = []
for i in range(len(lines)):
m = pat.match(lines[i].strip())
if m:
raw = m.group(1)
result.append({"type": norm[raw.lower()], "raw": raw, "method": m.group(2), "line": i})
return result
def get_proc_names(lines):
names = []
pat = re.compile(r'^\s*(?:' + kw_alt("Async") + r'\s+)?(?:' + kw_alt("Proc") + '|' + kw_alt("Func")
+ r')\s+([\w]+)\s*\(', re.IGNORECASE)
for line in lines:
m = pat.match(line)
if m:
names.append(m.group(1))
return names
def build_interceptor_core(method, interceptor_type, interceptor_name):
kw = BSL_KW[method["lang"]]
decorator = kw[DECORATOR_KEY[interceptor_type]]
async_prefix = (kw["Async"] + " ") if method["is_async"] else ""
keyword = kw["Func"] if method["is_function"] else kw["Proc"]
end_keyword = kw["EndFunc"] if method["is_function"] else kw["EndProc"]
lines = []
if method["context"]:
lines.append(method["context"])
lines.append('&%s("%s")' % (decorator, method["canonical"]))
lines.append("%s%s %s(%s)" % (async_prefix, keyword, interceptor_name, method["params_text"]))
if interceptor_type == "Before":
lines.append("\t// TODO: код перед вызовом оригинального метода")
elif interceptor_type == "After":
lines.append("\t// TODO: код после вызова оригинального метода")
elif interceptor_type == "Instead":
names_joined = ", ".join(method["param_names"])
if method["is_function"]:
lines.append("\t%s = %s(%s);" % (kw["ResultVar"], kw["Proceed"], names_joined))
lines.append("\t// TODO: доработать поведение")
lines.append("\t%s %s;" % (kw["Return"], kw["ResultVar"]))
else:
lines.append("\t%s(%s);" % (kw["Proceed"], names_joined))
lines.append("\t// TODO: доработать поведение")
elif interceptor_type == "ModificationAndControl":
lines.extend(method["body_lines"])
lines.append(end_keyword)
return lines
def build_wrapped_block(chain_arr, core, lang):
"""Wrap core with region/preprocessor lines, adding blank lines around each boundary."""
kw = BSL_KW[lang]
b = []
for w in chain_arr:
b.append(("#%s %s" % (kw["Region"], w["name"])) if w["kind"] == "region"
else ("#%s %s %s" % (kw["If"], w["cond"], kw["Then"])))
b.append("")
b.extend(core)
for w in reversed(chain_arr):
b.append("")
b.append(("#" + kw["EndRegion"]) if w["kind"] == "region" else ("#" + kw["EndIf"]))
return b
def normalize(line):
return re.sub(r'\s+', ' ', line).strip()
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
def control_key(lines):
return "\n".join([k for k in (x.strip() for x in lines) if k != ""])
# Parameter count of a signature params text. The platform compares only the number of
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
def param_count(params_text):
if not params_text or not params_text.strip():
return 0
return len([p for p in split_top_level(params_text) if p.strip()])
def parse_marked_body(body_lines):
v1 = []
ops = []
i = 0
n = len(body_lines)
while i < n:
kind = bsl_marker_kind(body_lines[i])
if kind == "Insert":
# Marker lines are kept verbatim: they carry the author's spelling, indent and — since
# a tail comment is legal — the note explaining the edit. Re-emitting a bare keyword
# would silently drop that note on -Actualize.
open_line = body_lines[i]
ins = []
i += 1
while i < n and bsl_marker_kind(body_lines[i]) != "EndInsert":
ins.append(body_lines[i])
i += 1
close_line = body_lines[i] if i < n else None
i += 1
ops.append({"kind": "insert", "after": len(v1) - 1, "lines": ins,
"open": open_line, "close": close_line})
elif kind == "Delete":
open_line = body_lines[i]
start_idx = len(v1)
i += 1
dels = []
while i < n and bsl_marker_kind(body_lines[i]) != "EndDelete":
dels.append(body_lines[i])
v1.append(body_lines[i])
i += 1
close_line = body_lines[i] if i < n else None
i += 1
ops.append({"kind": "delete", "start": start_idx, "end": len(v1) - 1, "lines": dels,
"open": open_line, "close": close_line})
else:
v1.append(body_lines[i])
i += 1
return {"v1": v1, "ops": ops}
def find_unique_index(v2norm, key):
found = -1
for k in range(len(v2norm)):
if v2norm[k] == key:
if found >= 0:
return -1
found = k
return found
def find_unique_run(v2norm, keys):
if not keys:
return -1
found = -1
for k in range(len(v2norm) - len(keys) + 1):
if v2norm[k:k + len(keys)] == keys:
if found >= 0:
return -1
found = k
return found
def test_significant(norm_line):
"""A significant line carries code: non-empty and not a whole-line comment (//).
Blank/comment-only lines are cosmetic — transparent to anchor/absorption matching."""
return norm_line != "" and not norm_line.startswith("//")
def significant_projection(norm):
"""Project normalized lines to significant-only. Returns (sig_values, orig_indices)."""
sig = []
idx = []
for i, ln in enumerate(norm):
if test_significant(ln):
sig.append(ln)
idx.append(i)
return sig, idx
def test_run_at(hay, keys, at):
"""True if normalized run `keys` sits in `hay` starting exactly at index `at` (contiguous)."""
if not keys or at < 0 or at + len(keys) > len(hay):
return False
return hay[at:at + len(keys)] == keys
def test_delete_absorbed(v2norm, before_ctx, after_ctx):
"""True if a deletion is already applied in v2: before/after context are now adjacent
(nothing between them), or the missing side sits at a body boundary (None = boundary)."""
if before_ctx is not None and after_ctx is not None:
for i in range(len(v2norm) - 1):
if v2norm[i] == before_ctx and v2norm[i + 1] == after_ctx:
return True
return False
if before_ctx is None and after_ctx is not None:
return len(v2norm) > 0 and v2norm[0] == after_ctx
if before_ctx is not None and after_ctx is None:
return len(v2norm) > 0 and v2norm[-1] == before_ctx
return False
def resolve_insertion_point_exact(v2norm, before_lines, after_lines):
"""Exact two-sided resolution over the given array.
Returns index to insert-after (-1 = top), or None if ambiguous/conflict."""
nb = len(before_lines)
na = len(after_lines)
# Tier A: adjacent pair (before[-1] then after[0] in v2), widening symmetrically
if nb >= 1 and na >= 1:
cands = [k for k in range(len(v2norm) - 1)
if v2norm[k] == before_lines[-1] and v2norm[k + 1] == after_lines[0]]
if len(cands) == 1:
return cands[0]
if len(cands) > 1:
w = 1
while len(cands) > 1 and (w < nb or w < na):
w += 1
filtered = []
for k in cands:
ok = True
if w <= nb:
if k - (w - 1) < 0 or v2norm[k - (w - 1)] != before_lines[nb - w]:
ok = False
if ok and w <= na:
if k + w >= len(v2norm) or v2norm[k + w] != after_lines[w - 1]:
ok = False
if ok:
filtered.append(k)
if not filtered:
break
cands = filtered
if len(cands) == 1:
return cands[0]
return None
# Tier B: one side changed -> single-side uniqueness
if nb >= 1:
bk = find_unique_index(v2norm, before_lines[-1])
if bk >= 0:
return bk
if na >= 1:
ak = find_unique_index(v2norm, after_lines[0])
if ak >= 0:
return ak - 1
return None
def resolve_insertion_point(v2norm, before_lines, after_lines):
"""Exact first (comments/blanks included — keeps the insert's position relative to a stable
comment); on failure, retry on significant lines only (transparent to vendor-added blanks/
comments) and map back to a full-v2 index. Returns insert-after idx or None."""
k = resolve_insertion_point_exact(v2norm, before_lines, after_lines)
if k is not None:
return k
sig, idx = significant_projection(v2norm)
bs = [x for x in before_lines if test_significant(x)]
as_ = [x for x in after_lines if test_significant(x)]
ksig = resolve_insertion_point_exact(sig, bs, as_)
if ksig is None:
return None
if ksig < 0:
return -1
return idx[ksig]
def truncate(s, n=60):
t = s.strip()
return (t[:n] + "…") if len(t) > n else t
def die(msg):
print(msg, file=sys.stderr)
sys.exit(1)
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Source-aware method interceptor for 1C extension (CFE)",
allow_abbrev=False,
)
parser.add_argument("-ExtensionPath", required=True)
parser.add_argument("-ConfigPath", required=False, default="")
parser.add_argument("-ModulePath", required=False, default="")
parser.add_argument("-MethodName", required=False, default="")
parser.add_argument("-InterceptorType", required=False, default="",
choices=["", "Before", "After", "Instead", "ModificationAndControl"])
parser.add_argument("-Check", action="store_true")
parser.add_argument("-Actualize", action="store_true")
args = ci_parse_args(parser)
extension_path = args.ExtensionPath
config_path = args.ConfigPath
module_path = args.ModulePath
method_name = args.MethodName
interceptor_type = args.InterceptorType
check_mode = args.Check
actualize_mode = args.Actualize
# --- Resolve extension path ---
if not os.path.isabs(extension_path):
extension_path = os.path.join(os.getcwd(), extension_path)
if os.path.isfile(extension_path):
extension_path = os.path.dirname(extension_path)
cfg_file = os.path.join(extension_path, "Configuration.xml")
if not os.path.isfile(cfg_file):
die("Configuration.xml не найден в расширении: %s" % extension_path)
# --- Read NamePrefix ---
ns = {"md": "http://v8.1c.ru/8.3/MDClasses"}
name_prefix = "Расш_"
try:
root = ET.parse(cfg_file).getroot()
props = root.find(".//md:Configuration/md:Properties", ns)
if props is not None:
pn = props.find("md:NamePrefix", ns)
if pn is not None and pn.text:
name_prefix = pn.text
nn = props.find("md:Name", ns)
if nn is not None and nn.text:
ext_name = nn.text
except ET.ParseError:
pass
if 'ext_name' not in dir():
ext_name = "Расширение"
# --- Batch modes: -Check / -Actualize over &ИзменениеИКонтроль ---
if check_mode or actualize_mode:
if check_mode and actualize_mode:
die("Укажите либо -Check, либо -Actualize, не оба.")
if not config_path:
die("Для -Check/-Actualize нужен -ConfigPath (сверка с исходником).")
cp = config_path
if not os.path.isabs(cp):
cp = os.path.join(os.getcwd(), cp)
if os.path.isfile(cp):
cp = os.path.dirname(cp)
if not os.path.isfile(os.path.join(cp, "Configuration.xml")):
die("Configuration.xml не найден в конфигурации-источнике: %s" % cp)
report_only = check_mode
verb = "КОНТРОЛЬ" if check_mode else "АКТУАЛИЗАЦИЯ"
safe = re.sub(r'[\\/:*?"<>|]', "_", ext_name)
run_root = os.path.join(tempfile.gettempdir(), "cfe-resync", safe)
if actualize_mode and os.path.isdir(run_root):
shutil.rmtree(run_root, ignore_errors=True)
targets = []
if module_path:
rp = get_module_rel_path(module_path)
mb = os.path.join(extension_path, *rp)
if os.path.isfile(mb):
targets.append(mb)
else:
for dirpath, _dirs, files in os.walk(extension_path):
for fn in files:
if fn.endswith(".bsl"):
targets.append(os.path.join(dirpath, fn))
results = []
for tb in targets:
scan = read_lines(tb)
mnames = [ic["method"] for ic in get_interceptors(scan) if ic["type"] == "ИзменениеИКонтроль"]
if method_name:
mnames = [m for m in mnames if m.lower() == method_name.lower()]
seen = set(); uniq = []
for m in mnames:
if m.lower() not in seen:
seen.add(m.lower()); uniq.append(m)
if not uniq:
continue
rel = rel_parts_under(extension_path, tb)
logical_module = module_path_from_rel(rel)
src_bsl2 = os.path.join(cp, *rel)
rel_no_bsl = os.path.join(*rel)
if rel_no_bsl.endswith(".bsl"):
rel_no_bsl = rel_no_bsl[:-4]
for mname in uniq:
mid = "%s.%s" % (logical_module, mname)
if not os.path.isfile(src_bsl2):
results.append({"id": mid, "status": "ИСТОЧНИК-НЕ-НАЙДЕН", "ext_bsl": tb}); continue
m = extract_method(read_lines(src_bsl2), mname)
if not m:
results.append({"id": mid, "status": "МЕТОД-ИСЧЕЗ", "ext_bsl": tb}); continue
tb_lines = read_lines(tb)
ic = next((x for x in get_interceptors(tb_lines)
if x["type"] == "ИзменениеИКонтроль" and x["method"].lower() == mname.lower()), None)
if not ic:
continue
folder = os.path.join(run_root, rel_no_bsl, m["canonical"])
results.append(resync_one(tb, tb_lines, ic, m, logical_module, folder, report_only))
total = len(results)
actual = sum(1 for r in results if r["status"] == "АКТУАЛЕН")
print("[%s] %s -> %s (на контроле: %d)" % (verb, ext_name, cp, total))
listed = [r for r in results if r["status"] != "АКТУАЛЕН"]
for pr in listed:
line = " %-22s %s" % (pr["status"], pr["id"])
if pr.get("reason"):
line += " %s" % pr["reason"]
print(line)
for n in pr.get("absorbed_notes", []):
print(" ⚠ комментарий не перенесён (код в основной конфигурации): %s" % n)
transf = sum(1 for r in results if r["status"] == "ПЕРЕНЕСЕНО В ОСНОВНУЮ")
if check_mode:
drift = sum(1 for r in results if r["status"] == "ДРЕЙФ")
confl = sum(1 for r in results if r["status"] == "КОНФЛИКТ")
gone = sum(1 for r in results if r["status"] in ("МЕТОД-ИСЧЕЗ", "ИСТОЧНИК-НЕ-НАЙДЕН"))
print("Итог: %d/%d актуальны · дрейф: %d · конфликтов: %d · перенесено в основную: %d · внимания: %d"
% (actual, total, drift, confl, transf, gone))
if (drift + confl + gone) > 0:
print("Починить: /cfe-patch-method -Actualize -ExtensionPath %s -ConfigPath %s" % (extension_path, cp))
sys.exit(1)
elif transf > 0:
print("Перенесённые в основную конфигурацию правки подчистит: /cfe-patch-method -Actualize -ExtensionPath %s -ConfigPath %s" % (extension_path, cp))
sys.exit(0)
sys.exit(0)
else:
upd = sum(1 for r in results if r["status"] == "АКТУАЛИЗИРОВАН")
part = sum(1 for r in results if r["status"] == "ЧАСТИЧНО")
print("Итог: %d/%d актуальны · актуализировано: %d · частично: %d · перенесено в основную: %d" % (actual, total, upd, part, transf))
idx = write_resync_index(run_root, results, ext_name, cp, verb)
if idx:
print("Merge-воркспейс конфликтов (см. index.md): %s" % idx)
sys.exit(0)
# --- Generation mode: require ModulePath + MethodName + InterceptorType ---
if not (module_path and method_name and interceptor_type):
die("Нужны -ModulePath, -MethodName, -InterceptorType (генерация перехватчика). "
"Для проверки/актуализации контролируемых методов используйте -Check или -Actualize.")
# --- Resolve module file paths (ModulePath = logical name OR path to a .bsl) ---
has_config = bool(config_path)
if has_config:
if not os.path.isabs(config_path):
config_path = os.path.join(os.getcwd(), config_path)
if os.path.isfile(config_path):
config_path = os.path.dirname(config_path)
is_file_path = bool(re.search(r'[\\/]', module_path)) or module_path.endswith(".bsl")
if is_file_path:
mp_abs = module_path if os.path.isabs(module_path) else os.path.join(os.getcwd(), module_path)
rel_parts = get_rel_parts_from_file_path(module_path)
if not rel_parts:
die("Не удалось определить объект по пути модуля: %s\n"
"(нет распознаваемой типовой папки — Catalogs/Documents/CommonModules/…)" % module_path)
ext_bsl = os.path.join(extension_path, *rel_parts)
if has_config:
src_bsl = os.path.join(config_path, *rel_parts)
else:
ext_root_abs = os.path.normcase(os.path.abspath(extension_path)).rstrip("\\/")
mp_full = os.path.normcase(os.path.abspath(mp_abs))
if mp_full.startswith(ext_root_abs):
die("Путь модуля указывает внутрь расширения, а не на источник. "
"Укажите путь к модулю-источнику или -ConfigPath.")
src_bsl = mp_abs
else:
if not has_config:
die("Не указан -ConfigPath. Укажите путь к исходникам конфигурации "
"или передайте путь к файлу модуля в -ModulePath.")
if not os.path.isfile(os.path.join(config_path, "Configuration.xml")):
die("Configuration.xml не найден в конфигурации-источнике: %s" % config_path)
rel_parts = get_module_rel_path(module_path)
ext_bsl = os.path.join(extension_path, *rel_parts)
src_bsl = os.path.join(config_path, *rel_parts)
if not os.path.isfile(src_bsl):
die("Модуль-источник не найден: %s\n(проверьте ModulePath и ConfigPath)" % src_bsl)
# --- Extract original method ---
src_lines = read_lines(src_bsl)
method = extract_method(src_lines, method_name)
if not method:
die("Метод '%s' не найден в модуле-источнике: %s" % (method_name, src_bsl))
# --- Guard: functions cannot use Before/After ---
if method["is_function"] and interceptor_type in ("Before", "After"):
die("Метод '%s' — функция. Для функций доступны только Instead и ModificationAndControl "
"(перехват &Перед/&После к функциям неприменим)." % method_name)
decorator_ru = DECORATOR_MAP[interceptor_type]
# What the annotation will actually look like in the module: the source's spelling
decorator_out = BSL_KW[method["lang"]][DECORATOR_KEY[interceptor_type]]
# --- Read existing extension module (if any) ---
ext_exists = os.path.isfile(ext_bsl)
ext_lines = read_lines(ext_bsl) if ext_exists else []
existing_interceptors = get_interceptors(ext_lines) if ext_exists else []
existing_proc_names = get_proc_names(ext_lines) if ext_exists else []
dup = None
for ic in existing_interceptors:
if ic["type"] == decorator_ru and ic["method"].lower() == method_name.lower():
dup = ic
break
if dup:
if interceptor_type != "ModificationAndControl":
print('[ПРОПУЩЕН] Перехватчик &%s("%s") уже есть в модуле — дубль не создаётся.'
% (dup["raw"], method_name))
print(" Файл: %s" % ext_bsl)
sys.exit(0)
rel = rel_parts_under(extension_path, ext_bsl)
logical_module = module_path_from_rel(rel)
rel_no_bsl = os.path.join(*rel)
if rel_no_bsl.endswith(".bsl"):
rel_no_bsl = rel_no_bsl[:-4]
safe = re.sub(r'[\\/:*?"<>|]', "_", ext_name)
run_root = os.path.join(tempfile.gettempdir(), "cfe-resync", safe)
folder = os.path.join(run_root, rel_no_bsl, method["canonical"])
res = resync_one(ext_bsl, ext_lines, dup, method, logical_module, folder, False)
st = res["status"]
if st == "АКТУАЛЕН":
print('[АКТУАЛЕН] &ИзменениеИКонтроль("%s") — оригинал не менялся, изменений нет.' % method_name)
elif st == "АКТУАЛИЗИРОВАН":
msg = '[АКТУАЛИЗИРОВАН] &ИзменениеИКонтроль("%s") — тело обновлено, правок сохранено: %d' % (method_name, res.get("transferred", 0))
if res.get("absorbed", 0) > 0:
msg += ", перенесено в основную конфигурацию: %d" % res["absorbed"]
print(msg)
elif st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ":
print('[ПЕРЕНЕСЕНО В ОСНОВНУЮ] &ИзменениеИКонтроль("%s") — все правки (%d) уже в основной конфигурации, перехватчик можно удалить.'
% (method_name, res.get("absorbed", 0)))
elif st == "ЧАСТИЧНО":
idx = write_resync_index(run_root, [res], ext_name, config_path, "АКТУАЛИЗАЦИЯ")
msg = '[АКТУАЛИЗИРОВАН-ЧАСТИЧНО] &ИзменениеИКонтроль("%s") — сохранено: %d, конфликтов: %d' % (method_name, res.get("transferred", 0), res.get("disputed", 0))
if res.get("absorbed", 0) > 0:
msg += ", перенесено в основную: %d" % res["absorbed"]
print(msg)
print(" Конфликт помечен // [РЕСИНК-КОНФЛИКТ]. Папка метода: %s" % res.get("conflict_dir"))
if idx:
print(" Индекс: %s" % idx)
else:
print('[%s] &ИзменениеИКонтроль("%s") — %s' % (st, method_name, res.get("reason", "")))
for n in res.get("absorbed_notes", []):
print(" ⚠ комментарий не перенесён (код в основной конфигурации): %s" % n)
print(" Файл: %s" % ext_bsl)
sys.exit(0)
# --- New interceptor ---
candidate = name_prefix + method["canonical"]
taken = [n.lower() for n in existing_proc_names]
interceptor_name = candidate
if candidate.lower() in taken:
interceptor_name = candidate + "_" + decorator_out
core = build_interceptor_core(method, interceptor_type, interceptor_name)
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core, method["lang"])
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
flag_target = get_module_flag_target(rel_parts, extension_path)
if flag_target:
set_property_state_flag(flag_target["file"], flag_target["property"],
detect_format_version(extension_path))
# emit summary
placement = place_new.placement
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_out, method_name, placement))
print(" Файл: %s" % ext_bsl)
print(" Процедура: %s(%s)" % (interceptor_name, normalize(method["params_text"])))
if method["context"]:
print(" Контекст: %s" % method["context"])
if method["chain"]:
desc = " > ".join(
("Область:%s" % w["name"]) if w["kind"] == "region" else ("Если:%s" % w["cond"])
for w in method["chain"]
)
print(" Обрамление: %s" % desc)
def place_new(ext_bsl, ext_lines, ext_exists, chain, core, lang):
enc_bom = True # noqa
# find innermost region in chain that already exists in the extension module
reuse_region_idx = -1
reuse_line_idx = -1
if ext_exists:
for c in range(len(chain) - 1, -1, -1):
if chain[c]["kind"] == "region":
rname = chain[c]["name"]
rre = re.compile(r'^#' + kw_alt("Region") + r'\s+' + re.escape(rname) + r'\s*$', re.IGNORECASE)
for li in range(len(ext_lines)):
if rre.match(ext_lines[li].strip()):
reuse_region_idx = c
reuse_line_idx = li
break
if reuse_region_idx >= 0:
break
if reuse_region_idx >= 0:
inner_chain = chain[reuse_region_idx + 1:]
block = build_wrapped_block(inner_chain, core, lang)
depth = 0
close_idx = -1
for li in range(reuse_line_idx, len(ext_lines)):
t = ext_lines[li].strip()
if re.match(r'^#' + kw_alt("Region") + r'\s', t, re.IGNORECASE):
depth += 1
elif re.match(r'^#' + kw_alt("EndRegion"), t, re.IGNORECASE):
depth -= 1
if depth == 0:
close_idx = li
break
if close_idx < 0:
die("Не найден #КонецОбласти для региона (переиспользование)")
# strip trailing blank lines of the region content, then insert with air
last_content = close_idx - 1
while last_content >= 0 and ext_lines[last_content].strip() == "":
last_content -= 1
out = list(ext_lines[:last_content + 1])
out.append("")
out.extend(block)
out.append("")
out.extend(ext_lines[close_idx:])
write_bsl(ext_bsl, out)
place_new.placement = "в существующий регион '%s'" % chain[reuse_region_idx]["name"]
return
# full wrapper chain, append (or create)
block = build_wrapped_block(chain, core, lang)
block_text = "\r\n".join(block) + "\r\n"
bsl_dir = os.path.dirname(ext_bsl)
if not os.path.isdir(bsl_dir):
os.makedirs(bsl_dir, exist_ok=True)
if ext_exists:
with open(ext_bsl, "r", encoding="utf-8-sig", newline="") as f:
existing = f.read()
if not existing.strip():
with open(ext_bsl, "w", encoding="utf-8-sig", newline="") as f:
f.write(block_text)
place_new.placement = "заполнен модуль"
else:
sep = "\r\n" if existing.endswith("\n") else "\r\n\r\n"
with open(ext_bsl, "w", encoding="utf-8-sig", newline="") as f:
f.write(existing + sep + block_text)
place_new.placement = "дописан в модуль"
else:
with open(ext_bsl, "w", encoding="utf-8-sig", newline="") as f:
f.write(block_text)
place_new.placement = "создан модуль"
DIR_TO_TYPE = {
"Catalogs": "Catalog", "Documents": "Document", "Enums": "Enum", "CommonModules": "CommonModule",
"Reports": "Report", "DataProcessors": "DataProcessor", "ExchangePlans": "ExchangePlan",
"ChartsOfAccounts": "ChartOfAccounts", "ChartsOfCharacteristicTypes": "ChartOfCharacteristicTypes",
"ChartsOfCalculationTypes": "ChartOfCalculationTypes", "BusinessProcesses": "BusinessProcess",
"Tasks": "Task", "InformationRegisters": "InformationRegister",
"AccumulationRegisters": "AccumulationRegister", "AccountingRegisters": "AccountingRegister",
"CalculationRegisters": "CalculationRegister",
}
def module_path_from_rel(rel_parts):
dir0, name = rel_parts[0], rel_parts[1]
typ = DIR_TO_TYPE.get(dir0, dir0)
if dir0 == "CommonModules":
return "CommonModule.%s" % name
if len(rel_parts) >= 7 and rel_parts[2] == "Forms":
return "%s.%s.Form.%s" % (typ, name, rel_parts[3])
mod = rel_parts[-1][:-4] if rel_parts[-1].endswith(".bsl") else rel_parts[-1]
return "%s.%s.%s" % (typ, name, mod)
def rel_parts_under(root, full_path):
rel = os.path.relpath(os.path.abspath(full_path), os.path.abspath(root))
return [s for s in rel.replace("\\", "/").split("/") if s and s != "."]
def conflict_reason(disputed):
kinds = set(d["kind"] for d in disputed)
parts = []
if "insert" in kinds:
parts.append("якорь вставки изменён")
if "delete" in kinds:
parts.append("удаляемое исчезло")
return "; ".join(parts)
def write_conflict_folder(folder, method_id, ext_bsl, existing_name, method, v1, marked_body, v2, v1norm, v2norm, disputed, lang):
kw = BSL_KW[lang]
os.makedirs(folder, exist_ok=True)
write_bsl(os.path.join(folder, "base.bsl"), v1)
write_bsl(os.path.join(folder, "local.bsl"), marked_body)
write_bsl(os.path.join(folder, "remote.bsl"), v2)
md = []
md.append("# %s" % method_id)
md.append("Править: %s" % ext_bsl)
md.append('Метод: %s (&%s("%s"))' % (existing_name, kw["Control"], method["canonical"]))
md.append("Причина: %s" % conflict_reason(disputed))
md.append("")
md.append("## Не размещено — перенести вручную")
cn = 0
for d in disputed:
cn += 1
md.append("")
if d["kind"] == "insert":
md.append("### Конфликт №%d — вставка" % cn)
md.append("Как блок стоял в вашей версии (local):")
if d.get("before"):
for l in d["before"]:
md.append(l)
md.append(d.get("open") or ("#" + kw["Insert"])); md.extend(d["lines"])
md.append(d.get("close") or ("#" + kw["EndInsert"]))
if d.get("after"):
for l in d["after"]:
md.append(l)
md.append("")
md.append("Якорь (строки вокруг #%s) изменился/исчез в новом оригинале — блок не лёг автоматически (см. дифф base→remote ниже)." % kw["Insert"])
md.append("В модуле расширения блок припаркован в конце метода под меткой // [РЕСИНК-КОНФЛИКТ №%d] — найди по ней." % cn)
md.append("Куда переносить: если якорного кода в новом методе больше нет — он, вероятно, вынесен/отрефакторен (ищите в диффе новый вызов/процедуру). Размести адаптацию по смыслу: например пост-обработкой после нового вызова, либо в заимствованной процедуре, куда переехал код. При правке файла сохрани кодировку (UTF-8 с BOM).")
else:
md.append("### Конфликт №%d — удаление" % cn)
md.append("Строки для удаления не найдены в новом оригинале (изменились/исчезли):")
for l in d["lines"]:
md.append(" - " + l.strip())
md.append("В модуле расширения помечено меткой // [РЕСИНК-КОНФЛИКТ №%d]." % cn)
md.append("")
md.append("## Дифф base→remote (что изменилось в оригинале)")
for l in v1:
if normalize(l) not in v2norm:
md.append("- " + l)
for l in v2:
if normalize(l) not in v1norm:
md.append("+ " + l)
md.append("")
md.append("Рядом: base.bsl / local.bsl / remote.bsl")
write_bsl(os.path.join(folder, "conflict.md"), md)
def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder, report_only):
method_id = "%s.%s" % (logical_module, method["canonical"])
dec_line = dup["line"]
sig_line_idx = dec_line + 1
name_re = re.compile(r'^\s*(?:' + kw_alt("Async") + r'\s+)?(' + kw_alt("Proc") + '|' + kw_alt("Func")
+ r')\s+([\w]+)\s*\(', re.IGNORECASE)
m0 = name_re.match(ext_lines[sig_line_idx]) if sig_line_idx < len(ext_lines) else None
if not m0:
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру перехватчика"}
# The block we are about to rewrite is the interceptor already in the extension, so it is
# its spelling — not the source module's — that the rewritten block must keep.
lang = bsl_lang(m0.group(1))
kw = BSL_KW[lang]
existing_name = m0.group(2)
sig = read_signature(ext_lines, sig_line_idx)
if not sig:
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
ext_params_text, sig_end = sig
is_func = bool(re.match(r'^\s*(?:' + kw_alt("Async") + r'\s+)?' + kw_alt("Func") + r'\b',
ext_lines[sig_line_idx], re.IGNORECASE))
end_re = re.compile(r'^\s*' + (kw_alt("EndFunc") if is_func else kw_alt("EndProc")) + r'\b', re.IGNORECASE)
block_end = -1
for j in range(sig_end + 1, len(ext_lines)):
if end_re.match(ext_lines[j]):
block_end = j
break
if block_end < 0:
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не найден конец перехватчика"}
marked_body = ext_lines[sig_end + 1:block_end]
parsed = parse_marked_body(marked_body)
v1 = parsed["v1"]; ops = parsed["ops"]; v2 = method["body_lines"]
v1norm = [normalize(x) for x in v1]
v2norm = [normalize(x) for x in v2]
# Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
ext_param_count = param_count(ext_params_text)
src_param_count = param_count(method["params_text"])
params_drift = ext_param_count != src_param_count
params_reason = ("список параметров: в оригинале %d, в перехватчике %d"
% (src_param_count, ext_param_count)) if params_drift else ""
if not params_drift and control_key(v1) == control_key(v2):
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
insert_top = []; insert_after = {}; del_start = {}; del_end = {}; disputed = []; transferred = 0; absorbed = 0; absorbed_notes = []
v2sig, v2map = significant_projection(v2norm)
for op in ops:
if op["kind"] == "insert":
after = op["after"]
before_lines = v1norm[max(0, after - 2):after + 1] if after >= 0 else []
after_lines = v1norm[after + 1:after + 4]
k = resolve_insertion_point(v2norm, before_lines, after_lines)
# Payload already in the new original -> change carried into the main config.
# Match on significant lines only, so vendor-added blanks/comments don't hide it.
payload_sig = [x for x in (normalize(y) for y in op["lines"]) if test_significant(x)]
is_absorbed = False
if payload_sig:
if k is None:
is_absorbed = find_unique_run(v2sig, payload_sig) >= 0
else:
sig_start = sum(1 for j in v2map if j <= k)
is_absorbed = test_run_at(v2sig, payload_sig, sig_start)
if is_absorbed:
absorbed += 1
for pl in op["lines"]:
if normalize(pl).startswith("//"):
absorbed_notes.append(pl.strip())
elif k is None:
dbefore = v1[max(0, after - 2):after + 1] if after >= 0 else []
dafter = v1[after + 1:after + 4]
disputed.append({"kind": "insert", "lines": op["lines"], "open": op.get("open"),
"close": op.get("close"), "before": dbefore, "after": dafter})
elif k < 0:
insert_top.append(op); transferred += 1
else:
insert_after.setdefault(k, []).append(op); transferred += 1
else:
keys = v1norm[op["start"]:op["end"] + 1]
p = find_unique_run(v2norm, keys)
if p >= 0:
del_start[p] = op.get("open"); del_end[p + len(keys) - 1] = op.get("close"); transferred += 1
else:
# Nearest significant neighbours; adjacency in the significant projection means the
# block is already cut (blanks/comments left behind don't matter).
before_ctx = None
for z in range(op["start"] - 1, -1, -1):
if test_significant(v1norm[z]):
before_ctx = v1norm[z]; break
after_ctx = None
for z in range(op["end"] + 1, len(v1norm)):
if test_significant(v1norm[z]):
after_ctx = v1norm[z]; break
if test_delete_absorbed(v2sig, before_ctx, after_ctx):
absorbed += 1
else:
disputed.append({"kind": "delete", "lines": op["lines"]})
if report_only:
if disputed:
st = "КОНФЛИКТ"
elif transferred == 0 and absorbed > 0:
st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
else:
st = "ДРЕЙФ"
if params_drift and st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ":
st = "ДРЕЙФ"
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
if params_drift:
rsn = ("%s; %s" % (params_reason, rsn)) if rsn else params_reason
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
"absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes}
# Marker lines come back as they were read (open/close); the keyword form is only a fallback
# for a block whose closing marker the module never had.
def mk(orig, key):
return ("#" + kw[key]) if not orig else orig
new_body = []
for blk in insert_top:
new_body.append(mk(blk.get("open"), "Insert")); new_body.extend(blk["lines"])
new_body.append(mk(blk.get("close"), "EndInsert"))
for k in range(len(v2)):
if k in del_start:
new_body.append(mk(del_start[k], "Delete"))
new_body.append(v2[k])
if k in del_end:
new_body.append(mk(del_end[k], "EndDelete"))
if k in insert_after:
for blk in insert_after[k]:
new_body.append(mk(blk.get("open"), "Insert")); new_body.extend(blk["lines"])
new_body.append(mk(blk.get("close"), "EndInsert"))
if disputed:
new_body.append("\t// [РЕСИНК-КОНФЛИКТ] блоки ниже не легли автоматически — перенесите вручную (по № см. conflict.md / index.md в merge-воркспейсе, путь в выводе).")
cn = 0
for d in disputed:
cn += 1
if d["kind"] == "insert":
new_body.append("\t// [РЕСИНК-КОНФЛИКТ №%d] вставка — исходный якорь изменён в новом оригинале." % cn)
new_body.append(mk(d.get("open"), "Insert")); new_body.extend(d["lines"])
new_body.append(mk(d.get("close"), "EndInsert"))
else:
new_body.append("\t// [РЕСИНК-КОНФЛИКТ №%d] удаление — строки не найдены в новом оригинале:" % cn)
for l in d["lines"]:
new_body.append("\t// " + l.strip())
async_prefix = (kw["Async"] + " ") if method["is_async"] else ""
keyword = kw["Func"] if method["is_function"] else kw["Proc"]
end_keyword = kw["EndFunc"] if method["is_function"] else kw["EndProc"]
new_block = []
if method["context"]:
new_block.append(method["context"])
new_block.append('&%s("%s")' % (kw["Control"], method["canonical"]))
new_block.append("%s%s %s(%s)" % (async_prefix, keyword, existing_name, method["params_text"]))
new_block.extend(new_body)
new_block.append(end_keyword)
block_start = dec_line
if dec_line >= 1 and is_context_directive(ext_lines[dec_line - 1].strip()):
block_start = dec_line - 1
out = list(ext_lines[:block_start]) + new_block + list(ext_lines[block_end + 1:])
write_bsl(ext_bsl, out)
conflict_dir = None
if disputed:
conflict_dir = conflict_folder
write_conflict_folder(conflict_folder, method_id, ext_bsl, existing_name, method, v1, marked_body, v2, v1norm, v2norm, disputed, lang)
if disputed:
status = "ЧАСТИЧНО"
elif transferred == 0 and absorbed > 0:
status = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
else:
status = "АКТУАЛИЗИРОВАН"
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации — перехватчик можно удалить" if status == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
return {"id": method_id, "status": status, "ext_bsl": ext_bsl, "transferred": transferred,
"absorbed": absorbed, "disputed": len(disputed), "conflict_dir": conflict_dir, "reason": rsn,
"absorbed_notes": absorbed_notes}
def write_resync_index(run_root, results, ext_name, config_path, verb):
conflicts = [r for r in results if r["status"] == "ЧАСТИЧНО"]
if not conflicts:
return None
os.makedirs(run_root, exist_ok=True)
total = len(results)
actual = sum(1 for r in results if r["status"] == "АКТУАЛЕН")
upd = sum(1 for r in results if r["status"] == "АКТУАЛИЗИРОВАН")
lines = []
lines.append("[%s] %s -> %s" % (verb, ext_name, config_path))
lines.append("Итог: %d/%d актуальны · актуализировано: %d · конфликтов: %d" % (actual, total, upd, len(conflicts)))
lines.append("")
lines.append("Конфликты — править .bsl расширения:")
for c in conflicts:
lines.append(" ЧАСТИЧНО %s" % c["id"])
lines.append(" -> %s (%s)" % (c["ext_bsl"], c.get("reason", "")))
write_bsl(os.path.join(run_root, "index.md"), lines)
return run_root
if __name__ == "__main__":
main()