feat(cfe-patch-method): батч -Check/-Actualize контролируемых методов (v2.2)

После обновления КФ контролируемые методы (&ИзменениеИКонтроль) молча уезжают в
рассинхрон — платформа при загрузке не ругается, ошибка лишь в рантайме. Добавлены
два явных режима по всему расширению (или -ModulePath/-MethodName для сужения):

- -Check — отчёт: какие методы дрейфнули (ДРЕЙФ/КОНФЛИКТ/МЕТОД-ИСЧЕЗ), актуальные
  числом; ничего не пишет; exit 1 при наличии дрейфа.
- -Actualize — чинит пачкой: авто-перенос + merge-воркспейс на конфликтах.

Одиночный ресинк вынесен в общую функцию resync_one (report_only), одиночный путь и
батч используют её. Зона ответственности узкая — только тело &ИзменениеИКонтроль.

Merge-воркспейс переработан: тонкий index.md (список конфликтов + пути к .bsl расширения)
+ подпапка на метод (conflict.md с блоком/диффом + base/local/remote), без общей портянки.

cfe-validate: крошка-указатель [INFO] при наличии контролируемых методов -> /cfe-patch-method -Check.

Тесты: +check-clean/check-drift/actualize-batch. 16 кейсов, оба рантайма зелёные,
байтовый паритет ps1<->py. verify-snapshots (реальная 1С) Windows ps+py — 16/16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-18 20:37:58 +03:00
co-authored by Claude Opus 4.8
parent 8399f13e5a
commit 9aaa3a1c1e
29 changed files with 1609 additions and 256 deletions
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
# cfe-patch-method v2.1 — Source-aware method interceptor for 1C extension (CFE)
# cfe-patch-method v2.2 — 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
@@ -475,10 +476,12 @@ def main():
)
parser.add_argument("-ExtensionPath", required=True)
parser.add_argument("-ConfigPath", required=False, default="")
parser.add_argument("-ModulePath", required=True)
parser.add_argument("-MethodName", required=True)
parser.add_argument("-InterceptorType", required=True,
choices=["Before", "After", "Instead", "ModificationAndControl"])
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 = parser.parse_args()
extension_path = args.ExtensionPath
@@ -486,6 +489,8 @@ def main():
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):
@@ -506,8 +511,111 @@ def main():
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 = " %-16s %s" % (pr["status"], pr["id"])
if pr.get("reason"):
line += " %s" % pr["reason"]
print(line)
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 требуют внимания"
% (actual, total, drift, confl, gone))
if listed:
print("Починить: /cfe-patch-method -Actualize -ExtensionPath %s -ConfigPath %s" % (extension_path, cp))
sys.exit(1)
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 частично" % (actual, total, upd, part))
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)
@@ -580,7 +688,31 @@ def main():
% (decorator_ru, method_name))
print(" Файл: %s" % ext_bsl)
sys.exit(0)
resync(ext_bsl, ext_lines, dup, method, method_name, module_path)
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 == "АКТУАЛИЗИРОВАН":
print('[АКТУАЛИЗИРОВАН] &ИзменениеИКонтроль("%s") — тело обновлено, перенесено правок: %d'
% (method_name, res.get("transferred", 0)))
elif st == "ЧАСТИЧНО":
idx = write_resync_index(run_root, [res], ext_name, config_path, "АКТУАЛИЗАЦИЯ")
print('[АКТУАЛИЗИРОВАН-ЧАСТИЧНО] &ИзменениеИКонтроль("%s") — перенесено: %d, конфликтов: %d'
% (method_name, res.get("transferred", 0), res.get("disputed", 0)))
print(" Конфликт помечен // [РЕСИНК-КОНФЛИКТ]. Папка метода: %s" % res.get("conflict_dir"))
if idx:
print(" Индекс: %s" % idx)
else:
print('[%s] &ИзменениеИКонтроль("%s") — %s' % (st, method_name, res.get("reason", "")))
print(" Файл: %s" % ext_bsl)
sys.exit(0)
# --- New interceptor ---
@@ -688,16 +820,87 @@ def place_new(ext_bsl, ext_lines, ext_exists, chain, core):
place_new.placement = "создан модуль"
def resync(ext_bsl, ext_lines, dup, method, method_name, module_path):
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):
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"))' % (existing_name, method["canonical"]))
md.append("Причина: %s" % conflict_reason(disputed))
md.append("")
md.append("## Не размещено — перенести в метод вручную")
for d in disputed:
if d["kind"] == "insert":
md.append("#Вставка"); md.extend(d["lines"]); md.append("#КонецВставки")
else:
md.append("// не удалось найти для удаления:")
for l in d["lines"]:
md.append("// " + l.strip())
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*(?:Асинх\s+)?(?:Процедура|Функция)\s+([\w]+)\s*\(', re.IGNORECASE)
if sig_line_idx >= len(ext_lines) or not name_re.match(ext_lines[sig_line_idx]):
die('Не удалось найти сигнатуру существующего перехватчика &ИзменениеИКонтроль("%s")' % method_name)
existing_name = name_re.match(ext_lines[sig_line_idx]).group(1)
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": "не разобрать сигнатуру перехватчика"}
existing_name = m0.group(1)
sig = read_signature(ext_lines, sig_line_idx)
if not sig:
die("Не удалось разобрать сигнатуру существующего перехватчика")
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
_params, sig_end = sig
is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE))
end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE)
@@ -707,29 +910,18 @@ def resync(ext_bsl, ext_lines, dup, method, method_name, module_path):
block_end = j
break
if block_end < 0:
die("Не найден конец существующего перехватчика")
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"]
v1 = parsed["v1"]; ops = parsed["ops"]; v2 = method["body_lines"]
v1norm = [normalize(x) for x in v1]
v2norm = [normalize(x) for x in v2]
if "\n".join(v1norm) == "\n".join(v2norm):
print('[АКТУАЛЕН] &ИзменениеИКонтроль("%s") — оригинал не менялся, изменений нет.' % method_name)
print(" Файл: %s" % ext_bsl)
return
insert_top = []
insert_after = {}
del_start = set()
del_end = set()
disputed = []
transferred = [] # for the summary: {"kind", "anchor"}
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0
for op in ops:
if op["kind"] == "insert":
after = op["after"]
@@ -739,26 +931,25 @@ def resync(ext_bsl, ext_lines, dup, method, method_name, module_path):
if k is None:
disputed.append({"kind": "insert", "lines": op["lines"]})
elif k < 0:
insert_top.append(op["lines"])
transferred.append({"kind": "insert", "anchor": "(в начало метода)"})
insert_top.append(op["lines"]); transferred += 1
else:
insert_after.setdefault(k, []).append(op["lines"])
transferred.append({"kind": "insert", "anchor": v2[k]})
insert_after.setdefault(k, []).append(op["lines"]); transferred += 1
else:
keys = v1norm[op["start"]:op["end"] + 1]
p = find_unique_run(v2norm, keys)
if p >= 0:
del_start.add(p)
del_end.add(p + len(keys) - 1)
transferred.append({"kind": "delete", "anchor": op["lines"][0]})
del_start.add(p); del_end.add(p + len(keys) - 1); transferred += 1
else:
disputed.append({"kind": "delete", "lines": op["lines"]})
if report_only:
st = "КОНФЛИКТ" if disputed else "ДРЕЙФ"
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
"disputed": len(disputed), "reason": conflict_reason(disputed)}
new_body = []
for blk in insert_top:
new_body.append("#Вставка")
new_body.extend(blk)
new_body.append("#КонецВставки")
new_body.append("#Вставка"); new_body.extend(blk); new_body.append("#КонецВставки")
for k in range(len(v2)):
if k in del_start:
new_body.append("#Удаление")
@@ -767,18 +958,13 @@ def resync(ext_bsl, ext_lines, dup, method, method_name, module_path):
new_body.append("#КонецУдаления")
if k in insert_after:
for blk in insert_after[k]:
new_body.append("#Вставка")
new_body.extend(blk)
new_body.append("#КонецВставки")
new_body.append("#Вставка"); new_body.extend(blk); new_body.append("#КонецВставки")
if disputed:
new_body.append("\t// [РЕСИНК-КОНФЛИКТ] перенесите блоки ниже вручную — исходный якорь изменился в новой версии оригинала.")
new_body.append("\t// Материалы для анализа см. в выводе команды (файлы base/local/remote/merged).")
new_body.append("\t// Материалы: см. index.md / conflict.md в merge-воркспейсе (путь в выводе).")
for d in disputed:
if d["kind"] == "insert":
new_body.append("#Вставка")
new_body.extend(d["lines"])
new_body.append("#КонецВставки")
new_body.append("#Вставка"); new_body.extend(d["lines"]); new_body.append("#КонецВставки")
else:
new_body.append("\t// [РЕСИНК-КОНФЛИКТ] не удалось найти для удаления:")
for l in d["lines"]:
@@ -798,48 +984,36 @@ def resync(ext_bsl, ext_lines, dup, method, method_name, module_path):
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])
out.extend(new_block)
out.extend(ext_lines[block_end + 1:])
out = list(ext_lines[:block_start]) + new_block + list(ext_lines[block_end + 1:])
write_bsl(ext_bsl, out)
def print_transfer_summary(items):
for t in items:
if t["kind"] == "insert":
print(" • вставка после «%s»" % truncate(t["anchor"]))
else:
print(" • удаление «%s»" % truncate(t["anchor"]))
conflict_dir = None
if disputed:
# merge workspace (git-mergetool convention: base/local/remote/merged)
safe = re.sub(r'[\\/:*?"<>|]', '_', module_path)
tmp_root = os.path.join(tempfile.gettempdir(), "cfe-resync", safe + "." + method_name)
os.makedirs(tmp_root, exist_ok=True)
write_bsl(os.path.join(tmp_root, "base.bsl"), v1)
write_bsl(os.path.join(tmp_root, "local.bsl"), marked_body)
write_bsl(os.path.join(tmp_root, "remote.bsl"), v2)
write_bsl(os.path.join(tmp_root, "merged.bsl"), new_block)
diff = ["--- base -> remote (что изменилось в оригинале) ---"]
for l in v1:
if normalize(l) not in v2norm:
diff.append("- " + l)
for l in v2:
if normalize(l) not in v1norm:
diff.append("+ " + l)
write_bsl(os.path.join(tmp_root, "diff.txt"), diff)
conflict_dir = conflict_folder
write_conflict_folder(conflict_folder, method_id, ext_bsl, existing_name, method, v1, marked_body, v2, v1norm, v2norm, disputed)
status = "ЧАСТИЧНО" if disputed else "АКТУАЛИЗИРОВАН"
return {"id": method_id, "status": status, "ext_bsl": ext_bsl, "transferred": transferred,
"disputed": len(disputed), "conflict_dir": conflict_dir, "reason": conflict_reason(disputed)}
print('[АКТУАЛИЗИРОВАН-ЧАСТИЧНО] &ИзменениеИКонтроль("%s") — перенесено: %d, конфликтов: %d'
% (method_name, len(transferred), len(disputed)))
print_transfer_summary(transferred)
print(" Конфликтные блоки помечены // [РЕСИНК-КОНФЛИКТ] — разместите вручную.")
print(" Merge-воркспейс (base/local/remote/merged/diff):")
print(" %s" % tmp_root)
else:
print('[АКТУАЛИЗИРОВАН] &ИзменениеИКонтроль("%s") — тело обновлено, перенесено правок: %d'
% (method_name, len(transferred)))
print_transfer_summary(transferred)
print(" Файл: %s" % ext_bsl)
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__":