diff --git a/.gitignore b/.gitignore index a95908b..0b32ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ Thumbs.db node_modules/ dist/ target/ +.cargo-target/ *.log # Entornos y secretos diff --git a/Cargo.lock b/Cargo.lock index bdb2a78..0c99305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,7 +93,9 @@ dependencies = [ name = "archmaker-resolution" version = "0.1.0" dependencies = [ + "archmaker-canonicalization", "archmaker-rules", + "serde_json", ] [[package]] @@ -101,6 +103,7 @@ name = "archmaker-rules" version = "0.1.0" dependencies = [ "serde", + "serde_json", ] [[package]] diff --git a/crates/archmaker-core/fixtures/embedded-catalog.json b/crates/archmaker-core/fixtures/embedded-catalog.json index 9bf50aa..39bb6be 100644 --- a/crates/archmaker-core/fixtures/embedded-catalog.json +++ b/crates/archmaker-core/fixtures/embedded-catalog.json @@ -26,7 +26,8 @@ "options": [ { "id": "archmaker.kernel.linux", - "title": "Linux" + "title": "Linux", + "provides": ["fs.ext4"] } ] } @@ -50,5 +51,28 @@ } ] } + ], + "rules": [ + { + "id": "RULE-COMP-001" + }, + { + "id": "RULE-DM-001" + }, + { + "id": "RULE-KERNEL-001" + }, + { + "id": "RULE-BROWSER-001" + }, + { + "id": "RULE-PKG-001" + }, + { + "id": "RULE-KERNEL-002" + }, + { + "id": "RULE-DUP-001" + } ] } diff --git a/crates/archmaker-core/fixtures/local-catalog-mvp1.json b/crates/archmaker-core/fixtures/local-catalog-mvp1.json index f29d807..4cbd0ba 100644 --- a/crates/archmaker-core/fixtures/local-catalog-mvp1.json +++ b/crates/archmaker-core/fixtures/local-catalog-mvp1.json @@ -24,8 +24,16 @@ "title": "Kernel", "cardinality": "one", "options": [ - { "id": "archmaker.kernel.linux", "title": "Linux" }, - { "id": "archmaker.kernel.zen", "title": "Linux Zen" } + { + "id": "archmaker.kernel.linux", + "title": "Linux", + "provides": ["fs.ext4", "fs.btrfs"] + }, + { + "id": "archmaker.kernel.zen", + "title": "Linux Zen", + "provides": ["fs.ext4", "fs.btrfs"] + } ] } ] @@ -40,8 +48,16 @@ "title": "Filesystem", "cardinality": "one", "options": [ - { "id": "archmaker.filesystem.btrfs", "title": "Btrfs" }, - { "id": "archmaker.filesystem.ext4", "title": "Ext4" } + { + "id": "archmaker.filesystem.btrfs", + "title": "Btrfs", + "provides": ["fs.btrfs"] + }, + { + "id": "archmaker.filesystem.ext4", + "title": "Ext4", + "provides": ["fs.ext4"] + } ] } ] @@ -56,8 +72,14 @@ "title": "Bootloader", "cardinality": "one", "options": [ - { "id": "archmaker.boot.systemd-boot", "title": "systemd-boot" }, - { "id": "archmaker.boot.grub", "title": "GRUB" } + { + "id": "archmaker.boot.systemd-boot", + "title": "systemd-boot" + }, + { + "id": "archmaker.boot.grub", + "title": "GRUB" + } ] } ] @@ -72,7 +94,10 @@ "title": "Base devel", "cardinality": "one", "options": [ - { "id": "archmaker.pkg.base-devel", "title": "base-devel" } + { + "id": "archmaker.pkg.base-devel", + "title": "base-devel" + } ] } ] @@ -87,9 +112,18 @@ "title": "Compositor", "cardinality": "one", "options": [ - { "id": "archmaker.compositor.gnome", "title": "GNOME" }, - { "id": "archmaker.compositor.hyprland", "title": "Hyprland" }, - { "id": "archmaker.compositor.sway", "title": "Sway" } + { + "id": "archmaker.compositor.gnome", + "title": "GNOME" + }, + { + "id": "archmaker.compositor.hyprland", + "title": "Hyprland" + }, + { + "id": "archmaker.compositor.sway", + "title": "Sway" + } ] } ] @@ -104,8 +138,14 @@ "title": "Display manager", "cardinality": "one", "options": [ - { "id": "archmaker.dm.gdm", "title": "GDM" }, - { "id": "archmaker.dm.sddm", "title": "SDDM" } + { + "id": "archmaker.dm.gdm", + "title": "GDM" + }, + { + "id": "archmaker.dm.sddm", + "title": "SDDM" + } ] } ] @@ -120,8 +160,16 @@ "title": "Browser", "cardinality": "one", "options": [ - { "id": "archmaker.browser.firefox", "title": "Firefox" }, - { "id": "archmaker.browser.vivaldi", "title": "Vivaldi" } + { + "id": "archmaker.browser.firefox", + "title": "Firefox", + "provides": ["pkg.firefox"] + }, + { + "id": "archmaker.browser.vivaldi", + "title": "Vivaldi", + "provides": ["pkg.vivaldi"] + } ] } ] @@ -136,12 +184,47 @@ "title": "Apps", "cardinality": "many", "options": [ - { "id": "archmaker.app.steam", "title": "Steam" }, - { "id": "archmaker.app.vlc", "title": "VLC" }, - { "id": "archmaker.app.gimp", "title": "GIMP" } + { + "id": "archmaker.app.steam", + "title": "Steam", + "provides": ["pkg.steam"] + }, + { + "id": "archmaker.app.vlc", + "title": "VLC", + "provides": ["pkg.vlc"] + }, + { + "id": "archmaker.app.gimp", + "title": "GIMP", + "provides": ["pkg.gimp"] + } ] } ] } + ], + "rules": [ + { + "id": "RULE-COMP-001" + }, + { + "id": "RULE-DM-001" + }, + { + "id": "RULE-KERNEL-001" + }, + { + "id": "RULE-BROWSER-001" + }, + { + "id": "RULE-PKG-001" + }, + { + "id": "RULE-KERNEL-002" + }, + { + "id": "RULE-DUP-001" + } ] } diff --git a/crates/archmaker-core/src/core.rs b/crates/archmaker-core/src/core.rs index 7e85545..323e35b 100644 --- a/crates/archmaker-core/src/core.rs +++ b/crates/archmaker-core/src/core.rs @@ -13,7 +13,9 @@ use archmaker_domain::{ Selection, SelectionOrigin, SelectionValue, TargetRef, MANIFEST_DOMAIN_LABEL, }; use archmaker_resolution::{Catalog as ResolutionCatalog, CatalogOption}; -use archmaker_rules::{canonical_rules, evaluate_all, Diagnostic, Draft as RulesDraft}; +use archmaker_rules::{ + load_rules_by_ids, Diagnostic, Draft as RulesDraft, Rule, CANONICAL_RULES_PACK_JSON, +}; use crate::error::{CoreError, Result}; use crate::inputs::{ @@ -80,14 +82,31 @@ static UUID_COUNTER: AtomicU64 = AtomicU64::new(0); pub struct RealCore { pub catalog_embedded: Catalog, + /// Reglas activas cargadas desde fichas del catálogo (nunca inventadas en silencio). + pub active_rules: Vec, store_dir: Option, drafts: RefCell>, } +fn rules_from_catalog(catalog: &Catalog) -> Result> { + let source = CoreOp::LoadCatalog.source(); + let Some(refs) = catalog.rules.as_ref() else { + return Ok(Vec::new()); + }; + if refs.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec<&str> = refs.iter().map(|rule_ref| rule_ref.id.as_str()).collect(); + load_rules_by_ids(CANONICAL_RULES_PACK_JSON, &ids) + .map_err(|_| CoreError::schema_invalid(source)) +} + impl RealCore { pub fn new(catalog_embedded: Catalog) -> Self { + let active_rules = rules_from_catalog(&catalog_embedded).unwrap_or_default(); Self { catalog_embedded, + active_rules, store_dir: None, drafts: RefCell::new(HashMap::new()), } @@ -99,7 +118,13 @@ impl RealCore { .map_err(|_| CoreError::schema_invalid(source))?; let catalog: Catalog = serde_json::from_value(value).map_err(|_| CoreError::schema_invalid(source))?; - Ok(Self::new(catalog)) + let active_rules = rules_from_catalog(&catalog)?; + Ok(Self { + catalog_embedded: catalog, + active_rules, + store_dir: None, + drafts: RefCell::new(HashMap::new()), + }) } pub fn with_store_dir(mut self, dir: PathBuf) -> Self { @@ -613,13 +638,20 @@ fn resolve_internal( } let (provided, _) = provided_required(&view, &catalog); view.provided = provided.into_iter().collect(); - let resolved = archmaker_resolution::resolve(&view, &catalog); - let digest = resolution_content_digest( + let resolved = archmaker_resolution::resolve(&view, &catalog, &core.active_rules); + let digest = ContentDigest { + algorithm: DigestAlgorithm::Sha256, + value: resolved.resolution_digest.clone(), + kind: Some(ContentKind::Content), + }; + // Parity check: core digest helper must match resolution crate (NFR-DET-001). + let expected = resolution_content_digest( &resolved.effective_selections, &resolved.provided_capabilities, &resolved.required_capabilities, &resolved.diagnostics, ); + debug_assert_eq!(digest.value, expected.value); let input_pairs: BTreeSet<(String, String)> = draft .selections .iter() @@ -890,18 +922,15 @@ impl CorePort for RealCore { fn validate_draft(&self, input: ValidateDraftInput) -> Result { let source = CoreOp::ValidateDraft.source(); - self.ensure_catalog_match(&input.catalog_ref, source)?; - ensure_draft_shape(&input.draft, source)?; if let Some(target) = &input.target_ref { ensure_target(target, &union_targets(), source)?; } - let catalog = resolution_catalog(&self.catalog_embedded); - let mut view = rules_view(&input.draft); - let (provided, _) = provided_required(&view, &catalog); - view.provided = provided.into_iter().collect(); - let mut diagnostics = evaluate_all(&canonical_rules(), &view); + // Same pipeline as resolve/buildManifest (stages 6–9): integrity, rules, resolution. + let internal = resolve_internal(self, &input.draft, &input.catalog_ref, source)?; + let mut diagnostics = internal.diagnostics; sort_diagnostics(&mut diagnostics); - let blocking = diagnostics.iter().any(|diagnostic| diagnostic.blocking); + let blocking = !internal.conflicts.is_empty() + || diagnostics.iter().any(|diagnostic| diagnostic.blocking); Ok(ValidationResult { diagnostics, blocking, diff --git a/crates/archmaker-resolution/Cargo.toml b/crates/archmaker-resolution/Cargo.toml index 730ac23..28e0f45 100644 --- a/crates/archmaker-resolution/Cargo.toml +++ b/crates/archmaker-resolution/Cargo.toml @@ -6,3 +6,5 @@ license.workspace = true [dependencies] archmaker-rules = { path = "../archmaker-rules" } +archmaker-canonicalization = { path = "../archmaker-canonicalization" } +serde_json = "1" diff --git a/crates/archmaker-resolution/src/lib.rs b/crates/archmaker-resolution/src/lib.rs index 122fd32..11b4a26 100644 --- a/crates/archmaker-resolution/src/lib.rs +++ b/crates/archmaker-resolution/src/lib.rs @@ -1,19 +1,16 @@ -//! Crate `archmaker-resolution` — fase I5 (T-I5-02 del plan MVP-0). +//! Crate `archmaker-resolution` — resolución determinista (etapas 8–9). //! -//! Resolución determinista de selecciones efectivas, capabilities y conflictos -//! (etapa 9 del pipeline) más diagnósticos de reglas (etapa 8). Sin shell, -//! sin filesystem, sin red: solo std más `archmaker-rules`. -//! -//! - [`resolve`] es pura y determinista: mismo `(draft, catalog)` → mismo -//! [`ResolveResult`] byte a byte, incluido [`ResolveResult::resolution_digest`]. -//! - Los diagnósticos se emiten ordenados por `(path, code, source)` -//! (`diagnostic.schema.json#/$defs/diagnosticList`). -//! - El digest es FNV-1a de 64 bits sobre la serialización canónica interna; -//! identidad estable para MVP (el `contentDigest` SHA-256 llega con I6/core). +//! - [`resolve`] es pura: mismo `(draft, catalog, rules)` → mismo [`ResolveResult`]. +//! - Diagnósticos ordenados por `(path, code, source)`. +//! - Digest SHA-256 canónico (`archmaker:resolution:v1`), alineado con core. use std::collections::{BTreeMap, BTreeSet}; -use archmaker_rules::{canonical_rules, evaluate, rule_dm_001, Diagnostic, Draft, Severity}; +use archmaker_rules::{ + evaluate, evaluate_all, rule_dm_001, Diagnostic, Draft, Effect, Rule, Severity, RULE_DM_001, +}; + +const RESOLUTION_DOMAIN: &str = "archmaker:resolution:v1"; // --------------------------------------------------------------------------- // Catálogo mínimo (vista de resolución) @@ -89,6 +86,7 @@ pub struct ResolveResult { pub required_capabilities: BTreeSet, pub conflicts: Vec, pub diagnostics: Vec, + /// Hex SHA-256 del payload canónico de resolución. pub resolution_digest: String, } @@ -116,15 +114,21 @@ fn sorted_diagnostics(mut diagnostics: Vec) -> Vec { diagnostics } -/// FNV-1a de 64 bits, hex minúscula. Determinista entre procesos y -/// plataformas para la misma entrada. -fn fnv1a64_hex(input: &str) -> String { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for byte in input.bytes() { - hash ^= byte as u64; - hash = hash.wrapping_mul(0x0100_0000_01b3); +fn id_matches(stored: &str, wanted: &str) -> bool { + if stored == wanted { + return true; + } + if stored.rsplit('.').next() == Some(wanted) { + return true; + } + if wanted.rsplit('.').next() == Some(stored) { + return true; } - format!("{hash:016x}") + false +} + +fn step_matches(step: &str, wanted: &str) -> bool { + id_matches(step, wanted) } fn digest_input( @@ -133,39 +137,106 @@ fn digest_input( required: &BTreeSet, diagnostics: &[Diagnostic], ) -> String { - let mut out = String::new(); - for (step, options) in effective { - out.push_str(step); - out.push('='); - out.push_str(&options.join(",")); - out.push(';'); - } - out.push('|'); - out.push_str(&provided.iter().cloned().collect::>().join(",")); - out.push('|'); - out.push_str(&required.iter().cloned().collect::>().join(",")); - out.push('|'); - out.push_str( - &diagnostics - .iter() - .map(|d| d.code.as_str()) - .collect::>() - .join(","), + let mut root = serde_json::Map::new(); + let effective_value: serde_json::Map = effective + .iter() + .map(|(step, options)| { + ( + step.clone(), + serde_json::Value::Array( + options + .iter() + .map(|option| serde_json::Value::String(option.clone())) + .collect(), + ), + ) + }) + .collect(); + root.insert( + "effective".to_string(), + serde_json::Value::Object(effective_value), + ); + let set_to_json = |set: &BTreeSet| { + serde_json::Value::Array( + set.iter() + .map(|item| serde_json::Value::String(item.clone())) + .collect(), + ) + }; + root.insert("provided".to_string(), set_to_json(provided)); + root.insert("required".to_string(), set_to_json(required)); + root.insert( + "diagnostics".to_string(), + serde_json::Value::Array( + diagnostics + .iter() + .map(|diagnostic| serde_json::Value::String(diagnostic.code.clone())) + .collect(), + ), ); - out + let value = serde_json::Value::Object(root); + let canonical = archmaker_canonicalization::canonicalize(&value); + archmaker_canonicalization::digest(&canonical, RESOLUTION_DOMAIN) } -/// Resuelve el draft contra el catálogo. +fn apply_dm_derivation( + effective: &mut BTreeMap>, + conflicts: &mut Vec, + provided: &mut BTreeSet, + required: &mut BTreeSet, + catalog: &Catalog, + rules: &[Rule], +) { + let dm_rule = rules + .iter() + .find(|rule| rule.id == RULE_DM_001) + .cloned() + .unwrap_or_else(rule_dm_001); + let Effect::Derive { target, value } = &dm_rule.effect else { + return; + }; + let view = Draft { + selections: effective.clone(), + provided: provided.clone(), + }; + if evaluate(&dm_rule, &view).is_none() { + return; + } + let existing = effective + .iter() + .find(|(step, _)| step_matches(step, target)) + .map(|(step, options)| (step.clone(), options.clone())); + match existing { + None => { + effective.insert(target.clone(), vec![value.clone()]); + let recomputed = capabilities_of(effective, catalog); + *provided = recomputed.0; + *required = recomputed.1; + } + Some((_, current)) if current.iter().any(|v| id_matches(v, value)) => {} + Some((step, _)) => { + conflicts.push(Conflict { + severity: Severity::Error, + path: format!("/selections/{step}"), + message_key: "diagnostic.dmDerived".to_string(), + capability_id: None, + suggestions: vec![format!( + "Set {target} to {value} or change the compositor selection." + )], + }); + } + } +} + +/// Resuelve el draft contra el catálogo aplicando las reglas dadas. /// -/// 1. Congela selecciones efectivas (ordenadas por paso; opciones ordenadas). -/// 2. Calcula capabilities aportadas/requeridas desde el catálogo. -/// 3. Aplica la derivación RULE-DM-001 (`compositor == gnome` → `dm = gdm`) -/// solo si `dm` no está fijado manualmente; ante fijación manual distinta -/// registra conflicto sin mutación silenciosa. -/// 4. Evalúa las 7 reglas canónicas sobre la vista enriquecida. +/// 1. Congela selecciones efectivas. +/// 2. Calcula capabilities aportadas/requeridas. +/// 3. Aplica RULE-DM-001 si está en `rules` (derivación explicable). +/// 4. Evalúa las reglas sobre la vista enriquecida. /// 5. Registra conflictos por `requires` insatisfechos. -/// 6. Ordena diagnósticos por `(path, code, source)` y calcula el digest. -pub fn resolve(draft: &Draft, catalog: &Catalog) -> ResolveResult { +/// 6. Ordena diagnósticos y calcula el digest SHA-256. +pub fn resolve(draft: &Draft, catalog: &Catalog, rules: &[Rule]) -> ResolveResult { let mut effective: BTreeMap> = draft .selections .iter() @@ -177,46 +248,23 @@ pub fn resolve(draft: &Draft, catalog: &Catalog) -> ResolveResult { .collect(); let mut conflicts: Vec = Vec::new(); - let (mut provided, mut required) = capabilities_of(&effective, catalog); - // Derivación RULE-DM-001 (explicable y reversible, nunca silenciosa). - let dm_rule = rule_dm_001(); - let dm_view = Draft { - selections: effective.clone(), - provided: provided.clone(), - }; - if evaluate(&dm_rule, &dm_view).is_some() { - match effective.get("dm") { - None => { - effective.insert("dm".to_string(), vec!["gdm".to_string()]); - let recomputed = capabilities_of(&effective, catalog); - provided = recomputed.0; - required = recomputed.1; - } - Some(current) if current.iter().any(|v| v == "gdm" || v.ends_with(".gdm")) => {} - Some(_) => { - conflicts.push(Conflict { - severity: Severity::Error, - path: "/selections/dm".to_string(), - message_key: "diagnostic.dmDerived".to_string(), - capability_id: None, - suggestions: vec![ - "Set dm to gdm or change the compositor selection.".to_string() - ], - }); - } - } - } + apply_dm_derivation( + &mut effective, + &mut conflicts, + &mut provided, + &mut required, + catalog, + rules, + ); - // Vista enriquecida para la etapa 8 (provides evaluado en la etapa 9). let enriched = Draft { selections: effective.clone(), provided: provided.clone(), }; - let diagnostics = evaluate_all_sorted(&enriched); + let diagnostics = sorted_diagnostics(evaluate_all(rules, &enriched)); - // Conflictos por requires insatisfechos. for option_id in effective.values().flatten() { if let Some(option) = catalog.find(option_id) { for requirement in &option.requires { @@ -236,12 +284,7 @@ pub fn resolve(draft: &Draft, catalog: &Catalog) -> ResolveResult { } conflicts.sort_by(|a, b| (&a.path, &a.message_key).cmp(&(&b.path, &b.message_key))); - let resolution_digest = fnv1a64_hex(&digest_input( - &effective, - &provided, - &required, - &diagnostics, - )); + let resolution_digest = digest_input(&effective, &provided, &required, &diagnostics); ResolveResult { effective_selections: effective, @@ -253,13 +296,10 @@ pub fn resolve(draft: &Draft, catalog: &Catalog) -> ResolveResult { } } -fn evaluate_all_sorted(draft: &Draft) -> Vec { - sorted_diagnostics(archmaker_rules::evaluate_all(&canonical_rules(), draft)) -} - #[cfg(test)] mod tests { use super::*; + use archmaker_rules::canonical_rules; fn option(id: &str, provides: &[&str], requires: &[&str]) -> CatalogOption { CatalogOption::new( @@ -289,16 +329,23 @@ mod tests { d } + fn rules() -> Vec { + canonical_rules() + } + #[test] fn gnome_derives_gdm_with_info_diagnostic() { let result = resolve( &draft(&[("compositor", &["gnome"]), ("kernel", &["linux"])]), &fixture_catalog(), + &rules(), ); - assert_eq!( - result.effective_selections.get("dm"), - Some(&vec!["gdm".to_string()]) - ); + let dm = result.effective_selections.iter().find(|(step, _)| { + step_matches(step, "dm") || step_matches(step, "archmaker.environment.dm") + }); + assert!(dm.is_some()); + let (_, values) = dm.unwrap(); + assert!(values.iter().any(|v| id_matches(v, "gdm"))); assert!(result.diagnostics.iter().any(|d| d.code == "AM-RULE-002")); assert!(result.conflicts.is_empty()); } @@ -312,6 +359,7 @@ mod tests { ("kernel", &["linux"]), ]), &fixture_catalog(), + &rules(), ); assert_eq!( result.effective_selections.get("dm"), @@ -322,10 +370,10 @@ mod tests { #[test] fn kernel_fs_conflict_end_to_end() { - // btrfs seleccionado pero el catálogo solo aporta fs.ext4. let result = resolve( &draft(&[("kernel", &["linux"]), ("fs", &["btrfs"])]), &fixture_catalog(), + &rules(), ); assert!(result .diagnostics @@ -339,6 +387,7 @@ mod tests { let result = resolve( &draft(&[("kernel", &["linux"]), ("pkg", &["zfs-module"])]), &catalog, + &rules(), ); assert!(result .conflicts @@ -355,6 +404,7 @@ mod tests { let result = resolve( &draft(&[("compositor", &["gnome", "sway"])]), &fixture_catalog(), + &rules(), ); let keys: Vec<(&str, &str, &str)> = result .diagnostics @@ -377,17 +427,18 @@ mod tests { ("fs", &["ext4"]), ]); let catalog = fixture_catalog(); - let first = resolve(&draft, &catalog); - let second = resolve(&draft, &catalog); + let first = resolve(&draft, &catalog, &rules()); + let second = resolve(&draft, &catalog, &rules()); assert_eq!(first, second); assert_eq!(first.resolution_digest, second.resolution_digest); + assert_eq!(first.resolution_digest.len(), 64); } #[test] fn digest_changes_with_input() { let catalog = fixture_catalog(); - let a = resolve(&draft(&[("kernel", &["linux"])]), &catalog); - let b = resolve(&draft(&[("kernel", &["linux-lts"])]), &catalog); + let a = resolve(&draft(&[("kernel", &["linux"])]), &catalog, &rules()); + let b = resolve(&draft(&[("kernel", &["linux-lts"])]), &catalog, &rules()); assert_ne!(a.resolution_digest, b.resolution_digest); } @@ -400,11 +451,21 @@ mod tests { ("fs", &["ext4"]), ]), &fixture_catalog(), + &rules(), ); assert!(result.provided_capabilities.contains("fs.ext4")); assert!(result.provided_capabilities.contains("pkg.firefox")); - // fs.ext4 aportado ⇒ sin AM-RULE-007; pkg.firefox aportado ⇒ sin AM-RULE-006. assert!(!result.diagnostics.iter().any(|d| d.code == "AM-RULE-007")); assert!(!result.diagnostics.iter().any(|d| d.code == "AM-RULE-006")); } + + #[test] + fn empty_rules_do_not_invent_canonical_set() { + let result = resolve( + &draft(&[("compositor", &["gnome", "sway"])]), + &fixture_catalog(), + &[], + ); + assert!(result.diagnostics.is_empty()); + } } diff --git a/crates/archmaker-rules/Cargo.toml b/crates/archmaker-rules/Cargo.toml index 82da2db..ea9101b 100644 --- a/crates/archmaker-rules/Cargo.toml +++ b/crates/archmaker-rules/Cargo.toml @@ -6,3 +6,4 @@ license.workspace = true [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/archmaker-rules/fixtures/canonical-rules.json b/crates/archmaker-rules/fixtures/canonical-rules.json new file mode 100644 index 0000000..8fba022 --- /dev/null +++ b/crates/archmaker-rules/fixtures/canonical-rules.json @@ -0,0 +1,101 @@ +[ + { + "id": "RULE-COMP-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "error", + "blocking": true, + "when": { + "count_gt": ["archmaker.environment.compositor", 1] + }, + "action": "block", + "diagnosticCode": "AM-RULE-001", + "note": "Only one compositor is allowed." + }, + { + "id": "RULE-DM-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "info", + "blocking": false, + "when": { + "eq": ["archmaker.environment.compositor", "archmaker.compositor.gnome"] + }, + "action": "derive", + "target": "archmaker.environment.dm", + "allowed": ["archmaker.dm.gdm"], + "diagnosticCode": "AM-RULE-002", + "note": "GNOME compositor derives GDM." + }, + { + "id": "RULE-KERNEL-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "error", + "blocking": true, + "when": { + "not": { "required": "archmaker.base.kernel" } + }, + "action": "block", + "diagnosticCode": "AM-RULE-004", + "note": "Exactly one kernel is required." + }, + { + "id": "RULE-BROWSER-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "warning", + "blocking": false, + "when": { + "eq": [{ "count": "archmaker.apps.browser" }, 0] + }, + "action": "suggest", + "diagnosticCode": "AM-RULE-005", + "note": "A browser is suggested but optional." + }, + { + "id": "RULE-PKG-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "warning", + "blocking": false, + "when": { + "all": [ + { "selected": "archmaker.apps.apps-core" }, + { "not": { "provides": "pkg.placeholder" } } + ] + }, + "action": "suggest", + "diagnosticCode": "AM-RULE-006", + "note": "Package availability is expanded per selected pkg option at evaluate time." + }, + { + "id": "RULE-KERNEL-002", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "error", + "blocking": true, + "when": { + "all": [ + { "selected": "archmaker.base.filesystem" }, + { "not": { "provides": "fs.placeholder" } } + ] + }, + "action": "block", + "diagnosticCode": "AM-RULE-007", + "note": "Filesystem capability conflict is expanded per selected fs option at evaluate time." + }, + { + "id": "RULE-DUP-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "severity": "error", + "blocking": true, + "when": { + "count_gt": ["archmaker.pkg.package", 1] + }, + "action": "block", + "diagnosticCode": "AM-RULE-008", + "note": "Duplicate option identity is detected across selections at evaluate time." + } +] diff --git a/crates/archmaker-rules/src/lib.rs b/crates/archmaker-rules/src/lib.rs index b9fbe58..d1f5931 100644 --- a/crates/archmaker-rules/src/lib.rs +++ b/crates/archmaker-rules/src/lib.rs @@ -12,11 +12,18 @@ //! Notas de alcance: //! - `count_gt` es legacy (solo migración): nunca se ejecuta; se normaliza a //! `Gt(Count(..), n)` mediante [`normalize_count_gt`] (CON-002). +//! - `target_is` está diferido a v1: el parser emite `AM-RULE-009` y nunca lo ejecuta. //! - `RULE-GPU-001` (`AM-RULE-003`) es no implementable en MVP: su efecto `change` //! heredado no declara `target` (ver [`RULE_GPU_001_STATUS`]). -//! - Un operador fuera del vocabulario es irrepresentable en este AST; a nivel de -//! documento JSON lo rechaza el schema y el runtime debe emitir `AM-RULE-009` -//! bloqueante (nunca evaluarlo como `false` silencioso). +//! - Un operador fuera del vocabulario produce `AM-RULE-009` bloqueante al parsear +//! (nunca se evalúa como `false` silencioso). + +mod parse; + +pub use parse::{ + load_rules_by_ids, parse_rule_document, parse_rules_pack, UnsupportedOperatorError, + CANONICAL_RULES_PACK_JSON, +}; use std::collections::{BTreeMap, BTreeSet}; @@ -26,17 +33,21 @@ use serde::{Deserialize, Serialize}; // Operadores y AST // --------------------------------------------------------------------------- -/// Vocabulario ejecutable en MVP (subconjunto canónico de `$defs.operator`). +/// Vocabulario ejecutable canónico de `$defs.operator` (MVP-2). /// /// `Count` es un término (devuelve entero, no booleano) y por eso solo aparece /// como [`Operand`], nunca como [`Condition`] directa. `Gt` es la forma -/// normalizada del legacy `count_gt`. +/// normalizada del legacy `count_gt` (nunca se ejecuta el token `count_gt`). +/// `target_is` no tiene variante: el parser lo rechaza con `AM-RULE-009`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Operator { Eq, + Ne, + In, Count, Gt, All, + Any, Not, Required, Selected, @@ -48,9 +59,12 @@ impl Operator { pub fn as_str(self) -> &'static str { match self { Operator::Eq => "eq", + Operator::Ne => "ne", + Operator::In => "in", Operator::Count => "count", Operator::Gt => "gt", Operator::All => "all", + Operator::Any => "any", Operator::Not => "not", Operator::Required => "required", Operator::Selected => "selected", @@ -73,14 +87,14 @@ pub enum Operand { } /// Condición booleana cerrada (`rule.schema.json#/$defs/condition`). -/// -/// Variantes `Any`/`Ne`/`In` del schema no las usa ninguna regla MVP del -/// inventario; se expresan con `All`+`Not`+`Eq` cuando se necesiten. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Condition { Eq(Operand, Operand), + Ne(Operand, Operand), + In(Operand, Vec), Gt(Operand, Operand), All(Vec), + Any(Vec), Not(Box), Required(String), Selected(String), @@ -312,7 +326,7 @@ fn values_equal(left: &Value, right: &Value) -> bool { } /// Evalúa una condición cerrada sobre el draft. Pares heterogéneos en -/// `Eq`/`Gt` se evalúan como `false` (nunca lanzan). +/// `Eq`/`Ne`/`Gt`/`In` se evalúan como `false` (nunca lanzan). pub fn eval_condition(condition: &Condition, draft: &Draft) -> bool { match condition { Condition::Eq(left, right) => { @@ -321,6 +335,18 @@ pub fn eval_condition(condition: &Condition, draft: &Draft) -> bool { _ => false, } } + Condition::Ne(left, right) => { + match (resolve_operand(left, draft), resolve_operand(right, draft)) { + (Some(l), Some(r)) => !values_equal(&l, &r), + _ => false, + } + } + Condition::In(needle, haystack) => match resolve_operand(needle, draft) { + Some(value) => haystack.iter().any(|candidate| { + resolve_operand(candidate, draft).is_some_and(|other| values_equal(&value, &other)) + }), + None => false, + }, Condition::Gt(left, right) => { match (resolve_operand(left, draft), resolve_operand(right, draft)) { (Some(Value::Int(a)), Some(Value::Int(b))) => a > b, @@ -328,6 +354,7 @@ pub fn eval_condition(condition: &Condition, draft: &Draft) -> bool { } } Condition::All(items) => items.iter().all(|c| eval_condition(c, draft)), + Condition::Any(items) => items.iter().any(|c| eval_condition(c, draft)), Condition::Not(inner) => !eval_condition(inner, draft), Condition::Required(reference) => draft.count_ref(reference) >= 1, Condition::Selected(id) => draft.is_selected(id), @@ -359,11 +386,11 @@ pub fn is_implementable(rule_id: &str) -> bool { rule_id != RULE_GPU_001 } -/// RULE-COMP-001: compositor único. `count_gt(compositor,1)` normalizado. +/// RULE-COMP-001: compositor único. `count_gt` legacy normalizado. pub fn rule_comp_001() -> Rule { Rule::new( RULE_COMP_001, - normalize_count_gt("compositor", 1), + normalize_count_gt("archmaker.environment.compositor", 1), Effect::Block, "AM-RULE-001", ) @@ -374,12 +401,12 @@ pub fn rule_dm_001() -> Rule { Rule::new( RULE_DM_001, Condition::Eq( - Operand::EntityRef("compositor".to_string()), - Operand::EntityRef("gnome".to_string()), + Operand::EntityRef("archmaker.environment.compositor".to_string()), + Operand::EntityRef("archmaker.compositor.gnome".to_string()), ), Effect::Derive { - target: "dm".to_string(), - value: "gdm".to_string(), + target: "archmaker.environment.dm".to_string(), + value: "archmaker.dm.gdm".to_string(), }, "AM-RULE-002", ) @@ -389,7 +416,9 @@ pub fn rule_dm_001() -> Rule { pub fn rule_kernel_001() -> Rule { Rule::new( RULE_KERNEL_001, - Condition::Not(Box::new(Condition::Required("kernel".to_string()))), + Condition::Not(Box::new(Condition::Required( + "archmaker.base.kernel".to_string(), + ))), Effect::Block, "AM-RULE-004", ) @@ -399,47 +428,46 @@ pub fn rule_kernel_001() -> Rule { pub fn rule_browser_001() -> Rule { Rule::new( RULE_BROWSER_001, - Condition::Eq(Operand::Count("browser".to_string()), Operand::Int(0)), + Condition::Eq( + Operand::Count("archmaker.apps.browser".to_string()), + Operand::Int(0), + ), Effect::Suggest, "AM-RULE-005", ) } -/// RULE-PKG-001: `all([selected(pkg), not(provides(pkg.))])` por cada -/// paquete del grupo `pkg`. La condición almacenada es el esquema del -/// inventario; [`evaluate`] la expande por elemento. +/// RULE-PKG-001: disponibilidad de paquetes; [`evaluate`] expande por elemento. pub fn rule_pkg_001() -> Rule { Rule::new( RULE_PKG_001, Condition::All(vec![ - Condition::Selected("pkg".to_string()), - Condition::Not(Box::new(Condition::Provides("pkg".to_string()))), + Condition::Selected("archmaker.apps.apps-core".to_string()), + Condition::Not(Box::new(Condition::Provides("pkg.placeholder".to_string()))), ]), Effect::Suggest, "AM-RULE-006", ) } -/// RULE-KERNEL-002: conflicto kernel/filesystem por cada `fs` seleccionado -/// cuya capability `fs.` no aporta el kernel. +/// RULE-KERNEL-002: conflicto kernel/filesystem; [`evaluate`] expande por `fs`. pub fn rule_kernel_002() -> Rule { Rule::new( RULE_KERNEL_002, Condition::All(vec![ - Condition::Selected("fs".to_string()), - Condition::Not(Box::new(Condition::Provides("fs".to_string()))), + Condition::Selected("archmaker.base.filesystem".to_string()), + Condition::Not(Box::new(Condition::Provides("fs.placeholder".to_string()))), ]), Effect::Block, "AM-RULE-007", ) } -/// RULE-DUP-001: identidad de paquete duplicada. `count_gt(packageId,1)` -/// normalizado; el runtime comprueba toda identidad repetida (DEC-002). +/// RULE-DUP-001: identidad duplicada; el runtime comprueba toda identidad. pub fn rule_dup_001() -> Rule { Rule::new( RULE_DUP_001, - normalize_count_gt("package", 1), + normalize_count_gt("archmaker.pkg.package", 1), Effect::Block, "AM-RULE-008", ) @@ -536,22 +564,36 @@ fn diagnostic_for(rule: &Rule) -> Diagnostic { } } -/// RULE-PKG-001 expandida: dispara si algún paquete del grupo `pkg` no tiene +fn group_options_any(draft: &Draft, keys: &[&str]) -> Vec { + for key in keys { + let options = draft.group_options(key); + if !options.is_empty() { + return options; + } + } + Vec::new() +} + +/// RULE-PKG-001 expandida: dispara si algún paquete del grupo apps/pkg no tiene /// su capability `pkg.` en lo aportado. fn eval_pkg_availability(draft: &Draft) -> bool { - draft.group_options("pkg").iter().any(|option| { - let capability = format!("pkg.{}", short_id(option)); - !draft.provided.contains(&capability) - }) + group_options_any(draft, &["pkg", "archmaker.apps.apps-core", "apps-core"]) + .iter() + .any(|option| { + let capability = format!("pkg.{}", short_id(option)); + !draft.provided.contains(&capability) + }) } /// RULE-KERNEL-002 expandida: dispara si algún `fs` seleccionado no tiene su /// capability `fs.` en lo aportado por el kernel. fn eval_kernel_fs_conflict(draft: &Draft) -> bool { - draft.group_options("fs").iter().any(|option| { - let capability = format!("fs.{}", short_id(option)); - !draft.provided.contains(&capability) - }) + group_options_any(draft, &["fs", "archmaker.base.filesystem", "filesystem"]) + .iter() + .any(|option| { + let capability = format!("fs.{}", short_id(option)); + !draft.provided.contains(&capability) + }) } /// RULE-DUP-001 expandida: dispara si alguna identidad de opción aparece más @@ -732,9 +774,12 @@ mod tests { fn operator_vocabulary_tokens() { let tokens: Vec<&str> = [ Operator::Eq, + Operator::Ne, + Operator::In, Operator::Count, Operator::Gt, Operator::All, + Operator::Any, Operator::Not, Operator::Required, Operator::Selected, @@ -745,14 +790,127 @@ mod tests { .collect(); assert_eq!( tokens, - vec!["eq", "count", "gt", "all", "not", "required", "selected", "provides"] + vec![ + "eq", "ne", "in", "count", "gt", "all", "any", "not", "required", "selected", + "provides" + ] ); } + #[test] + fn any_ne_in_operators_evaluate() { + let draft = draft_with("compositor", &["gnome"]); + assert!(eval_condition( + &Condition::Any(vec![ + Condition::Eq( + Operand::EntityRef("compositor".into()), + Operand::EntityRef("sway".into()), + ), + Condition::Eq( + Operand::EntityRef("compositor".into()), + Operand::EntityRef("gnome".into()), + ), + ]), + &draft + )); + assert!(eval_condition( + &Condition::Ne( + Operand::EntityRef("compositor".into()), + Operand::EntityRef("sway".into()), + ), + &draft + )); + assert!(eval_condition( + &Condition::In( + Operand::EntityRef("compositor".into()), + vec![ + Operand::EntityRef("sway".into()), + Operand::EntityRef("gnome".into()), + ], + ), + &draft + )); + } + #[test] fn evaluation_is_deterministic() { let draft = draft_with("compositor", &["gnome", "sway"]); let rule = rule_comp_001(); assert_eq!(evaluate(&rule, &draft), evaluate(&rule, &draft)); } + + #[test] + fn catalog_pack_matches_oracle_canonical_rules() { + let loaded = load_rules_by_ids( + CANONICAL_RULES_PACK_JSON, + &[ + RULE_COMP_001, + RULE_DM_001, + RULE_KERNEL_001, + RULE_BROWSER_001, + RULE_PKG_001, + RULE_KERNEL_002, + RULE_DUP_001, + ], + ) + .expect("pack loads"); + let oracle = canonical_rules(); + assert_eq!(loaded.len(), oracle.len()); + for (sheet, fixed) in loaded.iter().zip(oracle.iter()) { + assert_eq!(sheet.id, fixed.id); + assert_eq!(sheet.code, fixed.code); + assert_eq!(sheet.effect, fixed.effect); + let draft = draft_with("compositor", &["gnome", "sway"]); + assert_eq!( + evaluate(sheet, &draft).map(|d| d.code), + evaluate(fixed, &draft).map(|d| d.code), + "oracle mismatch for {}", + sheet.id + ); + } + } + + #[test] + fn unknown_and_target_is_emit_am_rule_009() { + let unknown = serde_json::json!({ + "id": "RULE-TEST-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "when": { "bogus": true }, + "action": "block", + "diagnosticCode": "AM-RULE-001" + }); + let err = parse_rule_document(&unknown).expect_err("unknown op"); + assert_eq!(err.code, "AM-RULE-009"); + let target_is = serde_json::json!({ + "id": "RULE-TEST-002", + "documentVersion": "vNext", + "schemaVersion": "1", + "when": { "target_is": "archinstall" }, + "action": "block", + "diagnosticCode": "AM-RULE-001" + }); + let err = parse_rule_document(&target_is).expect_err("target_is deferred"); + assert_eq!(err.code, "AM-RULE-009"); + } + + #[test] + fn count_gt_normalizes_and_never_stays_raw() { + let doc = serde_json::json!({ + "id": "RULE-COMP-001", + "documentVersion": "vNext", + "schemaVersion": "1", + "when": { "count_gt": ["archmaker.environment.compositor", 1] }, + "action": "block", + "diagnosticCode": "AM-RULE-001" + }); + let rule = parse_rule_document(&doc).expect("legacy normalizes"); + assert_eq!( + rule.condition, + Condition::Gt( + Operand::Count("archmaker.environment.compositor".into()), + Operand::Int(1) + ) + ); + } } diff --git a/crates/archmaker-rules/src/parse.rs b/crates/archmaker-rules/src/parse.rs new file mode 100644 index 0000000..0569570 --- /dev/null +++ b/crates/archmaker-rules/src/parse.rs @@ -0,0 +1,285 @@ +//! Parseo de documentos `Rule` (rule.schema.json) a AST ejecutable. +//! +//! - `count_gt` se normaliza a `Gt(Count(..), n)` y nunca queda como token crudo. +//! - `target_is` y cualquier operador fuera del vocabulario ejecutable producen +//! [`UnsupportedOperatorError`] con código `AM-RULE-009` bloqueante. + +use serde_json::Value; + +use crate::{ + code_info, normalize_count_gt, Condition, Diagnostic, Effect, Operand, Rule, Severity, SOURCE, +}; + +/// Pack canónico de las 7 reglas implementables (oráculo MVP-2). +pub const CANONICAL_RULES_PACK_JSON: &str = include_str!("../fixtures/canonical-rules.json"); + +/// Error tipado al rechazar un operador no ejecutable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsupportedOperatorError { + pub code: &'static str, + pub operator: String, + pub path: String, +} + +impl UnsupportedOperatorError { + pub fn am_rule_009(operator: impl Into, path: impl Into) -> Self { + Self { + code: "AM-RULE-009", + operator: operator.into(), + path: path.into(), + } + } + + pub fn into_diagnostic(self) -> Diagnostic { + let (severity, blocking, message_key) = code_info(self.code); + Diagnostic { + code: self.code.to_string(), + severity, + blocking, + path: self.path, + message_key: message_key.to_string(), + source: SOURCE.to_string(), + rule_id: String::new(), + suggestions: vec![format!( + "Operator '{}' is outside the executable vocabulary.", + self.operator + )], + } + } +} + +type ParseResult = Result; + +/// Parsea un documento Rule JSON. Rechaza operadores desconocidos y `target_is`. +pub fn parse_rule_document(value: &Value) -> ParseResult { + let obj = value + .as_object() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("document", "/"))?; + let id = obj + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("id", "/id"))? + .to_string(); + let action = obj + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("action", "/action"))?; + let when = obj + .get("when") + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("when", "/when"))?; + let condition = parse_condition(when, "/when")?; + let code = obj + .get("diagnosticCode") + .and_then(Value::as_str) + .unwrap_or("AM-RULE-009") + .to_string(); + let effect = parse_effect(action, obj)?; + Ok(Rule::new(id, condition, effect, code)) +} + +/// Parsea un array JSON de documentos Rule. +pub fn parse_rules_pack(bytes: &str) -> ParseResult> { + let value: Value = serde_json::from_str(bytes) + .map_err(|_| UnsupportedOperatorError::am_rule_009("pack", "/"))?; + let items = value + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("pack", "/"))?; + let mut rules = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + let mut rule = parse_rule_document(item).map_err(|mut err| { + err.path = format!("/{index}{}", err.path); + err + })?; + // Preserve severity-derived defaults when diagnostic code maps known severities. + let (severity, _, _) = code_info(&rule.code); + if matches!(severity, Severity::Error) && matches!(rule.effect, Effect::Suggest) { + // Keep declared effect; inventory is authoritative for action. + } + let _ = &mut rule; + rules.push(rule); + } + Ok(rules) +} + +/// Carga del pack solo las reglas cuyos ids aparecen en `wanted`, en ese orden. +/// No inventa reglas ausentes: un id pedido y no encontrado es error de ficha. +pub fn load_rules_by_ids(pack_json: &str, wanted: &[&str]) -> ParseResult> { + let pack = parse_rules_pack(pack_json)?; + let mut out = Vec::with_capacity(wanted.len()); + for id in wanted { + let Some(rule) = pack.iter().find(|rule| rule.id == *id).cloned() else { + return Err(UnsupportedOperatorError::am_rule_009( + format!("missing:{id}"), + "/rules", + )); + }; + out.push(rule); + } + Ok(out) +} + +fn parse_effect(action: &str, obj: &serde_json::Map) -> ParseResult { + match action { + "block" => Ok(Effect::Block), + "suggest" => Ok(Effect::Suggest), + "derive" | "change" => { + let target = obj + .get("target") + .and_then(Value::as_str) + .ok_or_else(|| UnsupportedOperatorError::am_rule_009(action, "/target"))? + .to_string(); + let value = obj + .get("allowed") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str) + .ok_or_else(|| UnsupportedOperatorError::am_rule_009(action, "/allowed"))? + .to_string(); + Ok(Effect::Derive { target, value }) + } + "lock" => { + // MVP-2: lock se modela como derive sin valor mutado; se rechaza sin target. + Err(UnsupportedOperatorError::am_rule_009("lock", "/action")) + } + other => Err(UnsupportedOperatorError::am_rule_009(other, "/action")), + } +} + +fn parse_condition(value: &Value, path: &str) -> ParseResult { + let obj = value + .as_object() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("condition", path))?; + if obj.len() != 1 { + let key = obj.keys().next().cloned().unwrap_or_else(|| "empty".into()); + return Err(UnsupportedOperatorError::am_rule_009(key, path)); + } + let (op, payload) = obj.iter().next().expect("len == 1"); + match op.as_str() { + "all" => Ok(Condition::All(parse_condition_list(payload, path)?)), + "any" => Ok(Condition::Any(parse_condition_list(payload, path)?)), + "not" => Ok(Condition::Not(Box::new(parse_condition( + payload, + &format!("{path}/not"), + )?))), + "eq" => { + let (left, right) = parse_operand_pair(payload, path)?; + Ok(Condition::Eq(left, right)) + } + "ne" => { + let (left, right) = parse_operand_pair(payload, path)?; + Ok(Condition::Ne(left, right)) + } + "in" => { + let items = payload + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("in", path))?; + if items.len() != 2 { + return Err(UnsupportedOperatorError::am_rule_009("in", path)); + } + let needle = parse_operand(&items[0], &format!("{path}/in/0"))?; + let haystack = items[1] + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("in", path))? + .iter() + .enumerate() + .map(|(i, item)| parse_operand(item, &format!("{path}/in/1/{i}"))) + .collect::>>()?; + if haystack.is_empty() { + return Err(UnsupportedOperatorError::am_rule_009("in", path)); + } + Ok(Condition::In(needle, haystack)) + } + "required" => { + let reference = payload + .as_str() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("required", path))? + .to_string(); + Ok(Condition::Required(reference)) + } + "selected" => { + let id = payload + .as_str() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("selected", path))? + .to_string(); + Ok(Condition::Selected(id)) + } + "provides" => { + let capability = payload + .as_str() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("provides", path))? + .to_string(); + Ok(Condition::Provides(capability)) + } + "count_gt" => { + let items = payload + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("count_gt", path))?; + if items.len() != 2 { + return Err(UnsupportedOperatorError::am_rule_009("count_gt", path)); + } + let reference = items[0] + .as_str() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("count_gt", path))?; + let threshold = items[1] + .as_i64() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("count_gt", path))?; + Ok(normalize_count_gt(reference, threshold)) + } + "count" => Err(UnsupportedOperatorError::am_rule_009("count", path)), // count is a term, not a boolean condition root + "target_is" => Err(UnsupportedOperatorError::am_rule_009("target_is", path)), + other => Err(UnsupportedOperatorError::am_rule_009(other, path)), + } +} + +fn parse_condition_list(value: &Value, path: &str) -> ParseResult> { + let items = value + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("list", path))?; + if items.is_empty() { + return Err(UnsupportedOperatorError::am_rule_009("list", path)); + } + items + .iter() + .enumerate() + .map(|(i, item)| parse_condition(item, &format!("{path}/{i}"))) + .collect() +} + +fn parse_operand_pair(value: &Value, path: &str) -> ParseResult<(Operand, Operand)> { + let items = value + .as_array() + .ok_or_else(|| UnsupportedOperatorError::am_rule_009("pair", path))?; + if items.len() != 2 { + return Err(UnsupportedOperatorError::am_rule_009("pair", path)); + } + Ok(( + parse_operand(&items[0], &format!("{path}/0"))?, + parse_operand(&items[1], &format!("{path}/1"))?, + )) +} + +fn parse_operand(value: &Value, path: &str) -> ParseResult { + if let Some(flag) = value.as_bool() { + return Ok(Operand::Bool(flag)); + } + if let Some(number) = value.as_i64() { + return Ok(Operand::Int(number)); + } + if let Some(text) = value.as_str() { + return Ok(Operand::EntityRef(text.to_string())); + } + if let Some(obj) = value.as_object() { + if obj.len() == 1 { + if let Some(count_ref) = obj.get("count").and_then(Value::as_str) { + return Ok(Operand::Count(count_ref.to_string())); + } + let key = obj + .keys() + .next() + .cloned() + .unwrap_or_else(|| "operand".into()); + return Err(UnsupportedOperatorError::am_rule_009(key, path)); + } + } + Err(UnsupportedOperatorError::am_rule_009("operand", path)) +} diff --git a/docs/00-governance/go-no-go.md b/docs/00-governance/go-no-go.md index a2ea7ea..4c79538 100644 --- a/docs/00-governance/go-no-go.md +++ b/docs/00-governance/go-no-go.md @@ -21,9 +21,13 @@ reviewers: - Reevaluación slice MVP-1 (MVP-1-08): **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slice MVP-1** (dominio, catálogo local, `importDraft` / MIG-5.1). Baseline emitida: `implementation-baseline-mvp1` (el tag `implementation-baseline-mvp0.1` no se movió). -- Alcance autorizado: `docs/10-delivery/walking-skeleton.md` (DOC-DEL-WSK-001) y - `docs/10-delivery/mvp1-scope.md` (DOC-DEL-MVP1-001). Bloqueados: runner real, root, discos, - shell arbitraria, catálogos remotos, firma criptográfica de catálogo en carga, Enterprise. +- Reevaluación slice MVP-2 (MVP-2-07): **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2** + (reglas de catálogo, resolución de dominio, validación que bloquea manifiesto). Baseline objetivo: + `implementation-baseline-mvp2` (tags previos intactos). +- Alcance autorizado: `docs/10-delivery/walking-skeleton.md` (DOC-DEL-WSK-001), + `docs/10-delivery/mvp1-scope.md` (DOC-DEL-MVP1-001) y `docs/10-delivery/mvp2-scope.md` + (DOC-DEL-MVP2-001). Bloqueados: runner real, root, discos, shell arbitraria, catálogos remotos, + firma criptográfica de catálogo en carga, Enterprise. ## Motivo del resultado mecánico @@ -89,10 +93,11 @@ Resultado derivado: **Clear** — todos los gates G0–G10 están `complete`. ## Consecuencia inmediata -Autorizado: el walking skeleton MVP-0 definido en `docs/10-delivery/walking-skeleton.md` -y el slice **MVP-1** (dominio / schema / catálogo local / `importDraft`) según -`docs/10-delivery/mvp1-scope.md`. **No** autorizado: runner real, root, discos, shell -arbitraria, catálogos remotos, firma criptográfica de catálogo en carga, ni Enterprise. +Autorizado: el walking skeleton MVP-0 definido en `docs/10-delivery/walking-skeleton.md`, +el slice **MVP-1** (`mvp1-scope.md`) y el slice **MVP-2** (reglas de catálogo / resolución / +validación) según `docs/10-delivery/mvp2-scope.md`. **No** autorizado: runner real, root, +discos, shell arbitraria, catálogos remotos, firma criptográfica de catálogo en carga, ni +Enterprise. ## Decisiones de alcance (C3 — G1-C03) diff --git a/docs/00-governance/plan-status.md b/docs/00-governance/plan-status.md index bffb3b6..300f940 100644 --- a/docs/00-governance/plan-status.md +++ b/docs/00-governance/plan-status.md @@ -16,8 +16,8 @@ reviewers: # Estado de planificación - Estado: in-review -- Gate global: CONDITIONAL-CLEAR — walking skeleton MVP-0 + slice MVP-1 (sin runner) -- Baseline vigente: `implementation-baseline-mvp1` (predecesor `implementation-baseline-mvp0.1` intacto) +- Gate global: CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2 (sin runner) +- Baseline vigente: `implementation-baseline-mvp1`; sucesor `implementation-baseline-mvp2` (emitir con CI verde) - Fecha de corte: 2026-10-12 ## Bloqueos P0 diff --git a/docs/07-validation/operator-spec.md b/docs/07-validation/operator-spec.md index 242828e..12906eb 100644 --- a/docs/07-validation/operator-spec.md +++ b/docs/07-validation/operator-spec.md @@ -5,7 +5,7 @@ priority: P0 documentStatus: in-review approvalStatus: pending implementationStatus: complete -verificationStatus: not-verified +verificationStatus: partial releaseStatus: ineligible owners: - quality diff --git a/docs/07-validation/pipeline.md b/docs/07-validation/pipeline.md index 39f9787..e7aafec 100644 --- a/docs/07-validation/pipeline.md +++ b/docs/07-validation/pipeline.md @@ -5,7 +5,7 @@ priority: P0 documentStatus: in-review approvalStatus: pending implementationStatus: complete -verificationStatus: not-verified +verificationStatus: partial releaseStatus: ineligible owners: - quality diff --git a/docs/07-validation/rule-inventory.md b/docs/07-validation/rule-inventory.md index cd3c60e..af189bf 100644 --- a/docs/07-validation/rule-inventory.md +++ b/docs/07-validation/rule-inventory.md @@ -2,10 +2,10 @@ id: DOC-VAL-RULES-001 phase: MVP priority: P0 -documentStatus: draft +documentStatus: in-review approvalStatus: pending -implementationStatus: partial -verificationStatus: not-verified +implementationStatus: complete +verificationStatus: partial releaseStatus: ineligible owners: - quality @@ -15,13 +15,14 @@ reviewers: # Inventario de reglas -ID `DOC-VAL-RULES-001` · Estado `draft` · Propietario Calidad · Última revisión 2026-10-06. +ID `DOC-VAL-RULES-001` · Estado `in-review` · Propietario Calidad · Última revisión 2026-10-12. Requisitos relacionados: FR-CAT-001, FR-RESOLVE-001, FR-VALIDATE-001; NFR-DET-001, NFR-MIG-001. -> Documento `draft`. Ninguna ficha está `accepted`. Las fichas heredadas conservan su fila original; -> lo nuevo se marca explícitamente. Cada condición usa **solo** operadores del vocabulario cerrado de -> `contracts/json-schema/rule.schema.json#/$defs.operator` (ver `docs/07-validation/operator-spec.md`); -> no hay operadores indefinidos. +> Las **7 reglas implementables** viven como fichas en +> `crates/archmaker-rules/fixtures/canonical-rules.json` y se cargan vía `catalog.rules` +> (MVP-2). El código fijo `canonical_rules()` es oráculo de paridad. `RULE-GPU-001` sigue +> no implementable. Cada condición usa **solo** operadores del vocabulario cerrado de +> `contracts/json-schema/rule.schema.json#/$defs.operator` (ver `docs/07-validation/operator-spec.md`). ## Resumen diff --git a/docs/10-delivery/backlog.md b/docs/10-delivery/backlog.md index a153c69..2a51206 100644 --- a/docs/10-delivery/backlog.md +++ b/docs/10-delivery/backlog.md @@ -111,6 +111,20 @@ Los issues AUD-001..AUD-023 están **supersedidos** por la evidencia del walking Detalle y exclusiones: [`mvp1-scope.md`](mvp1-scope.md). +## Issues MVP-2 + +| Issue | Título | Dueño | Estado | Gate | +| -------- | ---------------------------------------- | ------------ | ------- | -------- | +| MVP-2-01 | Aprobar alcance DOC-DEL-MVP2-001 | Product | closed | G2,G10 | +| MVP-2-02 | AST operadores canónicos + AM-RULE-009 | Validation | closed | G7 | +| MVP-2-03 | Reglas desde fichas del catálogo | Data | closed | G4,G7 | +| MVP-2-04 | Resolver dominio + digest SHA-256 | Architecture | closed | G4,G5,G7 | +| MVP-2-05 | validateDraft bloquea como buildManifest | Quality | closed | G5,G7,G9 | +| MVP-2-06 | Trazabilidad ampliada MVP-2 | Quality | closed | G2,G9 | +| MVP-2-07 | Tag `implementation-baseline-mvp2` | Release | planned | G10 | + +Detalle y exclusiones: [`mvp2-scope.md`](mvp2-scope.md). + ## Reglas - Cada issue P0 tiene owner, fecha objetivo y criterio de cierre (ver `status-model.md`). diff --git a/docs/10-delivery/mvp2-scope.md b/docs/10-delivery/mvp2-scope.md new file mode 100644 index 0000000..bbcaac3 --- /dev/null +++ b/docs/10-delivery/mvp2-scope.md @@ -0,0 +1,76 @@ +--- +id: DOC-DEL-MVP2-001 +phase: MVP +priority: P0 +documentStatus: accepted +approvalStatus: approved +implementationStatus: complete +verificationStatus: partial +releaseStatus: ineligible +owners: + - quality + - architecture +reviewers: + - independent-reviewer +dependsOn: + - id: DOC-DEL-MVP1-001 + expectedDocumentStatus: accepted + - id: DOC-GOV-GNG-001 + - id: DOC-VAL-RULES-001 +--- + +# Alcance MVP-2 — rules / resolver / validation + +- Documento: DOC-DEL-MVP2-001 · Estado: accepted · Propietario: Calidad/Arquitectura · Fecha: 2026-10-12 +- Entrada: baseline `implementation-baseline-mvp1` + gates G0–G10 `complete` (slice). +- Alcance **accepted** (MVP-2-01). Implementación de reglas de catálogo, resolución de dominio y + pipeline de validación autorizada bajo las exclusiones de este documento; runner y privilegios + siguen fuera. + +## Objetivo + +Hacer que `resolveDraft` y `validateDraft` evalúen las fichas `Rule` del catálogo local y +resuelvan contra el catálogo de dominio con huella SHA-256 canónica, bloqueando el manifiesto +ante diagnóstico o conflicto tipado. + +## Incluido + +| Área | Contenido | Anclas | +| ---------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| Operadores | AST ejecutable canónico (`any`/`ne`/`in` incluidos); `count_gt` normalizado; `target_is` rechazado | FR-VALIDATE-001, operator-spec, AM-RULE-009 | +| Reglas | Fichas del pack canónico referenciadas por `catalog.rules`; sin inventar reglas | FR-VALIDATE-001, rule-inventory | +| Resolución | Catálogo de dominio (`provides`/`requires`); conflictos tipados; digest SHA-256 | FR-RESOLVE-001, NFR-DET-001 | +| Pipeline | `validateDraft` ≡ etapas 6–9 de resolve/buildManifest | FR-VALIDATE-001, pipeline.md | + +## Excluido (explícito) + +- Runner v1, root, discos, shell, sidecars, catálogos remotos. +- Presets (`FR-PRESET-001` deferred). +- `RULE-GPU-001` (sin target; SRC-002/SRC-003 pendientes). +- `target_is` (diferido a v1). +- Etapas 10–12 del pipeline (hardware, firma/freshness, exportador → MVP-3). +- Rediseño visual. + +## Issues trazables + +| # | Issue | Depende de | Gate | Owner | Criterio Done | +| -------- | --------------------------------------------- | ---------- | -------- | ------------ | ------------------------------------- | +| MVP-2-01 | Aprobar alcance DOC-DEL-MVP2-001 | — | G2,G10 | Product | Documento `accepted` | +| MVP-2-02 | AST operadores canónicos + AM-RULE-009 | 01 | G7 | Validation | Corpus operadores + tests Rust | +| MVP-2-03 | Cargar reglas desde fichas del catálogo | 02 | G4,G7 | Data | Pack + `catalog.rules`; oráculo verde | +| MVP-2-04 | Resolver dominio + digest SHA-256 | 02,03 | G4,G5,G7 | Architecture | FR-RESOLVE-001; FNV retirado | +| MVP-2-05 | validateDraft bloquea igual que buildManifest | 03,04 | G5,G7,G9 | Quality | FR-VALIDATE-001 | +| MVP-2-06 | Trazabilidad MVP-2 sin huérfanos | 04,05 | G2,G9 | Quality | Matriz verde | +| MVP-2-07 | Tag `implementation-baseline-mvp2` | 01–06 | G10 | Release | Tag anotado; CI verde; go-no-go | + +## Trazabilidad + +- Requisitos: FR-RESOLVE-001, FR-VALIDATE-001, NFR-DET-001. +- Validadores: VAL-RULE, corpus operadores, VAL-DOC. +- Pruebas: TST-RULE-\*, parity resolve/validate, core_contract resolve/validate/build. +- Amenazas: sin superficie nueva de shell/red/disco. + +## Criterio de salida + +- Issues MVP-2-01..07 cerrados o con residual fechado. +- Tag `implementation-baseline-mvp2` emitido; tags MVP-0.1 y MVP-1 intactos. diff --git a/docs/10-delivery/next-issues.md b/docs/10-delivery/next-issues.md index 6616252..6bfb4d2 100644 --- a/docs/10-delivery/next-issues.md +++ b/docs/10-delivery/next-issues.md @@ -64,6 +64,20 @@ Fuente canónica de alcance: [`mvp1-scope.md`](mvp1-scope.md). | MVP-1-07 | Trazabilidad ampliada MVP-1 | 04,05,06 | G2,G9 | Quality | Sin huérfanos | closed | | MVP-1-08 | Tag `implementation-baseline-mvp1` | 01–07 | G10 | Release | Tag + CI verde | closed | +## Issues MVP-2 + +Fuente canónica de alcance: [`mvp2-scope.md`](mvp2-scope.md). + +| # | Issue | Depende de | Gate | Owner | Criterio Done | Estado | +| -------- | --------------------------------------- | ---------- | -------- | ------------ | ---------------------------- | ------- | +| MVP-2-01 | Aprobar alcance DOC-DEL-MVP2-001 | — | G2,G10 | Product | Documento `accepted` | closed | +| MVP-2-02 | AST operadores canónicos + AM-RULE-009 | 01 | G7 | Validation | Corpus + tests Rust | closed | +| MVP-2-03 | Cargar reglas desde fichas del catálogo | 02 | G4,G7 | Data | Pack + `catalog.rules` | closed | +| MVP-2-04 | Resolver dominio + digest SHA-256 | 02,03 | G4,G5,G7 | Architecture | FR-RESOLVE-001; FNV retirado | closed | +| MVP-2-05 | validateDraft ≡ buildManifest (bloqueo) | 03,04 | G5,G7,G9 | Quality | FR-VALIDATE-001 | closed | +| MVP-2-06 | Trazabilidad MVP-2 sin huérfanos | 04,05 | G2,G9 | Quality | Matriz verde | closed | +| MVP-2-07 | Tag `implementation-baseline-mvp2` | 01–06 | G10 | Release | Tag + CI verde | planned | + ## Trazabilidad de los issues - Requisitos: ISSUE-003 (`FR-*`, `NFR-*`), ISSUE-006 (FR-DRAFT/EXPORT, NFR-MIG), ISSUE-007 (FR-VALIDATE/NFR-DET), ISSUE-008 (NFR-PORT/SEC/OBS). @@ -74,7 +88,7 @@ Fuente canónica de alcance: [`mvp1-scope.md`](mvp1-scope.md). ## Regla de cierre del plan -El veredicto vigente es **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slice MVP-1** +El veredicto vigente es **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2** (`docs/00-governance/go-no-go.md`), con gates G0–G10 `complete`. MVP-1-01..08 están `closed`; -baseline `implementation-baseline-mvp1` emitida (no se movió `implementation-baseline-mvp0.1`). +MVP-2-01..06 están `closed`; MVP-2-07 cierra al emitir `implementation-baseline-mvp2` con CI verde. La autorización del MVP completo y del runner exige su propio gate. diff --git a/docs/10-delivery/roadmap.md b/docs/10-delivery/roadmap.md index e230475..a3764e8 100644 --- a/docs/10-delivery/roadmap.md +++ b/docs/10-delivery/roadmap.md @@ -23,7 +23,7 @@ PD-0 fuentes; PD-1 producto; PD-2 arquitectura; PD-3 datos; PD-4 interfaces; PD- - MVP-0.1 walking skeleton stabilizado (baseline `implementation-baseline-mvp0.1`): CorePort, adapters Tauri/WASM, canonicalización, reglas, resolución, manifest, export, security, CI, supply chain mínimo. Sin runner ni privilegios. - MVP-1 domain/schema/catalog/migrations — baseline `implementation-baseline-mvp1` ([`mvp1-scope.md`](mvp1-scope.md)). -- MVP-2 rules/resolver/validation. +- MVP-2 rules/resolver/validation — alcance en [`mvp2-scope.md`](mvp2-scope.md); hacia el tag `implementation-baseline-mvp2`. - MVP-3 manifest/exporters. - MVP-4 React + MockCorePort + design system. - MVP-5 Tauri/WASM/persistencia/paridad. diff --git a/docs/10-delivery/tests-inventory.json b/docs/10-delivery/tests-inventory.json index f6b5d73..10a5c88 100644 --- a/docs/10-delivery/tests-inventory.json +++ b/docs/10-delivery/tests-inventory.json @@ -1,6 +1,6 @@ { - "generated_at": "1791820748", - "git_sha": "bed7e320d20e7edd801eab41793bffbb919cc047", + "generated_at": "1791822953", + "git_sha": "773487e554710eb1e536d824f0a1002bae1c2688", "tests": [ { "id": "TST-ARCHMAKER-CANONICALIZATION-VECTORS-GOLDEN_CORPUS_BYTE_IDENTITY_AND_DIGEST", diff --git a/docs/10-delivery/traceability-matrix.json b/docs/10-delivery/traceability-matrix.json index 3e81c7f..f8d1daa 100644 --- a/docs/10-delivery/traceability-matrix.json +++ b/docs/10-delivery/traceability-matrix.json @@ -1,7 +1,7 @@ { "metadata": { - "generated_at": "1791820748", - "git_sha": "bed7e320d20e7edd801eab41793bffbb919cc047", + "generated_at": "1791822953", + "git_sha": "773487e554710eb1e536d824f0a1002bae1c2688", "requirements_file": "docs/01-product/requirements.md", "inventory_file": "docs/10-delivery/tests-inventory.json", "validators_file": "docs/10-delivery/validators.json" @@ -70,7 +70,7 @@ "evidence_count": 34, "has_executable_evidence": true, "passes_ci": true, - "evidence_types": ["schema", "integration", "negative", "unit"], + "evidence_types": ["negative", "unit", "schema", "integration"], "coverage_status": "covered" }, { @@ -126,7 +126,7 @@ "evidence_count": 41, "has_executable_evidence": true, "passes_ci": true, - "evidence_types": ["schema", "integration", "negative", "unit"], + "evidence_types": ["negative", "unit", "schema", "integration"], "coverage_status": "covered" }, { @@ -232,11 +232,11 @@ "has_executable_evidence": true, "passes_ci": true, "evidence_types": [ - "schema", - "canonicalize", - "integration", "unit", - "negative" + "integration", + "negative", + "schema", + "canonicalize" ], "coverage_status": "covered" }, @@ -276,7 +276,7 @@ "evidence_count": 6, "has_executable_evidence": true, "passes_ci": true, - "evidence_types": ["integration", "negative"], + "evidence_types": ["negative", "integration"], "coverage_status": "covered" }, { @@ -357,7 +357,7 @@ "evidence_count": 13, "has_executable_evidence": true, "passes_ci": true, - "evidence_types": ["canonicalize", "integration", "negative", "unit"], + "evidence_types": ["negative", "unit", "integration", "canonicalize"], "coverage_status": "covered" }, { diff --git a/docs/10-delivery/traceability-matrix.md b/docs/10-delivery/traceability-matrix.md index 84765d3..d84fad9 100644 --- a/docs/10-delivery/traceability-matrix.md +++ b/docs/10-delivery/traceability-matrix.md @@ -17,7 +17,7 @@ reviewers: # Matriz de Trazabilidad FR/NFR -> TST/VAL -- **Generado**: bed7e320 +- **Generado**: 773487e5 - **Total requisitos**: 17 - **En alcance (mvp0.1)**: 13 - **Diferidos**: 3