fix(meta-compile): #38 — регистрация в Configuration.xml без пересериализации (сохранение xmlns)

Блок «Register in Configuration.xml» py-порта пересоздавал весь файл через
ElementTree.write, который объявляет в корне только namespace из имён элементов/
атрибутов и молча выкидывает объявления, живущие лишь в значениях атрибутов
(xsi:type="app:ApplicationUsePurpose" в UsePurposes). Необъявленный префикс → XDTO
читает значение как anyType → Конфигуратор отказывается грузить Configuration.xml.

Теперь ET используется только read-only (поиск ChildObjects + проверка дубля), а
запись — текстовой вставкой в исходное содержимое с сохранением BOM/EOL/всех xmlns
байт-в-байт (как уже делает subsystem-compile). Многострочная группировка по типу,
обработка self-closing <ChildObjects/>, идемпотентность сохранены.

PS1-порт (XmlDocument хранит xmlns как атрибутные узлы) багу не подвержен —
функциональных правок нет, версия синхронно поднята до v1.15.

tests(runner): снят xmlns-strip нормализатора для Configuration.xml — он маскировал
баг и прятал бы регрессии. Полный python-suite 472/472; на старом коде un-masked
runner ловит поломку (30/33). Радиус изменения нулевой (прочие навыки не роняют xmlns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-09 12:26:00 +03:00
parent 54225aeaf3
commit a6ca515c96
3 changed files with 61 additions and 51 deletions
@@ -1,4 +1,4 @@
# meta-compile v1.14 — Compile 1C metadata object from JSON # meta-compile v1.15 — Compile 1C metadata object from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# meta-compile v1.14 — Compile 1C metadata object from JSON # meta-compile v1.15 — Compile 1C metadata object from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -2772,21 +2772,19 @@ reg_result = None
child_tag = obj_type child_tag = obj_type
if os.path.isfile(config_xml_path): if os.path.isfile(config_xml_path):
# Parse preserving whitespace via raw string manipulation # Read raw content, preserving BOM/EOL byte-for-byte (newline='' => no translation).
with open(config_xml_path, 'r', encoding='utf-8-sig') as f: with open(config_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
config_content = f.read() config_content = f.read()
ns = 'http://v8.1c.ru/8.3/MDClasses' ns = 'http://v8.1c.ru/8.3/MDClasses'
ET.register_namespace('', ns) # ET is used ONLY read-only here: to locate ChildObjects and detect a duplicate.
# Parse all namespaces used in the file # We deliberately do NOT re-serialize Configuration.xml with ElementTree.write():
# Use iterparse to collect namespace prefixes # it drops every xmlns declaration used only inside attribute VALUES (e.g.
namespaces_in_file = {} # xsi:type="app:ApplicationUsePurpose" in UsePurposes) because ET never sees those
for evt, elem in ET.iterparse(config_xml_path, events=['start-ns']): # prefixes in element/attribute names. The dropped declaration makes XDTO read the
prefix, uri = elem # value as anyType and Designer refuses to load the file (issue #38). Registration is
if prefix: # therefore done by raw-text insertion, preserving BOM, EOL and all namespaces
namespaces_in_file[prefix] = uri # byte-for-byte (same approach as subsystem-compile).
ET.register_namespace(prefix, uri)
tree = ET.parse(config_xml_path) tree = ET.parse(config_xml_path)
root = tree.getroot() root = tree.getroot()
@@ -2797,41 +2795,47 @@ if os.path.isfile(config_xml_path):
if config_elem is not None: if config_elem is not None:
child_objects = config_elem.find(f'{{{ns}}}ChildObjects') child_objects = config_elem.find(f'{{{ns}}}ChildObjects')
if child_objects is not None: if child_objects is None:
reg_result = 'no-childobj'
else:
existing = child_objects.findall(f'{{{ns}}}{child_tag}') existing = child_objects.findall(f'{{{ns}}}{child_tag}')
already_exists = False already_exists = any((e.text or '').strip() == obj_name for e in existing)
for e in existing:
if (e.text or '').strip() == obj_name:
already_exists = True
break
if already_exists: if already_exists:
reg_result = 'already' reg_result = 'already'
else: else:
new_elem = ET.SubElement(child_objects, f'{{{ns}}}{child_tag}') eol = '\r\n' if '\r\n' in config_content else '\n'
new_elem.text = obj_name entry = f'<{child_tag}>{esc_xml(obj_name)}</{child_tag}>'
if existing: block = re.search(r'<ChildObjects\s*>.*?</ChildObjects>', config_content, re.S)
# Insert after last existing element of same type if block is None:
last_elem = existing[-1] # Empty self-closing <ChildObjects/> => open it with the first entry.
all_children = list(child_objects) empty = re.search(r'<ChildObjects\s*/>', config_content)
idx = all_children.index(last_elem) if empty is None:
child_objects.remove(new_elem) reg_result = 'no-childobj'
child_objects.insert(idx + 1, new_elem) else:
replacement = f'<ChildObjects>{eol}\t\t\t{entry}{eol}\t\t</ChildObjects>'
# Write back preserving BOM new_content = config_content[:empty.start()] + replacement + config_content[empty.end():]
tree.write(config_xml_path, encoding='utf-8', xml_declaration=True) write_utf8_bom(config_xml_path, new_content)
# Re-read to add BOM, fix declaration quotes, ensure trailing newline reg_result = 'added'
with open(config_xml_path, 'r', encoding='utf-8') as f: else:
raw = f.read() close_same = f'</{child_tag}>'
if raw.startswith("<?xml version='1.0' encoding='utf-8'?>"): last_same = config_content.rfind(close_same, block.start(), block.end())
raw = raw.replace("<?xml version='1.0' encoding='utf-8'?>", '<?xml version="1.0" encoding="UTF-8"?>', 1) if last_same != -1:
if not raw.endswith('\n'): # After the last element of the same type (keeps them grouped).
raw += '\n' insert_at = last_same + len(close_same)
write_utf8_bom(config_xml_path, raw) new_content = (config_content[:insert_at]
reg_result = 'added' + f'{eol}\t\t\t{entry}'
else: + config_content[insert_at:])
reg_result = 'no-childobj' else:
# No element of this type yet: new line before </ChildObjects>,
# reusing the block's existing closing indent for </ChildObjects>.
close_at = config_content.rfind('</ChildObjects>', block.start(), block.end())
new_content = (config_content[:close_at]
+ f'\t{entry}{eol}\t\t'
+ config_content[close_at:])
write_utf8_bom(config_xml_path, new_content)
reg_result = 'added'
else: else:
reg_result = 'no-config' reg_result = 'no-config'
+14 -8
View File
@@ -292,7 +292,7 @@ function buildArgs(skillConfig, caseData, workDir, inputFilePath, runtime) {
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
function normalizeXmlContent(text) { function normalizeXmlContent(text, opts = {}) {
let s = text; let s = text;
// 1. XML declaration: normalize quotes and encoding case // 1. XML declaration: normalize quotes and encoding case
s = s.replace( s = s.replace(
@@ -301,8 +301,13 @@ function normalizeXmlContent(text) {
); );
// 2. Remove &#13; (CR encoded as XML entity by Python etree) // 2. Remove &#13; (CR encoded as XML entity by Python etree)
s = s.replace(/&#13;/g, ''); s = s.replace(/&#13;/g, '');
// 3. Strip xmlns declarations (Python etree strips unused ones) // 3. Strip xmlns declarations (Python etree strips unused ones).
s = s.replace(/\s+xmlns(?::[\w]+)?="[^"]*"/g, ''); // Skipped for Configuration.xml: those declarations are load-bearing (they back
// xsi:type values like app:ApplicationUsePurpose in UsePurposes) and dropping them
// is exactly the corruption of issue #38 — keeping them lets the test guard against it.
if (!opts.keepXmlns) {
s = s.replace(/\s+xmlns(?::[\w]+)?="[^"]*"/g, '');
}
// 4. Normalize self-closing tags: remove space before /> // 4. Normalize self-closing tags: remove space before />
s = s.replace(/\s*\/>/g, '/>'); s = s.replace(/\s*\/>/g, '/>');
// 5. Collapse whitespace between tags: "> \n\t <" → "><" // 5. Collapse whitespace between tags: "> \n\t <" → "><"
@@ -314,14 +319,15 @@ function normalizeXmlContent(text) {
return s; return s;
} }
function normalizeContent(text, config) { function normalizeContent(text, config, relFile) {
// Strip BOM // Strip BOM
let s = text.replace(/^\uFEFF/, ''); let s = text.replace(/^\uFEFF/, '');
// Normalize line endings // Normalize line endings
s = s.replace(/\r\n/g, '\n'); s = s.replace(/\r\n/g, '\n');
// Normalize XML differences (Python etree serialization quirks) // Normalize XML differences (Python etree serialization quirks)
if (config?.runtime === 'python') { if (config?.runtime === 'python') {
s = normalizeXmlContent(s); const base = relFile ? relFile.split(/[\\/]/).pop() : '';
s = normalizeXmlContent(s, { keepXmlns: base === 'Configuration.xml' });
} }
// Normalize UUIDs // Normalize UUIDs
@@ -403,8 +409,8 @@ function compareSnapshot(workDir, snapshotDir, snapshotConfig) {
const actualRaw = readFileSync(actualPath, 'utf8'); const actualRaw = readFileSync(actualPath, 'utf8');
const snapshotRaw = readFileSync(snapshotPath, 'utf8'); const snapshotRaw = readFileSync(snapshotPath, 'utf8');
const actual = normalizeContent(actualRaw, snapshotConfig); const actual = normalizeContent(actualRaw, snapshotConfig, relFile);
const expected = normalizeContent(snapshotRaw, snapshotConfig); const expected = normalizeContent(snapshotRaw, snapshotConfig, relFile);
if (actual !== expected) { if (actual !== expected) {
// Find first differing line // Find first differing line
@@ -446,7 +452,7 @@ function updateSnapshot(workDir, snapshotDir, snapshotConfig) {
mkdirSync(dirname(dst), { recursive: true }); mkdirSync(dirname(dst), { recursive: true });
const raw = readFileSync(src, 'utf8'); const raw = readFileSync(src, 'utf8');
const normalized = normalizeContent(raw, snapshotConfig); const normalized = normalizeContent(raw, snapshotConfig, relFile);
writeFileSync(dst, normalized, 'utf8'); writeFileSync(dst, normalized, 'utf8');
} }
} }