Compare commits

...
8 changed files with 2217 additions and 32 deletions
+30 -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,23 @@ 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)
}
File diff suppressed because it is too large Load Diff
+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.
@@ -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.