From 3fd87bd8be534a37acbf0ce59f854267a10847b2 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 21:57:16 -0700 Subject: [PATCH] feat(source): add bounded read-only mixed filesystem discovery --- crates/oa-core/src/language.rs | 1 + .../oa-core/src/language/analysis_source.rs | 2 + .../src/language/analysis_source/mixed.rs | 68 ++-- .../analysis_source/mixed/discovery.rs | 99 ++++++ .../analysis_source/mixed/discovery/tests.rs | 144 ++++++++ crates/oa-core/src/source_files.rs | 90 ++++- crates/oa-core/tests/mixed_source_files.rs | 318 ++++++++++++++++++ harness/analysis-mixed-cli-core-20260925.md | 48 +++ 8 files changed, 739 insertions(+), 31 deletions(-) create mode 100644 crates/oa-core/src/language/analysis_source/mixed/discovery.rs create mode 100644 crates/oa-core/src/language/analysis_source/mixed/discovery/tests.rs create mode 100644 crates/oa-core/tests/mixed_source_files.rs create mode 100644 harness/analysis-mixed-cli-core-20260925.md diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index e2a2ca04..c6648d18 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -5,6 +5,7 @@ mod semantic_source; mod source3_bridge; // Mixed source8 is exposed only through the separate borrowed read-only checker. mod analysis_source; +pub(crate) use analysis_source::MixedDiscovery; pub use analysis_source::{CheckedMixed, check_mixed_bundle}; #[path = "migration_v2/source.rs"] mod v2_source; diff --git a/crates/oa-core/src/language/analysis_source.rs b/crates/oa-core/src/language/analysis_source.rs index 1109b5a8..3558377c 100644 --- a/crates/oa-core/src/language/analysis_source.rs +++ b/crates/oa-core/src/language/analysis_source.rs @@ -118,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result { #[cfg(test)] mod tests; + +pub(crate) use mixed::discovery::MixedDiscovery; diff --git a/crates/oa-core/src/language/analysis_source/mixed.rs b/crates/oa-core/src/language/analysis_source/mixed.rs index 6d558ee7..84fef343 100644 --- a/crates/oa-core/src/language/analysis_source/mixed.rs +++ b/crates/oa-core/src/language/analysis_source/mixed.rs @@ -395,30 +395,15 @@ fn parse_impl<'s>( let failure = |error| located(path, source, ordinary_error(error)); let tokens = lex::tokens(source, &mut budget).map_err(failure)?; let metadata_before = budget.producer_entries(); - let mut parser = Parser { + let (ordinary, state) = parse_unit( source, - tokens: Vec::new(), - cursor: 0, - value_depth: 0, - limits: limits.model, - mixed: Some(State { - source, - tokens: &tokens, - budget: &mut budget, - model: &mut model, - typed: Vec::new(), - value_ends: BTreeMap::new(), - lexical_owners: Vec::new(), - pending_projection: 0, - reserved_values: 0, - reserved_owners: 0, - }), - }; - let ordinary = parser - .unit(path == &bundle.entry, true) - .map_err(|e| located(path, source, e))?; - let state = parser.mixed.take().expect("private parser state"); - drop(parser); + &tokens, + &mut budget, + &mut model, + limits.model, + path == &bundle.entry, + ) + .map_err(|e| located(path, source, e))?; // Projected ordinary AST nodes/fields are bounded before constructing a // second position representation. Count references as slots, not syntax. let projection_slots = if entry_producers { @@ -564,3 +549,40 @@ fn ordinary_position_slots(unit: &SourceUnit) -> usize { mod tests; pub(super) mod project; + +/// Shared grammar setup only. Complete parsing still retains/projects its state; +/// discovery drops that state after moving includes without a projection. +fn parse_unit<'a>( + source: &'a str, + tokens: &'a [lex::Token], + budget: &'a mut Budget, + model: &'a mut ModelCounts, + limits: ParseLimits, + entry: bool, +) -> Result<(SourceUnit, State<'a>), Error> { + let mut parser = Parser { + source, + tokens: Vec::new(), + cursor: 0, + value_depth: 0, + limits, + mixed: Some(State { + source, + tokens, + budget, + model, + typed: Vec::new(), + value_ends: BTreeMap::new(), + lexical_owners: Vec::new(), + pending_projection: 0, + reserved_values: 0, + reserved_owners: 0, + }), + }; + let ordinary = parser.unit(entry, true)?; + let state = parser.mixed.take().expect("mixed parser state"); + drop(parser); + Ok((ordinary, state)) +} + +pub(crate) mod discovery; diff --git a/crates/oa-core/src/language/analysis_source/mixed/discovery.rs b/crates/oa-core/src/language/analysis_source/mixed/discovery.rs new file mode 100644 index 00000000..b5bd886a --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/discovery.rs @@ -0,0 +1,99 @@ +//! A separate conservative discovery phase; no model admission or edit positions. +use super::*; +use crate::source_project::{IncludeLocation, SourceLocation, resolve_include}; + +pub(crate) struct MixedDiscovery { + budget: Budget, + model: ModelCounts, + failed: Option>, +} +impl MixedDiscovery { + pub(crate) fn new(limits: ProjectLimits) -> Result { + let mut budget = Budget::new(source_profile(limits)).map_err(|e| e.message)?; + budget.enable_text_producers(0).map_err(|e| e.message)?; + budget.enable_entry_producers(0, 0).map_err(|e| e.message)?; + Ok(Self { + budget, + model: ModelCounts { + limits, + graph: 0, + records: 0, + includes: 0, + }, + failed: None, + }) + } + + pub(crate) fn inspect( + &mut self, + path: &str, + source: &str, + entry: bool, + ) -> Result, Vec> { + if let Some(failed) = &self.failed { + return Err(failed.clone()); + } + let result = self.inspect_unit(path, source, entry); + if let Err(failed) = &result { + self.failed = Some(failed.clone()); + } + result + } + + fn inspect_unit( + &mut self, + path: &str, + source: &str, + entry: bool, + ) -> Result, Vec> { + let failure = |error| located(path, source, ordinary_error(error)); + // Conservative per-inspected-file reservation, before its scanner/parser. + self.budget + .reserve_metadata(1, Span::default()) + .map_err(failure)?; + let tokens = lex::tokens(source, &mut self.budget).map_err(failure)?; + let limits = self.model.limits.model; + let (ordinary, state) = parse_unit( + source, + &tokens, + &mut self.budget, + &mut self.model, + limits, + entry, + ) + .map_err(|e| located(path, source, e))?; + // No unit-drop refunds: remaining reservations are cumulative phase work. + // In-parser quoted/UUID/key temporary release behavior is unchanged. + drop(state); + let mut includes = Vec::new(); + for (raw, span) in ordinary.includes { + let target = resolve_include(path, &raw).map_err(|message| { + let mut diagnostic = language::diagnostic( + source, + Error { + code: "invalid_path", + offset: span.start, + message, + }, + ); + diagnostic.span = span; + vec![SourceDiagnostic { + path: path.into(), + diagnostic, + related: Vec::new(), + }] + })?; + includes.push(IncludeLocation { + location: SourceLocation { + path: path.into(), + span, + }, + target, + }); + } + Ok(includes) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/mixed/discovery/tests.rs b/crates/oa-core/src/language/analysis_source/mixed/discovery/tests.rs new file mode 100644 index 00000000..7f996d11 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/discovery/tests.rs @@ -0,0 +1,144 @@ +use super::*; + +const MAIN: &str = "arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\ninclude \"part.arcadia\";\n}\n"; +const PART: &str = "include \"last.arcadia\";\r\n"; +const LAST: &str = "//é\r\n"; + +#[test] +fn mixed_discovery_accumulates_work_and_reservations_after_unit_drop() { + let limits = ProjectLimits { + model: ParseLimits { + max_source_bytes: 4096, + max_records: 1, + max_graph_objects: 3, + max_step_references: 17, + }, + max_files: 7, + max_include_depth: 9, + max_include_edges: 11, + }; + let mut phase = MixedDiscovery::new(limits).unwrap(); + let profile = phase.budget.limits(); + assert_eq!( + ( + profile.bytes, + profile.tokens, + profile.entries, + profile.references, + profile.frames, + profile.numeric_units + ), + (4096, 4096, 262144, 17, 256, 50_000_000) + ); + assert_eq!(phase.budget.producer_text(), Some((0, 0))); + assert_eq!(phase.budget.producer_entries(), Some(0)); + let includes = phase.inspect("main.arcadia", MAIN, true).unwrap(); + assert_eq!(includes.len(), 1); + assert_eq!(includes[0].target, "part.arcadia"); + drop(includes); + assert_eq!(phase.budget.producer_text(), Some((20, 0))); + assert_eq!(phase.budget.producer_entries(), Some(1)); + assert_eq!(phase.budget.usage.entries, 2); + let includes = phase.inspect("part.arcadia", PART, false).unwrap(); + assert_eq!(includes[0].target, "last.arcadia"); + drop(includes); + assert_eq!(phase.budget.producer_text(), Some((32, 0))); + assert!( + phase + .inspect("last.arcadia", LAST, false) + .unwrap() + .is_empty() + ); + assert_eq!( + ( + phase.budget.usage.bytes, + phase.budget.usage.tokens, + phase.budget.usage.entries, + phase.budget.usage.references + ), + (132, 31, 3, 0) + ); + assert_eq!(phase.budget.producer_entries(), Some(3)); + assert_eq!(phase.budget.producer_text(), Some((32, 0))); + assert_eq!(phase.budget.numeric_units(), 0); + assert_eq!( + (phase.model.records, phase.model.graph, phase.model.includes), + (1, 0, 2) + ); + assert_eq!(phase.model.limits.max_files, 7); + assert_eq!(phase.model.limits.max_include_depth, 9); + assert_eq!(phase.model.limits.max_include_edges, 11); + assert_eq!(phase.model.limits.model.max_records, 1); + assert_eq!(phase.model.limits.model.max_graph_objects, 3); +} + +#[test] +fn mixed_discovery_cumulative_entry_boundary_and_terminal_original_failure() { + // Deliberate private constructor fixture: fresh policy, never a widened owner. + for cap in [6, 5] { + let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap(); + phase.budget = Budget::new(Limits { + entries: cap, + bytes: 4096, + ..source_profile(ProjectLimits::default()) + }) + .unwrap(); + phase.budget.enable_text_producers(0).unwrap(); + phase.budget.enable_entry_producers(0, 0).unwrap(); + phase.inspect("main.arcadia", MAIN, true).unwrap(); + phase.inspect("part.arcadia", PART, false).unwrap(); + let last = phase.inspect("last.arcadia", LAST, false); + if cap == 6 { + assert!(last.unwrap().is_empty()); + } else { + let errors = last.unwrap_err(); + assert_eq!(errors[0].path, "last.arcadia"); + assert_eq!(errors[0].diagnostic.span, Span { start: 0, end: 1 }); + assert!(errors[0].diagnostic.message.contains("entry limit")); + let usage = phase.budget.usage; + assert_eq!( + phase + .inspect("later.arcadia", "malformed", false) + .unwrap_err(), + errors + ); + assert_eq!(phase.budget.usage, usage); + } + } + let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap(); + let errors = phase + .inspect("broken.arcadia", "arcadia 8; project p \"P\" {}", true) + .unwrap_err(); + assert_eq!( + errors[0].diagnostic.message, + "Mixed source8 requires explicit UUIDs." + ); + let usage = phase.budget.usage; + assert_eq!( + phase.inspect("later.arcadia", LAST, false).unwrap_err(), + errors + ); + assert_eq!(phase.budget.usage, usage); +} + +#[test] +fn mixed_discovery_numeric_and_reference_work_is_not_restarted_per_unit() { + let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap(); + for (alias, number) in [("f", 2), ("g", 3)] { + let source = format!( + "calculation {alias} \"C\" id \"00000000-0000-0000-0000-{number:012}\" {{ input x id \"00000000-0000-0000-0000-000000000001\" : Number; result Number; body $x + 1; }}" + ); + let before = phase.budget.numeric_units(); + phase + .inspect(&format!("{alias}.arcadia"), &source, false) + .unwrap(); + if number == 2 { + assert!(phase.budget.numeric_units() > 0); + } else { + assert_eq!(phase.budget.numeric_units(), 2 * before); + } + assert_eq!(phase.budget.usage.references, number - 1); + assert_eq!(phase.budget.producer_text().unwrap().1, 0); + } + assert_eq!(phase.model.records, 2); +} diff --git a/crates/oa-core/src/source_files.rs b/crates/oa-core/src/source_files.rs index 524ec44e..90aa9f20 100644 --- a/crates/oa-core/src/source_files.rs +++ b/crates/oa-core/src/source_files.rs @@ -379,6 +379,59 @@ pub fn expect_new_file( Ok(()) } +/// Captured mixed source bytes and discovery diagnostics, without save authority. +/// Handle discovery errors before checking the complete bundle. This is not an +/// atomic snapshot against another process and never performs journal recovery. +pub struct MixedSourceSnapshot { + bundle: SourceBundle, + diagnostics: Vec, +} +impl MixedSourceSnapshot { + pub fn bundle(&self) -> &SourceBundle { + &self.bundle + } + pub fn diagnostics(&self) -> &[SourceDiagnostic] { + &self.diagnostics + } +} + +/// Discover explicit-ID mixed source8 through the shared grammar without writes. +/// Complete semantic checking remains `language::check_mixed_bundle`; discovery +/// alone does not admit a model. Source bytes are capped before disk reads. +pub fn load_mixed_source( + entry: &Path, + mut limits: ProjectLimits, +) -> Result { + limits.model.max_source_bytes = limits.model.max_source_bytes.min(64 * 1024 * 1024); + let (root, entry) = root_entry(entry)?; + let snapshot = DiskProject { + root, + bundle: SourceBundle { + entry, + files: BTreeMap::new(), + }, + expectations: BTreeMap::new(), + reachable: BTreeSet::new(), + diagnostics: Vec::new(), + }; + let mut discovery = language::MixedDiscovery::new(limits).map_err(SourceFileError::Limit)?; + let captured = discover(snapshot, limits, DiscoveryMode::Mixed(&mut discovery))?; + drop(discovery); + let DiskProject { + bundle, + diagnostics, + .. + } = captured; + Ok(MixedSourceSnapshot { + bundle, + diagnostics, + }) +} + +enum DiscoveryMode<'a> { + Ordinary, + Mixed(&'a mut language::MixedDiscovery), +} /// Merge editor overlays and discover only files absent from retained buffers. /// Existing drafts (including detached ones) and their disk baselines survive. pub fn discover_missing( @@ -390,6 +443,14 @@ pub fn discover_missing( result.bundle.files.extend(overlays.clone()); result.reachable.clear(); result.diagnostics.clear(); + discover(result, limits, DiscoveryMode::Ordinary) +} + +fn discover( + mut result: DiskProject, + limits: ProjectLimits, + mut mode: DiscoveryMode<'_>, +) -> Result { check_snapshot(&result, limits)?; let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::)]; let mut edges = 0usize; @@ -492,12 +553,20 @@ pub fn discover_missing( } } } - match inspect_includes( - &path, - &result.bundle.files[&path], - path == result.bundle.entry, - limits, - ) { + let includes = match &mut mode { + DiscoveryMode::Ordinary => inspect_includes( + &path, + &result.bundle.files[&path], + path == result.bundle.entry, + limits, + ), + DiscoveryMode::Mixed(discovery) => discovery.inspect( + &path, + &result.bundle.files[&path], + path == result.bundle.entry, + ), + }; + match includes { Ok(includes) => { edges = edges.saturating_add(includes.len()); if edges > limits.max_include_edges { @@ -517,7 +586,12 @@ pub fn discover_missing( .map(|include| (include.target, depth + 1, Some(include.location))), ); } - Err(diagnostics) => result.diagnostics.extend(diagnostics), + Err(diagnostics) => { + result.diagnostics.extend(diagnostics); + if matches!(mode, DiscoveryMode::Mixed(_)) { + break; + } + } } } check_snapshot(&result, limits)?; @@ -525,7 +599,7 @@ pub fn discover_missing( &result.root, result.expectations.keys().chain(result.bundle.files.keys()), )?; - if result.diagnostics.is_empty() { + if result.diagnostics.is_empty() && matches!(mode, DiscoveryMode::Ordinary) { match compile_bundle(&result.reachable_bundle(), limits) { Ok(compiled) => result.diagnostics = compiled.diagnostics, Err(diagnostics) => result.diagnostics = diagnostics, diff --git a/crates/oa-core/tests/mixed_source_files.rs b/crates/oa-core/tests/mixed_source_files.rs new file mode 100644 index 00000000..68bc75ae --- /dev/null +++ b/crates/oa-core/tests/mixed_source_files.rs @@ -0,0 +1,318 @@ +//! Actual filesystem discovery followed by the public borrowed complete checker. +use oa_core::{analysis as a, language, source_files::*, source_project::*, *}; +use std::{collections::BTreeMap, fs}; + +fn id(n: u128) -> Id { + Id::from_u128(n) +} +fn document(body: &str) -> String { + format!( + "arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n", + id(1) + ) +} +fn write(files: &[(&str, String)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, text) in files { + fs::write(dir.path().join(path), text).unwrap(); + } + dir +} +fn unchanged(dir: &tempfile::TempDir, files: &[(&str, String)]) { + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), files.len()); + for (path, text) in files { + assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes()); + } +} +fn calculation(alias: &str, n: u128, body: a::Expr) -> a::Definition { + a::Definition { + id: id(n), + alias: alias.into(), + name: "Calculation".into(), + description: String::new(), + body: a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters: vec![a::Parameter { + id: a::ParameterId(id(1)), + name: "x".into(), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + }], + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(body), + }), + } +} +fn calc(alias: &str, n: u128, body: &str) -> String { + format!( + "calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}", + id(n), + id(1) + ) +} + +#[test] +fn mixed_files_complete_model_nested_ordinary_scoped_cross_file_and_original_bytes() { + let files = [ + ( + "main.arcadia", + document( + "// include \"ignored.arcadia\"; 海\ninclude \"f.arcadia\"; include \"g.arcadia\";", + ), + ), + ( + "f.arcadia", + format!( + "{}\noa_entity team \"Team\" id \"{}\" {{ oa_actor crew \"Crew\" id \"{}\" {{}} }}\r\n", + calc("f", 2, "@g(x: $x)"), + id(4), + id(5) + ), + ), + ("g.arcadia", calc("g", 3, "$x")), + ]; + let dir = write(&files); + let snapshot = + load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap(); + assert!( + snapshot.diagnostics().is_empty(), + "{:?}", + snapshot.diagnostics() + ); + assert_eq!(snapshot.bundle().entry, "main.arcadia"); + assert_eq!( + snapshot.bundle().files, + files + .iter() + .map(|(p, t)| (p.to_string(), t.clone())) + .collect() + ); + let mut expected = Project::empty(); + expected.id = id(1); + expected.alias = "p".into(); + expected.name = "Project".into(); + expected.definitions.insert( + id(2), + calculation( + "f", + 2, + a::Expr::Call { + definition: a::DefinitionRef::Local(id(3)), + arguments: BTreeMap::from([( + a::ParameterId(id(1)), + a::Expr::Parameter(a::ParameterId(id(1))), + )]), + }, + ), + ); + expected.definitions.insert( + id(3), + calculation("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))), + ); + for (n, alias, name, kind, parent) in [ + (4, "team", "Team", Kind::Entity, None), + (5, "crew", "Crew", Kind::Actor, Some(id(4))), + ] { + expected.elements.insert( + id(n), + Element { + id: id(n), + alias: alias.into(), + name: name.into(), + description: String::new(), + parent, + body: ElementBody::new(kind, Layer::Oa).unwrap(), + }, + ); + } + let checked = + language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()).unwrap(); + assert_eq!(checked.project(), &expected); + assert!(checked.warnings().is_empty()); + drop(checked); + drop(snapshot); + unchanged(&dir, &files); +} + +#[test] +fn mixed_files_terminal_syntax_preserves_first_path_and_still_reports_journal() { + let files = [ + ( + "main.arcadia", + document("include \"bad.arcadia\"; include \"later.arcadia\";"), + ), + ("bad.arcadia", "oa_entity missing \"Missing\" {}".into()), + ("later.arcadia", "must not parse".into()), + ]; + let dir = write(&files); + fs::write( + dir.path().join(".arcadia-save-main.arcadia.journal"), + b"unrecovered", + ) + .unwrap(); + let snapshot = + load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap(); + assert_eq!(snapshot.diagnostics().len(), 2); + assert_eq!(snapshot.diagnostics()[0].path, "bad.arcadia"); + assert_eq!( + snapshot.diagnostics()[0].diagnostic.message, + "Mixed source8 requires explicit UUIDs." + ); + assert_eq!(snapshot.diagnostics()[1].diagnostic.code, "pending_save"); + assert!(!snapshot.bundle().files.contains_key("later.arcadia")); + assert_eq!( + fs::read(dir.path().join("later.arcadia")).unwrap(), + b"must not parse" + ); + assert_eq!( + fs::read(dir.path().join(".arcadia-save-main.arcadia.journal")).unwrap(), + b"unrecovered" + ); + for (path, text) in &files { + assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes()); + } + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 4); +} + +#[test] +fn mixed_files_identity_classes_and_malformed_units_are_discovery_errors() { + let valid = calc("f", 2, "$x"); + let cases = [ + document("").replace(&format!(" id \"{}\"", id(1)), ""), + document("oa_entity e \"E\" {}"), + document(&valid.replace(&format!("id \"{}\"", id(2)), "")), + document(&valid.replace(&format!("input x id \"{}\"", id(1)), "input x")), + document("calculation malformed"), + ]; + for (i, text) in cases.into_iter().enumerate() { + let files = [("main.arcadia", text)]; + let dir = write(&files); + let snapshot = + load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap(); + assert_eq!(snapshot.diagnostics().len(), 1); + assert_eq!(snapshot.diagnostics()[0].path, "main.arcadia"); + if i < 4 { + assert_eq!( + snapshot.diagnostics()[0].diagnostic.message, + "Mixed source8 requires explicit UUIDs." + ); + } else { + assert_eq!(snapshot.diagnostics()[0].diagnostic.code, "invalid_source"); + } + unchanged(&dir, &files); + } +} + +#[test] +fn mixed_files_include_failures_and_cycles_do_not_become_partial_admission() { + for (include, code) in [ + ("missing.arcadia", "missing_include"), + ("../escape.arcadia", "invalid_path"), + ] { + let files = [("main.arcadia", document(&format!("include \"{include}\";")))]; + let dir = write(&files); + let snapshot = + load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap(); + assert!( + snapshot + .diagnostics() + .iter() + .any(|d| d.diagnostic.code == code) + ); + unchanged(&dir, &files); + } + let files = [ + ("main.arcadia", document("include \"part.arcadia\";")), + ("part.arcadia", "include \"part.arcadia\";".into()), + ]; + let dir = write(&files); + let snapshot = + load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap(); + assert!(snapshot.diagnostics().is_empty()); + let errors = match language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()) { + Ok(_) => panic!("cycle admitted"), + Err(errors) => errors, + }; + assert!(errors.iter().any(|d| d.diagnostic.code == "include_cycle")); + unchanged(&dir, &files); +} + +#[test] +fn mixed_files_limits_encoding_nonregular_and_physical_aliases() { + let files = [ + ("main.arcadia", document("include \"part.arcadia\";")), + ("part.arcadia", "include \"last.arcadia\";\r\n".into()), + ("last.arcadia", "//é\r\n".into()), + ]; + let dir = write(&files); + let entry = dir.path().join("main.arcadia"); + for (kind, cap) in [("files", 3), ("depth", 2), ("edges", 2), ("bytes", 132)] { + for allowed in [cap, cap - 1] { + let mut limits = ProjectLimits::default(); + match kind { + "files" => limits.max_files = allowed, + "depth" => limits.max_include_depth = allowed, + "edges" => limits.max_include_edges = allowed, + _ => limits.model.max_source_bytes = allowed, + }; + let result = load_mixed_source(&entry, limits); + if allowed == cap { + assert!(result.unwrap().diagnostics().is_empty(), "{kind}"); + } else { + match result { + Err(_) => {} + Ok(snapshot) => assert!(!snapshot.diagnostics().is_empty(), "{kind}"), + } + } + unchanged(&dir, &files); + } + } + fs::write(dir.path().join("last.arcadia"), [0xff]).unwrap(); + let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap(); + assert!( + snapshot + .diagnostics() + .iter() + .any(|d| d.diagnostic.code == "source_encoding") + ); + fs::remove_file(dir.path().join("last.arcadia")).unwrap(); + fs::create_dir(dir.path().join("last.arcadia")).unwrap(); + let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap(); + assert!( + snapshot + .diagnostics() + .iter() + .any(|d| d.diagnostic.code == "invalid_path") + ); + let collision = write(&[ + ( + "main.arcadia", + document("include \"part.arcadia\"; include \"PART.arcadia\";"), + ), + ("part.arcadia", "// case alias\n".into()), + ]); + assert!(matches!( + load_mixed_source( + &collision.path().join("main.arcadia"), + ProjectLimits::default() + ), + Err(SourceFileError::Path(_)) + )); + let aliases = write(&[ + ( + "main.arcadia", + document("include \"a.arcadia\"; include \"b.arcadia\";"), + ), + ("a.arcadia", "// same physical file\n".into()), + ]); + fs::hard_link( + aliases.path().join("a.arcadia"), + aliases.path().join("b.arcadia"), + ) + .unwrap(); + assert!(matches!( + load_mixed_source( + &aliases.path().join("main.arcadia"), + ProjectLimits::default() + ), + Err(SourceFileError::Path(_)) + )); +} diff --git a/harness/analysis-mixed-cli-core-20260925.md b/harness/analysis-mixed-cli-core-20260925.md new file mode 100644 index 00000000..d01352ca --- /dev/null +++ b/harness/analysis-mixed-cli-core-20260925.md @@ -0,0 +1,48 @@ +# Mixed CLI core discovery implementation + +Base bab6f96a; selected root contract f810fb40. This contribution owns core only. +source_files::load_mixed_source returns opaque MixedSourceSnapshot with immutable +bundle/diagnostics views. It consumes a fresh private DiskProject after final +checks and drops root/expectations/reachability; no reachable-bundle clone, model, +index, save or recovery authority escapes. The byte allowance is clamped to the +existing64MiB ceiling before disk reads; other caller fields are preserved. + +The existing discovery loop has a private borrowed ordinary/mixed selector. +Ordinary overlays, aggregation, limits, final compilation and journal behavior +are preserved. Mixed selects a shared Budget/ModelCounts phase and skips ordinary +compilation. Syntax/budget failure records the original file diagnostic and stops +parsing later siblings, while final disk/physical/journal checks still execute. +Disk/encoding/path failures retain existing classifications and safeguards. + +Both producer modes start once at zero. Each inspected file reserves its phase +slot before scanning. Bytes/tokens/references/numeric/syntax/model work accumulate +across units. Remaining text/value/owner reservations remain conservative after +unit destruction; in-parser temporary/UUID/key releases are unchanged. No edit +positions or complete model are constructed during discovery. Final public +semantic checking is a separate pass with the original caller policy. + +A narrow shared parse_unit helper centralizes exactly the existing Parser setup, +unit(entry,true) call, state extraction and Parser drop. Complete mixed parsing +keeps all subsequent metadata/projection/traversal/accounting operations. Source +review finds no semantic blocker; helper-frame effects still require parent's +actual existing stack gates and prologue comparison, not a source-only claim. + +Three internal discovery tests cover exact132-byte/31-token/3-event chain totals, +conservative text32/metadata3 after unit drops, all temporary balances, original +policy capture, cumulative6/5 entry boundary with no later-file remapping, syntax +failure terminality and positive cumulative numeric/reference work. The tight +entry profile is explicitly a fresh internal fixture, not a public policy knob. + +Five external mixed_source_files tests exercise real filesystem capture and +independently authored complete model equality (nested ordinary participants, +cross-file calculations and scoped parameters), original bytes, four missing-ID +classes, malformed syntax, unparsed later sibling plus preserved pending journal, +missing/escaping/cyclic includes, file/byte/depth/edge boundaries, invalid UTF-8, +nonregular files, case collisions and hard-link aliases. CLI worker separately +owns OA/SA rich command fixtures/report/actual executable tests and documentation. +No old test body or expected outcome was changed. + +Only owned Rust files were formatted directly with skip_children=true. No Cargo, +Rust compiler, rustdoc, CLI execution or remote operation ran in this contribution. +Parent owns all runtime, production ordinary-regression, stack, lint and combined +source acceptance. Existing depth/platform/accounting limitations remain intact. \ No newline at end of file