From d11b99bb4ab1c817951557551db3a3c7cbd5c425 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 17:20:46 -0700 Subject: [PATCH] Carry retained source ownership through private parser phase checks --- .../src/language/analysis_source/budget.rs | 4 + .../src/language/analysis_source/mixed.rs | 46 +- .../language/analysis_source/mixed/carried.rs | 121 ++++ .../analysis_source/mixed/carried/tests.rs | 655 ++++++++++++++++++ .../language/analysis_source/mixed/project.rs | 11 +- ...s-carried-phase-implementation-20260925.md | 21 + 6 files changed, 850 insertions(+), 8 deletions(-) create mode 100644 crates/oa-core/src/language/analysis_source/mixed/carried.rs create mode 100644 crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs create mode 100644 harness/analysis-carried-phase-implementation-20260925.md diff --git a/crates/oa-core/src/language/analysis_source/budget.rs b/crates/oa-core/src/language/analysis_source/budget.rs index fef55596..c0cf1124 100644 --- a/crates/oa-core/src/language/analysis_source/budget.rs +++ b/crates/oa-core/src/language/analysis_source/budget.rs @@ -64,6 +64,10 @@ impl Budget { failed: None, }) } + /// Immutable exact policy snapshot for private deliberate-snapshot continuation. + pub(super) fn limits(&self) -> Limits { + self.limits + } pub fn active(&self) -> Result<(), Error> { self.failed.clone().map_or(Ok(()), Err) } diff --git a/crates/oa-core/src/language/analysis_source/mixed.rs b/crates/oa-core/src/language/analysis_source/mixed.rs index b092d775..c59cdb5d 100644 --- a/crates/oa-core/src/language/analysis_source/mixed.rs +++ b/crates/oa-core/src/language/analysis_source/mixed.rs @@ -27,6 +27,8 @@ use crate::language::{ use crate::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic, traversal::Traversal}; use std::collections::BTreeMap; +mod carried; + struct ModelCounts { limits: ProjectLimits, graph: usize, @@ -44,6 +46,8 @@ pub(super) struct ParsedMixedSyntax<'s> { // Includes the conservative dropped value-end subtotal. The semantic // phase adds syntax entries to this once, not tokens/positions again. metadata: usize, + // Kept through consumed resolution/indexing; fixes the live backing owner. + carried: Option>, } struct FileSyntax<'s> { @@ -246,6 +250,15 @@ fn parse_with_profile( limits: ProjectLimits, profile: Limits, ) -> Result, Vec> { + parse_impl(bundle, limits, profile, None) +} + +fn parse_impl<'s>( + bundle: &'s SourceBundle, + limits: ProjectLimits, + profile: Limits, + carried: Option>, +) -> Result, Vec> { let entry_source = bundle.files.get(&bundle.entry).map_or("", String::as_str); let at_entry = |error| located(&bundle.entry, entry_source, ordinary_error(error)); let maximum = Limits::source8(ParseLimits { @@ -266,6 +279,9 @@ fn parse_with_profile( ))); } let mut budget = Budget::new(profile).map_err(at_entry)?; + if let Some(input) = &carried { + input.check(&budget, 0, 0).map_err(at_entry)?; + } let bytes = bundle .files .values() @@ -275,9 +291,13 @@ fn parse_with_profile( .index_bytes(bytes, Span::default()) .map_err(at_entry)?; let mut metadata = bundle.files.len(); - budget - .lowering_entries(metadata, Span::default()) - .map_err(at_entry)?; + if let Some(input) = &carried { + input.check(&budget, 0, metadata).map_err(at_entry)?; + } else { + budget + .lowering_entries(metadata, Span::default()) + .map_err(at_entry)?; + } // Tighter private byte/file-slot limits precede folded-path table and // traversal allocations. Public Traversal's own validation order is intact. let mut traversal = Traversal::new(bundle, limits)?; @@ -325,10 +345,16 @@ fn parse_with_profile( .and_then(|n| n.checked_add(projection_slots)) .and_then(|n| n.checked_add(state.lexical_owners.len())) .ok_or_else(|| failure(super::error(Span::default(), "Metadata size overflow.")))?; - state - .budget - .lowering_entries(metadata, Span::default()) - .map_err(failure)?; + if let Some(input) = &carried { + input + .check(state.budget, state.budget.usage.entries, metadata) + .map_err(failure)?; + } else { + state + .budget + .lowering_entries(metadata, Span::default()) + .map_err(failure)?; + } let positions = language::edit_positions_from_tokens( source, &ordinary, @@ -367,12 +393,18 @@ fn parse_with_profile( } traversal.finish()?; budget.active().map_err(at_entry)?; + if let Some(input) = &carried { + input + .check(&budget, budget.usage.entries, metadata) + .map_err(at_entry)?; + } Ok(ParsedMixedSyntax { bundle, limits, files: parsed.into_values().collect(), budget, metadata, + carried, }) } diff --git a/crates/oa-core/src/language/analysis_source/mixed/carried.rs b/crates/oa-core/src/language/analysis_source/mixed/carried.rs new file mode 100644 index 00000000..bbc04821 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/carried.rs @@ -0,0 +1,121 @@ +//! Fixture-constructible carried phase authority, not an emitted-output result. +//! Checks existing logical phase totals; does not meter every parser allocation. +use super::*; +use project::accounting::{Ledger, Slots}; + +struct SnapshotPolicy { + limits: Limits, + project_limits: ProjectLimits, +} + +/// The allocation-owning bundle guard. No production constructor exists yet. +struct RetainedSnapshot { + bundle: SourceBundle, + policy: SnapshotPolicy, + retained: Slots, +} +enum SnapshotBacking<'s> { + Borrowed { + bundle: &'s SourceBundle, + policy: SnapshotPolicy, + }, + Retained(&'s RetainedSnapshot), +} + +/// Neither Clone nor Copy: fixes the bundle, policy and retained allocation owner. +pub(super) struct CarriedInput<'s> { + backing: SnapshotBacking<'s>, +} +impl<'s> CarriedInput<'s> { + fn policy(&self) -> &SnapshotPolicy { + match &self.backing { + SnapshotBacking::Borrowed { policy, .. } => policy, + SnapshotBacking::Retained(owner) => &owner.policy, + } + } + fn bundle(&self) -> &'s SourceBundle { + match &self.backing { + SnapshotBacking::Borrowed { bundle, .. } => bundle, + SnapshotBacking::Retained(owner) => &owner.bundle, + } + } + pub(super) fn slots(&self) -> Slots { + match &self.backing { + SnapshotBacking::Borrowed { .. } => Slots::default(), + SnapshotBacking::Retained(owner) => owner.retained, + } + } + pub(super) fn check( + &self, + budget: &Budget, + syntax: usize, + metadata: usize, + ) -> Result<(), super::super::Error> { + let at = Span::default(); + let total = self + .slots() + .add(Slots::entries(syntax), at)? + .add(Slots::entries(metadata), at)?; + // A local check, not another reservation retained in the syntax owner. + Ledger::default().reserve(budget, total, at) + } +} + +/// Input already binds the actual bundle; callers cannot supply a replacement. +fn parse_carried(input: CarriedInput<'_>) -> Result, Vec> { + let bundle = input.bundle(); + let policy = input.policy(); + parse_impl(bundle, policy.project_limits, policy.limits, Some(input)) +} + +#[cfg(test)] +impl SnapshotPolicy { + fn captured(syntax: &ParsedMixedSyntax<'_>) -> Self { + Self { + limits: syntax.budget.limits(), + project_limits: syntax.limits, + } + } +} +#[cfg(test)] +impl RetainedSnapshot { + fn fixture(bundle: SourceBundle, policy: SnapshotPolicy) -> Result { + let at = Span::default(); + let mut text = bundle.entry.len(); + for (path, source) in &bundle.files { + text = Slots::text(text) + .add(Slots::text(path.len()), at)? + .add(Slots::text(source.len()), at)? + .text; + } + let retained = Slots { + entries: bundle.files.len(), + occurrences: 0, + text, + }; + Ok(Self { + bundle, + policy, + retained, + }) + } + fn input(&self) -> CarriedInput<'_> { + CarriedInput { + backing: SnapshotBacking::Retained(self), + } + } +} +#[cfg(test)] +impl<'s> CarriedInput<'s> { + fn borrowed(bundle: &'s SourceBundle, syntax: &ParsedMixedSyntax<'_>) -> Self { + Self { + backing: SnapshotBacking::Borrowed { + bundle, + policy: SnapshotPolicy::captured(syntax), + }, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs b/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs new file mode 100644 index 00000000..72516aaf --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs @@ -0,0 +1,655 @@ +use super::*; +use crate::language::analysis_source::budget::Usage; +fn doc(body: &str) -> String { + format!( + "arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}\n" + ) +} +fn empty() -> SourceBundle { + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([("main.arcadia".into(), doc(""))]), + } +} +fn chain() -> SourceBundle { + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([ + ("main.arcadia".into(), doc("include \"part.arcadia\";")), + ( + "part.arcadia".into(), + "include \"last.arcadia\";\r\n".into(), + ), + ("last.arcadia".into(), "//é\r\n".into()), + ]), + } +} +fn profile(entries: usize, bytes: usize) -> Limits { + Limits { + entries, + bytes, + ..Limits::source8(ParseLimits::UNLIMITED) + } +} +// Fixture-only policies may deliberately be too tight to admit the target. +// These are validated profiles, not a production old-owner transition. +fn fixture_policy(limits: Limits, project_limits: ProjectLimits) -> SnapshotPolicy { + SnapshotPolicy { + limits: Budget::new(limits).unwrap().limits(), + project_limits, + } +} +fn owner(bundle: SourceBundle, limits: Limits) -> RetainedSnapshot { + RetainedSnapshot::fixture(bundle, fixture_policy(limits, ProjectLimits::default())).unwrap() +} +fn parse_failure(result: Result, Vec>) -> SourceDiagnostic { + let errors = match result { + Ok(_) => panic!("unexpected syntax success"), + Err(e) => e, + }; + assert_eq!(errors.len(), 1); + errors.into_iter().next().unwrap() +} +fn resolve_failure(syntax: ParsedMixedSyntax<'_>) -> SourceDiagnostic { + let failure = match project::resolve(syntax) { + Ok(_) => panic!("unexpected resolution success"), + Err(e) => e, + }; + assert_eq!(failure.diagnostics.len(), 1); + failure.diagnostics.into_iter().next().unwrap() +} +fn diagnostic(d: &SourceDiagnostic, path: &str, start: usize, end: usize, message: &str) { + assert_eq!(d.path, path); + assert_eq!(d.diagnostic.code, "invalid_source"); + assert_eq!(d.diagnostic.span, Span { start, end }); + assert_eq!(d.diagnostic.message, message); + assert!(d.related.is_empty()); +} +fn expected_project() -> crate::Project { + crate::Project { + schema_version: 8, + id: Id::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + alias: "p".into(), + name: "Project".into(), + description: String::new(), + definitions: BTreeMap::new(), + elements: BTreeMap::new(), + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + } +} +#[test] +fn real_retained_bundles_keep_exact_guard_work_and_once_only_resolved_baseline() { + for (bundle, c, u, m, live, entries, bytes) in [ + ( + empty(), + Slots { + entries: 1, + occurrences: 0, + text: 102, + }, + Usage { + bytes: 78, + tokens: 19, + entries: 1, + references: 0, + deepest_frame: 0, + }, + 11, + Slots { + entries: 13, + occurrences: 0, + text: 110, + }, + 17, + 112, + ), + ( + chain(), + Slots { + entries: 3, + occurrences: 0, + text: 180, + }, + Usage { + bytes: 132, + tokens: 31, + entries: 3, + references: 0, + deepest_frame: 0, + }, + 19, + Slots { + entries: 25, + occurrences: 0, + text: 212, + }, + 29, + 214, + ), + ] { + let owner = owner(bundle, profile(entries, bytes)); + assert_eq!(owner.retained, c); + let original = owner.bundle.clone(); + let parsed = parse_carried(owner.input()).unwrap(); + assert_eq!(parsed.metadata, m); + assert_eq!(parsed.budget.usage, u); + assert_eq!(parsed.budget.numeric_units(), 0); + assert!(std::ptr::eq(parsed.bundle, &owner.bundle)); + assert!( + matches!(&parsed.carried.as_ref().unwrap().backing,SnapshotBacking::Retained(retained) if std::ptr::eq(*retained,&owner)) + ); + let resolved = project::resolve(parsed).unwrap(); + assert_eq!(resolved.project(), &expected_project()); + assert!(resolved.warnings().is_empty()); + let (syntax, stored) = resolved.phase_state(); + assert_eq!(stored, live); + assert_eq!(syntax.budget.usage, u); + assert_eq!(syntax.carried.as_ref().unwrap().slots(), c); + assert_eq!( + syntax.files.iter().map(|f| f.path).collect::>(), + original + .files + .keys() + .map(String::as_str) + .collect::>() + ); + drop(resolved); + assert_eq!(owner.bundle, original); + } +} +#[test] +fn independent_combined_syntax_caps_and_canonical_failure_files() { + for (bundle, cap, path) in [(empty(), 13, "main.arcadia"), (chain(), 25, "part.arcadia")] { + let pass = owner(bundle.clone(), profile(cap, 1024)); + drop(parse_carried(pass.input()).unwrap()); + let fail = owner(bundle, profile(cap - 1, 1024)); + let d = parse_failure(parse_carried(fail.input())); + diagnostic(&d, path, 0, 1, "Source8 lowering entry limit exceeded."); + assert_eq!((d.diagnostic.line, d.diagnostic.column), (1, 1)); + } + let owner = owner(chain(), profile(20, 1024)); + diagnostic( + &parse_failure(parse_carried(owner.input())), + "main.arcadia", + 0, + 1, + "Source8 lowering entry limit exceeded.", + ); +} +#[test] +fn legacy_zero_timing_is_preserved_but_explicit_zero_carry_still_combines() { + let bundle = empty(); + let original = + parse_with_profile(&bundle, ProjectLimits::default(), profile(11, 1024)).unwrap(); + diagnostic( + &parse_failure(parse_carried(CarriedInput::borrowed(&bundle, &original))), + "main.arcadia", + 0, + 1, + "Source8 lowering entry limit exceeded.", + ); + diagnostic( + &resolve_failure(original), + "main.arcadia", + 0, + 0, + "Source8 lowering entry limit exceeded.", + ); + let original = + parse_with_profile(&bundle, ProjectLimits::default(), profile(12, 1024)).unwrap(); + let carried = parse_carried(CarriedInput::borrowed(&bundle, &original)).unwrap(); + assert_eq!(carried.budget.usage, original.budget.usage); + assert_eq!(carried.metadata, 11); +} +#[test] +fn resolver_entry_and_catalog_text_caps_include_c_once_after_parser_success() { + for (bundle, entries, bytes) in [(empty(), 17, 112), (chain(), 29, 214)] { + for limits in [profile(entries - 1, bytes), profile(entries, bytes - 1)] { + let held = owner(bundle.clone(), limits); + let parsed = parse_carried(held.input()).unwrap(); + diagnostic( + &resolve_failure(parsed), + "main.arcadia", + 11, + 73, + if limits.entries == entries - 1 { + "Source8 lowering entry limit exceeded." + } else { + "Source8 index/output byte limit exceeded." + }, + ); + drop( + project::resolve( + parse_with_profile(&bundle, ProjectLimits::default(), limits).unwrap(), + ) + .unwrap(), + ); + } + } +} +#[test] +fn syntax_text_accounting_is_after_decoding_and_has_precise_original_location() { + for (bundle, bytes, path, start, end) in [ + (empty(), 109, "main.arcadia", 11, 73), + (chain(), 211, "part.arcadia", 0, 23), + ] { + let held = owner(bundle, profile(100, bytes)); + let parsed = parse_carried(held.input()).unwrap(); + assert_eq!( + parsed + .files + .iter() + .find_map(|f| f.ordinary.header.as_ref()) + .unwrap() + .project + .name, + "Project" + ); + diagnostic( + &resolve_failure(parsed), + path, + start, + end, + "Source8 index/output byte limit exceeded.", + ); + } +} +#[test] +fn initial_c_and_file_slots_are_checked_before_traversal() { + for (bundle, cap) in [(empty(), 1), (chain(), 5)] { + let held = owner(bundle, profile(cap, 1024)); + diagnostic( + &parse_failure(parse_carried(held.input())), + "main.arcadia", + 0, + 1, + "Source8 lowering entry limit exceeded.", + ); + } + for (cap, code) in [(1, "invalid_source"), (2, "project_limit")] { + let held = RetainedSnapshot::fixture( + empty(), + fixture_policy( + profile(cap, 1024), + ProjectLimits { + max_files: 0, + ..ProjectLimits::default() + }, + ), + ) + .unwrap(); + assert_eq!( + parse_failure(parse_carried(held.input())).diagnostic.code, + code + ); + } + let held = owner(empty(), profile(100, 101)); + diagnostic( + &parse_failure(parse_carried(held.input())), + "main.arcadia", + 0, + 1, + "Source8 index/output byte limit exceeded.", + ); + let held = owner(empty(), profile(100, 102)); + drop(parse_carried(held.input()).unwrap()); +} +#[test] +fn explicit_fault_injections_cover_occurrence_caps_and_unreachable_overflows() { + let mut held = owner(empty(), profile(100, 1024)); + let maximum = held.policy.limits.entries + held.policy.limits.references; + // Not real bundle ownership: only a fault-injected dimension check. + held.retained.occurrences = maximum; + drop(parse_carried(held.input()).unwrap()); + held.retained.occurrences = maximum + 1; + diagnostic( + &parse_failure(parse_carried(held.input())), + "main.arcadia", + 0, + 1, + "Source8 lowering occurrence limit exceeded.", + ); + held.retained = Slots { + entries: usize::MAX, + occurrences: 0, + text: 0, + }; + let budget = Budget::new(profile(100, 1024)).unwrap(); + // Valid hard limits reject C first: direct injection reaches arithmetic. + assert_eq!( + held.input().check(&budget, 0, 1).unwrap_err().message, + "Mixed auxiliary storage size overflow." + ); + held.retained = Slots::default(); + assert_eq!( + held.input() + .check(&budget, usize::MAX, 1) + .unwrap_err() + .message, + "Mixed auxiliary storage size overflow." + ); + assert_eq!( + Slots::text(usize::MAX) + .add(Slots::text(1), Span::default()) + .unwrap_err() + .message, + "Mixed auxiliary storage size overflow." + ); + let mut ledger = Ledger { + live: Slots::entries(usize::MAX), + }; + assert_eq!( + ledger + .reserve(&budget, Slots::entries(1), Span::default()) + .unwrap_err() + .message, + "Mixed auxiliary storage size overflow." + ); +} + +fn same_policy(syntax: &ParsedMixedSyntax<'_>, policy: &SnapshotPolicy) { + let a = syntax.budget.limits(); + let b = policy.limits; + assert_eq!( + [a.bytes, a.tokens, a.entries, a.references, a.frames], + [b.bytes, b.tokens, b.entries, b.references, b.frames] + ); + assert_eq!(a.numeric_units, b.numeric_units); + let a = syntax.limits; + let b = policy.project_limits; + assert_eq!( + [ + a.max_files, + a.max_include_depth, + a.max_include_edges, + a.model.max_source_bytes, + a.model.max_graph_objects, + a.model.max_records, + a.model.max_step_references + ], + [ + b.max_files, + b.max_include_depth, + b.max_include_edges, + b.model.max_source_bytes, + b.model.max_graph_objects, + b.model.max_records, + b.model.max_step_references + ] + ); +} +#[test] +fn exact_policy_capture_preserves_every_field_and_starts_fresh_snapshot_work() { + let base = empty(); + let next = chain(); + let limits = Limits { + bytes: 4096, + tokens: 2048, + entries: 1024, + references: 128, + frames: 64, + numeric_units: 4096, + }; + let caller = ProjectLimits { + max_files: 7, + max_include_depth: 5, + max_include_edges: 9, + model: ParseLimits { + max_source_bytes: 8192, + max_graph_objects: 10, + max_records: 30, + max_step_references: 256, + }, + }; + let seed = parse_with_profile(&base, caller, limits).unwrap(); + assert_eq!(seed.budget.usage.bytes, 78); + let input = CarriedInput::borrowed(&next, &seed); + let captured = SnapshotPolicy::captured(&seed); + drop(seed); + let parsed = parse_carried(input).unwrap(); + same_policy(&parsed, &captured); + assert_eq!( + parsed.budget.usage, + Usage { + bytes: 132, + tokens: 31, + entries: 3, + references: 0, + deepest_frame: 0 + } + ); + assert_eq!(parsed.budget.numeric_units(), 0); + let resolved = project::resolve(parsed).unwrap(); + same_policy(resolved.phase_state().0, &captured); +} +#[test] +fn inherited_byte_token_and_traversal_limits_reach_the_real_parser() { + let seed_bundle = empty(); + let bundle = chain(); + for (bytes, tokens, success) in [(132, 31, true), (131, 31, false), (132, 30, false)] { + let limits = Limits { + tokens, + ..profile(100, bytes) + }; + let seed = parse_with_profile(&seed_bundle, ProjectLimits::default(), limits).unwrap(); + let result = parse_carried(CarriedInput::borrowed(&bundle, &seed)); + if success { + drop(project::resolve(result.unwrap()).unwrap()); + } else { + let d = parse_failure(result); + if bytes == 131 { + diagnostic( + &d, + "main.arcadia", + 0, + 1, + "Source8 index/output byte limit exceeded.", + ); + } else { + diagnostic(&d, "last.arcadia", 5, 6, "Source8 tokens limit exceeded."); + assert_eq!((d.diagnostic.line, d.diagnostic.column), (1, 5)); + } + } + } + for (files, depth, edges, success) in [ + (3, 2, 2, true), + (2, 2, 2, false), + (3, 1, 2, false), + (3, 2, 1, false), + ] { + let caller = ProjectLimits { + max_files: files, + max_include_depth: depth, + max_include_edges: edges, + ..ProjectLimits::default() + }; + let seed = parse_with_profile(&seed_bundle, caller, profile(100, 1024)).unwrap(); + let result = parse_carried(CarriedInput::borrowed(&bundle, &seed)); + if success { + drop(project::resolve(result.unwrap()).unwrap()); + } else { + let d = parse_failure(result); + assert_eq!(d.diagnostic.code, "project_limit"); + if files == 2 { + assert_eq!(d.path, "main.arcadia"); + } else { + assert_eq!(d.path, "part.arcadia"); + assert_eq!( + d.diagnostic.span, + Span { + start: 0, + end: if edges == 1 { 1 } else { 23 } + } + ); + } + } + } +} +fn typed(expression: &str) -> SourceBundle { + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([( + "main.arcadia".into(), + doc(&format!( + "calculation c \"C\" id \"00000000-0000-0000-0000-000000000002\" {{ input x id \"00000000-0000-0000-0000-000000000003\" : Number; result Number; body {expression}; }}" + )), + )]), + } +} +#[test] +fn captured_numeric_reference_frame_and_model_policies_are_behavioral_not_just_copied() { + let base = empty(); + for (target, limits, expected) in [ + ( + typed("1"), + Limits { + numeric_units: 72, + ..profile(1000, 4096) + }, + "NumericUnits", + ), + ( + typed("$x"), + Limits { + references: 0, + ..profile(1000, 4096) + }, + "Source8 references limit exceeded.", + ), + ( + typed("abs(1)"), + Limits { + frames: 0, + ..profile(1000, 4096) + }, + "Source8 syntax frames limit exceeded.", + ), + ] { + let seed = parse_with_profile(&base, ProjectLimits::default(), limits).unwrap(); + let d = parse_failure(parse_carried(CarriedInput::borrowed(&target, &seed))); + assert!(d.diagnostic.message.contains(expected), "{:?}", d); + } + for expression in ["1", "$x", "abs(1)"] { + let target = typed(expression); + let seed = + parse_with_profile(&base, ProjectLimits::default(), profile(1000, 4096)).unwrap(); + drop( + project::resolve(parse_carried(CarriedInput::borrowed(&target, &seed)).unwrap()) + .unwrap(), + ); + } + // Nonzero old numeric work must not leak into the second snapshot. + let one = typed("1"); + let limits = Limits { + numeric_units: 73, + ..profile(1000, 4096) + }; + let seed = parse_with_profile(&one, ProjectLimits::default(), limits).unwrap(); + assert_eq!(seed.budget.numeric_units(), 73); + let parsed = parse_carried(CarriedInput::borrowed(&one, &seed)).unwrap(); + assert_eq!(parsed.budget.numeric_units(), 73); + assert_eq!(parsed.budget.usage, seed.budget.usage); + for (graph, records, steps) in [(0, 10, 10), (10, 1, 10), (10, 10, 0)] { + let caller = ProjectLimits { + model: ParseLimits { + max_graph_objects: graph, + max_records: records, + max_step_references: steps, + ..ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + let seed = parse_with_profile( + &base, + caller, + Limits::source8(ParseLimits { + max_step_references: steps, + ..ParseLimits::UNLIMITED + }), + ) + .unwrap(); + let target = if steps == 0 { typed("$x") } else { typed("1") }; + let errors = parse_carried(CarriedInput::borrowed(&target, &seed)); + if steps == 0 { + assert!( + parse_failure(errors) + .diagnostic + .message + .contains("references") + ); + } else { + assert!(parse_failure(errors).diagnostic.message.contains("limit")); + } + } +} +#[test] +fn zero_carried_mixed_oa_sa_unicode_results_and_index_ownership_match_legacy() { + for (activity, property, layer) in [ + ("oa_activity", "oa_property", "Oa"), + ("sa_function", "sa_property", "Sa"), + ] { + let body = format!( + "node n id \"00000000-0000-0000-0000-000000000004\" {{ diagram @d; element @v; }}\n{property} v \"Value\" id \"00000000-0000-0000-0000-000000000003\" {{ subject @w; type Number; base 1; }}\n{activity} w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {{}}\ndiagram d \"Diagram\" id \"00000000-0000-0000-0000-000000000005\" {{ layer {layer}; }}" + ); + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([( + "main.arcadia".into(), + format!("// 雨\r\n{}", doc(&body).replace('\n', "\r\n")), + )]), + }; + let legacy = parse(&bundle, ProjectLimits::default()).unwrap(); + let carried = parse_carried(CarriedInput::borrowed(&bundle, &legacy)).unwrap(); + assert_eq!(legacy.budget.usage, carried.budget.usage); + assert_eq!( + legacy.budget.numeric_units(), + carried.budget.numeric_units() + ); + assert_eq!(legacy.metadata, carried.metadata); + let legacy = project::resolve(legacy).unwrap(); + let carried = project::resolve(carried).unwrap(); + assert_eq!(legacy.project(), carried.project()); + assert_eq!(legacy.warnings(), carried.warnings()); + assert_eq!(legacy.phase_state().1, carried.phase_state().1); + assert!(carried.phase_state().0.carried.is_some()); + let legacy = legacy.index().unwrap().index_ordinary().unwrap(); + let carried = carried.index().unwrap().index_ordinary().unwrap(); + assert_eq!(legacy.names(), carried.names()); + assert_eq!(legacy.typed_occurrences(), carried.typed_occurrences()); + assert_eq!( + legacy.ordinary_occurrences(), + carried.ordinary_occurrences() + ); + assert!(std::ptr::eq(carried.bundle(), &bundle)); + } +} + +#[test] +fn ordinary_position_key_copy_is_counted_at_existing_postparse_text_phase() { + let source = doc( + "oa_activity a \"A\" id \"00000000-0000-0000-0000-000000000002\" { description \"é\"; }", + ); + let start = source.find("description \"é\";").unwrap(); + let end = start + "description \"é\";".len(); + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([("main.arcadia".into(), source)]), + }; + let retained_text = bundle.files["main.arcadia"].len() + 24; + // Existing text metric: project8 + alias/name2 + field-span key11 + + // AST field key11 + decoded string2 + projected field key11 =45. + // The final11-byte key is copied before resolve accounts this phase. + let held = owner(bundle, profile(1000, retained_text + 44)); + let parsed = parse_carried(held.input()).unwrap(); + assert_eq!(parsed.files[0].positions.len(), 1); + diagnostic( + &resolve_failure(parsed), + "main.arcadia", + start, + end, + "Source8 index/output byte limit exceeded.", + ); +} diff --git a/crates/oa-core/src/language/analysis_source/mixed/project.rs b/crates/oa-core/src/language/analysis_source/mixed/project.rs index 6be7898a..c695cb59 100644 --- a/crates/oa-core/src/language/analysis_source/mixed/project.rs +++ b/crates/oa-core/src/language/analysis_source/mixed/project.rs @@ -28,6 +28,10 @@ impl ResolvedMixedProject<'_> { pub fn project(&self) -> &Project { &self.project } + #[cfg(test)] + pub(super) fn phase_state(&self) -> (&ParsedMixedSyntax<'_>, Slots) { + (&self.syntax, self.storage.live) + } pub fn warnings(&self) -> &[SourceDiagnostic] { &self.warnings } @@ -438,7 +442,12 @@ fn active_references( pub(in crate::language::analysis_source) fn resolve( mut syntax: ParsedMixedSyntax<'_>, ) -> Result, Failure> { - let mut storage = Ledger::default(); + let mut storage = Ledger { + live: syntax + .carried + .as_ref() + .map_or_else(Slots::default, |input| input.slots()), + }; let lower_storage = accounting::syntax(&syntax, &mut storage)?; let catalog = catalog(&syntax, &mut storage)?; let entry = entry(&syntax); diff --git a/harness/analysis-carried-phase-implementation-20260925.md b/harness/analysis-carried-phase-implementation-20260925.md new file mode 100644 index 00000000..5b5162f1 --- /dev/null +++ b/harness/analysis-carried-phase-implementation-20260925.md @@ -0,0 +1,21 @@ +# Private carried-baseline phase-check implementation + +2026-09-25. Based on `18073a6f2eabce83fc83997f094e331eec835a16`; implements the limited prerequisite selected in `harness/analysis-carried-phase-contract-20260925.md` (parent evidence commit `a2c781773454ee932a9e292e6dc93f551f9f86c5`). Source and tests are authored; compilation/runtime acceptance belong to parent gates and are not claimed here. + +`mixed/carried.rs` introduces an opaque non-Clone input backed by either a fixed borrowed bundle with captured policy or a borrowed allocation-owning RetainedSnapshot. Every new constructor is `cfg(test)`. The retained fixture owns the actual complete SourceBundle and computes exactly F map slots, zero lower occurrence slots and all source/key/entry UTF-8 bytes. The parser cannot substitute a different bundle at invocation. There is no production emitter/old-owner transition, accepted-output result, public source8 activation or editing authority. + +The shared parser implementation has explicit legacy versus carried mode. Existing entry functions choose legacy and preserve their separate metadata checkpoints. Carried mode checks C initially, C+file metadata before traversal, C+U+M before every position projection and at complete syntax return, even when C is zero. Checks use existing logical Slots arithmetic and inherited ceilings; they are not additional retained reservations. A narrow read-only Budget policy getter preserves all Limits values. Every snapshot still has one fresh Budget and normal independent Usage/Exact work, with exact caller ProjectLimits. + +ParsedMixedSyntax retains the guard. Consumed resolution seeds its Ledger from that guard once, then runs the existing syntax accounting and subsequent reservation/release sequence unchanged. Successful ownership naturally retains the guard through syntax inside resolved/indexed owners. A cfg(test) immutable `phase_state` observer exposes syntax/Slots for sibling tests; it exposes no mutation or production constructor. + +Temporal scope is deliberately limited: current-unit parsing and syntax string decoding/copying can precede aggregate phase refusal, and retained syntax text is counted during resolution. Existing grammar event checks remain before their declared allocations; this prerequisite does not add universal parser allocation admission. Model/serde/raw validator heap, allocator capacity/RSS and stack frames keep their established exclusions. Stronger before-growth obligations, output creation, whole actual-output equality and accepted edited-output ownership remain future requirements. This work neither closes nor waives them. + +Twelve authored tests cover independent E/H bundle counts and whole Project oracles, real retained guard identity, exact new Usage, once-only resolver +C retention, parser 13/12 and 25/24 boundaries, resolver 17/16 and 29/28 entry peaks, 112/111 and 214/213 text peaks, canonical original diagnostic paths/spans, initial C/file checks, postdecode syntax text and ordinary position-key copy accounting, unchanged legacy versus explicit carried-zero timing, all captured Limits/ProjectLimits fields, real byte/token/reference/frame/Exact/model/file/depth/include policies, and OA/SA Unicode/CRLF compatibility through both index families. Nonzero occurrence and impossible arithmetic cases are explicitly labeled fault injection; the actual retained bundle owner has no occurrence storage. + +E is 78 source bytes with C=(1,0,102), U 1 / M 11 and final (13,0,110). H is 132 source bytes with C=(3,0,180), U 3 / M 19 and final (25,0,212); its final comment contains actual é and is 6 UTF-8 bytes. These expectations come from the independent parent oracle, not application outputs. Syntax-only exact caps are not reported as full-resolve successes. Parser default resource spans remain 0..1; resolver restores 0..0 or the original header/include extent. + +Zero carried mode intentionally refuses E cap 11 at preprojection, while legacy cap 11 still produces syntax and then refuses resolution at U+M 12. This distinction preserves the old API timing without numerically bypassing the new carried contract. Borrowed policy capture from a still-live fixture witness is test-only propagation evidence, not proof old auxiliary B was consumed or dropped. + +No Cargo/build/test execution was performed by the implementation agent. Targeted rustfmt on owned files and raw-LF normalization are authoring checks only. Parent owns source review integration, exact-source commits, compiler/focused/full-suite gates and publication. Inherited full-suite failures and formatting issues are not addressed by this patch; parent gates will compare actual outcomes. The documented environment-sensitive upstream stack observation remains a limitation. Existing small-stack controls were not edited or waived. + +Independent test-source review corrected two pre-execution fixture mistakes: use the accepted oa_property/sa_property keywords, and distinguish the parser include-edge error span 0..1 from the traversal include-depth span 0..23. These are source-review corrections, not recorded runtime failures.