diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index ed63fc16..7be9fb56 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -1664,17 +1664,25 @@ impl Parser<'_> { } } -pub(crate) fn resolve_declarations( +// The construction phase is shared with private mixed resolution. Migration and +// complete-model admission remain exclusively in each caller's finishing tail. +struct ConstructedDeclarations { + project: Project, + legacy_elements: serde_json::Map, +} + +fn construct_declarations<'a>( mut project: Project, - declarations: &[Declaration], + declarations: impl IntoIterator, aliases: &BTreeMap, - limits: ParseLimits, -) -> Result { + mut before_fields: impl FnMut(&Declaration) -> Result<(), Error>, +) -> Result { let legacy = project.schema_version == 3; let mut legacy_elements = serde_json::Map::new(); let mut nodes = Vec::new(); - for (index, declaration) in declarations.iter().enumerate() { + for (index, declaration) in declarations.into_iter().enumerate() { (|| -> Result<(), Error> { + before_fields(declaration)?; let mut fields = declaration .fields .iter() @@ -1899,6 +1907,18 @@ pub(crate) fn resolve_declarations( .map_err(|error| (index, error))?; target.nodes.insert(node.id, node); } + Ok(ConstructedDeclarations { project, legacy_elements }) +} + +pub(crate) fn resolve_declarations( + project: Project, + declarations: &[Declaration], + aliases: &BTreeMap, + limits: ParseLimits, +) -> Result { + let legacy = project.schema_version == 3; + let ConstructedDeclarations { project, legacy_elements } = + construct_declarations(project, declarations, aliases, |_| Ok(()))?; if legacy { let migrated = source3_bridge::from_scratch(project, legacy_elements) .and_then(|old| crate::persistence::v3::migrate(old, limits)) diff --git a/crates/oa-core/src/language/analysis_source/lower.rs b/crates/oa-core/src/language/analysis_source/lower.rs index 1e2f02b3..14b5be02 100644 --- a/crates/oa-core/src/language/analysis_source/lower.rs +++ b/crates/oa-core/src/language/analysis_source/lower.rs @@ -124,7 +124,7 @@ struct Symbol { } #[derive(Default)] -struct Tables { +pub(super) struct Tables { aliases: BTreeMap, ids: BTreeSet, parameters: BTreeMap>, @@ -157,7 +157,7 @@ impl Tables { self.entries += 1; Ok(()) } - fn insert( + pub(super) fn insert( &mut self, id: Id, alias: &str, @@ -233,7 +233,11 @@ impl Tables { Ok(()) } - fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> { + pub(super) fn typed( + &mut self, + declaration: &Declaration, + budget: &Budget, + ) -> Result<(), Error> { let header = &declaration.header; let id = identity(&header.identity, header.alias.span)?; self.insert( @@ -299,31 +303,49 @@ pub(super) fn lower( })?; } } + lower_bodies( + &tables, + units + .iter() + .flat_map(|unit| unit.declarations.iter().map(move |d| (unit.file, d))), + budget, + ) +} + +/// Catalog and borrowed declarations must be from the same private witness. +/// The mixed caller reserves aggregate live storage before entering this body; +/// the legacy wrapper retains its original individual phase checks. +pub(super) fn lower_bodies<'a>( + tables: &Tables, + declarations: impl IntoIterator, + budget: &Budget, +) -> Result { + budget + .active() + .map_err(|error| Failure { file: None, error })?; let mut output = Lowered { definitions: BTreeMap::new(), elements: BTreeMap::new(), occurrences: Vec::new(), expressions: Vec::new(), }; - for unit in units { - for declaration in &unit.declarations { - let owner = declaration - .header - .identity - .value - .expect("collected stable identity"); - let mut resolver = Resolver { - tables: &tables, - output: &mut output, - budget, - file: unit.file, - owner, - }; - resolver.declaration(declaration).map_err(|error| Failure { - file: Some(unit.file), - error, - })?; - } + for (file, declaration) in declarations { + let owner = declaration + .header + .identity + .value + .expect("collected stable identity"); + let mut resolver = Resolver { + tables, + output: &mut output, + budget, + file, + owner, + }; + resolver.declaration(declaration).map_err(|error| Failure { + file: Some(file), + error, + })?; } Ok(output) } diff --git a/crates/oa-core/src/language/analysis_source/mixed.rs b/crates/oa-core/src/language/analysis_source/mixed.rs index cf82d30b..b092d775 100644 --- a/crates/oa-core/src/language/analysis_source/mixed.rs +++ b/crates/oa-core/src/language/analysis_source/mixed.rs @@ -41,6 +41,9 @@ pub(super) struct ParsedMixedSyntax<'s> { limits: ProjectLimits, files: Vec>, budget: Budget, + // Includes the conservative dropped value-end subtotal. The semantic + // phase adds syntax entries to this once, not tokens/positions again. + metadata: usize, } struct FileSyntax<'s> { @@ -369,6 +372,7 @@ fn parse_with_profile( limits, files: parsed.into_values().collect(), budget, + metadata, }) } @@ -398,3 +402,5 @@ fn ordinary_position_slots(unit: &SourceUnit) -> usize { #[cfg(test)] mod tests; + +pub(super) mod project; diff --git a/crates/oa-core/src/language/analysis_source/mixed/project.rs b/crates/oa-core/src/language/analysis_source/mixed/project.rs new file mode 100644 index 00000000..f5b1dc6b --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/project.rs @@ -0,0 +1,639 @@ +//! Private complete-Project admission from the actual complete syntax witness. +//! No Index, update authority, public source8 dispatch or replacement policy. +use super::super::{ + Binding, Budget, Error as TypedError, Expression, ExpressionKind, Reference, Span, + declarations as d, error, lower, +}; +use super::{FileSyntax, ParsedMixedSyntax}; +use crate::language::{Ast, DeclKind}; +use crate::source_project::{SourceDiagnostic, SourceLocation}; +use crate::{Id, Kind, Project, RelationKind, analysis, language, validation}; +use std::collections::BTreeMap; +mod accounting; +use accounting::{Ledger, Slots, add}; + +/// Read-only authority: every component originates in the consumed syntax. +pub(in crate::language::analysis_source) struct ResolvedMixedProject<'s> { + syntax: ParsedMixedSyntax<'s>, + project: Project, + warnings: Vec, + static_limits: analysis::AdmissionLimits, + static_usage: analysis::AdmissionUsage, + expressions: Vec, + occurrences: Vec, + storage: Ledger, +} +impl ResolvedMixedProject<'_> { + pub fn project(&self) -> &Project { + &self.project + } + pub fn warnings(&self) -> &[SourceDiagnostic] { + &self.warnings + } +} + +#[derive(Debug)] +pub(in crate::language::analysis_source) struct Failure { + pub diagnostics: Vec, +} +impl Failure { + // Rejection transport is not retained in a successful witness. In particular + // a storage refusal needs a small diagnostic even when its allowance is zero. + fn typed(syntax: &ParsedMixedSyntax<'_>, file: Option, e: TypedError) -> Self { + let mut failure = Self::ordinary( + syntax, + file, + language::Error { + code: "invalid_source", + offset: e.span.start, + message: e.message, + }, + ); + failure.diagnostics[0].diagnostic.span = e.span; + failure + } + fn ordinary(syntax: &ParsedMixedSyntax<'_>, file: Option, e: language::Error) -> Self { + let file = file.unwrap_or_else(|| entry(syntax)); + let unit = &syntax.files[file]; + Self { + diagnostics: super::located(unit.path, unit.source, e), + } + } +} + +#[derive(Clone, Copy)] +enum OwnerKind { + Project, + Ordinary(usize), + Typed(usize), +} +#[derive(Clone, Copy)] +struct Owner { + file: usize, + kind: OwnerKind, + span: Span, +} +struct Catalog { + tables: lower::Tables, + aliases: BTreeMap, + owners: BTreeMap, + tables_storage: Slots, + aliases_storage: Slots, + owners_storage: Slots, +} +fn entry(syntax: &ParsedMixedSyntax<'_>) -> usize { + syntax + .files + .iter() + .position(|f| f.path == syntax.bundle.entry) + .expect("complete entry") +} +fn category(kind: DeclKind) -> lower::Category { + match kind { + DeclKind::Element(kind, layer) => lower::Category::Element(kind, layer), + DeclKind::Relation(_) => lower::Category::Relation, + DeclKind::Diagram => lower::Category::Diagram, + DeclKind::Node => lower::Category::Node, + DeclKind::Artifact => lower::Category::Artifact, + DeclKind::Trace => lower::Category::Trace, + } +} +impl Catalog { + fn insert( + &mut self, + syntax: &ParsedMixedSyntax<'_>, + ledger: &mut Ledger, + id: Id, + alias: &str, + owner: Owner, + ) -> Result<(), Failure> { + // Alias first, even when both aliases and IDs collide. Owner is an + // occurrence position, never a lookup through the rejected UUID. + let prior = self + .aliases + .get(alias) + .map(|id| ("alias", self.owners[id])) + .or_else(|| self.owners.get(&id).map(|o| ("identity", *o))); + if let Some((domain, prior)) = prior { + let mut failure = Failure::typed( + syntax, + Some(owner.file), + error(owner.span, format!("Duplicate global {domain}.")), + ); + failure.diagnostics[0].related.push(SourceLocation { + path: syntax.files[prior.file].path.into(), + span: prior.span, + }); + return Err(failure); + } + let alias_slots = Slots { + entries: 1, + text: alias.len(), + ..Slots::default() + }; + ledger + .reserve( + &syntax.budget, + alias_slots + .add(Slots::entries(1), owner.span) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?, + owner.span, + ) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?; + self.aliases_storage = self + .aliases_storage + .add(alias_slots, owner.span) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?; + self.owners_storage = self + .owners_storage + .add(Slots::entries(1), owner.span) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?; + self.aliases.insert(alias.into(), id); + self.owners.insert(id, owner); + Ok(()) + } + fn reserve_tables( + &mut self, + syntax: &ParsedMixedSyntax<'_>, + ledger: &mut Ledger, + slots: Slots, + owner: Owner, + ) -> Result<(), Failure> { + ledger + .reserve(&syntax.budget, slots, owner.span) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?; + self.tables_storage = self + .tables_storage + .add(slots, owner.span) + .map_err(|e| Failure::typed(syntax, Some(owner.file), e))?; + Ok(()) + } +} +fn catalog(syntax: &ParsedMixedSyntax<'_>, ledger: &mut Ledger) -> Result { + let mut catalog = Catalog { + tables: lower::Tables::default(), + aliases: BTreeMap::new(), + owners: BTreeMap::new(), + tables_storage: Slots::default(), + aliases_storage: Slots::default(), + owners_storage: Slots::default(), + }; + let file = entry(syntax); + let header = syntax.files[file] + .ordinary + .header + .as_ref() + .expect("entry header"); + let owner = Owner { + file, + kind: OwnerKind::Project, + span: header.span, + }; + catalog.insert( + syntax, + ledger, + header.project.id, + &header.project.alias, + owner, + )?; + catalog.reserve_tables( + syntax, + ledger, + Slots { + entries: 2, + text: header.project.alias.len(), + ..Slots::default() + }, + owner, + )?; + catalog + .tables + .insert( + header.project.id, + &header.project.alias, + lower::Category::Project, + header.span, + &syntax.budget, + ) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + for (file, unit) in syntax.files.iter().enumerate() { + let mut ordinary = unit.ordinary.declarations.iter().enumerate().peekable(); + let mut typed = unit.typed.iter().enumerate().peekable(); + while ordinary.peek().is_some() || typed.peek().is_some() { + let use_ordinary = match (ordinary.peek(), typed.peek()) { + (Some((_, a)), Some((_, b))) => a.span.start < b.span.start, + (Some(_), None) => true, + _ => false, + }; + if use_ordinary { + let (index, d) = ordinary.next().expect("ordinary head"); + let owner = Owner { + file, + kind: OwnerKind::Ordinary(index), + span: d.span, + }; + catalog.insert(syntax, ledger, d.id, &d.alias, owner)?; + catalog.reserve_tables( + syntax, + ledger, + Slots { + entries: 2, + text: d.alias.len(), + ..Slots::default() + }, + owner, + )?; + catalog + .tables + .insert(d.id, &d.alias, category(d.kind), d.span, &syntax.budget) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + } else { + let (index, d) = typed.next().expect("typed head"); + let owner = Owner { + file, + kind: OwnerKind::Typed(index), + span: d.span, + }; + catalog.insert( + syntax, + ledger, + d.header.identity.value.expect("syntax explicit ID"), + &d.header.alias.name, + owner, + )?; + let mut slots = Slots { + entries: 2, + text: d.header.alias.name.len(), + ..Slots::default() + }; + let mut scratch = 0; + if let d::Body::Calculation { parameters, .. } = &d.body { + slots.entries = add( + slots.entries, + add(1, parameters.len(), d.span) + .map_err(|e| Failure::typed(syntax, Some(file), e))?, + d.span, + ) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + scratch = parameters.len(); // per-signature duplicate-ID set + for p in parameters { + slots.text = add(slots.text, p.name.name.len(), p.span) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + } + } + catalog.reserve_tables(syntax, ledger, slots, owner)?; + ledger + .reserve(&syntax.budget, Slots::entries(scratch), d.span) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + catalog + .tables + .typed(d, &syntax.budget) + .map_err(|e| Failure::typed(syntax, Some(file), e))?; + ledger.release(Slots::entries(scratch)); + } + } + } + Ok(catalog) +} + +fn object<'a>(value: Option<&'a Ast>, key: &str) -> Option<&'a Ast> { + match value { + Some(Ast::Object(fields, _)) => fields.get(key), + _ => None, + } +} +/// Selector only, not a decoder. This executes before ordinary fields clone. +fn active_references( + declaration: &language::Declaration, + budget: &mut Budget, +) -> Result<(), language::Error> { + let field = |key: &str| declaration.fields.get(key); + let arrays = match declaration.kind { + DeclKind::Element(Kind::Process | Kind::FunctionalChain | Kind::Scenario, _) => { + [field("steps"), None] + } + DeclKind::Element(Kind::Occurrence, _) => { + [object(object(field("body"), "Branch"), "occurrences"), None] + } + DeclKind::Relation(RelationKind::ContextualLink) => [ + object(field("source"), "references"), + object(field("target"), "references"), + ], + _ => [None, None], + }; + for array in arrays.into_iter().flatten() { + if let Ast::Array(items, _) = array { + for item in items { + let start = item.offset(); + budget + .reference(Span { start, end: start }) + .map_err(super::ordinary_error)?; + } + } + } + Ok(()) +} + +pub(in crate::language::analysis_source) fn resolve( + mut syntax: ParsedMixedSyntax<'_>, +) -> Result, Failure> { + let mut storage = Ledger::default(); + let lower_storage = accounting::syntax(&syntax, &mut storage)?; + let catalog = catalog(&syntax, &mut storage)?; + let entry = entry(&syntax); + // Only the empty model header is copied: retain its original syntax/header + // ownership and construct the final model through the shared decoder once. + let project = syntax.files[entry] + .ordinary + .header + .as_ref() + .expect("entry header") + .project + .clone(); + let count = syntax + .files + .iter() + .try_fold(0usize, |n, f| { + add(n, f.ordinary.declarations.len(), Span::default()) + }) + .map_err(|e| Failure::typed(&syntax, None, e))?; + storage + .reserve(&syntax.budget, Slots::entries(count), Span::default()) + .map_err(|e| Failure::typed(&syntax, None, e))?; + let declarations: Vec<_> = syntax + .files + .iter() + .enumerate() + .flat_map(|(file, unit)| unit.ordinary.declarations.iter().map(move |d| (file, d))) + .collect(); + let mut nodes = 0; + let constructed = language::construct_declarations( + project, + declarations.iter().map(|(_, d)| *d), + &catalog.aliases, + |d| { + active_references(d, &mut syntax.budget)?; + if matches!(d.kind, DeclKind::Node) { + storage + .reserve(&syntax.budget, Slots::entries(1), d.span) + .map_err(super::ordinary_error)?; + nodes += 1; + } + Ok(()) + }, + ) + .map_err(|(index, error)| { + Failure::ordinary( + &syntax, + declarations.get(index).map(|(file, _)| *file), + error, + ) + })?; + let language::ConstructedDeclarations { + mut project, + legacy_elements, + } = constructed; + debug_assert!(legacy_elements.is_empty()); // mixed syntax only admits source8 + drop(legacy_elements); + drop(declarations); + storage.release(Slots::entries(count)); + storage.release(Slots::entries(nodes)); + drop(catalog.aliases); + storage.release(catalog.aliases_storage); + + let all_lower = lower_storage + .all() + .map_err(|e| Failure::typed(&syntax, None, e))?; + storage + .reserve(&syntax.budget, all_lower, Span::default()) + .map_err(|e| Failure::typed(&syntax, None, e))?; + let lower::Lowered { + definitions, + elements, + occurrences, + expressions, + } = lower::lower_bodies( + &catalog.tables, + syntax + .files + .iter() + .enumerate() + .flat_map(|(file, unit)| unit.typed.iter().map(move |d| (file, d))), + &syntax.budget, + ) + .map_err(|e| Failure::typed(&syntax, e.file, e.error))?; + // One global arena transfers verbatim; only now are all Resolver temporaries + // dead. Output counts must fit the independently counted reservation. + storage.release(Slots { + entries: all_lower + .entries + .checked_sub(expressions.len()) + .expect("expression bound"), + occurrences: all_lower + .occurrences + .checked_sub(occurrences.len()) + .expect("occurrence bound"), + text: 0, + }); + drop(catalog.tables); + storage.release(catalog.tables_storage); + for (id, value) in definitions { + if project.definitions.contains_key(&id) { + return Err(Failure::typed( + &syntax, + None, + error(Span::default(), "Non-disjoint definition merge."), + )); + } + project.definitions.insert(id, value); + } + for (id, value) in elements { + if project.elements.contains_key(&id) { + return Err(Failure::typed( + &syntax, + None, + error(Span::default(), "Non-disjoint element merge."), + )); + } + project.elements.insert(id, value); + } + syntax + .limits + .model + .check_model(&project) + .map_err(|message| { + Failure::ordinary( + &syntax, + None, + language::Error { + code: "model_limit", + offset: 0, + message, + }, + ) + })?; + let (issues, admission) = validation::validate_with_usage(&project); + // Raw architecture Issues are excluded. Each transferred string, copied path + // and destination slot is admitted before mapping/retention; never truncate. + let mut warnings = Vec::new(); + let mut rejected = false; + for issue in issues { + let owner = issue + .element + .and_then(|id| catalog.owners.get(&id)) + .copied() + .unwrap_or(catalog.owners[&project.id]); + let span = field_span(&syntax.files[owner.file], owner, issue.field.as_deref()); + let unit = &syntax.files[owner.file]; + let text = [ + unit.path.len(), + issue.code.len(), + issue.field.as_ref().map_or(0, String::len), + issue.message.len(), + ] + .into_iter() + .try_fold(0, |n, k| add(n, k, span)) + .map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?; + storage + .reserve( + &syntax.budget, + Slots { + entries: 1, + occurrences: 0, + text, + }, + span, + ) + .map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?; + rejected |= issue.severity == validation::Severity::Error; + let prefix = &unit.source[..span.start]; + let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().expect("prefix").chars().count() + 1; + warnings.push(SourceDiagnostic { + path: unit.path.into(), + related: Vec::new(), + diagnostic: language::Diagnostic { + severity: issue.severity, + code: issue.code, + object_id: issue.element, + field: issue.field, + message: issue.message, + span, + line, + column, + }, + }); + } + if rejected { + return Err(Failure { + diagnostics: warnings, + }); + } + let (static_limits, static_usage) = admission.expect("successful validation retains admission"); + drop(catalog.owners); + storage.release(catalog.owners_storage); + Ok(ResolvedMixedProject { + syntax, + project, + warnings, + static_limits, + static_usage, + expressions, + occurrences, + storage, + }) +} + +/// Exact known field or explicit whole-owner fallback. Never guess by offsets. +fn field_span(file: &FileSyntax<'_>, owner: Owner, field: Option<&str>) -> Span { + let Some(field) = field else { + return owner.span; + }; + match owner.kind { + OwnerKind::Project => file + .ordinary + .header + .as_ref() + .and_then(|h| (field == "description").then_some(h.description).flatten()) + .unwrap_or(owner.span), + OwnerKind::Ordinary(index) => file.ordinary.declarations[index] + .field_spans + .get(field) + .copied() + .unwrap_or(owner.span), + OwnerKind::Typed(index) => typed_field(&file.typed[index], field).unwrap_or(owner.span), + } +} +fn typed_field(d: &d::Declaration, field: &str) -> Option { + if field == "description" { + return d.description.as_ref().map(|f| f.value_span); + } + if field == "alias" { + return Some(d.header.alias.span); + } + if field == "name" { + return Some(d.header.name_span); + } + match &d.body { + d::Body::QuantityKind { dimension, algebra } => match field { + "dimension" => Some(dimension.value_span), + "algebra" => Some(algebra.value_span), + _ => None, + }, + d::Body::Unit { + kind, + scale, + offset, + symbol, + } => match field { + "kind" => Some(kind.value_span), + "scale" => Some(scale.value_span), + "offset" => Some(offset.value_span), + "symbol" => Some(symbol.value_span), + _ => None, + }, + d::Body::Calculation { result, body, .. } => match field { + "result" => Some(result.value_span), + "body" => Some(body.value_span), + // Current semantic admission reports aggregate "parameters", not a + // qualified parameter/type path. Do not manufacture precision from + // a UUID substring; lowerer failures already carry exact file/span. + _ => None, + }, + d::Body::Property { + subject, + value_type, + base, + display_unit, + } => match field { + "subject" => Some(subject.value_span), + "type" | "value_type" => Some(value_type.value_span), + "base" => Some(base.value_span), + "display_unit" => display_unit.as_ref().map(|f| f.value_span), + _ => None, + }, + d::Body::Check { + subject, + documented_constraint, + predicate, + } => match field { + "subject" => subject.as_ref().map(|f| f.value_span), + "documented_constraint" => documented_constraint.as_ref().map(|f| f.value_span), + "predicate" => Some(predicate.value_span), + _ => None, + }, + d::Body::Case { + subject, + base, + overrides, + } => match field { + "subject" => subject.as_ref().map(|f| f.value_span), + "base" => Some(base.value_span), + "overrides" => Some(overrides.value_span), + _ => None, + }, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/mixed/project/accounting.rs b/crates/oa-core/src/language/analysis_source/mixed/project/accounting.rs new file mode 100644 index 00000000..2f718597 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/project/accounting.rs @@ -0,0 +1,352 @@ +//! Logical auxiliary storage, separate from model/serde and raw validator heap. +use super::*; +use crate::language::{EditKind, EditValue}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Slots { + pub entries: usize, + pub occurrences: usize, + pub text: usize, +} +impl Slots { + pub fn entries(entries: usize) -> Self { + Self { + entries, + ..Self::default() + } + } + pub fn text(text: usize) -> Self { + Self { + text, + ..Self::default() + } + } + pub fn add(self, other: Self, at: Span) -> Result { + Ok(Self { + entries: add(self.entries, other.entries, at)?, + occurrences: add(self.occurrences, other.occurrences, at)?, + text: add(self.text, other.text, at)?, + }) + } +} +pub(super) fn add(a: usize, b: usize, at: Span) -> Result { + a.checked_add(b) + .ok_or_else(|| error(at, "Mixed auxiliary storage size overflow.")) +} +pub(super) fn mul(a: usize, b: usize, at: Span) -> Result { + a.checked_mul(b) + .ok_or_else(|| error(at, "Mixed auxiliary storage size overflow.")) +} + +#[derive(Debug, Default)] +pub(super) struct Ledger { + pub live: Slots, +} +impl Ledger { + pub fn reserve(&mut self, budget: &Budget, slots: Slots, at: Span) -> Result<(), TypedError> { + let next = self.live.add(slots, at)?; + budget.lowering_entries(next.entries, at)?; + budget.lowering_occurrences(next.occurrences, at)?; + budget.index_bytes(next.text, at)?; + self.live = next; + Ok(()) + } + pub fn release(&mut self, slots: Slots) { + self.live.entries = self + .live + .entries + .checked_sub(slots.entries) + .expect("entry reservation"); + self.live.occurrences = self + .live + .occurrences + .checked_sub(slots.occurrences) + .expect("occurrence reservation"); + self.live.text = self + .live + .text + .checked_sub(slots.text) + .expect("text reservation"); + } +} + +/// Exact output-site counts and conservative whole-lowering temporary bound. +/// N expression nodes: at most 2N Visit/Build slots, N borrowed child slots, +/// N value/split slots. A arguments: A signature slots plus A seen-set slots. +/// `Resolver::call` borrows the callee signature. Its parameter Vec/seen set +/// contain only supplied arguments, including on incomplete/invalid calls; +/// no allocation is proportional to the full signature during body lowering. +/// The model expressions/maps themselves are outside this auxiliary ledger. +#[derive(Default)] +pub(super) struct LowerStorage { + pub nodes: usize, + pub occurrences: usize, + pub arguments: usize, +} +impl LowerStorage { + pub fn all(&self) -> Result { + let at = Span::default(); + Ok(Slots { + entries: add(mul(self.nodes, 5, at)?, mul(self.arguments, 2, at)?, at)?, + occurrences: self.occurrences, + text: 0, + }) + } +} + +enum Item<'a> { + Ast(&'a Ast), + Edit(&'a EditValue), + Expr(&'a Expression), +} +struct Walk<'a, 'b> { + ledger: &'b mut Ledger, + budget: &'b Budget, + work: Vec>, + lower: LowerStorage, +} +impl<'a> Walk<'a, '_> { + fn text(&mut self, text: &str, at: Span) -> Result<(), TypedError> { + self.ledger + .reserve(self.budget, Slots::text(text.len()), at) + } + fn push(&mut self, item: Item<'a>) -> Result<(), TypedError> { + self.ledger + .reserve(self.budget, Slots::entries(1), Span::default())?; + self.work.push(item); + Ok(()) + } + fn reference(&mut self, r: &Reference) -> Result<(), TypedError> { + self.text(&r.name, r.span)?; + self.lower.occurrences = add(self.lower.occurrences, 1, r.span)?; + Ok(()) + } + fn optional(&mut self, field: &Option>>) -> Result<(), TypedError> { + if let Some(r) = field.as_ref().and_then(|f| f.value.as_ref()) { + self.reference(r)?; + } + Ok(()) + } + fn ty(&mut self, ty: &d::TypeSyntax) -> Result<(), TypedError> { + if let d::ScalarKind::Quantity(r) = &ty.scalar.kind { + self.reference(r)?; + } + Ok(()) + } + fn binding(&mut self, binding: &'a Binding) -> Result<(), TypedError> { + if let Binding::Expression(e) = binding { + self.push(Item::Expr(e))?; + } + self.drain() + } + fn drain(&mut self) -> Result<(), TypedError> { + while let Some(item) = self.work.pop() { + self.ledger.release(Slots::entries(1)); + match item { + Item::Ast(ast) => match ast { + Ast::Literal(serde_json::Value::String(s), _) + | Ast::Word(s, _) + | Ast::Reference(s, _) => self.text(s, Span::default())?, + Ast::Array(items, _) => { + for child in items { + self.push(Item::Ast(child))?; + } + } + Ast::Object(fields, _) => { + for (key, child) in fields { + self.text(key, Span::default())?; + self.push(Item::Ast(child))?; + } + } + _ => {} + }, + Item::Edit(value) => match &value.kind { + EditKind::Reference(s) => self.text(s, value.span)?, + EditKind::Record(fields) => { + for (key, field) in fields { + self.text(key, field.span)?; + self.push(Item::Edit(&field.value))?; + } + } + EditKind::List(items) => { + for child in items { + self.push(Item::Edit(child))?; + } + } + _ => {} + }, + Item::Expr(e) => { + self.lower.nodes = add(self.lower.nodes, 1, e.span)?; + use ExpressionKind as E; + match &e.kind { + E::Quantity { unit, .. } | E::Property(unit) | E::Parameter(unit) => { + self.reference(unit)? + } + E::List(items) => { + for child in items { + self.push(Item::Expr(child))?; + } + } + E::Unary { operand, .. } => self.push(Item::Expr(operand))?, + E::Binary { left, right, .. } => { + self.push(Item::Expr(left))?; + self.push(Item::Expr(right))?; + } + E::If { + condition, + when_true, + when_false, + } => { + self.push(Item::Expr(condition))?; + self.push(Item::Expr(when_true))?; + self.push(Item::Expr(when_false))?; + } + E::Call { + definition, + arguments, + } => { + self.reference(definition)?; + self.lower.arguments = + add(self.lower.arguments, arguments.len(), e.span)?; + for argument in arguments { + self.reference(&argument.parameter)?; + self.push(Item::Expr(&argument.value))?; + } + } + E::Sum { values, initial } => { + self.push(Item::Expr(values))?; + self.push(Item::Expr(initial))?; + } + E::Power { base, .. } => self.push(Item::Expr(base))?, + E::AsKind { kind, value } => { + self.reference(kind)?; + self.push(Item::Expr(value))?; + } + E::Boolean(_) | E::Number(_) => {} + } + } + } + } + Ok(()) + } +} + +pub(super) fn syntax( + syntax: &ParsedMixedSyntax<'_>, + ledger: &mut Ledger, +) -> Result { + let at = Span::default(); + ledger + .reserve( + &syntax.budget, + Slots::entries( + add(syntax.metadata, syntax.budget.usage.entries, at) + .map_err(|e| Failure::typed(syntax, None, e))?, + ), + at, + ) + .map_err(|e| Failure::typed(syntax, None, e))?; + let mut walk = Walk { + ledger, + budget: &syntax.budget, + work: Vec::new(), + lower: LowerStorage::default(), + }; + for (file, unit) in syntax.files.iter().enumerate() { + let mut count = || -> Result<(), TypedError> { + if let Some(header) = &unit.ordinary.header { + for s in [ + &header.project.alias, + &header.project.name, + &header.project.description, + ] { + walk.text(s, header.span)?; + } + } + for (path, span) in &unit.ordinary.includes { + walk.text(path, *span)?; + } + for declaration in &unit.ordinary.declarations { + walk.text(&declaration.alias, declaration.span)?; + walk.text(&declaration.name, declaration.span)?; + for key in declaration.field_spans.keys() { + walk.text(key, declaration.span)?; + } + for (key, value) in &declaration.fields { + walk.text(key, declaration.span)?; + walk.push(Item::Ast(value))?; + walk.drain()?; + } + } + for (_, position) in &unit.positions { + walk.push(Item::Edit(&position.fields))?; + walk.drain()?; + } + for declaration in &unit.typed { + walk.text( + &declaration.header.alias.name, + declaration.header.alias.span, + )?; + walk.text(&declaration.header.name, declaration.header.name_span)?; + if let Some(f) = &declaration.description { + walk.text(&f.value, f.span)?; + } + match &declaration.body { + d::Body::QuantityKind { algebra, .. } => { + if let d::AlgebraSyntax::Affine(r) = &algebra.value { + walk.reference(r)?; + } + } + d::Body::Unit { kind, symbol, .. } => { + walk.reference(&kind.value)?; + walk.text(&symbol.value, symbol.span)?; + } + d::Body::Calculation { + parameters, + result, + body, + } => { + for p in parameters { + walk.reference(&p.name)?; + walk.ty(&p.value_type)?; + } + walk.ty(&result.value)?; + walk.binding(&body.value)?; + } + d::Body::Property { + subject, + value_type, + base, + display_unit, + } => { + walk.reference(&subject.value)?; + walk.ty(&value_type.value)?; + walk.binding(&base.value)?; + walk.optional(display_unit)?; + } + d::Body::Check { + subject, + documented_constraint, + predicate, + } => { + walk.optional(subject)?; + walk.optional(documented_constraint)?; + walk.binding(&predicate.value)?; + } + d::Body::Case { + subject, overrides, .. + } => { + walk.optional(subject)?; + for entry in &overrides.value { + walk.reference(&entry.target)?; + walk.binding(&entry.binding)?; + } + } + } + } + Ok(()) + }; + count().map_err(|e| Failure::typed(syntax, Some(file), e))?; + } + Ok(walk.lower) +} diff --git a/crates/oa-core/src/language/analysis_source/mixed/project/tests.rs b/crates/oa-core/src/language/analysis_source/mixed/project/tests.rs new file mode 100644 index 00000000..fb2f8189 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/project/tests.rs @@ -0,0 +1,946 @@ +//! Authored model/site oracles; never use write, Index, or a second lowering. +use super::super::{parse, parse_with_profile}; +use super::*; +use crate::analysis as a; +use crate::analysis_numeric::Exact; +use crate::source_project::{ProjectLimits, SourceBundle}; +use crate::*; + +fn id(n: usize) -> Id { + Id::parse_str(&format!("00000000-0000-0000-0000-{n:012}")).unwrap() +} +fn doc(body: &str) -> String { + format!( + "arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n", + id(1) + ) +} +fn single(body: &str) -> SourceBundle { + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([("main.arcadia".into(), doc(body))]), + } +} +fn bundle(layer: Layer) -> SourceBundle { + let (property, work, extra, diagram) = match layer { + Layer::Oa => ("oa_property", "oa_activity", String::new(), ""), + Layer::Sa => ( + "sa_property", + "sa_function", + format!("sa_system s \"System\" id \"{}\" {{}}", id(6)), + "layer Sa;", + ), + }; + single(&format!( + "node n id \"{}\" {{ diagram @d; element @v; }}\n{property} v \"Value\" id \"{}\" {{ subject @w; type Number; base 1; }}\n{work} w \"Work\" id \"{}\" {{}}\ndiagram d \"Diagram\" id \"{}\" {{ {diagram} }}\n{extra}", + id(4), + id(3), + id(2), + id(5) + )) +} +fn number(n: i64) -> a::Expr { + a::Expr::Number(Exact::from_integer(n)) +} +fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr { + a::Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} +fn expected(layer: Layer) -> Project { + let element = |n, alias: &str, name: &str, body| Element { + id: id(n), + alias: alias.into(), + name: name.into(), + description: String::new(), + parent: None, + body, + }; + let mut elements = BTreeMap::from([ + ( + id(2), + element( + 2, + "w", + "Work", + match layer { + Layer::Oa => ElementBody::Oa(OaBody::Activity), + Layer::Sa => ElementBody::Sa(SaBody::Function), + }, + ), + ), + ( + id(3), + element( + 3, + "v", + "Value", + ElementBody::shared_in( + layer, + SharedBody::EngineeringProperty { + subject: a::AnalysisSubject::Element(id(2)), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + base: a::Binding::Expression(number(1)), + display_unit: None, + }, + ), + ), + ), + ]); + if layer == Layer::Sa { + elements.insert( + id(6), + element(6, "s", "System", ElementBody::Sa(SaBody::System)), + ); + } + Project { + schema_version: 8, + id: id(1), + alias: "p".into(), + name: "Project".into(), + description: String::new(), + definitions: BTreeMap::new(), + elements, + relations: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + diagrams: BTreeMap::from([( + id(5), + Diagram { + id: id(5), + alias: "d".into(), + name: "Diagram".into(), + description: String::new(), + layer, + projection: ProjectionKind::Overview, + view_options: crate::view_options::ViewOptions { + hidden_edges: Vec::new(), + relation_kinds: crate::view_options::RelationKinds::All, + work_categories: crate::view_options::CategorySelection::All, + participant_categories: crate::view_options::CategorySelection::All, + coverage: crate::view_options::Coverage::All, + work_contexts: crate::view_options::ContextSelection::All, + exchange_labels: crate::view_options::ExchangeLabels::Name, + }, + routes: Vec::new(), + nodes: BTreeMap::from([( + id(4), + NodePlacement { + id: id(4), + alias: "n".into(), + element: id(3), + position: Position { x: 0., y: 0. }, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + port_anchors: Vec::new(), + }, + )]), + }, + )]), + } +} +fn accepted(bundle: &SourceBundle) -> ResolvedMixedProject<'_> { + resolve(parse(bundle, ProjectLimits::default()).unwrap()).unwrap_or_else(|e| panic!("{e:?}")) +} +fn rejected(bundle: &SourceBundle) -> Failure { + match resolve(parse(bundle, ProjectLimits::default()).unwrap()) { + Ok(_) => panic!("unexpected witness"), + Err(e) => e, + } +} +fn span(source: &str, context: &str, offset: usize, len: usize) -> Span { + assert_eq!( + source.matches(context).count(), + 1, + "ambiguous oracle {context}" + ); + let start = source.find(context).unwrap() + offset; + Span { + start, + end: start + len, + } +} + +#[test] +fn complete_oa_sa_forward_model_warning_and_original_authority() { + for layer in [Layer::Oa, Layer::Sa] { + let bundle = bundle(layer); + let parsed = parse(&bundle, ProjectLimits::default()).unwrap(); + let usage = parsed.budget.usage; + let numeric = parsed.budget.numeric_units(); + let resolved = resolve(parsed).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!(resolved.project(), &expected(layer)); + assert_eq!(resolved.syntax.budget.usage, usage); + assert_eq!(resolved.syntax.budget.numeric_units(), numeric); + assert!(std::ptr::eq(resolved.syntax.bundle, &bundle)); + assert_eq!(resolved.syntax.files[0].path, "main.arcadia"); + assert!(std::ptr::eq( + resolved.syntax.files[0].source.as_ptr(), + bundle.files["main.arcadia"].as_ptr() + )); + assert_eq!(resolved.warnings().len(), 1); + let warning = &resolved.warnings()[0]; + assert_eq!( + (&warning.path, warning.diagnostic.object_id), + (&bundle.entry, Some(id(2))) + ); + assert_eq!(warning.diagnostic.severity, validation::Severity::Warning); + assert_eq!(warning.diagnostic.code, "unallocated_leaf"); + assert_eq!(warning.diagnostic.field.as_deref(), Some("allocation")); + assert_eq!( + warning.diagnostic.message, + "Allocate leaf 'Work' to a performer." + ); + let declaration = format!( + "{} w \"Work\" id \"{}\" {{}}", + if layer == Layer::Oa { + "oa_activity" + } else { + "sa_function" + }, + id(2) + ); + assert_eq!( + warning.diagnostic.span, + span( + &bundle.files["main.arcadia"], + &declaration, + 0, + declaration.len() + ) + ); + assert_eq!(resolved.expressions.len(), 1); + assert_eq!(resolved.occurrences.len(), 1); + assert_eq!( + resolved.expressions[0].span, + span(&bundle.files["main.arcadia"], "base 1;", 5, 1) + ); + assert_eq!(resolved.static_limits, a::AdmissionLimits::REFERENCE); + assert!(resolved.static_usage.static_events > 0); + assert!(resolved.storage.live.entries > resolved.expressions.len()); + } +} + +#[test] +fn complete_global_arena_scoped_calls_and_coincident_file_offsets() { + let mut bundle = bundle(Layer::Oa); + let main = bundle.files.get_mut("main.arcadia").unwrap(); + *main = main + .replace( + "base 1;", + "base @f(y: 2, x: @g(x: 3, y: 4)) + @f(x: 5, y: 6);", + ) + .replace( + "node n", + "include \"b.arcadia\"; include \"a.arcadia\";\nnode n", + ); + for (file, alias, name, n, op) in [ + ("a.arcadia", "f", "F", 10, "+"), + ("b.arcadia", "g", "G", 11, "-"), + ] { + bundle.files.insert(file.into(), format!("calculation {alias} \"{name}\" id \"{}\" {{\ninput x id \"{}\" : Number;\ninput y id \"{}\" : Number;\nresult Number; body $x {op} $y;\n}}\n", id(n), id(1), id(12))); + } + let resolved = accepted(&bundle); + let mut model = expected(Layer::Oa); + for (alias, name, n, op) in [ + ("f", "F", 10, a::BinaryOp::Add), + ("g", "G", 11, a::BinaryOp::Subtract), + ] { + model.definitions.insert( + id(n), + a::Definition { + id: id(n), + alias: alias.into(), + name: name.into(), + description: String::new(), + body: a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters: [(1, "x"), (12, "y")] + .into_iter() + .map(|(n, name)| a::Parameter { + id: a::ParameterId(id(n)), + name: name.into(), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + }) + .collect(), + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(binary( + op, + a::Expr::Parameter(a::ParameterId(id(1))), + a::Expr::Parameter(a::ParameterId(id(12))), + )), + }), + }, + ); + } + let call = |n, x, y| a::Expr::Call { + definition: a::DefinitionRef::Local(id(n)), + arguments: BTreeMap::from([(a::ParameterId(id(1)), x), (a::ParameterId(id(12)), y)]), + }; + let Some(SharedBody::EngineeringProperty { base, .. }) = + model.elements.get_mut(&id(3)).unwrap().body.shared_mut() + else { + panic!("property") + }; + *base = a::Binding::Expression(binary( + a::BinaryOp::Add, + call(10, call(11, number(3), number(4)), number(2)), + call(10, number(5), number(6)), + )); + assert_eq!(resolved.project(), &model); + assert_eq!( + resolved + .syntax + .files + .iter() + .map(|f| f.path) + .collect::>(), + ["a.arcadia", "b.arcadia", "main.arcadia"] + ); + use lower::{Child as C, Field as F, Role as R, Target as T}; + let mut expressions = Vec::new(); + for (file, owner, op) in [(0, 10, "+"), (1, 11, "-")] { + let source = resolved.syntax.files[file].source; + let root = expressions.len(); + for (text, parent) in [ + (format!("$x {op} $y"), None), + ("$x".into(), Some((root, C::Left))), + ("$y".into(), Some((root, C::Right))), + ] { + expressions.push(lower::ExpressionSite { + file, + owner: id(owner), + field: F::Body, + parent, + span: span(source, &text, 0, text.len()), + }); + } + } + let source = &bundle.files["main.arcadia"]; + for (context, offset, len, parent) in [ + ("@f(y: 2, x: @g(x: 3, y: 4)) + @f(x: 5, y: 6)", 0, 44, None), + ("@f(y: 2, x: @g(x: 3, y: 4))", 0, 27, Some((6, C::Left))), + ("y: 2", 3, 1, Some((7, C::Argument(a::ParameterId(id(12)))))), + ( + "@g(x: 3, y: 4)", + 0, + 14, + Some((7, C::Argument(a::ParameterId(id(1))))), + ), + ("x: 3", 3, 1, Some((9, C::Argument(a::ParameterId(id(1)))))), + ("y: 4", 3, 1, Some((9, C::Argument(a::ParameterId(id(12)))))), + ("@f(x: 5, y: 6)", 0, 14, Some((6, C::Right))), + ("x: 5", 3, 1, Some((12, C::Argument(a::ParameterId(id(1)))))), + ( + "y: 6", + 3, + 1, + Some((12, C::Argument(a::ParameterId(id(12))))), + ), + ] { + expressions.push(lower::ExpressionSite { + file: 2, + owner: id(3), + field: F::Base, + parent, + span: span(source, context, offset, len), + }); + } + // Exact arena order, every parent edge and full original span, not counts only. + assert_eq!(resolved.expressions, expressions); + let mut occurrences = Vec::new(); + for (file, owner, root) in [(0, 10, 0), (1, 11, 3)] { + let source = resolved.syntax.files[file].source; + for (n, name, expression, role, field) in [ + ( + 1, + "x", + None, + R::ParameterDeclaration, + F::Input(a::ParameterId(id(1))), + ), + ( + 12, + "y", + None, + R::ParameterDeclaration, + F::Input(a::ParameterId(id(12))), + ), + (1, "x", Some(root + 1), R::ParameterUse, F::Body), + (12, "y", Some(root + 2), R::ParameterUse, F::Body), + ] { + let context = if expression.is_none() { + format!("input {name}") + } else { + format!("${name}") + }; + occurrences.push(lower::Occurrence { + file, + owner: id(owner), + field, + expression, + role, + target: T::Parameter { + calculation: id(owner), + parameter: a::ParameterId(id(n)), + }, + span: span( + source, + &context, + if expression.is_none() { 6 } else { 1 }, + 1, + ), + }); + } + } + for (context, offset, expression, role, target, field) in [ + ( + "subject @w", + 9, + None, + R::Reference, + T::Global(id(2)), + F::Subject, + ), + ( + "@f(y:", + 1, + Some(7), + R::CallTarget, + T::Global(id(10)), + F::Base, + ), + ( + "y: 2", + 0, + Some(7), + R::ArgumentLabel, + T::Parameter { + calculation: id(10), + parameter: a::ParameterId(id(12)), + }, + F::Base, + ), + ( + "x: @g", + 0, + Some(7), + R::ArgumentLabel, + T::Parameter { + calculation: id(10), + parameter: a::ParameterId(id(1)), + }, + F::Base, + ), + ( + "@g(x:", + 1, + Some(9), + R::CallTarget, + T::Global(id(11)), + F::Base, + ), + ( + "x: 3", + 0, + Some(9), + R::ArgumentLabel, + T::Parameter { + calculation: id(11), + parameter: a::ParameterId(id(1)), + }, + F::Base, + ), + ( + "y: 4", + 0, + Some(9), + R::ArgumentLabel, + T::Parameter { + calculation: id(11), + parameter: a::ParameterId(id(12)), + }, + F::Base, + ), + ( + "@f(x: 5", + 1, + Some(12), + R::CallTarget, + T::Global(id(10)), + F::Base, + ), + ( + "x: 5", + 0, + Some(12), + R::ArgumentLabel, + T::Parameter { + calculation: id(10), + parameter: a::ParameterId(id(1)), + }, + F::Base, + ), + ( + "y: 6", + 0, + Some(12), + R::ArgumentLabel, + T::Parameter { + calculation: id(10), + parameter: a::ParameterId(id(12)), + }, + F::Base, + ), + ] { + occurrences.push(lower::Occurrence { + file: 2, + owner: id(3), + field, + expression, + role, + target, + span: span(source, context, offset, 1), + }); + } + assert_eq!(resolved.occurrences, occurrences); + assert_eq!(resolved.expressions[0].span, resolved.expressions[3].span); + assert_ne!( + resolved.occurrences[0].target, + resolved.occurrences[4].target + ); +} + +#[test] +fn catalog_alias_first_entry_first_and_canonical_occurrence_owners() { + for reverse in [false, true] { + let ordinary = format!("oa_activity duplicate \"Ordinary\" id \"{}\" {{}}", id(2)); + let typed = format!( + "calculation duplicate \"Typed\" id \"{}\" {{ result Number; body 1; }}", + id(2) + ); + let (first, second) = if reverse { + (&typed, &ordinary) + } else { + (&ordinary, &typed) + }; + let bundle = single(&format!("{first}\n{second}")); + let failure = rejected(&bundle); + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Duplicate global alias." + ); + assert_eq!( + failure.diagnostics[0].diagnostic.span.start, + bundle.files["main.arcadia"].find(second).unwrap() + ); + assert_eq!( + failure.diagnostics[0].related[0].span, + span(&bundle.files["main.arcadia"], first, 0, first.len()) + ); + } + let mut bundle = single("include \"a.arcadia\";"); + bundle.files.insert( + "a.arcadia".into(), + format!( + "calculation p \"Conflict\" id \"{}\" {{ result Number; body 1; }}", + id(1) + ), + ); + let failure = rejected(&bundle); + assert_eq!(failure.diagnostics[0].path, "a.arcadia"); + assert_eq!(failure.diagnostics[0].related[0].path, "main.arcadia"); + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Duplicate global alias." + ); + bundle + .files + .get_mut("a.arcadia") + .unwrap() + .replace_range(12..13, "other"); + assert_eq!( + rejected(&bundle).diagnostics[0].diagnostic.message, + "Duplicate global identity." + ); +} + +#[test] +fn complete_static_architecture_and_unicode_warning_mapping() { + let mut bundle = bundle(Layer::Oa); + let source = bundle.files.get_mut("main.arcadia").unwrap(); + *source = format!("// 雪\r\n{}", source.replace('\n', "\r\n")); + let resolved = accepted(&bundle); + let warning = &resolved.warnings()[0].diagnostic; + assert_eq!(warning.line, 6); + assert_eq!(warning.column, 1); + let mut bad = bundle.clone(); + *bad.files.get_mut("main.arcadia").unwrap() = + bad.files["main.arcadia"].replace("base 1;", "base true + 1;"); + let failure = rejected(&bad); + assert_eq!(failure.diagnostics.len(), 1); // static short circuit omits architecture warning + let issue = &failure.diagnostics[0].diagnostic; + assert_eq!(issue.code, "analysis_type"); + assert_eq!(issue.object_id, Some(id(3))); + assert_eq!(issue.field.as_deref(), Some("base")); + assert_eq!( + issue.span, + span(&bad.files["main.arcadia"], "true + 1", 0, 8) + ); + let mut bad = bundle.clone(); + *bad.files.get_mut("main.arcadia").unwrap() = bad.files["main.arcadia"] + .replace("element @v;", "element @v; size { width 0; height 94; };"); + let failure = rejected(&bad); + assert!(failure.diagnostics.iter().any(|d| d.diagnostic.code == "invalid_bounds" && d.diagnostic.object_id == Some(id(4)))); + assert!( + failure + .diagnostics + .iter() + .any(|d| d.diagnostic.code == "unallocated_leaf") + ); +} + +#[test] +fn simultaneous_catalog_storage_is_not_individually_reset() { + let bundle = single(""); + // Ten raw tokens + one file slot + one syntax project =12 retained slots. + // Complete catalog: alias projection1, owner registry1, lower aliases1/IDs1. + // Each pair fits at14; their simultaneously live total requires16. + for (cap, expected) in [(15, false), (16, true)] { + let profile = super::super::super::budget::Limits { + entries: cap, + ..super::super::super::budget::Limits::source8(language::ParseLimits::UNLIMITED) + }; + let parsed = parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap(); + match resolve(parsed) { + Ok(value) => { + assert!(expected); + assert_eq!(value.storage.live.entries, 12); + } + Err(failure) => { + assert!(!expected); + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Source8 lowering entry limit exceeded." + ); + } + } + } +} + +#[test] +fn mapped_warning_path_copies_are_admitted_before_retention() { + let mut bundle = single(""); + let source = bundle + .files + .remove("main.arcadia") + .unwrap() + .replace("\"Project\"", "\"\""); + bundle.entry = format!("{}.arcadia", "a".repeat(180)); + bundle.files.insert(bundle.entry.clone(), source.clone()); + let roomy = accepted(&bundle); + assert_eq!(roomy.warnings().len(), 1); + assert_eq!(roomy.warnings()[0].diagnostic.code, "unnamed"); + assert_eq!(roomy.warnings()[0].path, bundle.entry); + let profile = super::super::super::budget::Limits { + bytes: source.len(), + ..super::super::super::budget::Limits::source8(language::ParseLimits::UNLIMITED) + }; + let parsed = parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap(); + let failure = match resolve(parsed) { + Ok(_) => panic!("truncated warnings or uncharged copied path"), + Err(e) => e, + }; + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Source8 index/output byte limit exceeded." + ); +} + +#[test] +fn ordinary_steps_add_once_to_inherited_typed_reference_budget() { + let body = format!( + "oa_process q \"Sequence\" id \"{}\" {{ steps [@o, @r]; }}\noa_occurrence o \"First\" id \"{}\" {{ parent @q; body {{ Control {{ kind Choice; }}; }}; }}\noa_property v \"Value\" id \"{}\" {{ subject @q; type Number; base 1; }}\noa_occurrence r \"Second\" id \"{}\" {{ parent @q; body {{ Control {{ kind Choice; }}; }}; }}", + id(2), + id(3), + id(4), + id(5) + ); + let bundle = single(&body); + let limits = ProjectLimits { + model: language::ParseLimits { + max_step_references: 3, + ..language::ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + let parsed = parse(&bundle, limits).unwrap(); + let before = parsed.budget.usage; + let numeric = parsed.budget.numeric_units(); + assert_eq!(before.references, 1); // property.subject; parent is excluded + let value = resolve(parsed).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!( + value.syntax.budget.usage, + super::super::super::budget::Usage { + references: 3, + ..before + } + ); + assert_eq!(value.syntax.budget.numeric_units(), numeric); + assert_eq!( + value.project.elements[&id(2)].steps().unwrap(), + &[id(3), id(5)] + ); + let tighter = ProjectLimits { + model: language::ParseLimits { + max_step_references: 2, + ..limits.model + }, + ..limits + }; + let failure = match resolve(parse(&bundle, tighter).unwrap()) { + Ok(_) => panic!("deduplicated or reset active references"), + Err(e) => e, + }; + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Source8 references limit exceeded." + ); + assert_eq!( + failure.diagnostics[0].diagnostic.span.start, + bundle.files["main.arcadia"].find("@o, @r").unwrap() + 4 + ); + let mut repeated = bundle.clone(); + *repeated.files.get_mut("main.arcadia").unwrap() = + repeated.files["main.arcadia"].replace("@o, @r", "@o, @o"); + let failure = match resolve(parse(&repeated, tighter).unwrap()) { + Ok(_) => panic!("repeated item was not reserved"), + Err(e) => e, + }; + assert_eq!( + failure.diagnostics[0].diagnostic.message, + "Source8 references limit exceeded." + ); +} + +#[test] +fn selector_counts_branch_and_context_chains_but_excludes_historical_and_scalars() { + // Intentionally malformed model shapes are permitted by syntax. Exercise + // the selector independently: it must not become a second shape decoder. + let bundle = single(&format!( + "oa_occurrence branch \"Branch\" id \"{}\" {{ parent @owner; body {{ Branch {{ occurrences [@x, @x]; guard nonsense; }}; }}; }}\ncontextual_link link \"Link\" id \"{}\" {{ owner @owner; source {{ references [@a, @a]; occurrence @x; }}; target {{ references [@b]; occurrence @y; }}; purpose {{ references [@excluded]; }}; }}\ntrace t \"Trace\" id \"{}\" {{ oa @x; sa @y; baseline {{ steps [@excluded]; references [@excluded]; }}; }}\noa_activity other \"Other\" id \"{}\" {{ parent @owner; references [@excluded]; }}", + id(2), + id(3), + id(4), + id(5) + )); + let mut parsed = parse(&bundle, ProjectLimits::default()).unwrap(); + assert_eq!(parsed.budget.usage.references, 0); + for d in &parsed.files[0].ordinary.declarations { + active_references(d, &mut parsed.budget).unwrap(); + } + assert_eq!(parsed.budget.usage.references, 5); // branch2 + source2 + target1 + let profile = super::super::super::budget::Limits { + references: 4, + ..super::super::super::budget::Limits::source8(language::ParseLimits::UNLIMITED) + }; + let mut tight = parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap(); + active_references(&tight.files[0].ordinary.declarations[0], &mut tight.budget).unwrap(); + let error = + active_references(&tight.files[0].ordinary.declarations[1], &mut tight.budget).unwrap_err(); + assert_eq!( + error.offset, + bundle.files["main.arcadia"].find("@b").unwrap() + ); + assert_eq!(error.message, "Source8 references limit exceeded."); +} + +#[test] +fn retained_nested_record_policy_cannot_be_replaced_at_resolution() { + let bundle = single(&format!( + "calculation c \"C\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body $x; }}", + id(2), + id(1) + )); + // Both policies admit the two declaration records. One scoped parameter + // and its body expression add two records at complete model admission:4. + let tight = ProjectLimits { + model: language::ParseLimits { + max_records: 3, + ..language::ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + let loose = ProjectLimits { + model: language::ParseLimits { + max_records: 4, + ..tight.model + }, + ..tight + }; + let first = parse(&bundle, tight).unwrap(); + let second = parse(&bundle, loose).unwrap(); + assert_eq!(first.budget.usage, second.budget.usage); + let failure = match resolve(first) { + Ok(_) => panic!("lost retained nested-record policy"), + Err(e) => e, + }; + assert_eq!(failure.diagnostics[0].diagnostic.code, "model_limit"); + let value = resolve(second).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!(value.syntax.limits.model.max_records, 4); + assert_eq!(value.project.definitions.len(), 1); +} + +#[test] +fn shared_ordinary_construction_preserves_legacy_scratch_and_existing_errors() { + let bundle = single(&format!("oa_activity w \"Work\" id \"{}\" {{}}", id(2))); + let parsed = parse(&bundle, ProjectLimits::default()).unwrap(); + let file = &parsed.files[0]; + let mut old = file.ordinary.header.as_ref().unwrap().project.clone(); + old.schema_version = 3; + let aliases = BTreeMap::from([("p".into(), id(1)), ("w".into(), id(2))]); + let migrated = language::resolve_declarations( + old, + &file.ordinary.declarations, + &aliases, + language::ParseLimits::UNLIMITED, + ) + .unwrap(); + assert_eq!(migrated.project.schema_version, SCHEMA_VERSION); + assert_eq!( + migrated.project.elements[&id(2)], + Element { + id: id(2), + alias: "w".into(), + name: "Work".into(), + description: String::new(), + parent: None, + body: ElementBody::Oa(OaBody::Activity) + } + ); + let scenario = single(&format!("oa_scenario q \"Scenario\" id \"{}\" {{}}", id(2))); + let value = accepted(&scenario); + assert!( + matches!(value.project.elements[&id(2)].shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::MixedLegacy, steps, .. }) if steps.is_empty()) + ); + let missing = single(&format!( + "node n id \"{}\" {{ diagram @d; }} diagram d \"D\" id \"{}\" {{}}", + id(2), + id(3) + )); + assert_eq!( + rejected(&missing).diagnostics[0].diagnostic.message, + "Missing required field 'element'." + ); + let conflict = single(&format!( + "oa_activity outer \"Outer\" id \"{}\" {{ oa_activity child \"Child\" id \"{}\" {{ parent none; }} }}", + id(2), + id(3) + )); + assert_eq!( + rejected(&conflict).diagnostics[0].diagnostic.code, + "conflicting_parent" + ); +} + +#[test] +fn inherited_storage_failure_and_success_drop_deep_syntax_on_128k_stack() { + let run = || { + let expression = format!("{}1{}", "abs(".repeat(127), ")".repeat(127)); + let bundle = single(&format!( + "calculation c \"C\" id \"{}\" {{ result Number; body {expression}; }}", + id(2) + )); + let value = accepted(&bundle); + assert_eq!(value.expressions.len(), 128); + drop(value); + let profile = super::super::super::budget::Limits { + entries: 1000, + ..super::super::super::budget::Limits::source8(language::ParseLimits::UNLIMITED) + }; + let parsed = parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap(); + match resolve(parsed) { + Ok(_) => panic!("deep live lower reservation unexpectedly fit"), + Err(e) => assert_eq!( + e.diagnostics[0].diagnostic.message, + "Source8 lowering entry limit exceeded." + ), + } + }; + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(run) + .unwrap() + .join() + .unwrap(); +} + +#[test] +fn scoped_parameter_faults_keep_real_file_and_unknown_fields_use_owner_fallback() { + let mut bundle = single("include \"a.arcadia\"; include \"b.arcadia\";"); + for (file, alias, n) in [("a.arcadia", "f", 10), ("b.arcadia", "g", 11)] { + bundle.files.insert(file.into(), format!("calculation {alias} \"F\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body $missing; }}", id(n), id(1))); + } + let parsed = parse(&bundle, ProjectLimits::default()).unwrap(); + for file in 0..2 { + let declaration = &parsed.files[file].typed[0]; + let owner = Owner { + file, + kind: OwnerKind::Typed(0), + span: declaration.span, + }; + for field in [ + "parameters".into(), + format!("parameters.{}.name", id(1)), + format!("unrelated.{}.type", id(1)), + ] { + assert_eq!( + field_span(&parsed.files[file], owner, Some(&field)), + declaration.span + ); + } + assert_eq!( + field_span(&parsed.files[file], owner, Some("body")), + span(parsed.files[file].source, "$missing", 0, 8) + ); + } + let failure = match resolve(parsed) { + Ok(_) => panic!("unknown scoped parameter"), + Err(e) => e, + }; + assert_eq!(failure.diagnostics[0].path, "a.arcadia"); + assert_eq!( + failure.diagnostics[0].diagnostic.span, + span(&bundle.files["a.arcadia"], "$missing", 1, 7) + ); +} + +#[test] +fn incomplete_call_borrows_large_signature_and_refuses_at_single_admission() { + let inputs = (0..40) + .map(|n| format!("input p{n} id \"{}\" : Number;", id(100 + n))) + .collect::>() + .join("\n"); + let bundle = single(&format!( + "calculation f \"F\" id \"{}\" {{ {inputs} result Number; body $p0; }}\ncalculation g \"G\" id \"{}\" {{ result Number; body @f(p0: 1); }}", + id(10), + id(11) + )); + let parsed = parse(&bundle, ProjectLimits::default()).unwrap(); + let mut storage = Ledger::default(); + let counted = accounting::syntax(&parsed, &mut storage).unwrap(); + assert_eq!(counted.arguments, 1); + assert_eq!(counted.nodes, 3); // f's $p0; g's call and literal + assert_eq!(counted.occurrences, 43); // forty declarations, use, target, label + let failure = match resolve(parsed) { + Ok(_) => panic!("incomplete call admitted"), + Err(e) => e, + }; + assert_eq!(failure.diagnostics.len(), 1); + assert_eq!(failure.diagnostics[0].diagnostic.code, "analysis_arguments"); + assert_eq!(failure.diagnostics[0].diagnostic.object_id, Some(id(11))); +} diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 8b96c88c..b5c878ab 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -180,14 +180,96 @@ fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) { } pub fn validate(p: &Project) -> Vec { + validate_with_usage(p).0 +} + +/// Runs the shared static/architectural route once and retains successful static +/// metadata without retaining a Project-borrowing environment. Architectural +/// issue production is outside the static meter and any caller auxiliary ledger; +/// callers must reserve separately before mapping or retaining these issues. +pub(crate) fn validate_with_usage( + p: &Project, +) -> ( + Vec, + Option<( + crate::analysis::AdmissionLimits, + crate::analysis::AdmissionUsage, + )>, +) { // Bound analysis trees before architectural visitors can clone an element. // Admission itself never enters this public validator recursively. - if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) { - let mut out = Vec::new(); - error(&mut out, failure.object, &failure.code, &failure.field, failure.message); - return out; + let environment = match crate::analysis::admit_structure_checked( + p, + crate::analysis::AdmissionLimits::REFERENCE, + ) { + Ok(environment) => environment, + Err(failure) => { + let mut out = Vec::new(); + error( + &mut out, + failure.object, + &failure.code, + &failure.field, + failure.message, + ); + return (out, None); + } + }; + let metadata = ( + environment.admission_limits(), + environment.admission_usage(), + ); + drop(environment); + (validate_structure(p), Some(metadata)) +} + +#[cfg(test)] +mod shared_admission_tests { + use super::*; + + #[test] + fn successful_static_metadata_survives_architectural_warnings() { + let mut project = Project::empty(); + project.name.clear(); + let (issues, metadata) = validate_with_usage(&project); + let (limits, usage) = metadata.expect("static admission succeeds"); + assert_eq!(limits, crate::analysis::AdmissionLimits::REFERENCE); + assert!(usage.static_events > 0); + assert_eq!(usage.stored_expression_nodes, 0); + assert!(issues.iter().any(|issue| issue.code == "unnamed" + && issue.element == Some(project.id) + && issue.severity == Severity::Warning)); + } + + #[test] + fn static_failure_returns_no_metadata_and_skips_architectural_warnings() { + use crate::analysis::{ + AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, ScalarType, + }; + let mut project = Project::empty(); + project.name.clear(); + let key = Id::from_u128(10); + project.definitions.insert( + key, + Definition { + id: Id::from_u128(11), + alias: "f".into(), + name: "F".into(), + description: String::new(), + body: DefinitionBody::Calculation(CalculationDefinition { + parameters: Vec::new(), + result: AnalysisType::Scalar(ScalarType::Number), + body: Binding::Unbound, + }), + }, + ); + let (issues, metadata) = validate_with_usage(&project); + assert!(metadata.is_none()); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].severity, Severity::Error); + assert_eq!(issues[0].element, Some(key)); + assert_eq!(issues[0].code, "identity_mismatch"); } - validate_structure(p) } /// Architecture/header validation without recursively re-entering analysis admission. diff --git a/harness/analysis-mixed-project-implementation-20260923.md b/harness/analysis-mixed-project-implementation-20260923.md new file mode 100644 index 00000000..9a68b47f --- /dev/null +++ b/harness/analysis-mixed-project-implementation-20260923.md @@ -0,0 +1,36 @@ +# Private mixed Project implementation + +Source-only checkpoint; compilation and runtime acceptance are pending. Base is `a578ac98a06772a60bec00ae287e65650680d119`. The accepted contract is `aecee2aff3f4410648a501a70cb2e6160c81adc5:harness/analysis-mixed-project-contract-20260923.md`; this note records implementation choices, not a replacement specification. + +`mixed::project::resolve` consumes the actual complete syntax witness. The sealed result retains Project, warnings, static admission metadata, original syntax/Budget/ProjectLimits/bundle, unchanged expression/occurrence vectors and their canonical file keys. Only Project/warnings are exposed read-only. The empty Project header is copied into model ownership; the original header remains in syntax. No declarations, typed models, tokens or Exact values are reparsed to establish authority. + +The catalog seeds the entry first and merges ordinary/typed headers by original declaration start within canonical file order. It checks alias before global ID, retaining both occurrence owners on collision. Existing typed-only catalog precedence remains unchanged. Parameter names/IDs remain calculation scoped. Ordinary construction is the shared existing loop with a pre-field hook; its legacy wrapper preserves source3 scratch and all migration/admission tails. Typed lowering shares the existing body resolver over borrowed declarations and one complete catalog. Moved model records must be disjoint before insertion. + +## Live auxiliary storage + +Every reservation uses checked arithmetic and tests the aggregate through the inherited Budget's noncharging entry, occurrence and owned-byte checks. No new Budget or reset occurs. Failure aborts the phase. Logical slots are not allocator capacity/RSS. + +| Representation | Reservation and end of lifetime | +| --- | --- | +| Original syntax | Retained mixed metadata subtotal plus parsed syntax entries. Includes the existing conservative dropped value-end subtotal. Tokens/positions are not counted a second time. An iterative walk counts separately owned strings, with each counting-stack push reserved first. Source bytes stay separately charged once. Baseline remains live. | +| Complete catalog | Lower aliases+global ID set: two entries/global and owned alias bytes. Scoped parameter map: one entry/calculation plus one entry/name and copied name bytes. Per-signature temporary ID set reserves one entry/parameter and releases after `Tables::typed` returns. | +| Alias projection and diagnostic owners | One entry+copied alias bytes/global for projection; one entry/global for owner registry. Projection drops after construction, owner registry after diagnostic mapping. Both overlap the lower catalog initially. | +| Ordinary coordination | One borrowed `(file,declaration)` entry/ordinary occurrence. One deferred-node entry before that declaration's field cloning. Release after construction/attachment returns and the views drop. | +| Typed lowering | Bounded syntax walk counts N expressions, A supplied call arguments, O emitted occurrences. Reserve `5N+2A` entries and O occurrences before lowering. N entries are retained expression sites. Temporaries are bounded by 2N Visit/Build slots, N borrowed child slots, N value/split slots, A call-parameter slots and A seen-set slots. Hold this conservative whole-phase allowance until all Resolver temporaries die; then release only the difference from actual retained global sites. | +| Mapped diagnostics | Before retention, reserve one output slot, copied canonical path bytes and transferred Issue code/field/message String bytes. No line cache is allocated. Owner registry and syntax/sites remain live during mapping. Failure cannot produce truncated-warning success. | + +The call signature is borrowed from the already accounted catalog; no lowerer allocation grows with unprovided callee parameters. Unknown/incomplete calls cannot exceed the supplied-argument bound. O includes parameter declarations, scalar-type references, non-null optional references, call targets/labels and expression reference forms; headers and display text count only as text. Failed lowering drops all partial output with its reservation. + +Model/serde allocations and architectural validation's raw Issue vector/strings/indexes remain explicitly outside this preallocation ledger. Model limits are checked on the complete Project; static analysis retains its existing independent meter. Raw Issues are never retained uncharged in an accepted result. Rejection diagnostics are small error transport outside accepted-witness storage, so exhaustion itself remains reportable. + +The ordinary hook selects exactly Process/FunctionalChain/Scenario steps, Occurrence Branch occurrences, and ContextualLink source then target reference arrays before field resolution/cloning. Repeated items count separately; scalar parent/owner, endpoint occurrence, unrelated fields and every Trace baseline snapshot are excluded. Recognizable malformed arrays are charged before the unchanged decoder reports their shape errors. + +## Admission, mapping and verification + +`validation::validate_with_usage` performs one static admission, copies owned usage/limits, drops the borrowing environment, then performs one architecture validation. `validate` uses the same route and returns its Issues. Static failure preserves the original short circuit. The mixed path maps all Issues and refuses any error. Known typed fields map precisely; unsupported semantic field paths, including the current aggregate `parameters`, explicitly fall back to their owner. Exact scoped-parameter lowerer failures retain their original file and full span; UUID substrings never manufacture precision. + +Authored tests cover whole OA/SA models and exact warnings; a complete 15-expression/18-occurrence arena oracle with repeated/reordered calls, scoped UUID reuse and coincident file offsets; catalog collisions; ordinary reference selectors/cumulative limits; simultaneous storage and diagnostic-path copying; inherited nested records; legacy scratch/default/errors; static/architecture refusal; parameter diagnostic fallback; large-signature incomplete calls; and 128 KiB success/rejection cleanup. The full model/site oracle was independently designed before implementation. Tests are unexecuted at this checkpoint. + +Required later gates: Windows listing, focused new Project tests, complete private typed tests (including lower/index/update and small-stack controls), existing public parser/traversal/migration regressions with preserved baseline disposition, and strict all-target core Clippy. The known inherited formatting/full-suite failures must remain explicit. Source formatting is a parsing check, not typechecking or runtime evidence. + +Index construction/verification, ordinary occurrence indexing, old-updater access, public source8 activation, stabilization, mixed edit composition, output recompile/admission, intended-Project equality and root Scenario/editor reconciliation remain deferred. `index.rs` and `update.rs` are unchanged.