From eaf3f5910c25d10a706399ff009c68d7e2eebbd3 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 19:07:01 +0300 Subject: [PATCH 1/4] feat(config): validate source-set dependencies - add optional dependsOn model and schema support - reject invalid dependency graphs before platform launch - validate deep chains with iterative traversal and memoized roots --- docs/schemas/v8project.schema.json | 7 + src/change_detection/source_sets.rs | 2 + src/cli/execute.rs | 3 + src/config/loader.rs | 107 +++++++++ src/config/model.rs | 4 + src/config/schema.rs | 9 + src/config/validate.rs | 322 +++++++++++++++++++++++++- src/mcp/port.rs | 1 + src/mcp/server.rs | 1 + src/mcp/service.rs | 1 + src/platform/edt.rs | 1 + src/use_cases/artifacts.rs | 3 + src/use_cases/build_project.rs | 20 ++ src/use_cases/check_syntax.rs | 3 + src/use_cases/configure_extensions.rs | 2 + src/use_cases/dump_config.rs | 3 + src/use_cases/extension_identity.rs | 1 + src/use_cases/external_artifacts.rs | 4 + src/use_cases/init_project.rs | 5 + src/use_cases/launch_app.rs | 1 + src/use_cases/run_tests.rs | 1 + src/use_cases/source_inventory.rs | 4 + src/use_cases/transport.rs | 1 + src/use_cases/workspace_lock.rs | 1 + 24 files changed, 497 insertions(+), 10 deletions(-) diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index fc3d72b..9fd85ed 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -322,6 +322,13 @@ "SourceSetSchema": { "additionalProperties": false, "properties": { + "dependsOn": { + "description": "Names of immediate source-set dependencies.", + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "description": "Source-set name used by CLI filters and diagnostics.", "type": "string" diff --git a/src/change_detection/source_sets.rs b/src/change_detection/source_sets.rs index 6adb256..24f2213 100644 --- a/src/change_detection/source_sets.rs +++ b/src/change_detection/source_sets.rs @@ -112,6 +112,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: std::path::PathBuf::from("src"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -140,6 +141,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: std::path::PathBuf::from("src"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 927e7b2..9953cef 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -2949,16 +2949,19 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext-sales".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("ext-sales"), + depends_on: Vec::new(), }, SourceSetConfig { name: "external-processors".to_owned(), purpose: SourceSetPurpose::ExternalDataProcessors, path: PathBuf::from("external-processors"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), diff --git a/src/config/loader.rs b/src/config/loader.rs index 4252661..616ea54 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -439,6 +439,31 @@ mod tests { ) } + fn dependency_config(source_sets: &str) -> String { + format!( + "workPath: work\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=build/ib\"\nsource-set:\n{source_sets}" + ) + } + + fn load_dependency_config( + source_sets: &str, + ) -> Result { + let dir = tempdir().expect("tempdir"); + let config_path = write_minimal_project_config( + &dir.path().join("project"), + &dependency_config(source_sets), + ); + load_config(config_path.to_str(), None) + } + + fn assert_dependency_error(source_sets: &str, expected: &str) { + let error = load_dependency_config(source_sets).expect_err("dependency graph must fail"); + assert_eq!( + error.to_string(), + format!("config validation failed: {expected}") + ); + } + #[test] fn load_config_defaults_missing_base_path_to_primary_config_dir() { let dir = tempdir().expect("tempdir"); @@ -456,6 +481,88 @@ mod tests { ); } + #[test] + fn load_config_accepts_valid_source_set_dependency_graph() { + let config = load_dependency_config( + " - name: main\n type: CONFIGURATION\n path: main\n - name: yaxunit\n type: EXTENSION\n path: yaxunit\n dependsOn:\n - main\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - yaxunit\n", + ) + .expect("valid dependency graph"); + + let serialized = serde_yaml::to_value(config).expect("serialize config"); + assert_eq!( + serialized["source-set"][2]["dependsOn"], + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("yaxunit".to_owned())]) + ); + } + + #[test] + fn load_config_keeps_source_set_dependencies_optional() { + let config = load_dependency_config( + " - name: main\n type: CONFIGURATION\n path: main\n - name: tests\n type: EXTENSION\n path: tests\n", + ) + .expect("legacy config without dependency graph"); + + let serialized = serde_yaml::to_value(config).expect("serialize config"); + assert!(serialized["source-set"][0].get("dependsOn").is_none()); + assert!(serialized["source-set"][1].get("dependsOn").is_none()); + } + + #[test] + fn load_config_rejects_unknown_source_set_dependency() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - missing\n", + "source-set 'tests' depends on unknown source-set 'missing'", + ); + } + + #[test] + fn load_config_rejects_self_source_set_dependency() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - tests\n", + "source-set 'tests' cannot depend on itself", + ); + } + + #[test] + fn load_config_rejects_duplicate_source_set_dependency() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - main\n - main\n", + "source-set 'tests' declares dependency 'main' more than once", + ); + } + + #[test] + fn load_config_rejects_external_artifact_source_set_dependency() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n - name: processors\n type: EXTERNAL_DATA_PROCESSORS\n path: processors\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - processors\n", + "source-set 'tests' dependency 'processors' must reference CONFIGURATION or EXTENSION source-set", + ); + } + + #[test] + fn load_config_rejects_source_set_dependency_cycle_with_full_path() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n dependsOn:\n - tests\n - name: yaxunit\n type: EXTENSION\n path: yaxunit\n dependsOn:\n - main\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - yaxunit\n", + "source-set dependency cycle: main -> tests -> yaxunit -> main", + ); + } + + #[test] + fn load_config_rejects_ambiguous_source_set_configuration_roots() { + assert_dependency_error( + " - name: main-a\n type: CONFIGURATION\n path: main-a\n - name: main-b\n type: CONFIGURATION\n path: main-b\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - main-a\n - main-b\n", + "source-set 'tests' dependencies must resolve to exactly one CONFIGURATION source-set; found [main-a, main-b]", + ); + } + + #[test] + fn load_config_rejects_missing_source_set_configuration_root() { + assert_dependency_error( + " - name: main\n type: CONFIGURATION\n path: main\n - name: yaxunit\n type: EXTENSION\n path: yaxunit\n - name: tests\n type: EXTENSION\n path: tests\n dependsOn:\n - yaxunit\n", + "source-set 'yaxunit' dependencies do not resolve to a CONFIGURATION source-set", + ); + } + #[test] fn load_config_applies_local_overlay_next_to_primary_config() { let dir = tempdir().expect("tempdir"); diff --git a/src/config/model.rs b/src/config/model.rs index 7f65d17..57e112e 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -215,6 +215,10 @@ pub struct SourceSetConfig { /// Path relative to the project base path (for DESIGNER) or EDT project path. pub path: PathBuf, + + /// Immediate source-set dependencies, addressed by source-set name. + #[serde(default, rename = "dependsOn", skip_serializing_if = "Vec::is_empty")] + pub depends_on: Vec, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] diff --git a/src/config/schema.rs b/src/config/schema.rs index 46b9c4d..55497bf 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -507,6 +507,9 @@ struct SourceSetSchema { purpose: SourceSetPurposeSchema, /// Source path relative to the primary config directory or an EDT project path. path: PathBuf, + /// Names of immediate source-set dependencies. + #[serde(default, rename = "dependsOn", skip_serializing_if = "Vec::is_empty")] + depends_on: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -1098,6 +1101,12 @@ mod tests { "type", "Source-set type", ); + assert_property_description_contains( + &main_schema, + &["SourceSetSchema"], + "dependsOn", + "immediate source-set dependencies", + ); assert_property_description_contains( &main_schema, &["ToolsSchema"], diff --git a/src/config/validate.rs b/src/config/validate.rs index 85ebd29..986921a 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::path::Path; use thiserror::Error; @@ -41,6 +41,40 @@ pub enum ConfigValidationError { #[error("source-set name contains unsafe path or filename characters: {0}")] InvalidSourceSetName(String), + #[error("source-set '{source_set}' depends on unknown source-set '{dependency}'")] + UnknownSourceSetDependency { + source_set: String, + dependency: String, + }, + + #[error("source-set '{source_set}' cannot depend on itself")] + SelfSourceSetDependency { source_set: String }, + + #[error("source-set '{source_set}' declares dependency '{dependency}' more than once")] + DuplicateSourceSetDependency { + source_set: String, + dependency: String, + }, + + #[error( + "source-set '{source_set}' dependency '{dependency}' must reference CONFIGURATION or EXTENSION source-set" + )] + UnsupportedSourceSetDependency { + source_set: String, + dependency: String, + }, + + #[error("source-set dependency cycle: {path}")] + SourceSetDependencyCycle { path: String }, + + #[error("source-set '{source_set}' dependencies do not resolve to a CONFIGURATION source-set")] + MissingSourceSetConfigurationRoot { source_set: String }, + + #[error( + "source-set '{source_set}' dependencies must resolve to exactly one CONFIGURATION source-set; found [{roots}]" + )] + AmbiguousSourceSetConfigurationRoots { source_set: String, roots: String }, + #[error("tools.client_mcp.extension.name must not duplicate project source-set name: {0}")] ToolExtensionNameDuplicatesSourceSet(String), @@ -265,22 +299,26 @@ fn validate_source_sets(config: &AppConfig) -> Result<(), ConfigValidationError> } let mut names = HashSet::::new(); - let mut resolved_paths = HashSet::::new(); - let mut edt_source_paths = Vec::new(); - for ss in &config.source_sets { - validate_source_set_name(&ss.name)?; - if config.format == SourceFormat::Edt && is_reserved_workdir_name(&ss.name) { + for source_set in &config.source_sets { + validate_source_set_name(&source_set.name)?; + if config.format == SourceFormat::Edt && is_reserved_workdir_name(&source_set.name) { return Err(ConfigValidationError::ReservedSourceSetName( - ss.name.clone(), + source_set.name.clone(), )); } - if !names.insert(ss.name.clone()) { + if !names.insert(source_set.name.clone()) { return Err(ConfigValidationError::DuplicateSourceSetName( - ss.name.clone(), + source_set.name.clone(), )); } + } + validate_source_set_dependencies(config)?; + + let mut resolved_paths = HashSet::::new(); + let mut edt_source_paths = Vec::new(); + for ss in &config.source_sets { let full_path = if ss.path.is_absolute() { ss.path.clone() } else { @@ -329,6 +367,202 @@ fn validate_source_sets(config: &AppConfig) -> Result<(), ConfigValidationError> Ok(()) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DependencyVisit { + Visiting, + Visited, +} + +struct DependencyFrame<'a> { + source_set: &'a SourceSetConfig, + next_dependency: usize, +} + +fn validate_source_set_dependencies(config: &AppConfig) -> Result<(), ConfigValidationError> { + if config + .source_sets + .iter() + .all(|source_set| source_set.depends_on.is_empty()) + { + return Ok(()); + } + + let source_sets = config + .source_sets + .iter() + .map(|source_set| (source_set.name.as_str(), source_set)) + .collect::>(); + + for source_set in &config.source_sets { + let mut dependencies = HashSet::new(); + for dependency in &source_set.depends_on { + if dependency == &source_set.name { + return Err(ConfigValidationError::SelfSourceSetDependency { + source_set: source_set.name.clone(), + }); + } + if !dependencies.insert(dependency.as_str()) { + return Err(ConfigValidationError::DuplicateSourceSetDependency { + source_set: source_set.name.clone(), + dependency: dependency.clone(), + }); + } + let Some(dependency_source_set) = source_sets.get(dependency.as_str()) else { + return Err(ConfigValidationError::UnknownSourceSetDependency { + source_set: source_set.name.clone(), + dependency: dependency.clone(), + }); + }; + if !matches!( + dependency_source_set.purpose, + SourceSetPurpose::Configuration | SourceSetPurpose::Extension + ) { + return Err(ConfigValidationError::UnsupportedSourceSetDependency { + source_set: source_set.name.clone(), + dependency: dependency.clone(), + }); + } + } + } + + let dependency_order = source_set_dependency_postorder(&config.source_sets, &source_sets)?; + validate_source_set_configuration_roots(&dependency_order)?; + + Ok(()) +} + +fn source_set_dependency_postorder<'a>( + source_sets_in_order: &'a [SourceSetConfig], + source_sets_by_name: &HashMap<&'a str, &'a SourceSetConfig>, +) -> Result, ConfigValidationError> { + let mut visits = HashMap::new(); + let mut path = Vec::new(); + let mut order = Vec::with_capacity(source_sets_in_order.len()); + + for source_set in source_sets_in_order { + let name = source_set.name.as_str(); + if visits.get(name) == Some(&DependencyVisit::Visited) { + continue; + } + + visits.insert(name, DependencyVisit::Visiting); + path.push(name); + let mut stack = vec![DependencyFrame { + source_set, + next_dependency: 0, + }]; + + while !stack.is_empty() { + let next_dependency = { + let Some(frame) = stack.last_mut() else { + break; + }; + match frame.source_set.depends_on.get(frame.next_dependency) { + Some(dependency) => { + frame.next_dependency += 1; + Some((frame.source_set, dependency.as_str())) + } + None => None, + } + }; + + let Some((dependent, dependency)) = next_dependency else { + let Some(completed) = stack.pop() else { + break; + }; + path.pop(); + visits.insert(completed.source_set.name.as_str(), DependencyVisit::Visited); + order.push(completed.source_set); + continue; + }; + + match visits.get(dependency).copied() { + Some(DependencyVisit::Visiting) => { + let mut cycle = path + .iter() + .copied() + .skip_while(|candidate| *candidate != dependency) + .collect::>(); + cycle.push(dependency); + return Err(ConfigValidationError::SourceSetDependencyCycle { + path: cycle.join(" -> "), + }); + } + Some(DependencyVisit::Visited) => {} + None => { + let dependency_source_set = source_sets_by_name + .get(dependency) + .copied() + .ok_or_else(|| ConfigValidationError::UnknownSourceSetDependency { + source_set: dependent.name.clone(), + dependency: dependency.to_owned(), + })?; + visits.insert(dependency, DependencyVisit::Visiting); + path.push(dependency); + stack.push(DependencyFrame { + source_set: dependency_source_set, + next_dependency: 0, + }); + } + } + } + } + + Ok(order) +} + +fn validate_source_set_configuration_roots( + dependency_order: &[&SourceSetConfig], +) -> Result<(), ConfigValidationError> { + let mut roots_by_source_set = HashMap::<&str, Vec<&str>>::new(); + + for source_set in dependency_order { + let roots = if source_set.purpose == SourceSetPurpose::Configuration { + vec![source_set.name.as_str()] + } else { + let mut roots = Vec::new(); + let mut unique_roots = HashSet::new(); + for dependency in &source_set.depends_on { + let dependency_roots = + roots_by_source_set + .get(dependency.as_str()) + .ok_or_else(|| ConfigValidationError::UnknownSourceSetDependency { + source_set: source_set.name.clone(), + dependency: dependency.clone(), + })?; + for root in dependency_roots { + if unique_roots.insert(*root) { + roots.push(*root); + } + } + } + roots + }; + + if source_set.purpose == SourceSetPurpose::Extension { + match roots.as_slice() { + [] => { + return Err(ConfigValidationError::MissingSourceSetConfigurationRoot { + source_set: source_set.name.clone(), + }); + } + [_root] => {} + [_first, _second, ..] => { + return Err( + ConfigValidationError::AmbiguousSourceSetConfigurationRoots { + source_set: source_set.name.clone(), + roots: roots.join(", "), + }, + ); + } + } + } + roots_by_source_set.insert(source_set.name.as_str(), roots); + } + + Ok(()) +} + fn validate_source_set_layout( format: SourceFormat, source_set: &SourceSetConfig, @@ -953,6 +1187,7 @@ fn validate_tool_extension_source( name: extension.name.clone(), purpose: SourceSetPurpose::Extension, path: source.path.clone(), + depends_on: Vec::new(), }; validate_ordinary_edt_source_set_layout(&source_set, &source.path).map_err(|error| { ConfigValidationError::ToolExtensionSourceLayoutInvalid(error.to_string()) @@ -987,7 +1222,7 @@ fn validate_tool_extension_edt_runtime_path( #[cfg(test)] mod tests { - use super::{validate, ConfigValidationError}; + use super::{validate, validate_source_set_dependencies, ConfigValidationError}; use crate::config::model::{ AppConfig, BuildConfig, BuilderBackend, PlatformToolConfig, SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, @@ -1022,6 +1257,7 @@ mod tests { name: name.to_owned(), purpose, path: relative_path(base, path), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1030,6 +1266,44 @@ mod tests { } } + #[test] + fn dependency_validation_handles_deep_chain_without_stack_growth() { + const DEPENDENCY_COUNT: usize = 10_000; + + let mut source_sets = Vec::with_capacity(DEPENDENCY_COUNT + 1); + source_sets.push(SourceSetConfig { + name: "main".to_owned(), + purpose: SourceSetPurpose::Configuration, + path: PathBuf::from("main"), + depends_on: Vec::new(), + }); + source_sets.extend((0..DEPENDENCY_COUNT).map(|index| SourceSetConfig { + name: format!("extension-{index}"), + purpose: SourceSetPurpose::Extension, + path: PathBuf::from(format!("extension-{index}")), + depends_on: vec![if index == 0 { + "main".to_owned() + } else { + format!("extension-{}", index - 1) + }], + })); + let config = AppConfig { + base_path: PathBuf::from("."), + work_path: PathBuf::from("target/dependency-validation"), + execution_timeout: 300_000, + format: SourceFormat::Designer, + builder: BuilderBackend::Designer, + infobase: crate::config::model::InfobaseConfig::file("File=/tmp/ib"), + source_sets, + build: BuildConfig::default(), + tools: ToolsConfig::default(), + mcp: Default::default(), + tests: TestsConfig::default(), + }; + + validate_source_set_dependencies(&config).expect("deep dependency chain must be valid"); + } + fn write_edt_project(project_dir: &Path, descriptor_xml: Option<&str>) { std::fs::create_dir_all(project_dir).expect("project dir"); std::fs::write( @@ -1128,6 +1402,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { @@ -1165,6 +1440,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { @@ -1202,6 +1478,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { @@ -1243,6 +1520,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1278,6 +1556,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1313,6 +1592,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1344,6 +1624,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig { partial_load_threshold: 0, @@ -1381,6 +1662,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1417,6 +1699,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1669,6 +1952,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext".to_owned(), @@ -1677,6 +1961,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), @@ -1936,6 +2221,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1966,6 +2252,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -1997,6 +2284,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2027,6 +2315,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2087,6 +2376,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: std::path::PathBuf::from("designer/main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2126,6 +2416,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2171,6 +2462,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2214,6 +2506,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2248,6 +2541,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2283,6 +2577,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2313,6 +2608,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: std::path::PathBuf::from("missing-path"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2348,6 +2644,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2384,6 +2681,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2651,6 +2949,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2699,6 +2998,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2743,6 +3043,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), @@ -2794,6 +3095,7 @@ mod tests { .strip_prefix(base.path()) .expect("relative") .to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), diff --git a/src/mcp/port.rs b/src/mcp/port.rs index 2e8db54..4db0af1 100644 --- a/src/mcp/port.rs +++ b/src/mcp/port.rs @@ -206,6 +206,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 3d9f443..82cb47b 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1520,6 +1520,7 @@ mod tests { name: String::from("main"), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("."), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 66cc806..670651b 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -2519,6 +2519,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: Path::new("src").to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { diff --git a/src/platform/edt.rs b/src/platform/edt.rs index a7dee72..7eb9e4f 100644 --- a/src/platform/edt.rs +++ b/src/platform/edt.rs @@ -849,6 +849,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { diff --git a/src/use_cases/artifacts.rs b/src/use_cases/artifacts.rs index db4cfff..475a522 100644 --- a/src/use_cases/artifacts.rs +++ b/src/use_cases/artifacts.rs @@ -1337,11 +1337,13 @@ mod tests { name: "configuration".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("configuration"), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext-sales".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("extensions/ext-sales"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), @@ -1386,6 +1388,7 @@ mod tests { name: name.to_owned(), purpose, path: PathBuf::from(name), + depends_on: Vec::new(), }); } diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index ead66b2..ee4293d 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -905,11 +905,13 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("ext"), + depends_on: Vec::new(), }, ], build: BuildConfig { @@ -945,11 +947,13 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("ext"), + depends_on: Vec::new(), }, ], build: BuildConfig { @@ -1197,6 +1201,7 @@ mod tests { name: "set".to_owned(), purpose, path: PathBuf::from("set"), + depends_on: Vec::new(), }; assert!(super::edt_export_requires_configuration_xml(&source_set( @@ -1253,6 +1258,7 @@ mod tests { name: "processors".to_owned(), purpose: SourceSetPurpose::ExternalDataProcessors, path: PathBuf::from("processors"), + depends_on: Vec::new(), }]; let result = run_build(&config, &build_args(true)).expect("build"); @@ -1400,6 +1406,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1463,6 +1470,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1503,6 +1511,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1564,6 +1573,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1623,6 +1633,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1670,6 +1681,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1727,6 +1739,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1777,6 +1790,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -1825,6 +1839,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.client_mcp.extension = Some(ToolExtensionConfig { name: "client_mcp".to_owned(), @@ -2077,6 +2092,7 @@ mod tests { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }]; prime_edt_snapshots(&config); fs::write( @@ -2117,6 +2133,7 @@ mod tests { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }]; prime_edt_snapshots(&config); fs::write( @@ -2164,6 +2181,7 @@ mod tests { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }]; prime_edt_snapshots(&config); fs::write( @@ -2238,6 +2256,7 @@ mod tests { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }]; prime_edt_snapshots(&config); fs::write( @@ -2302,6 +2321,7 @@ mod tests { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }]; prime_edt_snapshots(&config); fs::write( diff --git a/src/use_cases/check_syntax.rs b/src/use_cases/check_syntax.rs index 13ac47b..398dcc2 100644 --- a/src/use_cases/check_syntax.rs +++ b/src/use_cases/check_syntax.rs @@ -1117,6 +1117,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: Path::new(".").to_path_buf(), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { @@ -1146,11 +1147,13 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: Path::new("main-edt").to_path_buf(), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: Path::new("ext-edt").to_path_buf(), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), diff --git a/src/use_cases/configure_extensions.rs b/src/use_cases/configure_extensions.rs index 5747e80..8a4d3fc 100644 --- a/src/use_cases/configure_extensions.rs +++ b/src/use_cases/configure_extensions.rs @@ -297,11 +297,13 @@ mod tests { name: "configuration".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("configuration"), + depends_on: Vec::new(), }, SourceSetConfig { name: "client_mcp".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("exts/client-mcp"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), diff --git a/src/use_cases/dump_config.rs b/src/use_cases/dump_config.rs index dfec016..d913532 100644 --- a/src/use_cases/dump_config.rs +++ b/src/use_cases/dump_config.rs @@ -1268,11 +1268,13 @@ exit 0"#, name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }, SourceSetConfig { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("ext"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), @@ -1597,6 +1599,7 @@ exit 0"#, name: "main2".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }); let error = resolve_target( diff --git a/src/use_cases/extension_identity.rs b/src/use_cases/extension_identity.rs index 0b464fd..a847a92 100644 --- a/src/use_cases/extension_identity.rs +++ b/src/use_cases/extension_identity.rs @@ -21,6 +21,7 @@ mod tests { name: "SalesAddon".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("extensions/sales-project"), + depends_on: Vec::new(), }; assert_eq!(platform_extension_name(&source_set), "SalesAddon"); diff --git a/src/use_cases/external_artifacts.rs b/src/use_cases/external_artifacts.rs index 7b7666c..14207c4 100644 --- a/src/use_cases/external_artifacts.rs +++ b/src/use_cases/external_artifacts.rs @@ -328,11 +328,13 @@ mod tests { name: "external".to_owned(), purpose: SourceSetPurpose::ExternalDataProcessors, path: PathBuf::from("designer/external"), + depends_on: Vec::new(), }, SourceSetConfig { name: "reports".to_owned(), purpose: SourceSetPurpose::ExternalReports, path: PathBuf::from("designer/reports"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), @@ -358,11 +360,13 @@ mod tests { name: "external".to_owned(), purpose: SourceSetPurpose::ExternalDataProcessors, path: PathBuf::from("designer/external"), + depends_on: Vec::new(), }; let report = SourceSetConfig { name: "reports".to_owned(), purpose: SourceSetPurpose::ExternalReports, path: PathBuf::from("designer/reports"), + depends_on: Vec::new(), }; assert_eq!( diff --git a/src/use_cases/init_project.rs b/src/use_cases/init_project.rs index 86fbf52..1be63f7 100644 --- a/src/use_cases/init_project.rs +++ b/src/use_cases/init_project.rs @@ -775,11 +775,13 @@ mod tests { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: PathBuf::from("ext"), + depends_on: Vec::new(), }, SourceSetConfig { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), @@ -985,6 +987,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.edt_cli.path = Some(edt_script); config.tools.edt_cli.interactive_mode = false; @@ -1038,6 +1041,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.edt_cli.path = Some(edt_script); config.tools.edt_cli.interactive_mode = false; @@ -1093,6 +1097,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }]; config.tools.edt_cli.path = Some(edt_script); config.tools.edt_cli.interactive_mode = false; diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 176cbc2..ec065d8 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -489,6 +489,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("."), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { diff --git a/src/use_cases/run_tests.rs b/src/use_cases/run_tests.rs index 08e123f..d7598a8 100644 --- a/src/use_cases/run_tests.rs +++ b/src/use_cases/run_tests.rs @@ -549,6 +549,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig { diff --git a/src/use_cases/source_inventory.rs b/src/use_cases/source_inventory.rs index aa76335..3874f7f 100644 --- a/src/use_cases/source_inventory.rs +++ b/src/use_cases/source_inventory.rs @@ -141,21 +141,25 @@ mod tests { name: "ext".to_owned(), purpose: SourceSetPurpose::Extension, path: "extensions/ext".into(), + depends_on: Vec::new(), }, SourceSetConfig { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: "configuration".into(), + depends_on: Vec::new(), }, SourceSetConfig { name: "processors".to_owned(), purpose: SourceSetPurpose::ExternalDataProcessors, path: "external/processors".into(), + depends_on: Vec::new(), }, SourceSetConfig { name: "reports".to_owned(), purpose: SourceSetPurpose::ExternalReports, path: "external/reports".into(), + depends_on: Vec::new(), }, ], build: BuildConfig::default(), diff --git a/src/use_cases/transport.rs b/src/use_cases/transport.rs index 718d3f3..1d805e0 100644 --- a/src/use_cases/transport.rs +++ b/src/use_cases/transport.rs @@ -66,6 +66,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), diff --git a/src/use_cases/workspace_lock.rs b/src/use_cases/workspace_lock.rs index c09414e..92281a6 100644 --- a/src/use_cases/workspace_lock.rs +++ b/src/use_cases/workspace_lock.rs @@ -209,6 +209,7 @@ mod tests { name: "main".to_owned(), purpose: SourceSetPurpose::Configuration, path: PathBuf::from("main"), + depends_on: Vec::new(), }], build: BuildConfig::default(), tools: ToolsConfig::default(), From 222f2445083e6b28e5aae3779df18f1598257ef7 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 19:25:07 +0300 Subject: [PATCH 2/4] feat(build): resolve source-set dependencies - order full builds by stable dependency graph - expand scoped builds to transitive source-set closure - preserve legacy and backend build behavior --- src/use_cases/build_project.rs | 111 ++++++++++- src/use_cases/source_inventory.rs | 320 +++++++++++++++++++++++++++++- 2 files changed, 423 insertions(+), 8 deletions(-) diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index ee4293d..c592a1b 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -198,19 +198,14 @@ fn selected_ordered_source_sets<'a>( .map(str::trim) .filter(|name| !name.is_empty()) { - Some(name) => inventory - .ordered_source_sets() - .into_iter() - .find(|source_set| source_set.name == name) - .map(|source_set| vec![source_set]) - .ok_or_else(|| AppError::Validation(format!("unknown source-set '{name}'"))), + Some(name) => inventory.dependency_ordered_source_sets(Some(name)), None => { if source_set_name.is_some() { return Err(AppError::Validation( "build source-set requires a non-empty name".to_owned(), )); } - Ok(inventory.ordered_source_sets()) + inventory.dependency_ordered_source_sets(None) } } } @@ -984,6 +979,108 @@ mod tests { } } + fn dependency_build_config( + base_path: &Path, + work_path: &Path, + platform_path: &Path, + ) -> AppConfig { + let mut config = build_config( + base_path, + work_path, + platform_path, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.source_sets = vec![ + SourceSetConfig { + name: "tests".to_owned(), + purpose: SourceSetPurpose::Extension, + path: PathBuf::from("tests"), + depends_on: vec!["yaxunit".to_owned()], + }, + SourceSetConfig { + name: "unrelated".to_owned(), + purpose: SourceSetPurpose::Extension, + path: PathBuf::from("unrelated"), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "yaxunit".to_owned(), + purpose: SourceSetPurpose::Extension, + path: PathBuf::from("yaxunit"), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "main".to_owned(), + purpose: SourceSetPurpose::Configuration, + path: PathBuf::from("main"), + depends_on: Vec::new(), + }, + ]; + config + } + + fn create_dependency_source_tree(base_path: &Path) { + for source_set in ["main", "yaxunit", "tests", "unrelated"] { + let source_path = base_path.join(source_set); + fs::create_dir_all(&source_path).expect("source-set directory"); + fs::write( + source_path.join("Configuration.xml"), + format!(""), + ) + .expect("source-set marker"); + } + } + + #[cfg(unix)] + #[test] + fn full_build_executes_source_sets_in_stable_dependency_order() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let platform = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_dependency_source_tree(&base); + write_designer_script(&platform, &calls, None); + let config = dependency_build_config(&base, &work, &platform); + + let result = run_build(&config, &build_args(true)).expect("build"); + let source_sets = result + .steps + .iter() + .map(|step| step.source_set.as_str()) + .collect::>(); + + assert_eq!(source_sets, vec!["main", "unrelated", "yaxunit", "tests"]); + } + + #[cfg(unix)] + #[test] + fn scoped_build_executes_transitive_dependency_closure() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let platform = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_dependency_source_tree(&base); + write_designer_script(&platform, &calls, None); + let config = dependency_build_config(&base, &work, &platform); + let args = BuildArgs { + full_rebuild: true, + source_set: Some("tests".to_owned()), + }; + + let result = run_build(&config, &args).expect("build"); + let source_sets = result + .steps + .iter() + .map(|step| step.source_set.as_str()) + .collect::>(); + + assert_eq!(source_sets, vec!["main", "yaxunit", "tests"]); + } + #[cfg(unix)] #[test] fn execute_build_honors_interruption_before_load_safe_point() { diff --git a/src/use_cases/source_inventory.rs b/src/use_cases/source_inventory.rs index 3874f7f..85d852b 100644 --- a/src/use_cases/source_inventory.rs +++ b/src/use_cases/source_inventory.rs @@ -1,10 +1,12 @@ -use std::collections::HashMap; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet}; use std::path::PathBuf; use crate::change_detection::analyzer::ContextAnalysis; use crate::change_detection::source_sets::SourceSetsService; use crate::config::model::{AppConfig, SourceSetConfig, SourceSetPurpose}; use crate::domain::source_set::SourceSetContext; +use crate::support::error::AppError; /// Read-only runtime index for source-set orchestration. pub(crate) struct SourceSetInventory<'a> { @@ -61,6 +63,63 @@ impl<'a> SourceSetInventory<'a> { configuration } + /// Resolves a pre-validated dependency graph in stable canonical order. + pub(crate) fn dependency_ordered_source_sets( + &self, + selected_name: Option<&str>, + ) -> Result, AppError> { + let canonical = self.ordered_source_sets(); + if canonical + .iter() + .all(|source_set| source_set.depends_on.is_empty()) + { + return match selected_name { + Some(name) => self + .source_set(name) + .map(|source_set| vec![source_set]) + .ok_or_else(|| AppError::Validation(format!("unknown source-set '{name}'"))), + None => Ok(canonical), + }; + } + + let included = self.dependency_closure(selected_name, &canonical)?; + stable_topological_order(&canonical, &included) + } + + fn dependency_closure( + &self, + selected_name: Option<&str>, + canonical: &[&'a SourceSetConfig], + ) -> Result, AppError> { + let Some(selected_name) = selected_name else { + return Ok(canonical + .iter() + .map(|source_set| source_set.name.as_str()) + .collect()); + }; + + let selected = self + .source_set(selected_name) + .ok_or_else(|| AppError::Validation(format!("unknown source-set '{selected_name}'")))?; + let mut included = HashSet::new(); + let mut pending = vec![selected]; + while let Some(source_set) = pending.pop() { + if !included.insert(source_set.name.as_str()) { + continue; + } + for dependency_name in &source_set.depends_on { + let dependency = self.source_set(dependency_name).ok_or_else(|| { + AppError::Validation(format!( + "source-set '{}' depends on unknown source-set '{}'", + source_set.name, dependency_name + )) + })?; + pending.push(dependency); + } + } + Ok(included) + } + pub(crate) fn source_set(&self, name: &str) -> Option<&'a SourceSetConfig> { self.source_sets_by_name.get(name).copied() } @@ -117,6 +176,87 @@ fn index_contexts(contexts: &[SourceSetContext]) -> HashMap( + canonical: &[&'a SourceSetConfig], + included: &HashSet<&str>, +) -> Result, AppError> { + let canonical_index = canonical + .iter() + .enumerate() + .map(|(index, source_set)| (source_set.name.as_str(), index)) + .collect::>(); + let mut indegree = HashMap::new(); + let mut dependents = HashMap::<&str, Vec<&str>>::new(); + + for source_set in canonical + .iter() + .copied() + .filter(|source_set| included.contains(source_set.name.as_str())) + { + let mut dependency_count = 0; + for dependency in &source_set.depends_on { + if !canonical_index.contains_key(dependency.as_str()) { + return Err(AppError::Validation(format!( + "source-set '{}' depends on unknown source-set '{}'", + source_set.name, dependency + ))); + } + if !included.contains(dependency.as_str()) { + continue; + } + dependency_count += 1; + dependents + .entry(dependency.as_str()) + .or_default() + .push(source_set.name.as_str()); + } + indegree.insert(source_set.name.as_str(), dependency_count); + } + + let mut ready = indegree + .iter() + .filter_map(|(name, degree)| { + if *degree == 0 { + canonical_index.get(name).copied().map(Reverse) + } else { + None + } + }) + .collect::>(); + let mut ordered = Vec::with_capacity(included.len()); + + while let Some(Reverse(index)) = ready.pop() { + let source_set = canonical[index]; + ordered.push(source_set); + + for dependent in dependents + .get(source_set.name.as_str()) + .into_iter() + .flatten() + { + let Some(degree) = indegree.get_mut(dependent) else { + continue; + }; + if *degree == 0 { + continue; + } + *degree -= 1; + if *degree == 0 { + if let Some(index) = canonical_index.get(dependent).copied() { + ready.push(Reverse(index)); + } + } + } + } + + if ordered.len() != included.len() { + return Err(AppError::Validation( + "source-set dependency graph contains a cycle".to_owned(), + )); + } + Ok(ordered) +} + #[cfg(test)] mod tests { use super::SourceSetInventory; @@ -183,6 +323,184 @@ mod tests { assert_eq!(names, vec!["main", "ext", "processors", "reports"]); } + #[test] + fn dependency_ordered_source_sets_use_canonical_order_for_ready_nodes() { + let mut config = config(SourceFormat::Designer); + config.source_sets = vec![ + SourceSetConfig { + name: "tests".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/tests".into(), + depends_on: vec!["yaxunit".to_owned()], + }, + SourceSetConfig { + name: "main".to_owned(), + purpose: SourceSetPurpose::Configuration, + path: "configuration".into(), + depends_on: Vec::new(), + }, + SourceSetConfig { + name: "unrelated".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/unrelated".into(), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "yaxunit".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/yaxunit".into(), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "processors".to_owned(), + purpose: SourceSetPurpose::ExternalDataProcessors, + path: "external/processors".into(), + depends_on: Vec::new(), + }, + SourceSetConfig { + name: "reports".to_owned(), + purpose: SourceSetPurpose::ExternalReports, + path: "external/reports".into(), + depends_on: Vec::new(), + }, + ]; + let inventory = SourceSetInventory::new(&config); + + let names = inventory + .dependency_ordered_source_sets(None) + .expect("valid dependency graph") + .into_iter() + .map(|source_set| source_set.name.as_str()) + .collect::>(); + + assert_eq!( + names, + vec![ + "main", + "unrelated", + "yaxunit", + "tests", + "processors", + "reports" + ] + ); + } + + #[test] + fn dependency_ordered_source_sets_expand_scoped_diamond_once() { + let mut config = config(SourceFormat::Designer); + config.source_sets = vec![ + SourceSetConfig { + name: "tests".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/tests".into(), + depends_on: vec!["left".to_owned(), "right".to_owned()], + }, + SourceSetConfig { + name: "unrelated".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/unrelated".into(), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "right".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/right".into(), + depends_on: vec!["common".to_owned()], + }, + SourceSetConfig { + name: "left".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/left".into(), + depends_on: vec!["common".to_owned()], + }, + SourceSetConfig { + name: "common".to_owned(), + purpose: SourceSetPurpose::Extension, + path: "extensions/common".into(), + depends_on: vec!["main".to_owned()], + }, + SourceSetConfig { + name: "main".to_owned(), + purpose: SourceSetPurpose::Configuration, + path: "configuration".into(), + depends_on: Vec::new(), + }, + ]; + let inventory = SourceSetInventory::new(&config); + let names = inventory + .dependency_ordered_source_sets(Some("tests")) + .expect("valid dependency graph") + .into_iter() + .map(|source_set| source_set.name.as_str()) + .collect::>(); + + assert_eq!(names, vec!["main", "common", "right", "left", "tests"]); + } + + #[test] + fn dependency_ordered_source_sets_keep_legacy_canonical_order() { + let config = config(SourceFormat::Designer); + let inventory = SourceSetInventory::new(&config); + + let names = inventory + .dependency_ordered_source_sets(None) + .expect("valid dependency graph") + .into_iter() + .map(|source_set| source_set.name.as_str()) + .collect::>(); + + assert_eq!(names, vec!["main", "ext", "processors", "reports"]); + } + + #[test] + fn dependency_ordered_source_sets_reject_unknown_selection() { + let config = config(SourceFormat::Designer); + let inventory = SourceSetInventory::new(&config); + + let error = inventory + .dependency_ordered_source_sets(Some("missing")) + .expect_err("unknown selection must fail"); + + assert_eq!( + error.to_string(), + "validation error: unknown source-set 'missing'" + ); + } + + #[test] + fn dependency_ordered_source_sets_reject_unknown_dependency() { + let mut config = config(SourceFormat::Designer); + config.source_sets[0].depends_on = vec!["missing".to_owned()]; + let inventory = SourceSetInventory::new(&config); + + let error = inventory + .dependency_ordered_source_sets(Some("ext")) + .expect_err("unknown dependency must fail"); + + assert_eq!( + error.to_string(), + "validation error: source-set 'ext' depends on unknown source-set 'missing'" + ); + } + + #[test] + fn dependency_ordered_source_sets_reject_cycle() { + let mut config = config(SourceFormat::Designer); + config.source_sets[0].depends_on = vec!["main".to_owned()]; + config.source_sets[1].depends_on = vec!["ext".to_owned()]; + let inventory = SourceSetInventory::new(&config); + + let error = inventory + .dependency_ordered_source_sets(Some("ext")) + .expect_err("cycle must fail"); + + assert_eq!( + error.to_string(), + "validation error: source-set dependency graph contains a cycle" + ); + } + #[test] fn indexes_designer_and_edt_contexts_by_source_set_identity() { let config = config(SourceFormat::Edt); From 6b1a27e0a7a51a8bfdb91e76c9c5a427f2c89f5b Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 21:29:55 +0300 Subject: [PATCH 3/4] test(build): cover dependency-aware CLI behavior - verify scoped dependency order, change detection, and failure blocking - preserve yaxunit build-first behavior and result compatibility - document dependsOn workflow and architecture decision --- ARCHITECTURE.md | 7 + SKILL/SKILL.md | 6 +- SKILL/references/command-selection.md | 2 + SKILL/references/config-and-backends.md | 5 + SKILL/references/project-workflows.md | 4 + SKILL/references/testing.md | 4 +- docs/CAPABILITIES.md | 14 +- docs/CONFIGURATION.md | 15 +- docs/DEEP_DIVE.md | 13 +- examples/v8project.yaml | 2 + spec/architecture/invariants.md | 10 +- ...i-source-set-i-stabilnyy-poryadok-build.md | 68 ++++++ spec/decisions/README.md | 1 + tests/cli_build.rs | 219 ++++++++++++++++++ tests/cli_test.rs | 86 +++++++ 15 files changed, 441 insertions(+), 15 deletions(-) create mode 100644 spec/decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf2cf97..c7ef714 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,6 +68,10 @@ This result grammar is governed by [ADR-0016](spec/decisions/0016-edinyy-executi `v8project.yaml`, loaded into `AppConfig` and accepted by `config::validate`, is the main project configuration contract. `source-set.name` is a stable identity for runtime state, generated directories, diagnostics, and source-set selection. The supported `source-set[].type` contract and validation boundary are governed by [ADR-0017](spec/decisions/0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md). +`source-set[].dependsOn` declares immediate graph edges by stable name. Config validation rejects +unknown/self/duplicate/unsupported edges, cycles, and extension graphs without exactly one +configuration root. Build orchestration resolves a stable topological order and expands scoped +selection to its transitive dependency closure according to [ADR-0023](spec/decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md). `config init` must autodetect source-set types only from marker content: Designer `CONFIGURATION` / `EXTENSION` come from `Configuration.xml`, ordinary EDT `CONFIGURATION` / `EXTENSION` come from `.project` natures plus `DT-INF/PROJECT.PMF` (`EXTENSION` also requires `Base-Project`) and `src/Configuration/Configuration.mdo`, while EDT external `.epf`/`.erf` sources are discovered only through homogeneous aggregate roots of valid child projects classified by canonical `src/root.xml`, never through recursive descriptor scans, per-artifact fallback, or phantom source-set generation. The typed config model now splits MCP knobs into active HTTP/session settings and shared execution guardrails: @@ -113,6 +117,9 @@ Important staging note: - `builder=DESIGNER` uses the existing `DesignerDsl`. - `builder=IBCMD` uses `IbcmdDsl` with `config import/apply` for build and `config export` for dump; for EDT build the EDT export step still produces Designer-format files first, and for EDT dump the reverse path first updates an internal Designer snapshot before EDT import/publication. +- Dependency resolution happens above backend dispatch, so Designer, IBCMD and EDT-export build + paths consume the same ordered selection. A failed node prevents later selected nodes from + reaching platform DSL while preserving skipped steps in the existing result contract. - Builder backends are expected to stay interchangeable for implemented builder scenarios. Functionality added for the Designer builder should also be available through the IBCMD builder, or the gap must be documented explicitly. Future Designer agent mode should be added behind the same use-case contract. - Server infobase support is a target contract for all tools; file-only behavior must be documented as a current gap rather than treated as the permanent architecture. diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 2b0e4c5..c763f6f 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -70,10 +70,12 @@ v8-runner init ## Default Use-Case Routing - Source files changed and infobase may be stale: run `v8-runner build`. -- Only one source-set changed: use commands that accept `--source-set ` instead of rebuilding or materializing everything. +- Only one source-set changed: use commands that accept `--source-set `; for `build`, its + transitive `dependsOn` prerequisites are included automatically. - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. -- Behavior validation: run the relevant `v8-runner test ...` command; tests build first. +- Behavior validation: run the relevant `v8-runner test ...` command; tests build the full + dependency graph first and start the runner only after successful build completion. - Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run `v8-runner tools download yaxunit --sources`, `v8-runner tools download vanessa`, and `v8-runner tools download client-mcp --sources` for source-backed setup. Omit diff --git a/SKILL/references/command-selection.md b/SKILL/references/command-selection.md index 00abafc..a822e46 100644 --- a/SKILL/references/command-selection.md +++ b/SKILL/references/command-selection.md @@ -34,6 +34,8 @@ Limit build to one configured source-set: v8-runner build --source-set ``` +This scoped build includes the selected source-set's transitive `dependsOn` prerequisites. + Recover after branch switches, rebases, large object moves, or suspicious incremental state: ```bash diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 13f6ead..0849564 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -38,6 +38,11 @@ settings before CLI overrides. `source-set.name` is the stable identity for ordering, diagnostics, runtime contexts, generated directories, and command selection. Relative `source-set.path` values are resolved from the directory containing the primary `v8project.yaml`. +Optional `source-set.dependsOn` lists immediate dependencies by name. A scoped +`build --source-set ` includes the transitive dependency closure and orders dependencies +before dependents. When any dependency is declared, every extension must resolve transitively to +exactly one configuration root; unknown, self, duplicate, external source-set targets and cycles are +validation errors. Supported `source-set.type` values: diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index a9db7c8..05aa0cd 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -55,6 +55,10 @@ v8-runner build --full-rebuild `build` is a common workflow. For EDT projects it may export EDT sources to Designer files before applying them through the configured backend. For Designer projects it applies Designer sources directly through the configured backend. +When `source-set[].dependsOn` is configured, `build` runs dependencies before dependents. +`build --source-set ` includes the selected source-set's transitive prerequisites, so do not +manually issue separate builds for them. A failed prerequisite prevents dependent platform calls. + If `tools.client_mcp.extension` is configured, `build` also prepares that tool extension after the project source-set stage, including scoped `--source-set` builds. Source-backed tool extensions use their own change-detection state and are skipped when unchanged; use `build --full-rebuild` to force refresh. Do not add a tool extension as a project `source-set` or select it with `--source-set`. ## Syntax diff --git a/SKILL/references/testing.md b/SKILL/references/testing.md index 535b91e..08fad0c 100644 --- a/SKILL/references/testing.md +++ b/SKILL/references/testing.md @@ -1,6 +1,8 @@ # Testing -Use tests when behavior matters. Test commands build first, so do not run a separate `build` unless the user specifically asked for a build-only diagnosis. +Use tests when behavior matters. Test commands build the full configured source-set graph first, +including `dependsOn` ordering, so do not run a separate `build` unless the user specifically +asked for a build-only diagnosis. A failed prerequisite prevents the test runner from starting. ## YaXUnit diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 6483c06..d603d87 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -170,9 +170,11 @@ v8-runner extensions [--name ...] v8-runner build [--source-set ] [--full-rebuild] ``` -- Без `--source-set` обрабатывает все configured `source-set` в canonical order. -- С `--source-set` project stage анализирует и строит только указанный `source-set`; неизвестное - имя отклоняется как validation error. +- Без `--source-set` обрабатывает все configured `source-set`: при наличии `dependsOn` в stable + topological order, иначе в прежнем canonical order. +- С `--source-set` project stage строит выбранный `source-set` и транзитивное замыкание его + `dependsOn`; каждая зависимость выполняется один раз раньше dependent. Неизвестное имя + отклоняется как validation error. - Для `DESIGNER` выбирает incremental, partial или full path по изменённым файлам выбранного scope. - Для `EDT` сначала анализирует и экспортирует выбранные EDT `source-set`, затем грузит generated Designer files выбранным backend. @@ -186,7 +188,8 @@ v8-runner build [--source-set ] [--full-rebuild] - `tools.client_mcp.extension` не является project `source-set`; `--source-set` выбирает только project source-set. - Не является атомарной multi-source-set операцией: ранние успешные шаги не откатываются, если - поздний шаг падает. + поздний шаг падает. После failure оставшиеся selected source-set отмечаются `skipped` и не + запускаются. ## Проверка и валидация @@ -199,7 +202,8 @@ v8-runner test va v8-runner test va --feature login --filter-tag @smoke ``` -- Всегда сначала запускает `build`. +- Всегда сначала запускает полный `build`; при `dependsOn` prerequisite source-set загружаются + раньше dependent, а test runner запускается только после успешного завершения всего build graph. - `test yaxunit module ` требует непустое имя модуля. - `test va` использует профиль из `tests.va.profile`; `--feature`, `--filter-tag`, `--ignore-tag` и `--scenario-filter` переопределяют соответствующие списки выбранного профиля diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a6bf1ec..cba4b1c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -104,6 +104,7 @@ artifact без привязки к release tag. - top-level app keys: `workPath`, `execution_timeout`, `format`, `builder`, `infobase`, `source-set`, `build`, `tools`, `mcp`, `tests`; +- `source-set[]` использует camelCase key `dependsOn`; - `build` использует `partialLoadThreshold`; - `mcp.*` и `tests.*` используют `snake_case`; - canonical key для EDT tool section: `tools.edt_cli`; @@ -135,6 +136,8 @@ source-set: - name: ext type: EXTENSION path: ext + dependsOn: + - main build: partialLoadThreshold: 20 @@ -339,6 +342,7 @@ Credentials самой информационной базы. - `name` - `type` - `path` +- опциональный `dependsOn` — список имён непосредственных зависимостей `path` задаётся относительно каталога primary `v8project.yaml`, если он не абсолютный. @@ -353,6 +357,11 @@ Validation rules: - `name` должен быть уникальным и безопасным path segment; - `EXTENSION` требует хотя бы один `CONFIGURATION`, но external-only config допустим; +- `dependsOn` не может содержать неизвестное имя, текущий `source-set` или дубликат; +- dependency target должен иметь `type=CONFIGURATION` или `type=EXTENSION`; +- граф не может содержать циклы; +- если хотя бы один `dependsOn` задан, каждое `EXTENSION` должно транзитивно разрешаться ровно + в один `CONFIGURATION`; отсутствие или несколько configuration roots отклоняются; - для `format=DESIGNER` ordinary source-set должен указывать на корректный Designer root; - для `format=DESIGNER` external source-set должен быть aggregate root с top-level XML descriptors matching declared `type`; @@ -374,8 +383,10 @@ Validation rules: Порог между partial и full load. CLI selector `v8-runner build --source-set ` использует `source-set[].name` как stable -runtime identity и не добавляет отдельное поле конфигурации. Если selector не задан, `build` -обрабатывает все `source-set`. +runtime identity и не добавляет отдельное поле конфигурации. Если выбранный `source-set` имеет +`dependsOn`, `build` также включает его транзитивные зависимости и выполняет каждую ровно один +раз до dependent. Если selector не задан, `build` обрабатывает весь граф в stable topological +order; для конфигов без `dependsOn` сохраняется прежний canonical order. ### `tests` diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index 9295445..ddd79cc 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -40,7 +40,11 @@ MCP DTO в одном слое. Change detection выполняется on-demand во время build/export/load decision и не требует background watcher. `build --source-set ` ограничивает анализ, export/load decision и -runtime snapshot commit только указанным source-set. +runtime snapshot commit выбранным source-set и транзитивным замыканием его `dependsOn`. + +`dependsOn` задаёт directed acyclic graph по stable `source-set.name`. Resolver выполняет +dependencies раньше dependent, выбирая одновременно готовые nodes в прежнем canonical order. +Если `dependsOn` нигде не задан, старый порядок и single-source scoped build сохраняются. ## Пайплайн `build` @@ -59,14 +63,17 @@ runtime snapshot commit только указанным source-set. 4. Load/apply generated files через `DESIGNER` или `IBCMD`. Пайплайн намеренно не является атомарным across many `source-set`: поздний failure не откатывает -уже успешные ранние шаги. +уже успешные ранние шаги. Failure dependency останавливает platform execution для всех оставшихся +selected nodes; они остаются в structured result как `skipped` с причиной +`aborted after previous failure`. ## Проверка и тесты `test` и `syntax` проектируются как часть того же локального цикла, а не как отдельная эксплуатационная подсистема. -- `test` всегда сначала делает `build`, затем запускает YaXUnit или Vanessa Automation. +- `test` всегда сначала делает полный dependency-aware `build`, затем запускает YaXUnit или + Vanessa Automation. Test runner не стартует, если prerequisite build graph завершился ошибкой. - `syntax designer-*` работает только для `DESIGNER` source format. - `syntax edt` использует EDT `validate` и привязан к `format=EDT`. - Таймауты и interruption metadata должны проходить через общий command-level contract, а не diff --git a/examples/v8project.yaml b/examples/v8project.yaml index 7f250fa..fa1a300 100644 --- a/examples/v8project.yaml +++ b/examples/v8project.yaml @@ -24,6 +24,8 @@ source-set: # - name: my-extension # type: EXTENSION # path: extensions/my-extension + # dependsOn: + # - main # Build pipeline settings (optional) build: diff --git a/spec/architecture/invariants.md b/spec/architecture/invariants.md index a93e422..539df08 100644 --- a/spec/architecture/invariants.md +++ b/spec/architecture/invariants.md @@ -41,8 +41,14 @@ 14. `v8project.local.yaml` является optional local overlay рядом с primary config, применяется после `v8project.yaml` и до CLI overrides, не является самостоятельным `--config` entrypoint и не должен менять `source-set`, `format` или `builder`. 15. `basePath` не является public key в `v8project.yaml`; внутренний project base path считается равным каталогу primary config. 16. Tool extensions, включая `tools.client_mcp.extension`, не являются project `source-set`; их подготовка выполняется через общий механизм подготовки расширений на стадии `build`, а не на стадии `launch`. - -См. [ADR-0017](../decisions/0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md), [ADR-0018](../decisions/0018-perenesti-kontrakt-informatsionnoy-bazy-v-infobase.md), [ADR-0019](../decisions/0019-sozdavat-servernuyu-infobazu-cherez-ibcmd-pri-init-pri-otsutstvii.md), [ADR-0021](../decisions/0021-lokalnyy-overlay-config.md) и [ADR-0022](../decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md). +17. `source-set[].dependsOn` задаёт immediate dependency edges по stable name; graph validation + выполняется до platform DSL, а build использует единый stable topological resolver для всех + backend paths. +18. Scoped `build --source-set ` включает transitive dependency closure; failure dependency + не допускает platform execution dependent, а result сохраняет оставшиеся nodes как skipped без + добавления отдельной requested/expanded metadata. + +См. [ADR-0017](../decisions/0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md), [ADR-0018](../decisions/0018-perenesti-kontrakt-informatsionnoy-bazy-v-infobase.md), [ADR-0019](../decisions/0019-sozdavat-servernuyu-infobazu-cherez-ibcmd-pri-init-pri-otsutstvii.md), [ADR-0021](../decisions/0021-lokalnyy-overlay-config.md), [ADR-0022](../decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md) и [ADR-0023](../decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md). ## Workspace Lock diff --git a/spec/decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md b/spec/decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md new file mode 100644 index 0000000..84eba14 --- /dev/null +++ b/spec/decisions/0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md @@ -0,0 +1,68 @@ +# ADR-0023: Ввести зависимости source-set и стабильный порядок build + +- Статус: `accepted` +- Дата: `2026-07-26` +- Связанные решения: [ADR-0002](0002-izolirovat-runtime-state-po-source-set-pod-workpath.md), [ADR-0006](0006-sohranyat-transportno-neytralnyy-use-case-sloy.md), [ADR-0010](0010-razdelit-cli-output-dlya-cheloveka-i-ai-agenta.md), [ADR-0012](0012-on-demand-change-detection-i-faylovaya-partial-load-strategiya.md), [ADR-0017](0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md) + +## Контекст + +Project source-set имели stable identity и canonical order, но не могли выразить обязательный +порядок загрузки. Типичный test project требует цепочку `main -> yaxunit -> TESTS`: расширение +YaXUnit должно загружаться после основной конфигурации, а тестовое расширение — после YaXUnit. +Прежний `build --source-set TESTS` выбирал только `TESTS`, поэтому вызывающей стороне приходилось +знать и вручную воспроизводить prerequisites. + +Нужна единая semantics для Designer, IBCMD и EDT export/build paths без нового transport surface и +без изменения structured result schema. + +## Решение + +Добавить optional `source-set[].dependsOn` со списком имён непосредственных зависимостей. + +Validation boundary до platform DSL: + +1. имя dependency должно существовать и не совпадать с dependent; +2. один dependent не может повторять dependency; +3. dependency target должен иметь `type=CONFIGURATION` или `type=EXTENSION`; +4. graph должен быть acyclic; +5. при наличии dependency graph каждое `EXTENSION` должно транзитивно разрешаться ровно в один + `CONFIGURATION`. + +Build use case формирует stable topological order. Dependency всегда выполняется раньше dependent; +среди одновременно готовых nodes сохраняется существующий canonical priority и YAML order. +Конфиги без `dependsOn` сохраняют прежний порядок. + +`build --source-set ` выбирает указанный node и всё transitive dependency closure. Каждый +node выполняется не более одного раза. Dependency resolution находится выше backend dispatch, +поэтому Designer, IBCMD и EDT paths получают одну ordered selection. + +При failure текущего node остальные selected nodes не вызывают platform DSL и фиксируются в +существующих build steps как `skipped` с причиной `aborted after previous failure`. Уже успешные +шаги не откатываются. + +`test yaxunit` и `test va` сохраняют прежний outer workflow: сначала полный `build`, затем test +runner. Полный build теперь dependency-aware; failure prerequisites не допускает запуск runner. + +## Совместимость результата + +Публичные CLI/MCP result DTO не получают полей requested/expanded source-set или иной graph +metadata. Фактический порядок и skipped nodes остаются видимы через существующий список build +steps. Это сохраняет shared envelope и MCP tool surface без расширения контракта. + +## Неграницы + +1. Не выводить зависимости из BSL, metadata или файловой структуры. +2. Не применять graph к `tools.client_mcp.extension` и external tool preparation. +3. Не добавлять отдельную CLI/MCP команду для graph inspection. +4. Не делать multi-source-set build атомарным и не откатывать успешные prerequisites. +5. Не менять selection semantics команд, кроме project `build` и вложенного build перед tests. + +## Последствия + +1. `v8project.yaml` явно документирует runtime prerequisites. +2. Scoped build остаётся узким, но становится корректным: необходимые prerequisites включаются + автоматически. +3. Backend implementations не дублируют graph traversal. +4. Existing step contract достаточно для диагностики failure/skip без result metadata migration. +5. Config examples, schema, repo-local skill и integration tests должны синхронно отражать graph + semantics. diff --git a/spec/decisions/README.md b/spec/decisions/README.md index 9575fe4..bc7ab1e 100644 --- a/spec/decisions/README.md +++ b/spec/decisions/README.md @@ -26,6 +26,7 @@ - [ADR-0020: Упростить CLI-only `convert` до repo-aware конвертации текущих исходников проекта](0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md) — `accepted`, `2026-04-22` - [ADR-0021: Ввести локальный overlay для `v8project.yaml`](0021-lokalnyy-overlay-config.md) — `accepted`, `2026-05-02` - [ADR-0022: Ввести общий механизм подготовки расширений и использовать его для `client_mcp`](0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md) — `accepted`, `2026-05-02` +- [ADR-0023: Ввести зависимости source-set и стабильный порядок build](0023-zavisimosti-source-set-i-stabilnyy-poryadok-build.md) — `accepted`, `2026-07-26` ## Правила обновления diff --git a/tests/cli_build.rs b/tests/cli_build.rs index c0409f8..ff28286 100644 --- a/tests/cli_build.rs +++ b/tests/cli_build.rs @@ -28,6 +28,23 @@ fn write_build_script(path: &Path, fail_pattern: Option<&str>) { write_script(path, &body); } +fn write_recording_build_script(path: &Path, calls_log: &Path, fail_pattern: Option<&str>) { + let pattern_branch = fail_pattern + .map(|pattern| { + format!( + "if printf '%s' \"$args\" | grep -F -q -- '{}'; then exit 17; fi", + pattern + ) + }) + .unwrap_or_default(); + let body = format!( + "args=\"$*\"\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"/Out\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nprintf '%s\\n' \"$args\" >> '{}'\nif [ -n \"$out\" ]; then printf 'designer log for %s\\n' \"$args\" > \"$out\"; fi\n{}\nexit 0", + calls_log.display(), + pattern_branch + ); + write_script(path, &body); +} + fn write_ibcmd_script(path: &Path, calls_log: &Path, fail_pattern: Option<&str>) { let pattern_branch = fail_pattern .map(|pattern| { @@ -212,6 +229,39 @@ fn setup_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { (dir, config_path, binary_path, work_path) } +fn setup_dependency_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { + let dir = temp_workspace(); + let base_path = dir.path().join("project"); + let work_path = dir.path().join("work"); + let config_path = dir.path().join("v8project.yaml"); + let binary_path = dir.path().join("1cv8"); + let calls_log = dir.path().join("build.calls.log"); + + for source_set in ["main", "yaxunit", "TESTS"] { + let source_path = base_path.join(source_set); + fs::create_dir_all(&source_path).expect("source-set directory"); + fs::write( + source_path.join("Configuration.xml"), + format!("\n"), + ) + .expect("source-set marker"); + } + fs::create_dir_all(&work_path).expect("work"); + write_recording_build_script(&binary_path, &calls_log, None); + + fs::write( + &config_path, + format!( + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\n - name: TESTS\n type: EXTENSION\n path: project/TESTS\n dependsOn:\n - yaxunit\n - name: yaxunit\n type: EXTENSION\n path: project/yaxunit\n dependsOn:\n - main\ntools:\n platform:\n path: '{}'\n", + work_path.display(), + binary_path.display(), + ), + ) + .expect("config"); + + (dir, config_path, binary_path, calls_log) +} + fn setup_ibcmd_project() -> ( tempfile::TempDir, PathBuf, @@ -618,6 +668,175 @@ fn build_source_set_json_limits_steps_to_requested_source_set() { assert_eq!(steps[0]["mode"], "full"); } +#[test] +fn build_source_set_expands_main_yaxunit_tests_dependency_chain() { + let (dir, config_path, _binary_path, calls_log) = setup_dependency_project(); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "build", + "--source-set", + "TESTS", + "--full-rebuild", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + let source_sets = payload["data"]["steps"] + .as_array() + .expect("steps") + .iter() + .map(|step| step["source_set"].as_str().expect("source-set")) + .collect::>(); + let update_order = fs::read_to_string(&calls_log) + .expect("build calls") + .lines() + .filter(|line| line.contains("/UpdateDBCfg")) + .map(|line| { + if line.contains("-Extension yaxunit") { + "yaxunit" + } else if line.contains("-Extension TESTS") { + "TESTS" + } else { + "main" + } + }) + .collect::>(); + + assert_eq!(source_sets, vec!["main", "yaxunit", "TESTS"]); + assert_eq!(update_order, vec!["main", "yaxunit", "TESTS"]); + let data = payload["data"].as_object().expect("data"); + for metadata_key in [ + "requestedSourceSets", + "expandedSourceSets", + "requested_source_sets", + "expanded_source_sets", + ] { + assert!(!data.contains_key(metadata_key)); + } + + fs::write(&calls_log, "").expect("clear build calls"); + fs::write( + dir.path() + .join("project") + .join("TESTS") + .join("Configuration.xml"), + "\n", + ) + .expect("change dependent source-set"); + + let incremental = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "build", + "--source-set", + "TESTS", + ]) + .output() + .expect("run incremental command"); + + assert!( + incremental.status.success(), + "status={:?}\nstdout={}\nstderr={}", + incremental.status.code(), + String::from_utf8_lossy(&incremental.stdout), + String::from_utf8_lossy(&incremental.stderr) + ); + let incremental_payload: Value = + serde_json::from_slice(&incremental.stdout).expect("incremental json"); + let incremental_modes = incremental_payload["data"]["steps"] + .as_array() + .expect("incremental steps") + .iter() + .map(|step| { + ( + step["source_set"].as_str().expect("source-set"), + step["mode"].as_str().expect("mode"), + ) + }) + .collect::>(); + let incremental_updates = fs::read_to_string(calls_log) + .expect("incremental build calls") + .lines() + .filter(|line| line.contains("/UpdateDBCfg")) + .map(|line| { + if line.contains("-Extension yaxunit") { + "yaxunit" + } else if line.contains("-Extension TESTS") { + "TESTS" + } else { + "main" + } + }) + .collect::>(); + + assert_eq!( + incremental_modes, + vec![ + ("main", "skipped"), + ("yaxunit", "skipped"), + ("TESTS", "full") + ] + ); + assert_eq!(incremental_updates, vec!["TESTS"]); +} + +#[test] +fn build_dependency_failure_prevents_dependent_source_set_execution() { + let (_dir, config_path, binary_path, calls_log) = setup_dependency_project(); + write_recording_build_script( + &binary_path, + &calls_log, + Some("/UpdateDBCfg -Extension yaxunit"), + ); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "build", + "--source-set", + "TESTS", + "--full-rebuild", + ]) + .output() + .expect("run command"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(4)); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + let source_sets = payload["data"]["steps"] + .as_array() + .expect("steps") + .iter() + .map(|step| step["source_set"].as_str().expect("source-set")) + .collect::>(); + let calls = fs::read_to_string(calls_log).expect("build calls"); + + assert_eq!(source_sets, vec!["main", "yaxunit", "TESTS"]); + assert_eq!(payload["data"]["steps"][2]["mode"], "skipped"); + assert_eq!(payload["data"]["steps"][2]["ok"], false); + assert_eq!( + payload["data"]["steps"][2]["message"], + "aborted after previous failure" + ); + assert!(!calls.contains("-Extension TESTS"), "calls:\n{calls}"); +} + #[test] fn build_source_set_json_rejects_unknown_source_set() { let (_dir, config_path, _binary_path, _work_path) = setup_project(); diff --git a/tests/cli_test.rs b/tests/cli_test.rs index afa0928..ec081c2 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -168,6 +168,15 @@ fn write_config( fs::write(path, config).expect("config"); } +fn write_dependency_test_config(path: &Path, work_path: &Path, install_dir: &Path) { + let config = format!( + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: TESTS\n type: EXTENSION\n path: TESTS\n dependsOn:\n - yaxunit\n - name: yaxunit\n type: EXTENSION\n path: yaxunit\n dependsOn:\n - main\ntools:\n platform:\n path: '{}'\n", + work_path.display(), + install_dir.display(), + ); + fs::write(path, config).expect("config"); +} + fn setup_project( work_dir_name: &str, report_xml: &str, @@ -412,6 +421,83 @@ fn test_all_full_json_runs_build_first_and_returns_report() { assert_eq!(payload["data"]["retained_paths"], Value::Null); } +#[test] +fn test_yaxunit_builds_dependency_chain_before_enterprise_launch() { + let (dir, config_path, build_calls, _test_calls, captured_config) = setup_project( + "work", + JUNIT_SMOKE_REPORT_FIXTURE, + "12:00:00.000 [INF] ok", + 0, + false, + 5, + None, + ); + write_test_script( + &dir.path().join("platform").join("bin").join("1cv8c"), + &build_calls, + &captured_config, + JUNIT_SMOKE_REPORT_FIXTURE, + "12:00:00.000 [INF] ok", + 0, + None, + ); + let project_path = config_path.parent().expect("project path"); + for source_set in ["yaxunit", "TESTS"] { + let source_path = project_path.join(source_set); + fs::create_dir_all(&source_path).expect("source-set directory"); + fs::write( + source_path.join("Configuration.xml"), + format!("\n"), + ) + .expect("source-set marker"); + } + write_dependency_test_config( + &config_path, + &dir.path().join("work"), + &dir.path().join("platform"), + ); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "test", + "yaxunit", + "all", + ]) + .output() + .expect("run"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let event_order = fs::read_to_string(build_calls) + .expect("build and test calls") + .lines() + .filter(|line| line.contains("/UpdateDBCfg") || line.contains("RunUnitTests=")) + .map(|line| { + if line.contains("RunUnitTests=") { + "enterprise" + } else if line.contains("-Extension yaxunit") { + "yaxunit" + } else if line.contains("-Extension TESTS") { + "TESTS" + } else { + "main" + } + }) + .collect::>(); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + + assert_eq!(event_order, vec!["main", "yaxunit", "TESTS", "enterprise"]); + assert_eq!(payload["data"]["report"]["summary"]["total"], 1); +} + #[test] fn test_run_appends_enterprise_additional_launch_keys() { let (_dir, config_path, _build_calls, test_calls, _captured_config) = From 0b444926bc40af59be7d5fd043aa7779edc26b94 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 23:05:18 +0300 Subject: [PATCH 4/4] docs(build): clarify fail-fast dependency behavior - Document that failed builds skip all remaining selected source sets\n- Preserve the actual sequential build contract for independent nodes --- docs/DEEP_DIVE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index ddd79cc..5bcedc8 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -63,8 +63,9 @@ dependencies раньше dependent, выбирая одновременно г 4. Load/apply generated files через `DESIGNER` или `IBCMD`. Пайплайн намеренно не является атомарным across many `source-set`: поздний failure не откатывает -уже успешные ранние шаги. Failure dependency останавливает platform execution для всех оставшихся -selected nodes; они остаются в structured result как `skipped` с причиной +уже успешные ранние шаги. `build` использует fail-fast policy: failure любого source-set (в том +числе dependency) останавливает platform execution для всех оставшихся selected nodes, включая +независимые. Они остаются в structured result как `skipped` с причиной `aborted after previous failure`. ## Проверка и тесты