From 879e2a6cc00fd92025fbbbd11a90464c7d60660f Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 9 Aug 2026 23:34:40 +0100 Subject: [PATCH] feat(engine): compose federated corpus --- rust/rac-engine/src/composition.rs | 785 +++++++++++++++++++++++++++ rust/rac-engine/src/index_store.rs | 3 + rust/rac-engine/src/lib.rs | 1 + rust/rac-engine/src/relationships.rs | 375 ++++++++----- rust/rac-engine/src/rename.rs | 2 +- rust/rac-engine/src/resolve.rs | 15 +- rust/rac-engine/src/retrieve.rs | 16 + rust/rac-engine/tests/composition.rs | 494 +++++++++++++++++ 8 files changed, 1554 insertions(+), 137 deletions(-) create mode 100644 rust/rac-engine/src/composition.rs create mode 100644 rust/rac-engine/tests/composition.rs diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs new file mode 100644 index 00000000..5f196297 --- /dev/null +++ b/rust/rac-engine/src/composition.rs @@ -0,0 +1,785 @@ +//! Central source-aware corpus composition (ADR-136 through ADR-138). +//! +//! This module is intentionally dormant at the command boundary. The parent +//! verifier supplies already-validated items and declaration values later; +//! composition owns the single catalog/effective overlay every reader will +//! consume. It performs no filesystem or network work. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; + +use crate::corpus::{ArtifactKey, ArtifactPath, Layer}; +use crate::pycompat::py_casefold; +use crate::relationships::{ + resolution_index_from_rows, resolve_relationships, validation_from_rows_with_index, + validation_row_from_item, CorpusItem, Relationship, RelationshipValidation, + ResolutionCandidate, ResolutionIndex, ValidationRow, +}; +use crate::resolve::is_live_decision; + +pub const FINDING_CANONICAL_COLLISION: &str = "cross-corpus-canonical-id-collision"; +pub const FINDING_INVALID_OVERRIDE: &str = "cross-corpus-invalid-override"; + +/// A canonical identifier used by a local override operand. +/// +/// It deliberately cannot contain the qualified-reference delimiter. Whether +/// the value is truly canonical is then established by a direct lookup in the +/// appropriate layer; artifact aliases never participate in that lookup. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CanonicalId(String); + +impl CanonicalId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.trim() != value { + return Err(OverrideSyntaxError::InvalidCanonicalId); + } + if value.contains("::") { + return Err(OverrideSyntaxError::QualifiedLocalId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CanonicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// A child-local parent alias plus a canonical parent identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QualifiedCanonicalId { + alias: String, + canonical_id: CanonicalId, +} + +impl QualifiedCanonicalId { + pub fn new( + alias: impl Into, + canonical_id: CanonicalId, + ) -> Result { + let alias = alias.into(); + if !valid_source_alias(&alias) { + return Err(OverrideSyntaxError::InvalidSourceAlias); + } + Ok(Self { + alias, + canonical_id, + }) + } + + pub fn parse(value: &str) -> Result { + let Some((alias, canonical_id)) = value.split_once("::") else { + return Err(OverrideSyntaxError::ParentMustBeQualified); + }; + if canonical_id.contains("::") { + return Err(OverrideSyntaxError::InvalidQualifiedId); + } + Self::new(alias, CanonicalId::new(canonical_id)?) + } + + pub fn alias(&self) -> &str { + &self.alias + } + + pub fn canonical_id(&self) -> &CanonicalId { + &self.canonical_id + } +} + +impl fmt::Display for QualifiedCanonicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}::{}", self.alias, self.canonical_id) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideSyntaxError { + InvalidCanonicalId, + QualifiedLocalId, + ParentMustBeQualified, + InvalidQualifiedId, + InvalidSourceAlias, +} + +impl fmt::Display for OverrideSyntaxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidCanonicalId => "canonical id must be non-empty and unpadded", + Self::QualifiedLocalId => "local canonical id must not be qualified", + Self::ParentMustBeQualified => "parent canonical id must be qualified", + Self::InvalidQualifiedId => "qualified id must contain exactly one `::` delimiter", + Self::InvalidSourceAlias => "source alias must be lowercase and path-free", + }) + } +} + +impl std::error::Error for OverrideSyntaxError {} + +/// The source identity and child-local alias of the one verified parent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentIdentity { + pub source: String, + pub alias: String, +} + +impl ParentIdentity { + pub fn new( + source: impl Into, + alias: impl Into, + ) -> Result { + let alias = alias.into(); + if !valid_source_alias(&alias) { + return Err(OverrideSyntaxError::InvalidSourceAlias); + } + Ok(Self { + source: source.into(), + alias, + }) + } +} + +/// One typed, canonical-only declaration from `.decided/corpus.md`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct OverrideDeclaration { + pub parent: QualifiedCanonicalId, + pub replacement: CanonicalId, + pub rationale: CanonicalId, +} + +impl OverrideDeclaration { + pub fn parse( + parent: &str, + replacement: &str, + rationale: &str, + ) -> Result { + Ok(Self { + parent: QualifiedCanonicalId::parse(parent)?, + replacement: CanonicalId::new(replacement)?, + rationale: CanonicalId::new(rationale)?, + }) + } +} + +/// Why an override declaration did not become effective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum InvalidOverrideReason { + ParentAliasMismatch, + DuplicateParent, + ParentNotFound, + ParentAmbiguous, + ReplacementNotFound, + ReplacementAmbiguous, + ReplacementNotLocal, + Chained, + TypeMismatch, + RationaleNotFound, + RationaleAmbiguous, + RationaleNotLocal, + RationaleNotDecision, + RationaleNotLive, +} + +impl InvalidOverrideReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::ParentAliasMismatch => "parent-alias-mismatch", + Self::DuplicateParent => "duplicate-parent", + Self::ParentNotFound => "parent-not-found", + Self::ParentAmbiguous => "parent-ambiguous", + Self::ReplacementNotFound => "replacement-not-found", + Self::ReplacementAmbiguous => "replacement-ambiguous", + Self::ReplacementNotLocal => "replacement-not-local", + Self::Chained => "chained", + Self::TypeMismatch => "type-mismatch", + Self::RationaleNotFound => "rationale-not-found", + Self::RationaleAmbiguous => "rationale-ambiguous", + Self::RationaleNotLocal => "rationale-not-local", + Self::RationaleNotDecision => "rationale-not-decision", + Self::RationaleNotLive => "rationale-not-live", + } + } +} + +/// One deterministic composition finding. These are kept separate from the +/// released path-only relationship finding model until federation activates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompositionFinding { + pub code: &'static str, + pub message: String, + pub reason: Option, + pub artifacts: Vec, + pub paths: Vec, +} + +/// A validated policy redirect. All three endpoints are stable artifact keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedOverride { + pub declaration: OverrideDeclaration, + pub parent: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +/// Exact lookup failure against the composed effective view. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LookupError { + NotFound, + Ambiguous(Vec), + InvalidQualifiedReference, + QualifiedCanonicalRequired, +} + +/// The one composed read model. `items` owns the catalog exactly once; local +/// and effective corpora are stable ordered projections over it. +pub struct ComposedCorpus { + items: Vec, + local: Vec, + effective: Vec, + parent: Option, + overrides: Vec, + findings: Vec, + catalog_rows: Vec, + effective_rows: Vec, + resolution_index: ResolutionIndex, + item_by_key: HashMap, + captured_content: HashMap>, +} + +impl ComposedCorpus { + /// A local-only composition useful to consumers adopting the central model + /// before manifest activation. + pub fn local(mut items: Vec) -> Self { + items.sort_by(stable_item_order); + Self::build(items, None, Vec::new(), HashMap::new()) + } + + /// Compose one writable child with one already-verified read-only parent. + pub fn compose( + mut local: Vec, + mut inherited: Vec, + parent: ParentIdentity, + overrides: Vec, + ) -> Self { + local.append(&mut inherited); + local.sort_by(stable_item_order); + Self::build(local, Some(parent), overrides, HashMap::new()) + } + + /// Compose from verification-time snapshots. Captured bytes are owned by + /// this read model and served by stable key, so a consumer never reopens a + /// mutable parent path after digest verification. + pub fn compose_with_content( + mut local: Vec, + mut inherited: Vec, + parent: ParentIdentity, + overrides: Vec, + captured_content: impl IntoIterator)>, + ) -> Self { + local.append(&mut inherited); + local.sort_by(stable_item_order); + Self::build( + local, + Some(parent), + overrides, + captured_content.into_iter().collect(), + ) + } + + fn build( + items: Vec, + parent: Option, + mut declarations: Vec, + mut captured_content: HashMap>, + ) -> Self { + let local: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (item.origin.layer == Layer::Local).then_some(index)) + .collect(); + let inherited: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (item.origin.layer == Layer::Inherited).then_some(index)) + .collect(); + + let local_canonical = canonical_index(&items, &local); + let inherited_canonical = canonical_index(&items, &inherited); + declarations.sort(); + + let parent_counts = declarations + .iter() + .fold(BTreeMap::new(), |mut counts, declaration| { + let key = ( + declaration.parent.alias().to_string(), + py_casefold(declaration.parent.canonical_id().as_str()), + ); + *counts.entry(key).or_insert(0usize) += 1; + counts + }); + let declared_parent_ids: BTreeSet = declarations + .iter() + .map(|declaration| py_casefold(declaration.parent.canonical_id().as_str())) + .collect(); + + let mut findings = Vec::new(); + let mut valid = Vec::new(); + for declaration in declarations { + match validate_override( + &declaration, + parent.as_ref(), + &items, + &local_canonical, + &inherited_canonical, + &parent_counts, + &declared_parent_ids, + ) { + Ok(validated) => valid.push(validated), + Err((reason, item_indices)) => findings.push(invalid_override_finding( + &declaration, + reason, + &items, + &item_indices, + )), + } + } + + valid.sort_by(|left, right| left.declaration.cmp(&right.declaration)); + let cleared_collisions: BTreeSet<(ArtifactKey, ArtifactKey)> = valid + .iter() + .filter(|mapping| { + py_casefold(&mapping.parent.canonical_id) + == py_casefold(&mapping.replacement.canonical_id) + }) + .map(|mapping| (mapping.parent.clone(), mapping.replacement.clone())) + .collect(); + findings.extend(collision_findings( + &items, + &local_canonical, + &inherited_canonical, + &cleared_collisions, + )); + findings.sort_by(finding_order); + + let overridden: BTreeSet = + valid.iter().map(|mapping| mapping.parent.clone()).collect(); + let effective: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (!overridden.contains(&item.key)).then_some(index)) + .collect(); + let catalog_rows: Vec = items.iter().map(validation_row_from_item).collect(); + let effective_rows: Vec = effective + .iter() + .map(|index| catalog_rows[*index].clone()) + .collect(); + let resolution_index = + composed_resolution_index(&catalog_rows, &effective_rows, parent.as_ref(), &valid); + let item_by_key: HashMap = items + .iter() + .enumerate() + .map(|(index, item)| (item.key.clone(), index)) + .collect(); + captured_content.retain(|key, _| item_by_key.contains_key(key)); + + Self { + items, + local, + effective, + parent, + overrides: valid, + findings, + catalog_rows, + effective_rows, + resolution_index, + item_by_key, + captured_content, + } + } + + pub fn local_items(&self) -> impl ExactSizeIterator { + self.local.iter().map(|index| &self.items[*index]) + } + + pub fn catalog(&self) -> impl ExactSizeIterator { + self.items.iter() + } + + pub fn effective(&self) -> impl ExactSizeIterator { + self.effective.iter().map(|index| &self.items[*index]) + } + + pub fn parent(&self) -> Option<&ParentIdentity> { + self.parent.as_ref() + } + + pub fn overrides(&self) -> &[ValidatedOverride] { + &self.overrides + } + + pub fn findings(&self) -> &[CompositionFinding] { + &self.findings + } + + pub fn is_overridden(&self, key: &ArtifactKey) -> bool { + self.overrides.iter().any(|mapping| &mapping.parent == key) + } + + pub fn item(&self, key: &ArtifactKey) -> Option<&CorpusItem> { + self.item_by_key.get(key).map(|index| &self.items[*index]) + } + + /// Exact verification-time Markdown bytes, when the composition was built + /// from captured snapshots. Runtime filesystem locators remain available + /// on `item` for operations that explicitly need physical provenance. + pub fn content(&self, key: &ArtifactKey) -> Option<&[u8]> { + self.captured_content.get(key).map(Vec::as_slice) + } + + /// Resolve against the effective unqualified view, or the retained parent + /// catalog when the reference is explicitly qualified. + pub fn resolve(&self, reference: &str) -> Result<&CorpusItem, LookupError> { + if reference.contains("::") { + self.validate_qualified_reference(reference)?; + } + let candidates = self.resolution_index.get_reference(reference); + match candidates { + [] => Err(LookupError::NotFound), + [candidate] => self.item(&candidate.key).ok_or(LookupError::NotFound), + many => Err(LookupError::Ambiguous( + many.iter().map(|candidate| candidate.key.clone()).collect(), + )), + } + } + + fn validate_qualified_reference(&self, reference: &str) -> Result<(), LookupError> { + let Some((alias, canonical_id)) = reference.split_once("::") else { + return Err(LookupError::InvalidQualifiedReference); + }; + if canonical_id.is_empty() || canonical_id.contains("::") { + return Err(LookupError::InvalidQualifiedReference); + } + let Some(parent) = &self.parent else { + return Err(LookupError::NotFound); + }; + if alias != parent.alias { + return Err(LookupError::NotFound); + } + let canonical_fold = py_casefold(canonical_id); + let canonical_exists = self.items.iter().any(|item| { + item.origin.layer == Layer::Inherited + && item.origin.source == parent.source + && py_casefold(&item.key.canonical_id) == canonical_fold + }); + if canonical_exists { + return Ok(()); + } + let alias_exists = self.items.iter().any(|item| { + item.origin.layer == Layer::Inherited + && item.origin.source == parent.source + && crate::identity::artifact_identifiers(&item.artifact, item.spec, &item.path) + .iter() + .any(|identifier| py_casefold(identifier) == canonical_fold) + }); + if alias_exists { + Err(LookupError::QualifiedCanonicalRequired) + } else { + Err(LookupError::NotFound) + } + } + + /// Resolve all effective declared edges through the same index as exact + /// lookup, retaining qualified access to overridden parent history. + pub fn relationships(&self) -> Vec { + resolve_relationships(&self.effective_rows, &self.resolution_index) + } + + /// Resolve declared edges for every retained catalog record, including an + /// overridden parent's immutable history. Export uses this projection; + /// live reads and enforcement continue to use `relationships`. + pub fn catalog_relationships(&self) -> Vec { + resolve_relationships(&self.catalog_rows, &self.resolution_index) + } + + /// Run the existing relationship validator over source-aware keys. The + /// child repository root is intentionally supplied here so inherited + /// filesystem scope is checked against child code. + pub fn validate_relationships( + &self, + child_directory: &str, + recursive: bool, + ) -> RelationshipValidation { + validation_from_rows_with_index( + child_directory, + &self.effective_rows, + &self.catalog_rows, + recursive, + &self.resolution_index, + false, + ) + } +} + +fn valid_source_alias(alias: &str) -> bool { + let mut characters = alias.chars(); + let Some(first) = characters.next() else { + return false; + }; + first.is_ascii_lowercase() + && characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '-' | '_' | '.') + }) +} + +fn stable_item_order(left: &CorpusItem, right: &CorpusItem) -> std::cmp::Ordering { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) +} + +fn canonical_index(items: &[CorpusItem], indices: &[usize]) -> BTreeMap> { + let mut index: BTreeMap> = BTreeMap::new(); + for item_index in indices { + index + .entry(py_casefold(&items[*item_index].key.canonical_id)) + .or_default() + .push(*item_index); + } + index +} + +#[allow(clippy::too_many_arguments)] +fn validate_override( + declaration: &OverrideDeclaration, + parent: Option<&ParentIdentity>, + items: &[CorpusItem], + local_canonical: &BTreeMap>, + inherited_canonical: &BTreeMap>, + parent_counts: &BTreeMap<(String, String), usize>, + declared_parent_ids: &BTreeSet, +) -> Result)> { + let parent_id = py_casefold(declaration.parent.canonical_id().as_str()); + let replacement_id = py_casefold(declaration.replacement.as_str()); + let rationale_id = py_casefold(declaration.rationale.as_str()); + let Some(parent_identity) = parent else { + return Err((InvalidOverrideReason::ParentNotFound, Vec::new())); + }; + if declaration.parent.alias() != parent_identity.alias { + return Err((InvalidOverrideReason::ParentAliasMismatch, Vec::new())); + } + if parent_counts + .get(&(declaration.parent.alias().to_string(), parent_id.clone())) + .copied() + .unwrap_or_default() + > 1 + { + return Err((InvalidOverrideReason::DuplicateParent, Vec::new())); + } + + let parent_matches: Vec = inherited_canonical + .get(&parent_id) + .into_iter() + .flatten() + .copied() + .filter(|index| items[*index].origin.source == parent_identity.source) + .collect(); + let parent_index = match parent_matches.as_slice() { + [] => return Err((InvalidOverrideReason::ParentNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::ParentAmbiguous, many.to_vec())), + }; + + let replacement_matches = local_canonical + .get(&replacement_id) + .cloned() + .unwrap_or_default(); + let replacement_index = match replacement_matches.as_slice() { + [] if inherited_canonical.contains_key(&replacement_id) => { + return Err(( + InvalidOverrideReason::ReplacementNotLocal, + inherited_canonical[&replacement_id].clone(), + )); + } + [] => return Err((InvalidOverrideReason::ReplacementNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::ReplacementAmbiguous, many.to_vec())), + }; + if replacement_id != parent_id && declared_parent_ids.contains(&replacement_id) { + return Err(( + InvalidOverrideReason::Chained, + vec![parent_index, replacement_index], + )); + } + + let parent_type = items[parent_index].spec.map(|spec| spec.name.as_str()); + let replacement_type = items[replacement_index].spec.map(|spec| spec.name.as_str()); + if parent_type.is_none() || parent_type != replacement_type { + return Err(( + InvalidOverrideReason::TypeMismatch, + vec![parent_index, replacement_index], + )); + } + + let rationale_matches = local_canonical + .get(&rationale_id) + .cloned() + .unwrap_or_default(); + let rationale_index = match rationale_matches.as_slice() { + [] if inherited_canonical.contains_key(&rationale_id) => { + return Err(( + InvalidOverrideReason::RationaleNotLocal, + inherited_canonical[&rationale_id].clone(), + )); + } + [] => return Err((InvalidOverrideReason::RationaleNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::RationaleAmbiguous, many.to_vec())), + }; + if items[rationale_index].spec.map(|spec| spec.name.as_str()) != Some("decision") { + return Err(( + InvalidOverrideReason::RationaleNotDecision, + vec![rationale_index], + )); + } + if !is_live_decision(&items[rationale_index].artifact) { + return Err(( + InvalidOverrideReason::RationaleNotLive, + vec![rationale_index], + )); + } + + Ok(ValidatedOverride { + declaration: declaration.clone(), + parent: items[parent_index].key.clone(), + replacement: items[replacement_index].key.clone(), + rationale: items[rationale_index].key.clone(), + }) +} + +fn invalid_override_finding( + declaration: &OverrideDeclaration, + reason: InvalidOverrideReason, + items: &[CorpusItem], + item_indices: &[usize], +) -> CompositionFinding { + let mut ordered: Vec = item_indices.to_vec(); + ordered.sort_by(|left, right| stable_item_order(&items[*left], &items[*right])); + ordered.dedup(); + CompositionFinding { + code: FINDING_INVALID_OVERRIDE, + message: format!( + "override {} -> {} ({}) is invalid: {}", + declaration.parent, + declaration.replacement, + declaration.rationale, + reason.as_str() + ), + reason: Some(reason), + artifacts: ordered + .iter() + .map(|index| items[*index].key.clone()) + .collect(), + paths: ordered + .iter() + .map(|index| items[*index].artifact_path.clone()) + .collect(), + } +} + +fn collision_findings( + items: &[CorpusItem], + local: &BTreeMap>, + inherited: &BTreeMap>, + cleared: &BTreeSet<(ArtifactKey, ArtifactKey)>, +) -> Vec { + let mut findings = Vec::new(); + for (canonical_fold, local_indices) in local { + let Some(parent_indices) = inherited.get(canonical_fold) else { + continue; + }; + let fully_cleared = local_indices.len() == 1 + && parent_indices.len() == 1 + && cleared.contains(&( + items[parent_indices[0]].key.clone(), + items[local_indices[0]].key.clone(), + )); + if fully_cleared { + continue; + } + let mut indices: Vec = local_indices + .iter() + .chain(parent_indices) + .copied() + .collect(); + indices.sort_by(|left, right| stable_item_order(&items[*left], &items[*right])); + let display_id = indices + .first() + .map(|index| items[*index].key.canonical_id.as_str()) + .unwrap_or(canonical_fold); + findings.push(CompositionFinding { + code: FINDING_CANONICAL_COLLISION, + message: format!( + "canonical id {display_id} occurs in both local and inherited corpora" + ), + reason: None, + artifacts: indices + .iter() + .map(|index| items[*index].key.clone()) + .collect(), + paths: indices + .iter() + .map(|index| items[*index].artifact_path.clone()) + .collect(), + }); + } + findings +} + +fn finding_order(left: &CompositionFinding, right: &CompositionFinding) -> std::cmp::Ordering { + left.code + .cmp(right.code) + .then_with(|| left.paths.cmp(&right.paths)) + .then_with(|| left.artifacts.cmp(&right.artifacts)) + .then_with(|| left.reason.cmp(&right.reason)) + .then_with(|| left.message.cmp(&right.message)) +} + +fn composed_resolution_index( + catalog_rows: &[ValidationRow], + effective_rows: &[ValidationRow], + parent: Option<&ParentIdentity>, + overrides: &[ValidatedOverride], +) -> ResolutionIndex { + let mut index = resolution_index_from_rows(effective_rows); + let rows_by_key: HashMap<&ArtifactKey, &ValidationRow> = + catalog_rows.iter().map(|row| (&row.key, row)).collect(); + + if let Some(parent) = parent { + for row in catalog_rows.iter().filter(|row| { + row.origin.layer == Layer::Inherited && row.origin.source == parent.source + }) { + let qualified = format!("{}::{}", parent.alias, row.canonical_id); + index.insert( + ResolutionIndex::reference_key(&qualified), + ResolutionCandidate::from_row(row, qualified), + ); + } + } + for mapping in overrides { + let Some(replacement) = rows_by_key.get(&mapping.replacement) else { + continue; + }; + index.insert( + py_casefold(&mapping.parent.canonical_id), + ResolutionCandidate::from_row(replacement, mapping.parent.canonical_id.clone()), + ); + } + index +} diff --git a/rust/rac-engine/src/index_store.rs b/rust/rac-engine/src/index_store.rs index a25ec94b..f314ab71 100644 --- a/rust/rac-engine/src/index_store.rs +++ b/rust/rac-engine/src/index_store.rs @@ -801,6 +801,9 @@ impl MmapIndexReader { let mut rows = Vec::with_capacity(count.min(1 << 20) as usize); for _ in 0..count { rows.push(crate::retrieve::ScopeRow { + key: None, + artifact_path: None, + origin: None, id: reader.text()?, title: reader.text()?, status: reader.text()?, diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index 47fb079b..01550ff0 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod parse; pub mod classify; pub mod identity; pub mod corpus; +pub mod composition; pub mod validate; pub mod relationships; pub mod diff; diff --git a/rust/rac-engine/src/relationships.rs b/rust/rac-engine/src/relationships.rs index 8c99ca13..0908a458 100644 --- a/rust/rac-engine/src/relationships.rs +++ b/rust/rac-engine/src/relationships.rs @@ -362,23 +362,58 @@ fn validation_row_with_identity( } } -/// Insertion-ordered `{casefold(ident) -> [(path, ident)]}` index. +/// One source-aware resolution candidate. +/// +/// `path` is retained solely as the released display value. Identity and +/// deterministic ordering use `key` and `artifact_path`, never a checkout +/// path (ADR-135/ADR-136). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolutionCandidate { + pub key: ArtifactKey, + pub artifact_path: ArtifactPath, + pub path: String, + pub identifier: String, +} + +impl ResolutionCandidate { + pub(crate) fn from_row(row: &ValidationRow, identifier: String) -> Self { + Self { + key: row.key.clone(), + artifact_path: row.artifact_path.clone(), + path: row.path.clone(), + identifier, + } + } +} + +/// Insertion-ordered `{casefold(ident) -> [source-aware candidate]}` index. pub struct ResolutionIndex { order: Vec, - map: HashMap>, + map: HashMap>, } impl ResolutionIndex { - fn new() -> Self { + pub(crate) fn new() -> Self { ResolutionIndex { order: Vec::new(), map: HashMap::new(), } } - fn insert(&mut self, key: String, value: (String, String)) { + pub(crate) fn insert(&mut self, key: String, value: ResolutionCandidate) { match self.map.get_mut(&key) { - Some(v) => v.push(value), + Some(v) => { + if !v.iter().any(|candidate| { + candidate.key == value.key && candidate.artifact_path == value.artifact_path + }) { + v.push(value); + v.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + } + } None => { self.map.insert(key.clone(), vec![value]); self.order.push(key); @@ -386,11 +421,24 @@ impl ResolutionIndex { } } - pub fn get(&self, key: &str) -> &[(String, String)] { + pub fn get(&self, key: &str) -> &[ResolutionCandidate] { self.map.get(key).map(|v| v.as_slice()).unwrap_or(&[]) } - fn values(&self) -> impl Iterator> { + pub(crate) fn reference_key(reference: &str) -> String { + match reference.split_once("::") { + Some((alias, canonical_id)) if !canonical_id.contains("::") => { + format!("{alias}::{}", py_casefold(canonical_id)) + } + _ => py_casefold(reference), + } + } + + pub(crate) fn get_reference(&self, reference: &str) -> &[ResolutionCandidate] { + self.get(&Self::reference_key(reference)) + } + + fn values(&self) -> impl Iterator> { self.order.iter().map(|k| &self.map[k]) } } @@ -399,7 +447,10 @@ pub fn resolution_index_from_rows(rows: &[ValidationRow]) -> ResolutionIndex { let mut index = ResolutionIndex::new(); for row in rows { for ident in &row.identifiers { - index.insert(py_casefold(ident), (row.path.clone(), ident.clone())); + index.insert( + py_casefold(ident), + ResolutionCandidate::from_row(row, ident.clone()), + ); } } index @@ -491,20 +542,20 @@ pub(crate) fn normalized_scope_path(entry: &str) -> Option { fn resolved_unique<'a>( index: &'a ResolutionIndex, reference: &str, - source_path: &str, -) -> Option<&'a str> { - let targets = index.get(&py_casefold(reference)); - if targets.len() != 1 || targets[0].0 == source_path { + source_key: &ArtifactKey, +) -> Option<&'a ResolutionCandidate> { + let targets = index.get_reference(reference); + if targets.len() != 1 || targets[0].key == *source_key { return None; } - Some(&targets[0].0) + Some(&targets[0]) } /// Outcome of resolving one internal reference against the index: checked /// empty -> not found, then multiple -> ambiguous, then same-path -> self, /// else uniquely resolved. Shared by the issue and `Relationship` loops. enum ReferenceResolution<'a> { - Resolved(&'a str), + Resolved(&'a ResolutionCandidate), NotFound, Ambiguous, SelfRef, @@ -513,17 +564,17 @@ enum ReferenceResolution<'a> { fn classify_reference<'a>( index: &'a ResolutionIndex, reference: &str, - source_path: &str, + source_key: &ArtifactKey, ) -> ReferenceResolution<'a> { - let targets = index.get(&py_casefold(reference)); + let targets = index.get_reference(reference); if targets.is_empty() { ReferenceResolution::NotFound } else if targets.len() > 1 { ReferenceResolution::Ambiguous - } else if targets[0].0 == source_path { + } else if targets[0].key == *source_key { ReferenceResolution::SelfRef } else { - ReferenceResolution::Resolved(&targets[0].0) + ReferenceResolution::Resolved(&targets[0]) } } @@ -536,64 +587,69 @@ fn resolve_references( (checked, issues) } -/// Tarjan SCC over the sorted-adjacency graph; components of size > 1, -/// each sorted, ordered by first element. -fn cyclic_components(adjacency: &[(String, Vec)]) -> Vec> { - let adj: HashMap<&str, &Vec> = - adjacency.iter().map(|(k, v)| (k.as_str(), v)).collect(); - let mut nodes: Vec<&str> = adjacency +/// Tarjan SCC over source-aware keys. Traversal and output are ordered by the +/// corresponding stable `(source, relative_path)`, not display paths. +fn cyclic_components( + adjacency: &[(ArtifactKey, Vec)], + paths: &HashMap, +) -> Vec> { + let adj: HashMap> = adjacency.iter().cloned().collect(); + let mut nodes: Vec = adjacency .iter() - .flat_map(|(k, vs)| std::iter::once(k.as_str()).chain(vs.iter().map(|v| v.as_str()))) + .flat_map(|(key, values)| std::iter::once(key.clone()).chain(values.iter().cloned())) .collect(); - nodes.sort(); + nodes.sort_by(|left, right| paths[left].cmp(&paths[right]).then_with(|| left.cmp(right))); nodes.dedup(); - struct State<'a> { - indices: HashMap<&'a str, usize>, - lowlink: HashMap<&'a str, usize>, - on_stack: std::collections::HashSet<&'a str>, - stack: Vec<&'a str>, + struct State { + indices: HashMap, + lowlink: HashMap, + on_stack: std::collections::HashSet, + stack: Vec, counter: usize, - components: Vec>, + components: Vec>, } - fn strongconnect<'a>( - v: &'a str, - adj: &HashMap<&'a str, &'a Vec>, - st: &mut State<'a>, + fn strongconnect( + v: &ArtifactKey, + adj: &HashMap>, + paths: &HashMap, + st: &mut State, ) { - st.indices.insert(v, st.counter); - st.lowlink.insert(v, st.counter); + st.indices.insert(v.clone(), st.counter); + st.lowlink.insert(v.clone(), st.counter); st.counter += 1; - st.stack.push(v); - st.on_stack.insert(v); + st.stack.push(v.clone()); + st.on_stack.insert(v.clone()); if let Some(neighbors) = adj.get(v) { - for w in neighbors.iter() { - let w = w.as_str(); + for w in neighbors { if !st.indices.contains_key(w) { - strongconnect(w, adj, st); + strongconnect(w, adj, paths, st); let lw = st.lowlink[w]; let lv = st.lowlink[v]; - st.lowlink.insert(v, lv.min(lw)); + st.lowlink.insert(v.clone(), lv.min(lw)); } else if st.on_stack.contains(w) { let iw = st.indices[w]; let lv = st.lowlink[v]; - st.lowlink.insert(v, lv.min(iw)); + st.lowlink.insert(v.clone(), lv.min(iw)); } } } if st.lowlink[v] == st.indices[v] { - let mut component: Vec = Vec::new(); + let mut component: Vec = Vec::new(); loop { let w = st.stack.pop().expect("stack nonempty"); - st.on_stack.remove(w); - component.push(w.to_string()); - if w == v { + st.on_stack.remove(&w); + let complete = w == *v; + component.push(w); + if complete { break; } } if component.len() > 1 { - component.sort(); + component.sort_by(|left, right| { + paths[left].cmp(&paths[right]).then_with(|| left.cmp(right)) + }); st.components.push(component); } } @@ -609,19 +665,35 @@ fn cyclic_components(adjacency: &[(String, Vec)]) -> Vec> { }; for node in &nodes { if !st.indices.contains_key(node) { - strongconnect(node, &adj, &mut st); + strongconnect(node, &adj, paths, &mut st); } } - st.components.sort_by(|a, b| a[0].cmp(&b[0])); + st.components.sort_by(|left, right| { + paths[&left[0]] + .cmp(&paths[&right[0]]) + .then_with(|| left[0].cmp(&right[0])) + }); st.components } -fn cycle_issues(rows: &[ValidationRow], index: &ResolutionIndex) -> Vec { +fn cycle_issues( + rows: &[ValidationRow], + target_rows: &[ValidationRow], + index: &ResolutionIndex, +) -> Vec { + let paths: HashMap = target_rows + .iter() + .map(|row| (row.key.clone(), row.artifact_path.clone())) + .collect(); + let display_paths: HashMap<&ArtifactKey, &str> = target_rows + .iter() + .map(|row| (&row.key, row.path.as_str())) + .collect(); // Sorted acyclic edge kinds — today only `supersedes`. let mut issues = Vec::new(); for kind in ["supersedes"] { // `_acyclic_adjacency`: {source -> sorted unique resolved non-self targets}. - let mut adjacency: Vec<(String, Vec)> = Vec::new(); + let mut adjacency: Vec<(ArtifactKey, Vec)> = Vec::new(); for row in rows { if row.spec_name.is_none() { continue; @@ -632,27 +704,34 @@ fn cycle_issues(rows: &[ValidationRow], index: &ResolutionIndex) -> Vec = Vec::new(); + let mut targets: Vec = Vec::new(); for reference in refs { - if let Some(t) = resolved_unique(index, reference, &row.path) { - if !targets.iter().any(|x| x == t) { - targets.push(t.to_string()); + if let Some(target) = resolved_unique(index, reference, &row.key) { + if !targets.iter().any(|key| key == &target.key) { + targets.push(target.key.clone()); } } } if !targets.is_empty() { - targets.sort(); - adjacency.push((row.path.clone(), targets)); + targets.sort_by(|left, right| { + paths[left].cmp(&paths[right]).then_with(|| left.cmp(right)) + }); + adjacency.push((row.key.clone(), targets)); } } - for component in cyclic_components(&adjacency) { + for component in cyclic_components(&adjacency, &paths) { issues.push(RelationshipIssue { code: ISSUE_RELATIONSHIP_CYCLE.to_string(), source_path: None, relationship: Some(kind.to_string()), target: None, identifier: None, - paths: Some(component), + paths: Some( + component + .iter() + .map(|key| display_paths[key].to_string()) + .collect(), + ), }); } } @@ -699,41 +778,65 @@ pub fn validation_from_rows( directory: &str, rows: &[ValidationRow], recursive: bool, +) -> RelationshipValidation { + let index = resolution_index_from_rows(rows); + validation_from_rows_with_index(directory, rows, rows, recursive, &index, true) +} + +/// Source-aware validation core used by the composed read model. The caller +/// supplies the one resolution index so qualified references and override +/// redirects cannot diverge between graph construction and validation. +pub(crate) fn validation_from_rows_with_index( + directory: &str, + rows: &[ValidationRow], + target_rows: &[ValidationRow], + recursive: bool, + index: &ResolutionIndex, + include_duplicate_identifiers: bool, ) -> RelationshipValidation { let mut issues: Vec = Vec::new(); // Duplicate identifiers first, sorted by display identifier (casefold). - let mut ident_index = ResolutionIndex::new(); - for row in rows { - ident_index.insert( - py_casefold(&row.canonical_id), - (row.path.clone(), row.canonical_id.clone()), - ); - } - let mut duplicates: Vec<(String, Vec)> = Vec::new(); - for entries in ident_index.values() { - if entries.len() > 1 { - let display = entries - .iter() - .min_by(|a, b| a.0.cmp(&b.0)) - .expect("nonempty") - .1 - .clone(); - let mut paths: Vec = entries.iter().map(|(p, _)| p.clone()).collect(); - paths.sort(); - duplicates.push((display, paths)); + if include_duplicate_identifiers { + let mut ident_index = ResolutionIndex::new(); + for row in rows { + ident_index.insert( + py_casefold(&row.canonical_id), + ResolutionCandidate::from_row(row, row.canonical_id.clone()), + ); + } + let mut duplicates: Vec<(String, Vec)> = Vec::new(); + for entries in ident_index.values() { + if entries.len() > 1 { + let display = entries + .iter() + .min_by(|left, right| left.artifact_path.cmp(&right.artifact_path)) + .expect("nonempty") + .identifier + .clone(); + let mut paths: Vec<&ResolutionCandidate> = entries.iter().collect(); + paths.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + duplicates.push(( + display, + paths.into_iter().map(|entry| entry.path.clone()).collect(), + )); + } + } + duplicates.sort_by_cached_key(|entry| py_casefold(&entry.0)); + for (display, dup_paths) in duplicates { + issues.push(RelationshipIssue { + code: ISSUE_DUPLICATE_IDENTIFIER.to_string(), + source_path: None, + relationship: None, + target: None, + identifier: Some(display), + paths: Some(dup_paths), + }); } - } - duplicates.sort_by_cached_key(|a| py_casefold(&a.0)); - for (display, dup_paths) in duplicates { - issues.push(RelationshipIssue { - code: ISSUE_DUPLICATE_IDENTIFIER.to_string(), - source_path: None, - relationship: None, - target: None, - identifier: Some(display), - paths: Some(dup_paths), - }); } // Edge-legality: unsupported declared sections (canonical order per row). @@ -753,9 +856,8 @@ pub fn validation_from_rows( } } - let index = resolution_index_from_rows(rows); - let by_path: HashMap<&str, &ValidationRow> = - rows.iter().map(|r| (r.path.as_str(), r)).collect(); + let by_key: HashMap<&ArtifactKey, &ValidationRow> = + target_rows.iter().map(|row| (&row.key, row)).collect(); // Range violations. for row in rows { @@ -770,10 +872,13 @@ pub fn validation_from_rows( continue; } for reference in refs { - let Some(target) = resolved_unique(&index, reference, &row.path) else { + let Some(target) = resolved_unique(index, reference, &row.key) else { continue; }; - let Some(target_spec) = by_path[target].spec_name.as_deref() else { + let Some(target_row) = by_key.get(&target.key) else { + continue; + }; + let Some(target_spec) = target_row.spec_name.as_deref() else { continue; }; if !edge.range.contains(&target_spec) { @@ -801,10 +906,13 @@ pub fn validation_from_rows( continue; } for reference in refs { - let Some(target) = resolved_unique(&index, reference, &row.path) else { + let Some(target) = resolved_unique(index, reference, &row.key) else { continue; }; - if by_path[target].retired { + if by_key + .get(&target.key) + .is_some_and(|target_row| target_row.retired) + { issues.push(RelationshipIssue::reference( ISSUE_TARGET_SUPERSEDED, &row.path, @@ -817,10 +925,10 @@ pub fn validation_from_rows( } // Acyclicity. - issues.extend(cycle_issues(rows, &index)); + issues.extend(cycle_issues(rows, target_rows, index)); // Referential integrity. - let (checked, ref_issues) = resolve_references(rows, &index); + let (checked, ref_issues) = resolve_references(rows, index); issues.extend(ref_issues); // Code-scope existence (appended last). @@ -893,16 +1001,24 @@ fn resolution_labels( ) -> HashMap { // Resolution index over every alias of every item, in item order. let mut index = ResolutionIndex::new(); - let mut info: HashMap<&str, (String, Option<&'static ArtifactSpec>, Option)> = + let mut info: HashMap<&ArtifactKey, (String, Option<&'static ArtifactSpec>, Option)> = HashMap::new(); for item in items { let identifiers = artifact_identifiers(&item.artifact, item.spec, &item.path); for ident in &identifiers { - index.insert(py_casefold(ident), (item.path.clone(), ident.clone())); + index.insert( + py_casefold(ident), + ResolutionCandidate { + key: item.key.clone(), + artifact_path: item.artifact_path.clone(), + path: item.path.clone(), + identifier: ident.clone(), + }, + ); } let canonical = artifact_identifier(&item.artifact, item.spec, &item.path); info.insert( - item.path.as_str(), + &item.key, (canonical, item.spec, item.artifact.product.title.clone()), ); } @@ -915,7 +1031,8 @@ fn resolution_labels( continue; } let entries = index.get(&key); - let mut distinct: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect(); + let mut distinct: Vec<&ArtifactKey> = + entries.iter().map(|entry| &entry.key).collect(); distinct.sort(); distinct.dedup(); if distinct.len() != 1 { @@ -1151,10 +1268,6 @@ pub fn resolve_relationships( index: &ResolutionIndex, ) -> Vec { let mut out = Vec::new(); - let artifact_paths: HashMap<&str, &ArtifactPath> = rows - .iter() - .map(|row| (row.path.as_str(), &row.artifact_path)) - .collect(); for row in rows { for (section, refs) in &row.edges { let external = edge_spec(section).is_some_and(|e| e.external); @@ -1171,22 +1284,23 @@ pub fn resolve_relationships( }); continue; } - let (resolved, issue) = match classify_reference(index, reference, &row.path) { - ReferenceResolution::Resolved(target) => (Some(target.to_string()), None), + let (resolved, resolved_artifact, issue) = + match classify_reference(index, reference, &row.key) { + ReferenceResolution::Resolved(target) => ( + Some(target.path.clone()), + Some(target.artifact_path.clone()), + None, + ), ReferenceResolution::NotFound => { - (None, Some(ISSUE_TARGET_NOT_FOUND.to_string())) + (None, None, Some(ISSUE_TARGET_NOT_FOUND.to_string())) } ReferenceResolution::Ambiguous => { - (None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())) + (None, None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())) } ReferenceResolution::SelfRef => { - (None, Some(ISSUE_SELF_REFERENCE.to_string())) + (None, None, Some(ISSUE_SELF_REFERENCE.to_string())) } }; - let resolved_artifact = resolved - .as_deref() - .and_then(|path| artifact_paths.get(path).copied()) - .cloned(); out.push(Relationship { source_artifact: Some(row.artifact_path.clone()), source_path: row.path.clone(), @@ -1229,9 +1343,14 @@ pub struct RelationshipSummary { fn resolve_references_full( rows: &[ValidationRow], index: &ResolutionIndex, -) -> (usize, Vec, std::collections::HashSet) { +) -> ( + usize, + Vec, + std::collections::HashSet, +) { let mut issues = Vec::new(); - let mut resolved_targets: std::collections::HashSet = std::collections::HashSet::new(); + let mut resolved_targets: std::collections::HashSet = + std::collections::HashSet::new(); let mut checked = 0usize; for row in rows { if row.spec_name.is_none() { @@ -1243,9 +1362,9 @@ fn resolve_references_full( } for reference in refs { checked += 1; - let code = match classify_reference(index, reference, &row.path) { + let code = match classify_reference(index, reference, &row.key) { ReferenceResolution::Resolved(target) => { - resolved_targets.insert(target.to_string()); + resolved_targets.insert(target.key.clone()); continue; } ReferenceResolution::NotFound => ISSUE_TARGET_NOT_FOUND, @@ -1276,23 +1395,23 @@ pub fn summary_from_rows(rows: &[ValidationRow]) -> RelationshipSummary { let broken = ref_issues.len(); let valid = checked - broken; - let known_paths: Vec<&str> = rows + let known_keys: Vec<&ArtifactKey> = rows .iter() .filter(|r| r.spec_name.is_some()) - .map(|r| r.path.as_str()) + .map(|r| &r.key) .collect(); - let orphaned = known_paths + let orphaned = known_keys .iter() - .filter(|p| !resolved_targets.contains(**p)) + .filter(|key| !resolved_targets.contains(**key)) .count(); let artifacts_with_rels = rows .iter() .filter(|r| r.spec_name.is_some() && !r.edges.is_empty()) .count(); - let coverage = if known_paths.is_empty() { + let coverage = if known_keys.is_empty() { 1.0 } else { - crate::pycompat::py_round(artifacts_with_rels as f64 / known_paths.len() as f64, 4) + crate::pycompat::py_round(artifacts_with_rels as f64 / known_keys.len() as f64, 4) }; RelationshipSummary { total: checked, diff --git a/rust/rac-engine/src/rename.rs b/rust/rac-engine/src/rename.rs index 3f88ee15..c349c1bf 100644 --- a/rust/rac-engine/src/rename.rs +++ b/rust/rac-engine/src/rename.rs @@ -725,7 +725,7 @@ pub fn compute_rename( let mut targets: Vec<&str> = index .get(&py_casefold(old_ref)) .iter() - .map(|(path, _)| path.as_str()) + .map(|candidate| candidate.path.as_str()) .collect::>() .into_iter() .collect(); diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index 130d0c90..81e314fb 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -173,16 +173,15 @@ pub(crate) fn entry_from_item(item: &CorpusItem, inbound: i64) -> IndexEntry { } } -/// `inbound_counts_from_corpus`: `{path -> count of resolved edges pointing -/// at it}` — resolved, unique, non-self edges only; external edges (ADR-087) -/// never resolve. -fn inbound_counts(items: &[CorpusItem]) -> HashMap { +/// `inbound_counts_from_corpus`: `{ArtifactKey -> count}` for resolved, +/// unique, non-self edges. External edges (ADR-087) never resolve. +fn inbound_counts(items: &[CorpusItem]) -> HashMap { let rows: Vec<_> = items .iter() .map(validation_row_from_item) .collect(); let index = resolution_index_from_rows(&rows); - let mut counts: HashMap = HashMap::new(); + let mut counts: HashMap = HashMap::new(); for row in &rows { for (section, refs) in &row.edges { let external = edge_spec(section).map(|e| e.external).unwrap_or(false); @@ -191,8 +190,8 @@ fn inbound_counts(items: &[CorpusItem]) -> HashMap { } for r in refs { let targets = index.get(&py_casefold(r)); - if targets.len() == 1 && targets[0].0 != row.path { - *counts.entry(targets[0].0.clone()).or_insert(0) += 1; + if targets.len() == 1 && targets[0].key != row.key { + *counts.entry(targets[0].key.clone()).or_insert(0) += 1; } } } @@ -210,7 +209,7 @@ pub fn index_from_items(items: &[CorpusItem]) -> Vec { let inbound = inbound_counts(items); items .iter() - .map(|item| entry_from_item(item, *inbound.get(&item.path).unwrap_or(&0))) + .map(|item| entry_from_item(item, *inbound.get(&item.key).unwrap_or(&0))) .collect() } diff --git a/rust/rac-engine/src/retrieve.rs b/rust/rac-engine/src/retrieve.rs index e71eeba9..9b4f32de 100644 --- a/rust/rac-engine/src/retrieve.rs +++ b/rust/rac-engine/src/retrieve.rs @@ -24,6 +24,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; use crate::budget::py_slice_to; +use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath}; use crate::identity::artifact_identifier; use crate::pycompat::{py_casefold, py_strip, read_text_universal}; use crate::relationships::{ @@ -341,6 +342,12 @@ fn normalize_query(path: &str, root: &Path) -> Option { /// One live decision's declared `## Applies To` scope (`ScopeRow`). #[derive(Clone)] pub struct ScopeRow { + /// Absent only when reconstructed from the frozen v1 persistent store. + pub key: Option, + /// Absent only when reconstructed from the frozen v1 persistent store. + pub artifact_path: Option, + /// Absent only when reconstructed from the frozen v1 persistent store. + pub origin: Option, pub id: String, pub title: String, pub status: String, @@ -366,6 +373,9 @@ pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec { continue; } rows.push(ScopeRow { + key: Some(item.key.clone()), + artifact_path: Some(item.artifact_path.clone()), + origin: Some(item.origin.clone()), id: artifact_identifier(&item.artifact, Some(spec), &item.path), title: item.artifact.product.title.clone().unwrap_or_default(), status: artifact_status(&item.artifact), @@ -379,6 +389,9 @@ pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec { /// One governing decision (`GoverningDecision` — the fields retrieve and /// `decided decisions-for` read). pub struct GoverningDecision { + pub key: Option, + pub artifact_path: Option, + pub origin: Option, pub id: String, pub title: String, pub status: String, @@ -397,6 +410,9 @@ fn governing_decisions(rows: &[ScopeRow], directory: &str, path: &str) -> Vec ParentIdentity { + ParentIdentity::new(PARENT_SOURCE, PARENT_ALIAS).unwrap() +} + +fn item( + layer: Layer, + relative_path: &str, + id: &str, + artifact_type: &str, + status: &str, + relationships: &str, +) -> CorpusItem { + let required = match artifact_type { + "decision" => { + r#" +## Context + +Composition fixture. + +## Decision + +Keep resolution deterministic. + +## Consequences + +Every endpoint remains source-aware. +"# + } + "requirement" => { + r#" +## Problem + +Composition needs one resolver. + +## Requirements + +- [REQ-001] Resolution MUST remain deterministic. +"# + } + other => panic!("unsupported fixture type {other}"), + }; + let text = format!( + "---\nschema_version: 1\ntype: {artifact_type}\n---\n# {id}\n\n## ID\n\n{id}\n\n## Status\n\n{status}\n{required}\n{relationships}\n" + ); + let source = match layer { + Layer::Local => LOCAL_SOURCE, + Layer::Inherited => PARENT_SOURCE, + }; + let origin = match layer { + Layer::Local => CorpusLayer::local(source).origin(), + Layer::Inherited => { + CorpusLayer::inherited(source, PARENT_ALIAS, "sha256:0123456789abcdef").origin() + } + }; + let display = format!("/runtime/{source}/{relative_path}"); + CorpusItem::new( + display.clone(), + relative_path.to_string(), + parse_text(&text, &display), + spec_for(artifact_type), + origin, + PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new( + format!("/runtime/{source}"), + format!("/runtime/{source}/decisions"), + ), + display, + ), + ) +} + +fn declaration(parent_id: &str, replacement: &str, rationale: &str) -> OverrideDeclaration { + OverrideDeclaration::parse( + &format!("{PARENT_ALIAS}::{parent_id}"), + replacement, + rationale, + ) + .unwrap() +} + +fn lookup_error(corpus: &ComposedCorpus, reference: &str) -> LookupError { + match corpus.resolve(reference) { + Ok(item) => panic!( + "{reference} unexpectedly resolved to {}", + item.key.canonical_id + ), + Err(error) => error, + } +} + +#[test] +fn collisions_are_sourced_and_never_pick_a_layer() { + let local = item( + Layer::Local, + "z-local.md", + "SHARED-001", + "requirement", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "a-parent.md", + "SHARED-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + + assert_eq!(corpus.findings().len(), 1); + assert_eq!(corpus.findings()[0].code, FINDING_CANONICAL_COLLISION); + assert_eq!( + corpus.findings()[0].artifacts, + vec![ + ArtifactKey::new(LOCAL_SOURCE, "SHARED-001"), + ArtifactKey::new(PARENT_SOURCE, "SHARED-001"), + ] + ); + assert_eq!( + lookup_error(&corpus, "SHARED-001"), + LookupError::Ambiguous(vec![ + ArtifactKey::new(LOCAL_SOURCE, "SHARED-001"), + ArtifactKey::new(PARENT_SOURCE, "SHARED-001"), + ]) + ); + assert_eq!( + corpus + .catalog() + .map(|entry| entry.artifact_path.clone()) + .collect::>(), + vec![ + ArtifactPath::new(LOCAL_SOURCE, "z-local.md"), + ArtifactPath::new(PARENT_SOURCE, "a-parent.md"), + ] + ); +} + +#[test] +fn aliases_are_unique_only_and_qualification_requires_a_canonical_id() { + let local = item( + Layer::Local, + "shared.md", + "APP-001", + "requirement", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "shared.md", + "STD-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + + assert!(matches!( + corpus.resolve("shared"), + Err(LookupError::Ambiguous(keys)) if keys.len() == 2 + )); + assert_eq!( + lookup_error(&corpus, "standards::shared"), + LookupError::QualifiedCanonicalRequired + ); + assert_eq!( + lookup_error(&corpus, "Standards::STD-001"), + LookupError::NotFound + ); + let resolved = corpus.resolve("standards::std-001").unwrap(); + assert_eq!(resolved.key, ArtifactKey::new(PARENT_SOURCE, "STD-001")); +} + +#[test] +fn a_valid_override_redirects_only_the_parent_canonical_id_and_retains_history() { + let replacement = item( + Layer::Local, + "replacement.md", + "APP-REQ", + "requirement", + "Accepted", + "", + ); + let rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "parent-policy.md", + "STD-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- APP-ADR\n", + ); + let replacement_key = replacement.key.clone(); + let inherited_key = inherited.key.clone(); + let corpus = ComposedCorpus::compose_with_content( + vec![replacement, rationale], + vec![inherited], + parent(), + vec![declaration("STD-REQ", "APP-REQ", "APP-ADR")], + vec![ + (replacement_key.clone(), b"exact local bytes".to_vec()), + ( + inherited_key.clone(), + b"exact verified parent bytes".to_vec(), + ), + ], + ); + + assert!(corpus.findings().is_empty()); + assert_eq!(corpus.local_items().len(), 2); + assert_eq!(corpus.catalog().len(), 3); + assert_eq!(corpus.effective().len(), 2); + assert_eq!( + corpus.resolve("STD-REQ").unwrap().key, + ArtifactKey::new(LOCAL_SOURCE, "APP-REQ") + ); + assert_eq!( + corpus.resolve("standards::STD-REQ").unwrap().key, + ArtifactKey::new(PARENT_SOURCE, "STD-REQ") + ); + assert_eq!( + lookup_error(&corpus, "parent-policy"), + LookupError::NotFound + ); + assert_eq!( + lookup_error(&corpus, "standards::parent-policy"), + LookupError::QualifiedCanonicalRequired + ); + assert!(corpus.is_overridden(&ArtifactKey::new(PARENT_SOURCE, "STD-REQ"))); + assert_eq!( + corpus.overrides()[0].rationale, + ArtifactKey::new(LOCAL_SOURCE, "APP-ADR") + ); + assert_eq!( + corpus.content(&replacement_key), + Some(&b"exact local bytes"[..]) + ); + assert_eq!( + corpus.content(&inherited_key), + Some(&b"exact verified parent bytes"[..]) + ); + assert!(!corpus + .relationships() + .iter() + .any(|edge| edge.source_artifact + == Some(ArtifactPath::new(PARENT_SOURCE, "parent-policy.md")))); + assert!(corpus + .catalog_relationships() + .iter() + .any(|edge| edge.source_artifact + == Some(ArtifactPath::new(PARENT_SOURCE, "parent-policy.md")))); +} + +#[test] +fn a_same_id_override_is_the_only_way_to_clear_its_collision() { + let replacement = item( + Layer::Local, + "replacement.md", + "POLICY-001", + "requirement", + "Accepted", + "", + ); + let rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "policy.md", + "POLICY-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose( + vec![replacement, rationale], + vec![inherited], + parent(), + vec![declaration("POLICY-001", "POLICY-001", "APP-ADR")], + ); + + assert!(corpus.findings().is_empty()); + assert_eq!( + corpus.resolve("POLICY-001").unwrap().key, + ArtifactKey::new(LOCAL_SOURCE, "POLICY-001") + ); + assert_eq!( + corpus.resolve("standards::POLICY-001").unwrap().key, + ArtifactKey::new(PARENT_SOURCE, "POLICY-001") + ); +} + +#[test] +fn override_operands_are_canonical_local_and_decision_backed() { + assert_eq!( + CanonicalId::new("standards::STD-REQ"), + Err(OverrideSyntaxError::QualifiedLocalId) + ); + + let replacement = item( + Layer::Local, + "replacement-alias.md", + "APP-REQ", + "requirement", + "Accepted", + "", + ); + let draft_rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Proposed", + "", + ); + let inherited = item( + Layer::Inherited, + "policy.md", + "STD-REQ", + "requirement", + "Accepted", + "", + ); + let alias_operand = ComposedCorpus::compose( + vec![replacement.clone(), draft_rationale.clone()], + vec![inherited.clone()], + parent(), + vec![declaration("STD-REQ", "replacement-alias", "APP-ADR")], + ); + assert_eq!(alias_operand.findings()[0].code, FINDING_INVALID_OVERRIDE); + assert_eq!( + alias_operand.findings()[0].reason, + Some(InvalidOverrideReason::ReplacementNotFound) + ); + + let dead_rationale = ComposedCorpus::compose( + vec![replacement, draft_rationale], + vec![inherited], + parent(), + vec![declaration("STD-REQ", "APP-REQ", "APP-ADR")], + ); + assert_eq!(dead_rationale.findings()[0].code, FINDING_INVALID_OVERRIDE); + assert_eq!( + dead_rationale.findings()[0].reason, + Some(InvalidOverrideReason::RationaleNotLive) + ); +} + +#[test] +fn cross_source_relationships_resolve_to_typed_endpoints_and_check_types() { + let local = item( + Layer::Local, + "child-requirement.md", + "APP-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- standards::STD-ADR\n", + ); + let parent_decision = item( + Layer::Inherited, + "parent-decision.md", + "STD-ADR", + "decision", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![parent_decision], parent(), Vec::new()); + let edge = corpus + .relationships() + .into_iter() + .find(|edge| edge.relationship == "related_decisions") + .unwrap(); + assert_eq!( + edge.source_artifact, + Some(ArtifactPath::new(LOCAL_SOURCE, "child-requirement.md")) + ); + assert_eq!( + edge.resolved_artifact, + Some(ArtifactPath::new(PARENT_SOURCE, "parent-decision.md")) + ); + assert!(edge.issue.is_none()); + + let uppercase_alias = ComposedCorpus::compose( + vec![item( + Layer::Local, + "uppercase.md", + "APP-UPPER", + "requirement", + "Accepted", + "## Related Decisions\n\n- Standards::STD-ADR\n", + )], + vec![item( + Layer::Inherited, + "parent-decision.md", + "STD-ADR", + "decision", + "Accepted", + "", + )], + parent(), + Vec::new(), + ); + assert_eq!( + uppercase_alias.relationships()[0].issue.as_deref(), + Some(ISSUE_TARGET_NOT_FOUND) + ); + + let wrong_type = item( + Layer::Inherited, + "parent-requirement.md", + "STD-REQ", + "requirement", + "Accepted", + "", + ); + let local = item( + Layer::Local, + "child-requirement.md", + "APP-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- STD-REQ\n", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![wrong_type], parent(), Vec::new()); + assert!(corpus + .validate_relationships(".", true) + .issues + .iter() + .any(|issue| issue.code == ISSUE_TARGET_TYPE_MISMATCH)); +} + +#[test] +fn cross_source_cycles_are_computed_over_artifact_keys() { + let local = item( + Layer::Local, + "local.md", + "APP-ADR", + "decision", + "Accepted", + "## Supersedes\n\n- standards::STD-ADR\n", + ); + let inherited = item( + Layer::Inherited, + "parent.md", + "STD-ADR", + "decision", + "Accepted", + "## Supersedes\n\n- APP-ADR\n", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + let validation = corpus.validate_relationships(".", true); + let cycle = validation + .issues + .iter() + .find(|issue| issue.code == ISSUE_RELATIONSHIP_CYCLE) + .expect("cross-source cycle"); + assert_eq!( + cycle.paths.as_ref().unwrap(), + &vec![ + "/runtime/acme/app/local.md".to_string(), + "/runtime/acme/standards/parent.md".to_string(), + ] + ); +}