From 19ac7a5e9a498e7fd599cfb7f8374f7bc3889871 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 15:28:34 +0000 Subject: [PATCH] fix(partial-load): include metadata XML descriptor and full object tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При partial-load Designer выгрузки в Hierarchical-формате конфигуратор определяет тип и имя объекта по XML-дескриптору (`/.xml`), который лежит на уровень выше каталога объекта. Старый код добавлял в list-файл только сам BSL (плюс sibling `Module.xml` рядом с BSL, которого в Designer-формате не существует), поэтому Designer трактовал строку `CommonModules//Ext/Module.bsl` как property-путь Configuration и падал с ошибкой «Свойство не входит в состав объекта метаданных Configuration». Что сделано: - Введён whitelist 43 типов метаданных верхнего уровня (CommonModules, Documents, Catalogs, …). - На каждое BSL-изменение находится корневой объект `/` и в list добавляются: * XML-дескриптор `/.xml` * рекурсивно все файлы внутри каталога объекта (включая формы, дополнительные модули, шаблоны). - Старая EDT-ориентированная логика `sibling_xml`/`object_dir` удалена вместе с синтетическими тестами на `Catalogs.Items/...` — partial-load всегда работает в Hierarchical Designer-формате (в EDT-режиме конвертация EDT→Designer выполняется выше, в `plan_edt_export_step`). - Обновлены unit-тесты: добавлены кейсы для CommonModule, Document ObjectModule, Form Module, BSL вне whitelist + сохранены проверки threshold/deleted/Configuration.xml/symlink-escape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/change_detection/partial_load.rs | 444 +++++++++++++++++++++------ 1 file changed, 348 insertions(+), 96 deletions(-) diff --git a/src/change_detection/partial_load.rs b/src/change_detection/partial_load.rs index 97e5ca9..9342bed 100644 --- a/src/change_detection/partial_load.rs +++ b/src/change_detection/partial_load.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::change_detection::analyzer::{ChangeKind, FileChange}; @@ -9,6 +9,61 @@ pub const DEFAULT_PARTIAL_LOAD_THRESHOLD: usize = 20; /// The name of the root configuration descriptor — if changed, partial load is forbidden. const CONFIGURATION_XML: &str = "Configuration.xml"; +/// Top-level metadata-type directory names in the 1C Designer Hierarchical layout. +/// +/// When a `.bsl` change is located under `///...`, the +/// owning XML descriptor lives at `//.xml` (one level +/// above the object directory) and the related files for the object live under +/// `///`. Without this whitelist we cannot reliably +/// reconstruct the owning object from a deeply nested BSL path (think +/// `Documents//Forms/
/Ext/Form/Module.bsl`). +const METADATA_TYPES: &[&str] = &[ + "AccountingRegisters", + "AccumulationRegisters", + "BusinessProcesses", + "CalculationRegisters", + "Catalogs", + "ChartsOfAccounts", + "ChartsOfCalculationTypes", + "ChartsOfCharacteristicTypes", + "CommandGroups", + "CommonAttributes", + "CommonCommands", + "CommonForms", + "CommonModules", + "CommonPictures", + "CommonTemplates", + "Constants", + "DataProcessors", + "DefinedTypes", + "DocumentJournals", + "Documents", + "Enums", + "EventSubscriptions", + "ExchangePlans", + "ExternalDataSources", + "FilterCriteria", + "FunctionalOptions", + "FunctionalOptionsParameters", + "HTTPServices", + "InformationRegisters", + "Interfaces", + "Languages", + "Reports", + "Roles", + "ScheduledJobs", + "Sequences", + "SessionParameters", + "SettingsStorages", + "StyleItems", + "Styles", + "Subsystems", + "Tasks", + "WSReferences", + "WebServices", + "XDTOPackages", +]; + /// Decision made by [`decide`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadDecision { @@ -52,7 +107,7 @@ pub fn decide(changes: &[FileChange], source_root: &Path, threshold: usize) -> L /// Write a partial-load list file (UTF-8, one path per line, no empty lines). /// /// Paths are written relative to `source_root` as required by Designer's -/// `-listFile` parameter when running in agent mode. +/// `-listFile` parameter. pub fn write_list_file(paths: &[PathBuf], source_root: &Path, dest: &Path) -> std::io::Result<()> { let rel_paths = relative_paths(paths, source_root)?; let lines = rel_paths @@ -98,16 +153,29 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option не входит в состав объекта метаданных Configuration". + // So for every changed BSL we resolve /// + // and add the XML descriptor plus everything inside the object directory. + if !is_bsl(&change.path) { + continue; + } + + let Some(owner) = locate_metadata_object(&change.path, source_root) else { + continue; + }; + + push_if_safe_if_exists(&mut paths, &owner.xml, source_root, &root_real)?; - if let Some(object_dir) = object_dir(&change.path, source_root) { - push_if_safe_if_exists(&mut paths, &object_dir, source_root, &root_real)?; - } + if owner.dir.is_dir() { + collect_object_directory(&mut paths, &owner.dir, source_root, &root_real)?; } } @@ -116,6 +184,90 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option//.xml` — the XML descriptor Designer + /// uses to recognise the object's type and name. + xml: PathBuf, + /// `///` — contents recurse here for forms, + /// templates, manager/object modules, etc. + dir: PathBuf, +} + +/// Walks the relative path components looking for the first known metadata-type +/// directory followed by an object name. Returns the XML descriptor + object +/// directory pair, or `None` if the file does not sit inside a recognised +/// metadata container. +fn locate_metadata_object(bsl: &Path, source_root: &Path) -> Option { + let relative = bsl.strip_prefix(source_root).ok()?; + let components: Vec<&str> = relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect(); + + // Need at least //.bsl to make sense: + // a `.bsl` directly inside / is not a real Hierarchical layout. + for (idx, component) in components.iter().enumerate() { + if !is_metadata_type(component) { + continue; + } + if idx + 2 > components.len() { + // No object name after the metadata-type directory. + continue; + } + let object_name = components[idx + 1]; + // Sanity: the object name must not be a file (e.g. nested BSL right under + // /); skip and keep searching. + if object_name.ends_with(".bsl") || object_name.ends_with(".xml") { + continue; + } + + let mut xml = source_root.to_path_buf(); + let mut dir = source_root.to_path_buf(); + for prefix in &components[..=idx] { + xml.push(prefix); + dir.push(prefix); + } + xml.push(format!("{object_name}.xml")); + dir.push(object_name); + return Some(MetadataOwner { xml, dir }); + } + + None +} + +fn is_metadata_type(name: &str) -> bool { + METADATA_TYPES.iter().any(|known| *known == name) +} + +/// Recursively adds every regular file under `dir` (relative to `source_root`) +/// into `paths`. Unreadable entries are silently skipped — the goal is best +/// effort enumeration of the owning object's tree, not a strict invariant. +fn collect_object_directory( + paths: &mut Vec, + dir: &Path, + source_root: &Path, + root_real: &Path, +) -> Option<()> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Some(()); + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_object_directory(paths, &path, source_root, root_real)?; + } else if path.is_file() { + push_if_safe(paths, &path, source_root, root_real)?; + } + } + Some(()) +} + fn push_if_safe( paths: &mut Vec, candidate: &Path, @@ -174,33 +326,6 @@ fn is_bsl(path: &Path) -> bool { .unwrap_or(false) } -/// Return the XML descriptor alongside a `.bsl` file (same name, `.xml` ext). -fn sibling_xml(bsl: &Path) -> Option { - let parent = bsl.parent()?; - let stem = bsl.file_stem()?.to_str()?; - Some(parent.join(format!("{stem}.xml"))) -} - -/// Return the object directory that owns a `.bsl` module. -fn object_dir(bsl: &Path, source_root: &Path) -> Option { - let parent = bsl.parent()?; - let relative = bsl.strip_prefix(source_root).ok(); - let is_nested_module = bsl - .file_name() - .and_then(|n| n.to_str()) - .map(|name| name.eq_ignore_ascii_case("Module.bsl")) - .unwrap_or(false) - && relative - .map(|path| path.components().count() >= 4) - .unwrap_or(false); - - if is_nested_module { - return parent.parent()?.parent().map(Path::to_path_buf); - } - - Some(parent.to_path_buf()) -} - #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -208,29 +333,179 @@ mod tests { use tempfile::tempdir; use super::{ - decide, object_dir, relative_paths, write_list_file, LoadDecision, + decide, locate_metadata_object, relative_paths, write_list_file, LoadDecision, DEFAULT_PARTIAL_LOAD_THRESHOLD, }; use crate::change_detection::analyzer::{ChangeKind, FileChange}; + /// Helper: ensure that the file is created on disk so that `canonicalize` in the + /// production code succeeds. Returns the absolute path. + fn touch(root: &Path, relative: &str) -> PathBuf { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, b"").expect("write"); + path + } + #[test] - fn object_dir_uses_parent_for_top_level_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/ObjectModule.bsl"); + fn locate_metadata_object_for_common_module_designer_layout() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("CommonModules/MyModule/Ext/Module.bsl"); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("CommonModules/MyModule.xml")); + assert_eq!(owner.dir, root.join("CommonModules/MyModule")); + } + + #[test] + fn locate_metadata_object_for_document_object_module() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Ext/ObjectModule.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_for_form_module_walks_up_to_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + // Form's owning object is the document itself — Designer needs the document + // XML, not the form XML, to recognise the target during partial load. + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_returns_none_when_no_metadata_type_in_path() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Misc/something/Module.bsl"); + + assert!(locate_metadata_object(&bsl, root).is_none()); + } + + #[test] + fn decide_partial_load_for_common_module_adds_xml_and_object_dir_recursively() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let xml = touch(root, "CommonModules/MyModule.xml"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + // Sorted, deduped: the BSL itself (picked up both as the change and via the + // recursive walk of the object directory) + the XML descriptor. PathBuf + // ordering is component-wise, so "MyModule/Ext/Module.bsl" sorts before + // "MyModule.xml" — the shorter `MyModule` component beats `MyModule.xml`. + assert_eq!(decision, LoadDecision::Partial(vec![module, xml])); } #[test] - fn object_dir_uses_owning_object_for_nested_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/Forms/Form1/Module.bsl"); + fn decide_partial_load_for_document_object_module_includes_all_object_files() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let manager_module = touch(root, "Documents/Order/Ext/ManagerModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_descriptor = touch(root, "Documents/Order/Forms/MainForm/Ext/Form.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: object_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + let mut expected = vec![ + doc_xml, + object_module, + manager_module, + form_xml, + form_descriptor, + form_module, + ]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_partial_load_for_form_module_pulls_in_owning_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: form_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + // Editing one form module must still bring the document XML and sibling + // files (other modules, other forms) along so Designer can load the + // whole object coherently. + let mut expected = vec![doc_xml, object_module, form_xml, form_module]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_bsl_outside_metadata_whitelist_adds_only_the_file() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let module = touch(root, "Misc/SomeFolder/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + assert_eq!(decision, LoadDecision::Partial(vec![module])); } #[test] @@ -248,14 +523,14 @@ mod tests { fn relative_paths_returns_relative_entries_for_safe_paths() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let nested = root.join("Catalogs.Items"); - std::fs::create_dir_all(&nested).expect("mkdir"); - let file = nested.join("ObjectModule.bsl"); - std::fs::write(&file, "module").expect("write"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); - let rels = relative_paths(&[file.clone()], root).expect("relative paths"); + let rels = relative_paths(&[module.clone()], root).expect("relative paths"); - assert_eq!(rels, vec![PathBuf::from("Catalogs.Items/ObjectModule.bsl")]); + assert_eq!( + rels, + vec![PathBuf::from("CommonModules/MyModule/Ext/Module.bsl")] + ); } #[cfg(unix)] @@ -266,16 +541,15 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); std::fs::create_dir_all(&root).expect("root"); std::fs::create_dir_all(&outside).expect("outside"); std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = - relative_paths(&[link.join("ObjectModule.bsl")], &root).expect_err("expected error"); + let err = relative_paths(&[link.join("Module.bsl")], &root).expect_err("expected error"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } @@ -288,8 +562,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); let list_file = temp.path().join("partial.lst"); std::fs::create_dir_all(&root).expect("root"); @@ -297,45 +571,17 @@ mod tests { std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = write_list_file(&[link.join("ObjectModule.bsl")], &root, &list_file) + let err = write_list_file(&[link.join("Module.bsl")], &root, &list_file) .expect_err("expected invalid path"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - #[test] - fn decide_expands_bsl_to_xml_and_object_dir() { - let temp = tempdir().expect("tempdir"); - let root = temp.path(); - let object_dir = root.join("Catalogs.Items"); - let module = object_dir.join("ObjectModule.bsl"); - let xml = object_dir.join("ObjectModule.xml"); - - std::fs::create_dir_all(&object_dir).expect("create object dir"); - std::fs::write(&module, "module").expect("write module"); - std::fs::write(&xml, "").expect("write xml"); - - let decision = decide( - &[FileChange { - path: module.clone(), - kind: ChangeKind::Modified, - }], - root, - DEFAULT_PARTIAL_LOAD_THRESHOLD, - ); - - assert_eq!( - decision, - LoadDecision::Partial(vec![object_dir, module, xml]) - ); - } - #[test] fn decide_forces_full_when_configuration_xml_changed() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let config_xml = root.join("Configuration.xml"); - std::fs::write(&config_xml, "").expect("write config"); + let config_xml = touch(root, "Configuration.xml"); let decision = decide( &[FileChange { @@ -353,7 +599,9 @@ mod tests { fn decide_forces_full_when_deleted_files_exist() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let removed = root.join("Catalogs.Items").join("ObjectModule.bsl"); + // Even though the path itself does not need to exist for a deletion event, + // the Hierarchical layout is preserved for readability. + let removed = root.join("CommonModules/MyModule/Ext/Module.bsl"); let decision = decide( &[FileChange { @@ -373,10 +621,14 @@ mod tests { let root = temp.path(); let mut changes = Vec::new(); + // Each module sits in its own object directory but without an XML descriptor + // on disk — that keeps the per-change file count to exactly one and lets us + // count past the threshold predictably. for index in 0..=DEFAULT_PARTIAL_LOAD_THRESHOLD { - let path = root.join(format!("CommonModules/Module{index}.bsl")); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write(&path, "module").expect("write"); + let path = touch( + root, + &format!("CommonModules/Module{index}/Ext/Module.bsl"), + ); changes.push(FileChange { path, kind: ChangeKind::Modified, @@ -395,8 +647,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside_dir = temp.path().join("outside"); - let link_dir = root.join("Catalogs.Items"); - let escaped = outside_dir.join("ObjectModule.bsl"); + let link_dir = root.join("CommonModules"); + let escaped = outside_dir.join("Module.bsl"); std::fs::create_dir_all(&outside_dir).expect("outside"); std::fs::create_dir_all(&root).expect("root"); @@ -405,7 +657,7 @@ mod tests { let decision = decide( &[FileChange { - path: link_dir.join("ObjectModule.bsl"), + path: link_dir.join("Module.bsl"), kind: ChangeKind::Modified, }], &root,