feat(source): add bounded read-only mixed filesystem discovery
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -118,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use mixed::discovery::MixedDiscovery;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<SourceDiagnostic>>,
|
||||
}
|
||||
impl MixedDiscovery {
|
||||
pub(crate) fn new(limits: ProjectLimits) -> Result<Self, String> {
|
||||
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<IncludeLocation>, Vec<SourceDiagnostic>> {
|
||||
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<IncludeLocation>, Vec<SourceDiagnostic>> {
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<SourceDiagnostic>,
|
||||
}
|
||||
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<MixedSourceSnapshot, SourceFileError> {
|
||||
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<DiskProject, SourceFileError> {
|
||||
check_snapshot(&result, limits)?;
|
||||
let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::<SourceLocation>)];
|
||||
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,
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user