Add private complete mixed Project resolution with retained provenance

This commit is contained in:
2026-09-23 11:15:41 -07:00
parent a578ac98a0
commit 8f0d96ca63
8 changed files with 2135 additions and 32 deletions
+25 -5
View File
@@ -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<String, Value>,
}
fn construct_declarations<'a>(
mut project: Project,
declarations: &[Declaration],
declarations: impl IntoIterator<Item = &'a Declaration>,
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
mut before_fields: impl FnMut(&Declaration) -> Result<(), Error>,
) -> Result<ConstructedDeclarations, (usize, Error)> {
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<String, Id>,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
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))
@@ -124,7 +124,7 @@ struct Symbol {
}
#[derive(Default)]
struct Tables {
pub(super) struct Tables {
aliases: BTreeMap<String, Symbol>,
ids: BTreeSet<Id>,
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
@@ -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<Item = (usize, &'a Declaration)>,
budget: &Budget,
) -> Result<Lowered, Failure> {
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)
}
@@ -41,6 +41,9 @@ pub(super) struct ParsedMixedSyntax<'s> {
limits: ProjectLimits,
files: Vec<FileSyntax<'s>>,
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;
@@ -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<SourceDiagnostic>,
static_limits: analysis::AdmissionLimits,
static_usage: analysis::AdmissionUsage,
expressions: Vec<lower::ExpressionSite>,
occurrences: Vec<lower::Occurrence>,
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<SourceDiagnostic>,
}
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<usize>, 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<usize>, 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<String, Id>,
owners: BTreeMap<Id, Owner>,
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<Catalog, Failure> {
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<ResolvedMixedProject<'_>, 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<Span> {
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;
@@ -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<Self, super::super::super::Error> {
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<usize, super::super::super::Error> {
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<usize, super::super::super::Error> {
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<Slots, TypedError> {
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<Item<'a>>,
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<d::Field<Option<Reference>>>) -> 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<LowerStorage, Failure> {
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)
}
@@ -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::<Vec<_>>(),
["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::<Vec<_>>()
.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)));
}
+87 -5
View File
@@ -180,14 +180,96 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
}
pub fn validate(p: &Project) -> Vec<Issue> {
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<Issue>,
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.