feat(epf-build): исходники проверяются платформой до сборки

Сборка .epf/.erf не компилирует модули, поэтому сломанный BSL уезжал
пользователю и падал при открытии обработки. Прямой команды «проверь
внешнюю обработку» у платформы нет, но объект конфигурации она проверяет:
обработка кладётся в конфигурацию временной базы (stub-db-create
-EmbedSourceFile) и спрашивается /CheckConfig. При находках сборка
отменяется, в выводе — сообщение платформы и путь к файлу исходника.

Ключи -Checks и -Context; выключение -Checks off или externalCheck: false
в .v8-project.json. С выключенной проверкой поведение прежнее.

У копии объекта перевыпускаются все GUID: с идентификаторами исходника
платформа путает копию в базе с загружаемой внешней обработкой и через раз
отвечает «Исключение XDTO при чтении файла» на исправном исходнике.

Ручная матрица (база есть/нет × ps1/py × исправный/битый × с модулями/без)
вскрыла ещё три вещи: отказ платформы принять исходники при подготовке
проверки выдавался за сбой базы; py-порт стаба терял причину отказа
LoadConfigFromFiles (не было /Out, в PS он есть); копии общих утилит в
stub-db-create.py разошлись с эталоном db-create — у run_v8 не было
POSIX-ветки, из-за чего на darwin/Linux временная база с пробелом в пути
молча не находилась бы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013MQkqXxErBepYmoUycfkbn
This commit is contained in:
Nick Shirokov
2026-09-05 17:46:30 +03:00
co-authored by Claude Opus 5
parent a3ea0b7be6
commit 448f05ed11
23 changed files with 1081 additions and 67 deletions
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
# stub-db-create v1.9 — Create temp 1C infobase with metadata stubs for EPF/ERF build
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import io
import os
import shutil
import random
import re
import subprocess
@@ -64,11 +66,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
cmd = [v8path] + arguments
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
@@ -178,7 +200,6 @@ def assert_extra_args(extra, engine, hints):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
)
sys.exit(1)
if engine != "ibcmd":
@@ -187,7 +208,6 @@ def assert_extra_args(extra, engine, hints):
print(
f"Error: {b} is a batch command; passed via {param} it would replace "
f"the skill's own operation (a command line runs only its last batch command)",
file=sys.stderr,
)
sys.exit(1)
for k in owned:
@@ -195,7 +215,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
)
sys.exit(1)
@@ -263,14 +282,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
file=sys.stderr,
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
file=sys.stderr,
)
sys.exit(1)
if engine == "ibcmd":
@@ -1103,6 +1120,94 @@ def write_bom(path, content):
f.write(content)
# --- Внедрение проверяемого объекта в конфигурацию-заглушку ---
# Платформа не умеет проверять внешнюю обработку: /LoadExternalDataProcessorOrReportFromFiles
# только упаковывает XML и модули не компилирует. Зато она проверяет объект КОНФИГУРАЦИИ, а
# внешняя обработка отличается от него немногим (замер 8.3.24): корневым тегом, именем
# порождаемого объектного типа и отсутствием типа менеджера. Правим ровно эти точки и переносим
# остальное как есть — под проверку попадает всё, что написал автор, включая реквизиты, формы и
# макеты, а формат может расти без правок здесь.
#
# Подстановка типа делается ТОЛЬКО в .xml (это DefaultForm и основной реквизит формы); в .bsl
# такой же текст был бы кодом, и трогать его нельзя.
GUID_RE = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
def add_source_object_to_config(source_xml, cfg_dir):
# Копия объекта живёт в конфигурации базы, а следом в ту же базу грузится исходник как ВНЕШНЯЯ
# обработка. С одинаковыми идентификаторами платформа путает их и через раз отвечает «Исключение
# XDTO при чтении файла» на исправном исходнике — поэтому у копии все GUID свои, но согласованные
# между её файлами (ссылки внутри объекта идут по идентификатору).
guid_map = {}
def reissue(text):
def sub(m):
k = m.group(0).lower()
if k not in guid_map:
guid_map[k] = new_uuid()
return guid_map[k]
return GUID_RE.sub(sub, text)
with io.open(source_xml, encoding='utf-8-sig') as fh:
text = fh.read()
if re.search(r'<ExternalDataProcessor[\s>]', text):
ext_tag, cfg_tag, folder = 'ExternalDataProcessor', 'DataProcessor', 'DataProcessors'
elif re.search(r'<ExternalReport[\s>]', text):
ext_tag, cfg_tag, folder = 'ExternalReport', 'Report', 'Reports'
else:
return None
m = re.search(r'<Name>([^<]+)</Name>', text)
name = m.group(1) if m else os.path.splitext(os.path.basename(source_xml))[0]
conv = reissue(text)
conv = conv.replace('<%s ' % ext_tag, '<%s ' % cfg_tag).replace('<%s>' % ext_tag, '<%s>' % cfg_tag)
conv = conv.replace('</%s>' % ext_tag, '</%s>' % cfg_tag)
conv = conv.replace('%sObject.' % ext_tag, '%sObject.' % cfg_tag).replace('%s.' % ext_tag, '%s.' % cfg_tag)
# Тип менеджера у внешней обработки не объявлен, а объекту конфигурации он обязателен:
# без него платформа отвечает «отсутствует один или более типов объекта».
mgr = ('\t\t\t<xr:GeneratedType name="%sManager.%s" category="Manager">\r\n' % (cfg_tag, name) +
'\t\t\t\t<xr:TypeId>%s</xr:TypeId>\r\n' % new_uuid() +
'\t\t\t\t<xr:ValueId>%s</xr:ValueId>\r\n' % new_uuid() +
'\t\t\t</xr:GeneratedType>\r\n')
if '</InternalInfo>' in conv:
conv = re.sub(r'\s*</InternalInfo>', lambda _m: '\r\n' + mgr + '\t\t</InternalInfo>', conv, count=1)
else:
obj_type = ('\t\t\t<xr:GeneratedType name="%sObject.%s" category="Object">\r\n' % (cfg_tag, name) +
'\t\t\t\t<xr:TypeId>%s</xr:TypeId>\r\n' % new_uuid() +
'\t\t\t\t<xr:ValueId>%s</xr:ValueId>\r\n' % new_uuid() +
'\t\t\t</xr:GeneratedType>\r\n')
conv = re.sub('(<%s[^>]*>)' % cfg_tag,
lambda m2: m2.group(1) + '\r\n\t\t<InternalInfo>\r\n' + obj_type + mgr + '\t\t</InternalInfo>',
conv, count=1)
obj_dir = os.path.join(cfg_dir, folder)
os.makedirs(obj_dir, exist_ok=True)
write_bom(os.path.join(obj_dir, '%s.xml' % name), conv)
# Содержимое объекта — как есть; в XML та же подстановка типа, .bsl копируются байт в байт.
src_content = os.path.join(os.path.dirname(source_xml), name)
if os.path.isdir(src_content):
dst_content = os.path.join(obj_dir, name)
for root, _dirs, files in os.walk(src_content):
for fname in files:
full = os.path.join(root, fname)
rel = os.path.relpath(full, src_content)
dst = os.path.join(dst_content, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.splitext(fname)[1].lower() == '.xml':
with io.open(full, encoding='utf-8-sig') as fh:
t = fh.read()
t = reissue(t)
t = t.replace('%sObject.' % ext_tag, '%sObject.' % cfg_tag).replace('%s.' % ext_tag, '%s.' % cfg_tag)
write_bom(dst, t)
else:
shutil.copyfile(full, dst)
return {'tag': cfg_tag, 'name': name}
def main():
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
@@ -1111,6 +1216,9 @@ def main():
parser.add_argument('-SourceDir', required=True)
parser.add_argument('-V8Path', required=True)
parser.add_argument('-TempBasePath', default='')
# XML проверяемой обработки/отчёта: объект кладётся в конфигурацию-заглушку, чтобы платформа
# смогла проверить его штатными проверками. Без параметра стаб работает как раньше.
parser.add_argument('-EmbedSourceFile', default='')
parser.add_argument('-AdditionalV8Arguments', nargs='*', default=[],
help='Extra 1cv8 arguments, e.g. /UseHwLicenses+')
parser.add_argument('-AdditionalIbcmdArguments', nargs='*', default=[],
@@ -1122,10 +1230,13 @@ def main():
args.SourceDir = clean_path(args.SourceDir, "-SourceDir")
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.TempBasePath = clean_path(args.TempBasePath, "-TempBasePath")
args.EmbedSourceFile = clean_path(args.EmbedSourceFile, "-EmbedSourceFile")
type_map = scan_ref_types(args.SourceDir)
register_columns = scan_register_columns(args.SourceDir)
has_ref_types = len(type_map) > 0
embed_requested = bool(args.EmbedSourceFile and args.EmbedSourceFile.strip())
need_cfg = has_ref_types or embed_requested
stub_format_version = detect_stub_format_version(args.SourceDir)
stub_compat = stub_compatibility_mode(stub_format_version)
ns_decl = f'{NS} version="{stub_format_version}"'
@@ -1138,10 +1249,18 @@ def main():
if needs_registrator:
type_map.setdefault('Document', {})['\u0417\u0430\u0433\u043b\u0443\u0448\u043a\u0430\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430'] = True # ЗаглушкаРегистратора
if has_ref_types:
if need_cfg:
cfg_dir = os.path.join(temp_base, 'cfg')
os.makedirs(cfg_dir, exist_ok=True)
embedded = None
if embed_requested:
embedded = add_source_object_to_config(args.EmbedSourceFile, cfg_dir)
if not embedded:
print('Error: %s is neither ExternalDataProcessor nor ExternalReport' % args.EmbedSourceFile,
file=sys.stderr)
sys.exit(1)
# Configuration.xml
uuid_cfg = new_uuid()
uuid_lang = new_uuid()
@@ -1158,6 +1277,8 @@ def main():
tag = META_INFO[meta_type][0]
for name in names:
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
if embedded:
child_xml += '\n\t\t\t<%s>%s</%s>' % (embedded['tag'], embedded['name'], embedded['tag'])
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {ns_decl}>
@@ -1388,7 +1509,7 @@ def main():
print(f'Creating infobase (ibcmd): {temp_base}')
ib_data = tempfile.mkdtemp(prefix="stub_data_")
ib_args = [args.V8Path, 'infobase', 'create', f'--db-path={temp_base}', '--create-database']
if has_ref_types:
if need_cfg:
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
ib_args.append(f'--data={ib_data}')
ib_args.extend(extra_args)
@@ -1401,7 +1522,7 @@ def main():
print(result.stderr, file=sys.stderr)
print(f'Failed to create stub infobase (code: {result.returncode})', file=sys.stderr)
sys.exit(1)
if has_ref_types:
if need_cfg:
import shutil
shutil.rmtree(os.path.join(temp_base, 'cfg'), ignore_errors=True)
print(f'[OK] Stub database created: {temp_base}')
@@ -1417,13 +1538,24 @@ def main():
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
sys.exit(1)
if has_ref_types:
if need_cfg:
cfg_dir = os.path.join(temp_base, 'cfg')
# LoadConfigFromFiles
print('Loading configuration from files...')
load_log = os.path.join(tempfile.gettempdir(), 'stub_load_log.txt')
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"',
'/Out', f'"{load_log}"',
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
if result.returncode != 0:
# Причина отказа живёт только в /Out: в консоль пакетный 1cv8 не пишет ничего.
if os.path.isfile(load_log):
try:
with io.open(load_log, encoding='utf-8-sig', errors='replace') as fh:
text = fh.read().strip()
if text:
print(text)
except Exception:
pass
print_platform_output(result)
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
sys.exit(1)