feat(source): add bounded read-only mixed filesystem discovery

This commit is contained in:
2026-09-24 21:57:16 -07:00
parent f83f3bf45b
commit 3fd87bd8be
8 changed files with 739 additions and 31 deletions
+318
View File
@@ -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(_))
));
}