Compare commits

...
25 changed files with 2604 additions and 109 deletions
+66 -11
View File
@@ -170,6 +170,47 @@ fn disk_error(report: &mut Report, error: SourceFileError) {
report.fail(3, code, error.to_string());
}
/// Validate captured mixed source8 bytes without creating an editable candidate.
pub fn check_mixed(entry: &Path) -> Report {
let mut report = Report::empty(entry, "check-mixed");
let snapshot = match source_files::load_mixed_source(entry, LIMITS) {
Ok(snapshot) => snapshot,
Err(error) => {
disk_error(&mut report, error);
return report;
}
};
let diagnostics = if snapshot.diagnostics().is_empty() {
match language::check_mixed_bundle(snapshot.bundle(), LIMITS) {
Ok(checked) => checked.warnings().to_vec(),
Err(errors) => errors,
}
} else {
snapshot.diagnostics().to_vec()
};
if diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error)
{
report.status = "invalid".into();
report.exit_code = if diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
}) {
3
} else {
1
};
}
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
report
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
@@ -406,14 +447,19 @@ fn unified_diff(path: &str, before: &str, after: &str) -> String {
out
}
/// Read-only mixed checking cannot acquire ordinary edit/write authority.
pub enum Dispatch {
Editable { operation: Operation, write: bool },
CheckMixed,
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub dispatch: Dispatch,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia check-mixed ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\ncheck-mixed requires source8 and explicit UUIDs; it never migrates, stabilizes or writes.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
@@ -433,19 +479,28 @@ pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
let dispatch = match (command, positional.len()) {
("check-mixed", 2) if !write => Dispatch::CheckMixed,
("check" | "lint", 2) if !write => Dispatch::Editable {
operation: Operation::Check,
write,
},
("stabilize", 2) => Dispatch::Editable {
operation: Operation::Stabilize,
write,
},
("rename-alias", 4) => Dispatch::Editable {
operation: Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
write,
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
dispatch,
json,
})
}
+30 -19
View File
@@ -1,4 +1,4 @@
use oa_cli::{Report, arguments, prepare};
use oa_cli::{Dispatch, Report, arguments, check_mixed, prepare};
use std::{
io::{self, Write},
path::Path,
@@ -26,13 +26,21 @@ fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if report.operation == "check-mixed" {
writeln!(
err,
"{}: mixed source8 check; no files changed",
report.status
)?;
} else {
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
}
if let Some(migration) = &report.migration {
writeln!(
err,
@@ -67,19 +75,22 @@ fn run() -> io::Result<u8> {
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
Ok(args) => match args.dispatch {
Dispatch::CheckMixed => check_mixed(&args.entry),
Dispatch::Editable { operation, write } => match prepare(&args.entry, operation) {
Err(report) => *report,
Ok(prepared) => {
if write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
prepared.write()
} else {
prepared.into_report()
}
}
},
},
};
if json {
+490
View File
@@ -0,0 +1,490 @@
//! Actual executable tests. Expected values are authored independently of CLI output.
use oa_core::language::Span;
use oa_core::source_project::{SourceBundle, SourceDiagnostic};
pub use oa_core::*;
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::{Command as ProcessCommand, Output},
};
#[path = "../../oa-core/src/language/analysis_source/model_oracle.rs"]
mod independent_model;
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 authored_span(source: &str, literal: &str) -> Span {
assert_eq!(source.matches(literal).count(), 1);
let start = source.find(literal).unwrap();
Span {
start,
end: start + literal.len(),
}
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("arcadia-mixed-cli-{}", Id::new_v4()));
fs::create_dir(&p).unwrap();
Self(p)
}
fn put(&self, name: &str, bytes: impl AsRef<[u8]>) {
let p = self.0.join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, bytes).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("main.arcadia")
}
fn inventory(&self) -> BTreeMap<PathBuf, Option<Vec<u8>>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, Option<Vec<u8>>>) {
for item in fs::read_dir(dir).unwrap() {
let p = item.unwrap().path();
if p.is_dir() {
out.insert(p.strip_prefix(root).unwrap().into(), None);
walk(root, &p, out);
} else {
out.insert(
p.strip_prefix(root).unwrap().into(),
Some(fs::read(p).unwrap()),
);
}
}
}
let mut out = BTreeMap::new();
walk(&self.0, &self.0, &mut out);
out
}
fn run(&self, options: &[&str]) -> Output {
let before = self.inventory();
let result = ProcessCommand::new(env!("CARGO_BIN_EXE_arcadia"))
.arg("check-mixed")
.arg(self.entry())
.args(options)
.output()
.unwrap();
assert_eq!(
self.inventory(),
before,
"all file bytes and directory entries preserved"
);
result
}
fn expect(&self, exit: i32, diagnostics: Vec<Value>) {
let output = self.run(&["--json"]);
assert_eq!(
output.status.code(),
Some(exit),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
self.report(exit, diagnostics)
);
}
fn report(&self, exit: i32, diagnostics: Vec<Value>) -> Value {
json!({"format_version":1,"entry":self.entry().to_string_lossy(),"operation":"check-mixed",
"status":if exit==0 {"checked"}else{"invalid"},"exit_code":exit,"written":false,
"diagnostics":diagnostics,"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]})
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
fn diagnostic(source: &str, path: &str, span: Span, code: &str, message: &str) -> Value {
let prefix = &source[..span.start];
json!({"severity":"error","code":code,"message":message,"coordinate_space":"input",
"location":{"path":path,"start":span.start,"end":span.end,
"line":prefix.bytes().filter(|b|*b==b'\n').count()+1,
"column":prefix.rsplit('\n').next().unwrap().chars().count()+1},
"related":[],"object_id":null,"field":null})
}
fn warning(d: &SourceDiagnostic) -> Value {
let at = &d.diagnostic;
assert!(d.related.is_empty());
json!({"severity":"warning","code":at.code,"message":at.message,"coordinate_space":"input",
"location":{"path":d.path,"start":at.span.start,"end":at.span.end,"line":at.line,"column":at.column},
"related":[],"object_id":at.object_id.unwrap().to_string(),"field":at.field})
}
fn rich(layer: Layer, crlf: bool) -> (SourceBundle, Project, Vec<SourceDiagnostic>) {
let (ordinary, typed, work) = match layer {
Layer::Oa => (
include_str!(
"../../oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia"
),
include_str!(
"../../oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia"
),
"oa_activity",
),
Layer::Sa => (
include_str!(
"../../oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia"
),
include_str!(
"../../oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia"
),
"sa_function",
),
};
let mut expected = independent_model::project(layer);
let mut main = ordinary
.replace("\r\n", "\n")
.replacen("arcadia 7;", "arcadia 8;", 1);
assert!(main.starts_with("arcadia 8;"));
// Forward typed uses refer back into nested ordinary Work in this file.
main = main.replacen("{\n", "{\n include \"typed.arcadia\";\n", 1);
if layer == Layer::Sa {
let system = Element {
id: id(999),
alias: "system".into(),
name: "System".into(),
description: String::new(),
parent: None,
body: ElementBody::Sa(SaBody::System),
};
let at = main.rfind('}').unwrap();
main.insert_str(
at,
&format!(
" sa_system system \"System\" id \"{}\" {{}}\n",
system.id
),
);
assert!(expected.elements.insert(system.id, system).is_none());
}
let mut typed = typed.replace("\r\n", "\n");
// Textual argument order is independent of the model's parameter-ID map.
assert!(typed.contains("@combine(a: @receive_time, b: @dispatch_time)"));
typed = typed.replace(
"@combine(a: @receive_time, b: @dispatch_time)",
"@combine(b: @dispatch_time, a: @receive_time)",
);
if crlf {
main = format!("// 港🌊\r\n{}", main.replace('\n', "\r\n"));
typed = typed.replace('\n', "\r\n");
}
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), main),
("typed.arcadia".into(), typed),
]),
};
// Two nested leaves, so four warnings, unlike the earlier one-Work fixture.
// Existing validation orders both allocation warnings before coverage warnings.
let mut warnings = Vec::new();
for allocation in [true, false] {
for (alias, display) in [("receive", "Receive"), ("dispatch", "Dispatch")] {
let element = expected
.elements
.values()
.find(|e| e.alias == alias)
.unwrap();
let literal = format!("{work} {alias} \"{display}\" id \"{}\" {{}}", element.id);
let source = &bundle.files["main.arcadia"];
let span = authored_span(source, &literal);
let prefix = &source[..span.start];
warnings.push(SourceDiagnostic {
path: "main.arcadia".into(), related: Vec::new(),
diagnostic: language::Diagnostic {
severity: validation::Severity::Warning,
code: if allocation { "unallocated_leaf" } else { "work_without_behavior_context" }.into(),
object_id: Some(element.id),
field: allocation.then(|| "allocation".into()),
message: if allocation { format!("Allocate leaf '{display}' to a performer.") } else {
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.".into()
},
span, line: prefix.bytes().filter(|b| *b == b'\n').count() + 1,
column: prefix.rsplit('\n').next().unwrap().chars().count() + 1,
},
});
}
}
(bundle, expected, warnings)
}
#[test]
fn mixed_cli_complete_oa_sa_models_reports_and_unchanged_bytes() {
for layer in [Layer::Oa, Layer::Sa] {
for crlf in [false, true] {
let (mut bundle, expected, _) = rich(layer, crlf);
// Escaped include path and a decoy in a comment exercise real grammar discovery.
let main = bundle.files.get_mut("main.arcadia").unwrap();
*main = main.replace("typed.arcadia", "typed\\u002earcadia");
let comment = if crlf {
"// include \"missing.arcadia\"; 港🌊\r\n"
} else {
"// include \"missing.arcadia\"; 港🌊\n"
};
main.insert_str(0, comment);
// Rebuild only warning locations from unique authored declaration literals,
// never from parsed diagnostics or emitted reports.
let (_, _, mut warnings) = rich(layer, crlf);
for d in &mut warnings {
let e = &expected.elements[&d.diagnostic.object_id.unwrap()];
let work = if layer == Layer::Oa {
"oa_activity"
} else {
"sa_function"
};
let literal = format!("{work} {} \"{}\" id \"{}\" {{}}", e.alias, e.name, e.id);
let span = authored_span(main, &literal);
let prefix = &main[..span.start];
d.diagnostic.span = span;
d.diagnostic.line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
d.diagnostic.column = prefix.rsplit('\n').next().unwrap().chars().count() + 1;
}
let f = Fixture::new();
for (path, text) in &bundle.files {
f.put(path, text);
}
f.put("unrelated/keep.bin", [0, 255, 13, 10]);
let snapshot = source_files::load_mixed_source(&f.entry(), oa_cli::LIMITS).unwrap();
assert!(snapshot.diagnostics().is_empty());
assert_eq!(snapshot.bundle(), &bundle);
let checked = language::check_mixed_bundle(snapshot.bundle(), oa_cli::LIMITS)
.unwrap_or_else(|e| panic!("{e:#?}"));
assert_eq!(checked.project(), &expected);
assert_eq!(checked.warnings(), warnings);
f.expect(0, warnings.iter().map(warning).collect());
}
}
}
#[test]
fn mixed_cli_missing_id_classes_and_schema_are_exact_refusals() {
let ordinary = format!("oa_entity e \"Entity\" id \"{}\" {{}}", id(2));
let typed = format!(
"calculation f \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body $x; }}",
id(3),
id(1)
);
let valid = document(&format!("{ordinary}\n{typed}"));
let f = Fixture::new();
f.put("main.arcadia", &valid);
f.expect(0, vec![]);
for (needle, replacement, anchor) in [
(format!("id \"{}\"", id(1)), String::new(), "project"),
(format!("id \"{}\"", id(2)), String::new(), "oa_entity"),
(format!("id \"{}\"", id(3)), String::new(), "calculation"),
(
format!("input x id \"{}\"", id(1)),
"input x".into(),
"calculation",
),
] {
let source = valid.replacen(&needle, &replacement, 1);
let begin = source.find(anchor).unwrap();
let start = if anchor == "calculation" {
begin
} else {
begin + source[begin..].find('{').unwrap()
};
f.put("main.arcadia", &source);
f.expect(
1,
vec![diagnostic(
&source,
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Mixed source8 requires explicit UUIDs.",
)],
);
}
for schema in [3, 7, 9] {
let source = document("").replacen("arcadia 8", &format!("arcadia {schema}"), 1);
f.put("main.arcadia", &source);
f.expect(
1,
vec![diagnostic(
&source,
"main.arcadia",
Span { start: 8, end: 9 },
"invalid_source",
"Mixed source requires arcadia 8.",
)],
);
}
}
#[test]
fn mixed_cli_final_semantic_refusal_keeps_original_cross_file_coordinates() {
let f = Fixture::new();
f.put("main.arcadia", document("include \"part.arcadia\";"));
let part = format!(
"// 港🌊\r\ncalculation f \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body @missing(x: $x); }}\r\n",
id(2),
id(1)
);
let start = part.find("@missing").unwrap() + 1;
f.put("part.arcadia", &part);
f.expect(
1,
vec![diagnostic(
&part,
"part.arcadia",
Span {
start,
end: start + 7,
},
"invalid_source",
"Unknown global reference '@missing'.",
)],
);
}
#[test]
fn mixed_cli_disk_discovery_and_cycle_refusals_preserve_every_entry() {
let f = Fixture::new();
let source = document("include \"part.arcadia\";");
let span = authored_span(&source, "include \"part.arcadia\";");
f.put("main.arcadia", &source);
let missing = f.0.canonicalize().unwrap().join("part.arcadia");
let os_error = fs::symlink_metadata(&missing).unwrap_err();
f.expect(
3,
vec![diagnostic(
&source,
"main.arcadia",
span,
"missing_include",
&format!(
"Cannot load 'part.arcadia': Source file operation failed at {}: {os_error}",
missing.display()
),
)],
);
f.put("part.arcadia", [0xff]);
f.expect(
1,
vec![diagnostic(
&source,
"main.arcadia",
span,
"source_encoding",
"'part.arcadia' is not UTF-8 source.",
)],
);
let part = "include \"main.arcadia\";";
f.put("part.arcadia", part);
f.expect(
1,
vec![diagnostic(
part,
"part.arcadia",
Span { start: 0, end: 23 },
"include_cycle",
"Include cycle: main.arcadia -> part.arcadia -> main.arcadia.",
)],
);
let escape = document("include \"../escape.arcadia\";");
f.put("main.arcadia", &escape);
f.expect(
3,
vec![diagnostic(
&escape,
"main.arcadia",
authored_span(&escape, "include \"../escape.arcadia\";"),
"invalid_path",
"Include escapes the source project root.",
)],
);
f.put("main.arcadia", document(""));
let journal = f.0.join(".arcadia-save-main.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"keep").unwrap();
let physical =
f.0.canonicalize()
.unwrap()
.join(".arcadia-save-main.arcadia.journal");
f.expect(
3,
vec![diagnostic(
&document(""),
"main.arcadia",
Span { start: 0, end: 0 },
"pending_save",
&format!(
"A save was interrupted. Review and recover {} before saving this project.",
physical.display()
),
)],
);
}
#[test]
fn mixed_cli_usage_and_human_output_never_suggest_an_edit_candidate() {
let f = Fixture::new();
f.put("main.arcadia", document(""));
let output = f.run(&[]);
assert!(output.status.success());
assert!(output.stdout.is_empty());
assert_eq!(
output.stderr,
b"checked: mixed source8 check; no files changed\n"
);
for options in [
vec!["--write", "--json"],
vec!["--unknown", "--json"],
vec!["--json", "--json"],
] {
let output = f.run(&options);
assert_eq!(output.status.code(), Some(2));
assert!(output.stderr.is_empty());
let message = if options[0] == "--write" {
oa_cli::HELP.to_string()
} else if options[0] == "--unknown" {
"Unknown or repeated option: --unknown".into()
} else {
"Unknown or repeated option: --json".into()
};
assert_eq!(
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
json!({"format_version":1,"entry":"","operation":"usage","status":"rejected","exit_code":2,"written":false,
"diagnostics":[{"severity":"error","code":"usage","message":message,"coordinate_space":"operation","location":null,"related":[],"object_id":null,"field":null}],"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]})
);
}
}
#[test]
fn mixed_cli_direct_disk_error_uses_existing_operation_error_envelope() {
let f = Fixture::new();
f.put(".arcadia-save-reserved.arcadia", document(""));
let before = f.inventory();
let entry = f.0.join(".arcadia-save-reserved.arcadia");
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_arcadia"))
.arg("check-mixed")
.arg(&entry)
.arg("--json")
.output()
.unwrap();
assert_eq!(f.inventory(), before);
assert_eq!(output.status.code(), Some(3));
assert!(output.stderr.is_empty());
assert_eq!(
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
json!({"format_version":1,"entry":entry.to_string_lossy(),"operation":"check-mixed","status":"rejected","exit_code":3,"written":false,
"diagnostics":[{"severity":"error","code":"unsafe_path","message":"Unsafe source path: Non-canonical, reserved or overlong path '.arcadia-save-reserved.arcadia'.","coordinate_space":"operation","location":null,"related":[],"object_id":null,"field":null}],"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]})
);
}
+10 -3
View File
@@ -69,6 +69,8 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
assert!(source.starts_with("arcadia 7;"));
let migrated_source = source.replacen("arcadia 7;", "arcadia 8;", 1);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
@@ -76,6 +78,8 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["migration"]["from_schema"], 7);
assert_eq!(preview["migration"]["to_schema"], 8);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
@@ -94,7 +98,10 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
assert_eq!(
stripped, migrated_source,
"only identity clauses and the schema 7→8 header token may change"
);
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
@@ -306,9 +313,9 @@ fn rename_shows_missing_identity_and_separate_source_migration() {
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 8);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].diff.contains("+arcadia 8;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
+5 -31
View File
@@ -3,10 +3,10 @@ mod raw_tokens;
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
// Staged typed analysis only. Public mixed source8 admission waits for complete
// declaration lowering, source updates and the root-owned resource gate.
#[cfg(test)]
// 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;
use crate::*;
@@ -480,7 +480,6 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
cursor: 0,
value_depth: 0,
limits,
#[cfg(test)]
mixed: None,
}
.document()
@@ -916,7 +915,6 @@ pub(crate) fn parse_unit(
cursor: 0,
value_depth: 0,
limits,
#[cfg(test)]
mixed: None,
}
.unit(entry, true)
@@ -1215,12 +1213,10 @@ struct Parser<'a> {
cursor: usize,
value_depth: usize,
limits: ParseLimits,
#[cfg(test)]
mixed: Option<analysis_source::mixed::State<'a>>,
}
impl Parser<'_> {
fn token(&self, index: usize) -> Option<Lexeme> {
#[cfg(test)]
if let Some(mixed) = &self.mixed {
return mixed.token(index);
}
@@ -1236,7 +1232,6 @@ impl Parser<'_> {
})
}
fn syntax_entry(&mut self) -> Result<(), Error> {
#[cfg(test)]
{
let span = self.current().span;
if let Some(mixed) = &mut self.mixed {
@@ -1266,9 +1261,7 @@ impl Parser<'_> {
if self.current().class != TokenClass::String {
return Err(self.error("Expected a double-quoted string."));
}
#[cfg(test)]
let span = self.current().span;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
let raw = &self.source[span.start..span.end];
let value = mixed.quoted_text(raw, span)?;
@@ -1290,9 +1283,8 @@ impl Parser<'_> {
}
fn optional_id(&mut self) -> Result<Id, Error> {
if self.text() != "id" {
#[cfg(test)]
if self.mixed.is_some() {
return Err(self.error("Private mixed syntax requires explicit IDs."));
return Err(self.error("Mixed source8 requires explicit UUIDs."));
}
return Ok(Id::new_v4());
}
@@ -1321,21 +1313,15 @@ impl Parser<'_> {
Ok(id)
}
fn own_text(&mut self, bytes: usize, span: Span) -> Result<(), Error> {
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.own_text(bytes, span)?;
}
#[cfg(not(test))]
let _ = (bytes, span);
Ok(())
}
fn release_text(&mut self, bytes: usize) {
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.release_text(bytes);
}
#[cfg(not(test))]
let _ = bytes;
}
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
self.take("{")?;
@@ -1383,7 +1369,6 @@ impl Parser<'_> {
.span
.end;
self.take(";")?;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.projection(1, Span { start, end })?;
}
@@ -1412,7 +1397,6 @@ impl Parser<'_> {
self.value_depth += 1;
let result = self.value_inner();
self.value_depth -= 1;
#[cfg(test)]
if let Ok(ast) = &result {
let end = self
.token(self.cursor - 1)
@@ -1433,7 +1417,6 @@ impl Parser<'_> {
fn value_inner(&mut self) -> Result<Ast, Error> {
let token = self.current();
let offset = token.span.start;
#[cfg(test)]
if let Some(mixed) = &self.mixed
&& (token.class == TokenClass::Number || matches!(self.text(), "-" | "+"))
{
@@ -1541,17 +1524,14 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
#[cfg(test)]
let mixed_mode = self.mixed.is_some();
#[cfg(not(test))]
let mixed_mode = false;
if if mixed_mode {
self.text() != "8"
} else {
!matches!(self.text(), "3" | "4" | "5" | "6" | "7" | "8")
} {
return Err(self.error(if mixed_mode {
"Private mixed syntax requires arcadia 8."
"Mixed source requires arcadia 8."
} else {
"Unsupported schema version: expected arcadia 3, 4, 5, 6, 7 or 8."
}));
@@ -1561,7 +1541,6 @@ impl Parser<'_> {
self.take(";")?;
let start = self.current().span.start;
self.take("project")?;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.record(false, Span { start, end: start })?;
}
@@ -1606,7 +1585,6 @@ impl Parser<'_> {
let mut open: Vec<usize> = Vec::new();
loop {
let offset = self.current().span.start;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed
&& mixed.typed_header(self.cursor, open.is_empty())
{
@@ -1658,7 +1636,6 @@ impl Parser<'_> {
if !includes_allowed {
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
}
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.include(Span {
start: offset,
@@ -1705,7 +1682,6 @@ impl Parser<'_> {
header = Some(entry_header);
continue;
}
#[cfg(test)]
if self.mixed.is_some() && analysis_source::mixed::long_analysis_word(self.text()) {
return Err(self.error("Use the short source8 analysis declaration word."));
}
@@ -1713,7 +1689,6 @@ impl Parser<'_> {
let word = self.current().text(self.source);
let kind = declaration_kind(word)
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
mixed.record(
matches!(kind, DeclKind::Element(..) | DeclKind::Relation(..)),
@@ -1750,7 +1725,6 @@ impl Parser<'_> {
let body_start = self.current().span.end;
self.take("{")?;
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
#[cfg(test)]
if let Some(mixed) = &mut self.mixed {
let span = Span {
start: offset,
@@ -1,14 +1,16 @@
//! Unresolved typed source8 syntax, staged for the complete source adapter.
//! This module does not admit a Project or enable public source8.
//! Private typed syntax and complete read-only mixed source8 resolution.
mod budget;
mod cursor;
mod declarations;
#[cfg(test)]
mod index;
mod lex;
mod lower;
pub(super) mod mixed;
mod syntax;
#[cfg(test)]
mod update;
#[cfg(test)]
mod write;
#[cfg(test)]
@@ -18,6 +20,7 @@ use super::Span;
use crate::analysis::{BinaryOp, UnaryOp};
use crate::analysis_numeric::Exact;
use budget::Budget;
pub use mixed::project::{CheckedMixed, check_mixed_bundle};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
@@ -104,6 +107,7 @@ pub enum Binding {
Expression(Expression),
}
#[cfg(test)]
pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = cursor::Cursor::new(source, &tokens, budget);
@@ -114,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
#[cfg(test)]
mod tests;
pub(crate) use mixed::discovery::MixedDiscovery;
@@ -221,6 +221,7 @@ impl Budget {
Ok(value)
}
/// Immutable exact policy snapshot for private deliberate-snapshot continuation.
#[cfg(test)]
pub(super) fn limits(&self) -> Limits {
self.limits
}
@@ -294,6 +295,7 @@ impl Budget {
}
}
}
#[cfg(test)]
pub fn numeric_units(&self) -> u64 {
self.numeric.usage().numeric_units
}
@@ -42,6 +42,7 @@ impl Cursor<'_, '_> {
self.cursor += 1;
Ok(span)
}
#[cfg(test)]
pub(super) fn end(&self) -> Result<(), Error> {
if self.cursor != self.tokens.len() {
Err(error(
@@ -2,12 +2,15 @@
//! single complete-Project semantic admission after merging these additions.
use super::declarations::{self as d, Declaration, DeclarationKind};
use super::*;
#[cfg(test)]
use crate::Project;
use crate::analysis as a;
use crate::analysis_numeric::Dimension;
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
use crate::{Element, ElementBody, Id, Kind, Layer, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Caller-assigned file key; spans are original offsets within that file.
#[cfg(test)]
pub(super) struct Unit {
pub file: usize,
pub declarations: Vec<Declaration>,
@@ -180,6 +183,7 @@ impl Tables {
Ok(())
}
#[cfg(test)]
fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> {
let at = Span::default();
self.insert(project.id, &project.alias, Category::Project, at, budget)?;
@@ -270,6 +274,7 @@ impl Tables {
/// Inputs must come from the same live Budget and stabilization pass. No scan,
/// exact conversion or reference charge occurs here. The ordinary skeleton is
/// not semantically admitted: its typed forward references may need additions.
#[cfg(test)]
pub(super) fn lower(
ordinary: &Project,
units: &[Unit],
@@ -13,7 +13,7 @@
//! value-end entries, projected value/field/header/declaration positions, lexical
//! owner slots and the file table. Checks do not recharge syntax or latch errors;
//! every failure must return immediately. Tokens themselves use the token bound.
//! This deliberately bounded staging policy is not calibrated public source8.
//! The public read checker selects both producers; editing remains separately staged.
use super::{
budget::{Budget, Limits},
@@ -53,11 +53,15 @@ pub(super) struct ParsedMixedSyntax<'s> {
struct FileSyntax<'s> {
path: &'s str,
source: &'s str,
// Retained original indexing metadata; the read result preserves its accounted ownership.
#[cfg_attr(not(test), allow(dead_code))]
tokens: Vec<lex::Token>,
ordinary: SourceUnit,
typed: Vec<declarations::Declaration>,
positions: Vec<(Id, EditDeclaration)>,
// A lexical owner is an occurrence index, not only a possibly repeated UUID.
// Retained original indexing metadata; the read result preserves its accounted ownership.
#[cfg_attr(not(test), allow(dead_code))]
lexical_owners: Vec<Option<usize>>,
}
@@ -256,7 +260,7 @@ impl State<'_> {
return Err(Error {
code: "invalid_source",
offset: declaration.span.start,
message: "Private mixed syntax requires explicit IDs.".into(),
message: "Mixed source8 requires explicit UUIDs.".into(),
});
}
self.typed.push(declaration);
@@ -276,17 +280,22 @@ pub(in crate::language) fn long_analysis_word(word: &str) -> bool {
)
}
#[cfg(test)]
pub(super) fn parse(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
let profile = Limits::source8(ParseLimits {
let profile = source_profile(limits);
parse_with_profile(bundle, limits, profile)
}
fn source_profile(limits: ProjectLimits) -> Limits {
Limits::source8(ParseLimits {
// Storage is not the caller's final model record/ordered-step accounting.
max_source_bytes: limits.model.max_source_bytes,
max_step_references: limits.model.max_step_references,
..ParseLimits::UNLIMITED
});
parse_with_profile(bundle, limits, profile)
})
}
fn located(path: &str, source: &str, error: Error) -> Vec<SourceDiagnostic> {
@@ -297,6 +306,7 @@ fn located(path: &str, source: &str, error: Error) -> Vec<SourceDiagnostic> {
}]
}
#[cfg(test)]
fn parse_with_profile(
bundle: &SourceBundle,
limits: ProjectLimits,
@@ -385,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 {
@@ -554,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;
@@ -2,6 +2,7 @@
//! Checks existing logical phase totals; does not meter every parser allocation.
use super::*;
use project::accounting::{Ledger, Slots};
#[cfg(test)]
use project::ordinary_index::rename::emission::PendingRename;
struct SnapshotPolicy {
@@ -10,17 +11,20 @@ struct SnapshotPolicy {
}
/// The allocation-owning bundle guard. No production constructor exists yet.
#[cfg(test)]
struct RetainedSnapshot {
bundle: SourceBundle,
policy: SnapshotPolicy,
retained: Slots,
}
enum SnapshotBacking<'s> {
#[cfg(test)]
Pending(&'s PendingRename<'s>),
Borrowed {
bundle: &'s SourceBundle,
policy: SnapshotPolicy,
},
#[cfg(test)]
Retained(&'s RetainedSnapshot),
}
@@ -29,8 +33,12 @@ pub(super) struct CarriedInput<'s> {
backing: SnapshotBacking<'s>,
}
enum GuardBacking<'s> {
#[cfg(test)]
Pending(&'s PendingRename<'s>),
Borrowed,
Borrowed {
_bundle: &'s SourceBundle,
},
#[cfg(test)]
Retained(&'s RetainedSnapshot),
}
@@ -40,6 +48,18 @@ pub(super) struct CarriedGuard<'s> {
backing: GuardBacking<'s>,
}
impl<'s> CarriedInput<'s> {
fn fresh(bundle: &'s SourceBundle, limits: ProjectLimits) -> Self {
Self {
backing: SnapshotBacking::Borrowed {
bundle,
policy: SnapshotPolicy {
limits: source_profile(limits),
project_limits: limits,
},
},
}
}
#[cfg(test)]
pub(super) fn from_pending<'t: 's>(pending: &'s PendingRename<'t>) -> Self {
Self {
backing: SnapshotBacking::Pending(pending),
@@ -47,6 +67,7 @@ impl<'s> CarriedInput<'s> {
}
fn into_parts(self) -> (&'s SourceBundle, SnapshotPolicy, CarriedGuard<'s>) {
match self.backing {
#[cfg(test)]
SnapshotBacking::Pending(pending) => {
let (limits, project_limits) = pending.carried_policy();
(
@@ -64,9 +85,10 @@ impl<'s> CarriedInput<'s> {
bundle,
policy,
CarriedGuard {
backing: GuardBacking::Borrowed,
backing: GuardBacking::Borrowed { _bundle: bundle },
},
),
#[cfg(test)]
SnapshotBacking::Retained(owner) => (
&owner.bundle,
SnapshotPolicy {
@@ -83,8 +105,10 @@ impl<'s> CarriedInput<'s> {
impl CarriedGuard<'_> {
pub(super) fn slots(&self) -> Slots {
match &self.backing {
#[cfg(test)]
GuardBacking::Pending(pending) => pending.carried_slots(),
GuardBacking::Borrowed => Slots::default(),
GuardBacking::Borrowed { .. } => Slots::default(),
#[cfg(test)]
GuardBacking::Retained(owner) => owner.retained,
}
}
@@ -105,6 +129,7 @@ impl CarriedGuard<'_> {
}
/// Input already binds the actual bundle; callers cannot supply a replacement.
#[cfg(test)]
fn parse_carried(input: CarriedInput<'_>) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
let (bundle, policy, guard) = input.into_parts();
parse_impl(
@@ -205,3 +230,12 @@ mod text_producer;
#[cfg(test)]
mod entry_producer;
pub(super) fn parse_fresh(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
parse_entry_producers(CarriedInput::fresh(bundle, limits))
}
#[cfg(test)]
mod public_read;
@@ -0,0 +1,65 @@
//! Real public-constructor wiring; arbitrary fixture profiles are not public policy.
use super::*;
#[test]
fn public_read_fresh_constructor_keeps_zero_carry_both_modes_and_exact_policy() {
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: [("main.arcadia".into(), "arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\n\n}\n".into())].into(),
};
let original = bundle.clone();
for (source_bytes, references, expected_bytes, expected_tokens, expected_references) in [
(4096, 17, 4096, 4096, 17),
(usize::MAX, usize::MAX, 64 * 1024 * 1024, 1_048_576, 500_000),
] {
let limits = ProjectLimits {
max_files: 7,
max_include_depth: 9,
max_include_edges: 11,
model: ParseLimits {
max_source_bytes: source_bytes,
max_graph_objects: 3,
max_records: 1,
max_step_references: references,
},
};
// This is the same production helper the exported checker calls.
let syntax = parse_fresh(&bundle, limits).unwrap();
let guard = syntax.carried.as_ref().expect("explicit carried mode");
assert!(matches!(guard.backing, GuardBacking::Borrowed { .. }));
assert_eq!(guard.slots(), Slots::default());
assert_eq!(syntax.budget.producer_text(), Some((8, 0)));
assert_eq!(syntax.budget.producer_entries(), Some(11));
assert_eq!(syntax.metadata, 11);
let profile = syntax.budget.limits();
assert_eq!(profile.bytes, expected_bytes);
assert_eq!(profile.tokens, expected_tokens);
assert_eq!(profile.entries, 262_144);
assert_eq!(profile.references, expected_references);
assert_eq!(profile.frames, 256);
assert_eq!(profile.numeric_units, 50_000_000);
assert_eq!(syntax.limits.max_files, 7);
assert_eq!(syntax.limits.max_include_depth, 9);
assert_eq!(syntax.limits.max_include_edges, 11);
assert_eq!(syntax.limits.model.max_source_bytes, source_bytes);
assert_eq!(syntax.limits.model.max_graph_objects, 3);
assert_eq!(syntax.limits.model.max_records, 1);
assert_eq!(syntax.limits.model.max_step_references, references);
let prior = parse(&bundle, limits).unwrap();
assert_eq!(syntax.budget.usage, prior.budget.usage);
assert_eq!(syntax.budget.numeric_units(), prior.budget.numeric_units());
let resolved = project::resolve(syntax).unwrap();
let prior = project::resolve(prior).unwrap();
let checked = project::check_mixed_bundle(&bundle, limits).unwrap();
let mut expected = crate::Project::empty();
expected.id = Id::from_u128(1);
expected.alias = "p".into();
expected.name = "Project".into();
assert_eq!(resolved.project(), &expected);
assert_eq!(prior.project(), &expected);
assert_eq!(checked.project(), &expected);
assert!(checked.warnings().is_empty());
assert_eq!(resolved.warnings(), prior.warnings());
}
assert_eq!(bundle, original);
}
@@ -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);
}
@@ -1,5 +1,5 @@
//! Private complete-Project admission from the actual complete syntax witness.
//! Consumed read-only typed index; no update authority, public source8 dispatch or replacement policy.
//! Complete-Project admission and opaque public read-only mixed source8 witness.
//! The separate typed index/edit chain remains test-only; no replacement policy.
use super::super::{
Binding, Budget, Error as TypedError, Expression, ExpressionKind, Reference, Span,
declarations as d, error, lower,
@@ -22,17 +22,108 @@ pub(super) fn text_producer_inventory(syntax: &ParsedMixedSyntax<'_>) -> Result<
accounting::syntax(syntax, &mut ledger)?;
Ok(ledger.live)
}
#[cfg(test)]
pub(in crate::language::analysis_source) mod ordinary_index;
/// An admitted mixed source8 model borrowing its complete original source bundle.
///
/// This non-Clone result grants immutable validation queries only. It is not an
/// editable source index, stabilized output, or permission to format/save a typed
/// model through the ordinary source APIs. All input UUIDs must be explicit.
///
/// The source cannot be mutated while a later query uses the result:
///
/// ```compile_fail
/// use oa_core::{language::check_mixed_bundle, source_project::{SourceBundle, ProjectLimits}};
/// let mut bundle = SourceBundle {
/// entry: "main.arcadia".into(),
/// files: [("main.arcadia".into(),
/// "arcadia 8; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" {}".into())].into(),
/// };
/// let checked = check_mixed_bundle(&bundle, ProjectLimits::default()).unwrap();
/// bundle.files.clear();
/// assert_eq!(checked.project().alias, "p");
/// ```
///
/// Nor can the result outlive that source:
///
/// ```compile_fail
/// use oa_core::{language::check_mixed_bundle, source_project::{SourceBundle, ProjectLimits}};
/// let checked = {
/// let bundle = SourceBundle {
/// entry: "main.arcadia".into(),
/// files: [("main.arcadia".into(),
/// "arcadia 8; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" {}".into())].into(),
/// };
/// check_mixed_bundle(&bundle, ProjectLimits::default()).unwrap()
/// };
/// assert_eq!(checked.project().alias, "p");
/// ```
pub struct CheckedMixed<'s> {
resolved: ResolvedMixedProject<'s>,
}
impl CheckedMixed<'_> {
/// The complete admitted model, without mutation or ownership extraction.
pub fn project(&self) -> &Project {
self.resolved.project()
}
/// Actual located non-error validation diagnostics in validation order.
pub fn warnings(&self) -> &[SourceDiagnostic] {
self.resolved.warnings()
}
}
/// Validate a complete mixed ordinary/typed source8 bundle without changing bytes.
///
/// Every project, ordinary/typed declaration and typed parameter requires an
/// explicit UUID. Supplied files must be canonical and reachable from the entry.
/// Errors return no partial admitted model. There is no migration, ID generation,
/// filesystem discovery, formatting or editable-index construction.
///
/// Caller policy is applied across the entire bundle with the existing bounded
/// source8 syntax/storage/numeric policy; this is not an all-heap memory bound.
/// Ordinary parse/compile/edit/save APIs and CLI discovery remain separate.
///
/// ```
/// use oa_core::{language::check_mixed_bundle, source_project::{SourceBundle, ProjectLimits}};
/// let bundle = SourceBundle {
/// entry: "main.arcadia".into(),
/// files: [("main.arcadia".into(),
/// "arcadia 8; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" {}".into())].into(),
/// };
/// let checked = check_mixed_bundle(&bundle, ProjectLimits::default()).unwrap();
/// assert_eq!(checked.project().alias, "p");
/// assert!(checked.warnings().is_empty());
/// ```
pub fn check_mixed_bundle(
bundle: &crate::source_project::SourceBundle,
limits: crate::source_project::ProjectLimits,
) -> Result<CheckedMixed<'_>, Vec<SourceDiagnostic>> {
let syntax = super::carried::parse_fresh(bundle, limits)?;
let resolved = resolve(syntax).map_err(|failure| failure.diagnostics)?;
Ok(CheckedMixed { resolved })
}
/// Read-only authority: every component originates in the consumed syntax.
pub(in crate::language::analysis_source) struct ResolvedMixedProject<'s> {
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
syntax: ParsedMixedSyntax<'s>,
project: Project,
warnings: Vec<SourceDiagnostic>,
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
static_limits: analysis::AdmissionLimits,
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
static_usage: analysis::AdmissionUsage,
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
expressions: Vec<lower::ExpressionSite>,
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
occurrences: Vec<lower::Occurrence>,
// Retained ownership/admission evidence; consumed by the private test-only edit chain.
#[cfg_attr(not(test), allow(dead_code))]
storage: Ledger,
}
impl ResolvedMixedProject<'_> {
@@ -51,10 +142,12 @@ impl ResolvedMixedProject<'_> {
/// Complete original bundle authority with a typed-only occurrence view.
/// Ordinary globals are catalogued, but their authored occurrences are not indexed.
/// This owner cannot be converted into the legacy typed-only edit authority.
#[cfg(test)]
pub(in crate::language::analysis_source) struct IndexedMixedProject<'s> {
resolved: ResolvedMixedProject<'s>,
data: super::super::index::IndexData,
}
#[cfg(test)]
impl<'s> ResolvedMixedProject<'s> {
pub fn index(mut self) -> Result<IndexedMixedProject<'s>, Failure> {
let input = IndexInput {
@@ -72,6 +165,7 @@ impl<'s> ResolvedMixedProject<'s> {
})
}
}
#[cfg(test)]
impl IndexedMixedProject<'_> {
pub fn project(&self) -> &Project {
self.resolved.project()
@@ -108,12 +202,14 @@ impl IndexedMixedProject<'_> {
/// Constructor-only borrowed adapter. Its private file table prevents fabrication;
/// no API returns it and no alternative source/token table can be installed.
#[cfg(test)]
pub(in crate::language::analysis_source) struct IndexInput<'a> {
files: &'a [FileSyntax<'a>],
pub(in crate::language::analysis_source) project: &'a Project,
pub(in crate::language::analysis_source) expressions: &'a [lower::ExpressionSite],
pub(in crate::language::analysis_source) occurrences: &'a [lower::Occurrence],
}
#[cfg(test)]
impl IndexInput<'_> {
pub(in crate::language::analysis_source) fn declarations(
&self,
@@ -140,6 +236,7 @@ pub(in crate::language::analysis_source) struct Failure {
}
impl Failure {
/// Canonical attribution when justified; otherwise a safe explicit start fallback.
#[cfg(test)]
fn index(syntax: &ParsedMixedSyntax<'_>, mut failure: lower::Failure) -> Self {
failure.file = failure.file.filter(|&f| f < syntax.files.len());
if failure.file.is_none_or(|file| {
@@ -194,7 +194,6 @@ pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
#[cfg(test)]
mixed: None,
};
let ast = parser.value().ok()?;
+82 -8
View File
@@ -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,
+812
View File
@@ -0,0 +1,812 @@
//! External consumer: the linked oa-core library is not built with cfg(test).
//! Expected models use authored public values, never a parser or formatter oracle.
use oa_core::analysis as a;
use oa_core::analysis_numeric::Exact;
use oa_core::language::{ParseLimits, Span};
use oa_core::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic};
pub use oa_core::*;
use std::collections::BTreeMap;
// This existing helper constructs independent public model values only. It does
// not import private parser/index/writer code or enable cfg(test) in oa-core.
#[path = "../src/language/analysis_source/model_oracle.rs"]
mod independent_model;
fn id(n: u128) -> Id {
Id::from_u128(n)
}
fn empty_project() -> Project {
let mut p = Project::empty();
p.id = id(1);
p.alias = "p".into();
p.name = "Project".into();
p
}
fn document(body: &str) -> String {
format!(
"arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n",
id(1)
)
}
fn single(body: &str) -> SourceBundle {
SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([("main.arcadia".into(), document(body))]),
}
}
fn succeeds(
bundle: &SourceBundle,
limits: ProjectLimits,
expected: &Project,
warnings: &[SourceDiagnostic],
) {
let original = bundle.clone();
let checked = language::check_mixed_bundle(bundle, limits)
.unwrap_or_else(|errors| panic!("expected complete read: {errors:#?}"));
assert_eq!(checked.project(), expected);
assert_eq!(checked.warnings(), warnings);
assert_eq!(bundle, &original, "read success preserves every input byte");
drop(checked);
assert_eq!(bundle, &original);
}
fn fails(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDiagnostic> {
let original = bundle.clone();
let errors = match language::check_mixed_bundle(bundle, limits) {
Ok(_) => panic!("invalid input returned a successful read witness"),
Err(errors) => errors,
};
assert!(!errors.is_empty());
assert_eq!(bundle, &original, "read refusal preserves every input byte");
errors
}
fn authored_span(source: &str, literal: &str) -> Span {
assert_eq!(
source.matches(literal).count(),
1,
"ambiguous authored span"
);
let start = source.find(literal).unwrap();
Span {
start,
end: start + literal.len(),
}
}
fn error_at(
bundle: &SourceBundle,
errors: &[SourceDiagnostic],
path: &str,
span: Span,
code: &str,
message: &str,
) {
assert_eq!(errors.len(), 1, "{errors:#?}");
let error = &errors[0];
assert_eq!(error.path, path);
assert_eq!(error.diagnostic.severity, validation::Severity::Error);
assert_eq!(error.diagnostic.code, code);
assert_eq!(error.diagnostic.message, message);
assert_eq!(error.diagnostic.span, span);
let source = bundle.files.get(path).map_or("", String::as_str);
let prefix = &source[..span.start];
assert_eq!(
error.diagnostic.line,
prefix.bytes().filter(|b| *b == b'\n').count() + 1
);
assert_eq!(
error.diagnostic.column,
prefix.rsplit('\n').next().unwrap().chars().count() + 1
);
}
fn rich(layer: Layer, crlf: bool) -> (SourceBundle, Project, Vec<SourceDiagnostic>) {
let (ordinary, typed, work) = match layer {
Layer::Oa => (
include_str!("../src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia"),
include_str!("../src/language/analysis_source/lower/fixtures/typed-oa.arcadia"),
"oa_activity",
),
Layer::Sa => (
include_str!("../src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia"),
include_str!("../src/language/analysis_source/lower/fixtures/typed-sa.arcadia"),
"sa_function",
),
};
let mut expected = independent_model::project(layer);
let mut main = ordinary
.replace("\r\n", "\n")
.replacen("arcadia 7;", "arcadia 8;", 1);
assert!(main.starts_with("arcadia 8;"));
// Forward typed uses refer back into nested ordinary Work in this file.
main = main.replacen("{\n", "{\n include \"typed.arcadia\";\n", 1);
if layer == Layer::Sa {
let system = Element {
id: id(999),
alias: "system".into(),
name: "System".into(),
description: String::new(),
parent: None,
body: ElementBody::Sa(SaBody::System),
};
let at = main.rfind('}').unwrap();
main.insert_str(
at,
&format!(
" sa_system system \"System\" id \"{}\" {{}}\n",
system.id
),
);
assert!(expected.elements.insert(system.id, system).is_none());
}
let mut typed = typed.replace("\r\n", "\n");
// Textual argument order is independent of the model's parameter-ID map.
assert!(typed.contains("@combine(a: @receive_time, b: @dispatch_time)"));
typed = typed.replace(
"@combine(a: @receive_time, b: @dispatch_time)",
"@combine(b: @dispatch_time, a: @receive_time)",
);
if crlf {
main = format!("// 港🌊\r\n{}", main.replace('\n', "\r\n"));
typed = typed.replace('\n', "\r\n");
}
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), main),
("typed.arcadia".into(), typed),
]),
};
// Two nested leaves, so four warnings, unlike the earlier one-Work fixture.
// Existing validation orders both allocation warnings before coverage warnings.
let mut warnings = Vec::new();
for allocation in [true, false] {
for (alias, display) in [("receive", "Receive"), ("dispatch", "Dispatch")] {
let element = expected
.elements
.values()
.find(|e| e.alias == alias)
.unwrap();
let literal = format!("{work} {alias} \"{display}\" id \"{}\" {{}}", element.id);
let source = &bundle.files["main.arcadia"];
let span = authored_span(source, &literal);
let prefix = &source[..span.start];
warnings.push(SourceDiagnostic {
path: "main.arcadia".into(), related: Vec::new(),
diagnostic: language::Diagnostic {
severity: validation::Severity::Warning,
code: if allocation { "unallocated_leaf" } else { "work_without_behavior_context" }.into(),
object_id: Some(element.id),
field: allocation.then(|| "allocation".into()),
message: if allocation { format!("Allocate leaf '{display}' to a performer.") } else {
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.".into()
},
span, line: prefix.bytes().filter(|b| *b == b'\n').count() + 1,
column: prefix.rsplit('\n').next().unwrap().chars().count() + 1,
},
});
}
}
(bundle, expected, warnings)
}
#[test]
fn public_mixed_read_complete_oa_sa_models_and_all_located_warnings() {
for layer in [Layer::Oa, Layer::Sa] {
for crlf in [false, true] {
let (bundle, expected, warnings) = rich(layer, crlf);
assert_eq!(expected.definitions.len(), 4);
assert_eq!(warnings.len(), 4);
succeeds(&bundle, ProjectLimits::default(), &expected, &warnings);
}
}
}
fn calc(alias: &str, number: u128, body: &str) -> String {
format!(
"calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}",
id(number),
id(1)
)
}
fn calculation_model(alias: &str, number: u128, body: a::Expr) -> a::Definition {
a::Definition {
id: id(number),
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),
}),
}
}
#[test]
fn public_mixed_read_scoped_uuid_reuse_and_cross_file_call_are_not_global_collisions() {
let mut bundle = single("include \"f.arcadia\"; include \"g.arcadia\";");
bundle
.files
.insert("f.arcadia".into(), calc("f", 2, "@g(x: $x)"));
bundle.files.insert("g.arcadia".into(), calc("g", 3, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model(
"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_model("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))),
);
succeeds(&bundle, ProjectLimits::default(), &expected, &[]);
}
#[test]
fn public_mixed_read_explicit_identity_classes_versions_and_long_forms() {
let empty = single("");
succeeds(&empty, ProjectLimits::default(), &empty_project(), &[]);
let ordinary = format!("oa_entity e \"Entity\" id \"{}\" {{}}", id(2));
let typed = calc("f", 3, "$x");
let valid = single(&format!("{ordinary}\n{typed}"));
assert!(language::check_mixed_bundle(&valid, ProjectLimits::default()).is_ok());
// Each deletion starts from the same valid complete input.
for (literal, expected_start) in [
(format!("id \"{}\"", id(1)), None),
(format!("id \"{}\"", id(2)), Some("oa_entity")),
(format!("id \"{}\"", id(3)), Some("calculation")),
] {
let mut missing = valid.clone();
let text = missing.files.get_mut("main.arcadia").unwrap();
*text = text.replacen(&literal, "", 1);
let start = match expected_start {
None => text.find('{').unwrap(),
Some("oa_entity") => {
text.find("oa_entity").unwrap()
+ text[text.find("oa_entity").unwrap()..].find('{').unwrap()
}
Some(word) => text.find(word).unwrap(),
};
let errors = fails(&missing, ProjectLimits::default());
error_at(
&missing,
&errors,
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Mixed source8 requires explicit UUIDs.",
);
}
let mut parameter = valid.clone();
let text = parameter.files.get_mut("main.arcadia").unwrap();
*text = text.replace(&format!("input x id \"{}\"", id(1)), "input x");
let start = text.find("calculation").unwrap();
error_at(
&parameter,
&fails(&parameter, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Mixed source8 requires explicit UUIDs.",
);
for version in ["3", "7", "9"] {
let mut bad = empty.clone();
*bad.files.get_mut("main.arcadia").unwrap() =
document("").replacen("arcadia 8", &format!("arcadia {version}"), 1);
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span { start: 8, end: 9 },
"invalid_source",
"Mixed source requires arcadia 8.",
);
}
for word in [
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
let bad = single(&format!("{word} v \"Value\" id \"{}\" {{}}", id(2)));
let errors = fails(&bad, ProjectLimits::default());
let start = bad.files["main.arcadia"].find(word).unwrap();
assert_eq!(errors[0].path, "main.arcadia");
assert_eq!(errors[0].diagnostic.code, "invalid_source");
assert_eq!(
errors[0].diagnostic.span,
Span {
start,
end: start + 1
}
);
assert!(
errors[0].diagnostic.message.contains("short"),
"{errors:#?}"
);
}
}
fn chain() -> SourceBundle {
let mut bundle = single("include \"part.arcadia\";");
bundle.files.insert(
"part.arcadia".into(),
"include \"last.arcadia\";\r\n".into(),
);
bundle.files.insert("last.arcadia".into(), "//é\r\n".into());
bundle
}
#[test]
fn public_mixed_read_exact_input_file_edge_and_depth_boundaries() {
let empty = single("");
assert_eq!(empty.files["main.arcadia"].len(), 78);
for cap in [78, 77] {
let limits = ProjectLimits {
model: ParseLimits {
max_source_bytes: cap,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
if cap == 78 {
succeeds(&empty, limits, &empty_project(), &[]);
} else {
error_at(
&empty,
&fails(&empty, limits),
"main.arcadia",
Span { start: 0, end: 1 },
"invalid_source",
"Source8 index/output byte limit exceeded.",
);
}
}
let bundle = chain();
assert_eq!(bundle.files.values().map(String::len).sum::<usize>(), 132);
let fits = ProjectLimits {
max_files: 3,
max_include_edges: 2,
max_include_depth: 2,
model: ParseLimits {
max_source_bytes: 132,
..ParseLimits::UNLIMITED
},
};
succeeds(&bundle, fits, &empty_project(), &[]);
for (limits, path, span, code, message) in [
(
ProjectLimits {
max_files: 2,
..fits
},
"main.arcadia",
Span { start: 0, end: 0 },
"project_limit",
"Source project exceeds 2 files.",
),
(
ProjectLimits {
max_include_edges: 1,
..fits
},
"part.arcadia",
Span { start: 0, end: 1 },
"project_limit",
"Include graph exceeds 1 edges.",
),
(
ProjectLimits {
max_include_depth: 1,
..fits
},
"part.arcadia",
Span { start: 0, end: 23 },
"project_limit",
"Include depth exceeds 1.",
),
(
ProjectLimits {
model: ParseLimits {
max_source_bytes: 131,
..fits.model
},
..fits
},
"main.arcadia",
Span { start: 0, end: 1 },
"invalid_source",
"Source8 index/output byte limit exceeded.",
),
] {
error_at(&bundle, &fails(&bundle, limits), path, span, code, message);
}
}
#[test]
fn public_mixed_read_paths_missing_cycles_and_detached_files_are_errors() {
let good = chain();
succeeds(&good, ProjectLimits::default(), &empty_project(), &[]);
let mut missing = good.clone();
missing.files.remove("last.arcadia");
error_at(
&missing,
&fails(&missing, ProjectLimits::default()),
"part.arcadia",
Span { start: 0, end: 23 },
"missing_include",
"Included file 'last.arcadia' is missing.",
);
let mut detached = good.clone();
detached
.files
.insert("unused.arcadia".into(), "// valid but detached\n".into());
error_at(
&detached,
&fails(&detached, ProjectLimits::default()),
"unused.arcadia",
Span { start: 0, end: 0 },
"unreachable_file",
"Supplied source file is not reachable from the entry file.",
);
let mut cycle = good.clone();
cycle
.files
.insert("last.arcadia".into(), "include \"part.arcadia\";".into());
error_at(
&cycle,
&fails(&cycle, ProjectLimits::default()),
"last.arcadia",
Span { start: 0, end: 23 },
"include_cycle",
"Include cycle: part.arcadia -> last.arcadia -> part.arcadia.",
);
let mut invalid = good.clone();
invalid
.files
.insert("../escape.arcadia".into(), String::new());
error_at(
&invalid,
&fails(&invalid, ProjectLimits::default()),
"main.arcadia",
Span { start: 0, end: 0 },
"invalid_path",
"Non-canonical source path '../escape.arcadia'.",
);
let mut collision = good;
collision
.files
.insert("PART.arcadia".into(), "// detached uppercase\n".into());
let errors = fails(&collision, ProjectLimits::default());
error_at(
&collision,
&errors,
"main.arcadia",
Span { start: 0, end: 0 },
"duplicate_path",
"Case-colliding source paths 'PART.arcadia' and 'part.arcadia'.",
);
}
#[test]
fn public_mixed_read_final_nested_model_limit_is_distinct_from_syntax_entries() {
let bundle = single(&calc("f", 2, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))),
);
// Two declarations fit both policies. Parameter and expression make four
// final records; this does not pretend to set the internal entry quota.
let limits = ProjectLimits {
model: ParseLimits {
max_records: 4,
max_graph_objects: 1,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
succeeds(&bundle, limits, &expected, &[]);
let errors = fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_records: 3,
..limits.model
},
..limits
},
);
error_at(
&bundle,
&errors,
"main.arcadia",
Span { start: 0, end: 1 },
"model_limit",
&format!(
"Model exceeds admission limits (1 graph objects, 3 total records, {} ordered references).",
usize::MAX
),
);
let errors = fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_graph_objects: 0,
..limits.model
},
..limits
},
);
assert_eq!(errors[0].diagnostic.code, "model_limit");
let start = bundle.files["main.arcadia"].find("calculation").unwrap();
assert_eq!(
errors[0].diagnostic.span,
Span {
start,
end: start + 1
}
);
}
#[test]
fn public_mixed_read_typed_reference_caller_allowance_has_a_real_positive_control() {
let bundle = single(&calc("f", 2, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))),
);
let fits = ProjectLimits {
model: ParseLimits {
max_step_references: 1,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
succeeds(&bundle, fits, &expected, &[]);
let start = bundle.files["main.arcadia"].find("$x").unwrap() + 1;
error_at(
&bundle,
&fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_step_references: 0,
..fits.model
},
..fits
},
),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Source8 references limit exceeded.",
);
}
#[test]
fn public_mixed_read_malformed_duplicate_and_wrong_domain_references() {
let calculation = calc("f", 2, "1");
let valid = single(&calculation);
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Number(Exact::from_integer(1))),
);
succeeds(&valid, ProjectLimits::default(), &expected, &[]);
let mut malformed = valid.clone();
*malformed.files.get_mut("main.arcadia").unwrap() = document(&calc("f", 2, ""));
let start = malformed.files["main.arcadia"].find("body ;").unwrap() + 5;
error_at(
&malformed,
&fails(&malformed, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Expected an analysis expression.",
);
let mut unresolved = single("include \"typed.arcadia\";");
unresolved
.files
.insert("typed.arcadia".into(), calc("f", 2, "@missing(x: 1)"));
let start = unresolved.files["typed.arcadia"].find("@missing").unwrap() + 1;
error_at(
&unresolved,
&fails(&unresolved, ProjectLimits::default()),
"typed.arcadia",
Span {
start,
end: start + 7,
},
"invalid_source",
"Unknown global reference '@missing'.",
);
let ordinary = format!("oa_entity q \"Entity\" id \"{}\" {{}}", id(3));
let wrong = single(&format!("{ordinary}\n{}", calc("f", 2, "@q(x: 1)")));
let start = wrong.files["main.arcadia"].find("@q").unwrap() + 1;
error_at(
&wrong,
&fails(&wrong, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Wrong reference domain for '@q': Element(Entity, Oa).",
);
let duplicate = single(&format!("{calculation}\n{}", calc("f", 3, "1")));
let errors = fails(&duplicate, ProjectLimits::default());
let second = calc("f", 3, "1");
let span = authored_span(&duplicate.files["main.arcadia"], &second);
error_at(
&duplicate,
&errors,
"main.arcadia",
span,
"invalid_source",
"Duplicate global alias.",
);
assert_eq!(errors[0].related.len(), 1);
assert_eq!(errors[0].related[0].path, "main.arcadia");
assert_eq!(
errors[0].related[0].span,
authored_span(&duplicate.files["main.arcadia"], &calculation)
);
let second = calc("g", 2, "1");
let duplicate_id = single(&format!("{calculation}\n{second}"));
error_at(
&duplicate_id,
&fails(&duplicate_id, ProjectLimits::default()),
"main.arcadia",
authored_span(&duplicate_id.files["main.arcadia"], &second),
"invalid_source",
"Duplicate global identity.",
);
let duplicate_parameter = calc("f", 2, "$x").replace(
"result Number;",
&format!("input x id \"{}\" : Number; result Number;", id(4)),
);
let bad = single(&duplicate_parameter);
let at = bad.files["main.arcadia"].rfind("input x").unwrap() + 6;
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span {
start: at,
end: at + 1,
},
"invalid_source",
"Duplicate parameter name.",
);
let second_parameter = format!("input y id \"{}\" : Number;", id(1));
let duplicate_parameter_id = calc("f", 2, "$x").replace(
"result Number;",
&format!("{second_parameter} result Number;"),
);
let bad = single(&duplicate_parameter_id);
let at = authored_span(&bad.files["main.arcadia"], &second_parameter).start + 11;
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span {
start: at,
end: at + 1,
},
"invalid_source",
"Duplicate parameter identity.",
);
}
#[test]
fn public_mixed_read_unicode_columns_are_not_utf8_byte_columns() {
let source = "arcadia 8;\r\nproject p \"港🌊\" id \"00000000-0000-0000-0000-000000000001\" { oa_activity w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {} }\r\n";
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([("main.arcadia".into(), source.into())]),
};
let mut expected = empty_project();
expected.name = "港🌊".into();
expected.elements.insert(
id(2),
Element {
id: id(2),
alias: "w".into(),
name: "Work".into(),
description: String::new(),
parent: None,
body: ElementBody::Oa(OaBody::Activity),
},
);
let warnings = [
("unallocated_leaf", Some("allocation"), "Allocate leaf 'Work' to a performer."),
("work_without_behavior_context", None, "Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence."),
].map(|(code, field, message)| SourceDiagnostic {
path: "main.arcadia".into(), related: Vec::new(),
diagnostic: language::Diagnostic { severity: validation::Severity::Warning, code: code.into(), object_id: Some(id(2)), field: field.map(str::to_owned), message: message.into(), span: Span { start: 76, end: 141 }, line: 2, column: 60 },
});
succeeds(&bundle, ProjectLimits::default(), &expected, &warnings);
}
#[test]
fn public_mixed_read_ordinary_parity_uses_an_independent_complete_model() {
let b = single(&format!(
"oa_entity team \"Team\" id \"{}\" {{ oa_actor sailor \"Sailor\" id \"{}\" {{ description \"海\"; }} }}",
id(2),
id(3)
));
let mut expected = empty_project();
for (n, alias, name, description, parent, body) in [
(2, "team", "Team", "", None, ElementBody::Oa(OaBody::Entity)),
(
3,
"sailor",
"Sailor",
"海",
Some(id(2)),
ElementBody::Oa(OaBody::Actor),
),
] {
expected.elements.insert(
id(n),
Element {
id: id(n),
alias: alias.into(),
name: name.into(),
description: description.into(),
parent,
body,
},
);
}
succeeds(&b, ProjectLimits::default(), &expected, &[]);
let ordinary = language::parse_with_report(&b.files["main.arcadia"]).unwrap();
assert!(ordinary.migration.is_none());
assert_eq!(ordinary.project, expected);
let compiled = source_project::compile_bundle(&b, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected);
assert_eq!(compiled.bundle, b);
}
+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(_))
));
}
+25
View File
@@ -633,6 +633,31 @@ a { text-decoration: none; }
<dd class="text-suffix"><p><span class="docutils literal">.arcadia</span></p>
</dd>
</dl>
<section id="staged-source8-rust-read-api">
<h2>Staged source8 Rust read API</h2>
<p>The core library separately offers <span class="docutils literal"><span class="pre">language::check_mixed_bundle(&amp;SourceBundle,</span> ProjectLimits)</span> for complete ordinary/typed source8 validation. Its opaque
<span class="docutils literal">CheckedMixed</span> result borrows the unchanged input and exposes only <span class="docutils literal">project()</span>
and <span class="docutils literal">warnings()</span>. Explicit UUIDs are required for the project, every ordinary
and typed declaration, and typed parameters; no IDs are generated. All supplied
canonical files must be reachable from the entry. Errors return no admitted
model; success preserves actual located warnings and the complete model.</p>
<p>This bounded API selects both retained text and entry producer checks and carries
the caller's whole-project policy through admission. It does not promise an
all-heap memory bound. It performs no filesystem discovery, migration, formatting,
source stabilization, indexing, saving or history installation. Existing ordinary
parse/compile/update APIs, CLI commands, desktop and server flows remain separate;
a read result does not grant their typed editing or save authority. Generic and
typed depth policies and platform qualification limits remain unchanged. This
staged capability does not replace the broader conformance obligations below.</p>
<p>For filesystem input, <span class="docutils literal">arcadia <span class="pre">check-mixed</span> ROOT <span class="pre">[--json]</span></span> discovers included
files with the same mixed grammar and the existing disk/path/journal safeguards,
then checks the complete captured bundle. It requires explicit UUIDs and schema8;
<span class="docutils literal"><span class="pre">--write</span></span> is rejected. The discovery pass shares a conservative cumulative
resource budget across files and ends before a fresh complete-validation pass.
It does not claim one combined numeric-work allowance or an atomic snapshot
against concurrent external edits. Ordinary check/lint/edit commands retain their
existing contracts. See <a class="reference external" href="source-cli.md">Source CLI</a> for command use and limits.</p>
</section>
<section id="scope-and-conformance">
<h2>1. Scope and conformance</h2>
<p>This specification defines the shared Operational Analysis (OA) and System
+29
View File
@@ -6,6 +6,35 @@ Arcadia Studio OA / SA Language and Editing Contract
:Core implementation: ``crates/oa-core``
:Text suffix: ``.arcadia``
Staged source8 Rust read API
----------------------------------------
The core library separately offers ``language::check_mixed_bundle(&SourceBundle,
ProjectLimits)`` for complete ordinary/typed source8 validation. Its opaque
``CheckedMixed`` result borrows the unchanged input and exposes only ``project()``
and ``warnings()``. Explicit UUIDs are required for the project, every ordinary
and typed declaration, and typed parameters; no IDs are generated. All supplied
canonical files must be reachable from the entry. Errors return no admitted
model; success preserves actual located warnings and the complete model.
This bounded API selects both retained text and entry producer checks and carries
the caller's whole-project policy through admission. It does not promise an
all-heap memory bound. It performs no filesystem discovery, migration, formatting,
source stabilization, indexing, saving or history installation. Existing ordinary
parse/compile/update APIs, CLI commands, desktop and server flows remain separate;
a read result does not grant their typed editing or save authority. Generic and
typed depth policies and platform qualification limits remain unchanged. This
staged capability does not replace the broader conformance obligations below.
For filesystem input, ``arcadia check-mixed ROOT [--json]`` discovers included
files with the same mixed grammar and the existing disk/path/journal safeguards,
then checks the complete captured bundle. It requires explicit UUIDs and schema8;
``--write`` is rejected. The discovery pass shares a conservative cumulative
resource budget across files and ends before a fresh complete-validation pass.
It does not claim one combined numeric-work allowance or an atomic snapshot
against concurrent external edits. Ordinary check/lint/edit commands retain their
existing contracts. See `Source CLI <source-cli.md>`_ for command use and limits.
1. Scope and conformance
------------------------
+40
View File
@@ -64,3 +64,43 @@ are not a claim of interactive performance at the maximum project size.
For the notation itself, start with [the text-first examples](../examples/text-first/README.md)
and the [language contract](language.rst).
## Read-only mixed source8 checking
```sh
arcadia check-mixed path/to/main.arcadia --json
```
Use `check-mixed` for source8 containing ordinary declarations and typed analysis
(quantity kinds, units, calculations, properties, checks and cases). Every
project, ordinary declaration, typed definition and scoped parameter must have
an explicit UUID. This command does not migrate older headers, allocate UUIDs,
format, preview edits or save. `--write` is a usage error. Existing `check`,
`lint`, `stabilize` and `rename-alias` retain their ordinary-source behavior.
The loader discovers actual includes with the source grammar and existing
portable-path, regular-file, bounded-read and pending-journal checks. It captures
original bytes, runs a bounded discovery syntax pass, then checks the complete
borrowed bundle in a separate bounded verification pass. A discovery diagnostic
prevents complete verification; the first mixed syntax/work failure stops
further discovery parsing. Both passes derive limits from the same CLI policy;
numeric work is bounded per pass, not once across the whole command. Logical
producer limits are not a bound on all allocations or process memory.
The format-1 JSON envelope uses operation `check-mixed`, status `checked` or
`invalid` for completed source checking, and existing `rejected` for direct disk
errors. `changes` is empty, `written` is false, `identities_to_stabilize` is zero,
`migration` is null and `notes` is empty. Located diagnostics refer to original
`input` bytes, with half-open UTF-8 byte spans and one-based Unicode-scalar
line/column coordinates. Direct filesystem failures use the existing operation
coordinate space. Engineering warnings permit exit 0; invalid source/semantics
exit 1, usage exits 2, and disk/path/missing-file/pending-journal failures exit 3.
Invalid UTF-8 discovery diagnostics retain the existing source-error exit 1.
Human output goes to stderr and ends with `checked: mixed source8 check; no files
changed` on success, after any warnings. No source bytes or directory entries
are written, and pending saves are never recovered implicitly. Checking captured
bytes does not establish an atomic multi-file snapshot against concurrent writers.
This command provides validation only; mixed editing, adoption, GUI and history
integration remain outside this interface.
@@ -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.
@@ -0,0 +1,65 @@
# Mixed CLI read interface
Implements the selected `harness/analysis-mixed-cli-contract-20260925.md` contract
in the integration owner's root records. Local implementation checkpoint;
initial execution evidence and remaining acceptance boundaries are recorded below.
`oa_cli::check_mixed(&Path) -> Report` loads `MixedSourceSnapshot`, checks its
borrowed original bundle only when discovery diagnostics are empty, and copies
only returned diagnostics into the existing format-1 report. It never constructs
`Prepared`, compiles ordinary source, stabilizes identities, migrates or saves.
The closed `Dispatch` enum separates `CheckMixed` from `Editable { operation,
write }`; `Operation` and `prepare` retain their existing ordinary semantics.
Reports preserve input locations and explicit object IDs. Successful warnings
are retained. Source errors use `invalid`/1, loader missing/path/journal diagnostic
refusals use `invalid`/3, and direct `SourceFileError` retains the existing
`rejected`/3 mapping. Fixed fields are empty changes/notes, false written, zero
identity stabilization, and null migration. Human output names a mixed source8
check, with no edit-candidate summary. `--write`, unknown/repeated options and
wrong arity cannot enter either loader or preparation through mixed dispatch.
Six external `mixed_cli` tests execute the real Cargo-provided binary. The rich
OA/SA fixtures reuse previously authored ordinary/typed literals and independently
constructed public model values, with an explicit SA System, reordered call
labels, escaped include paths, decoy include comments, Unicode and LF/CRLF.
Their complete returned models and four ordered leaf warnings are checked before
complete CLI report comparison. The four warnings represent two leaf declarations
(allocation then coverage); they do not replace the earlier one-leaf oracle.
Other cases cover all four missing-UUID classes, schema refusal, cross-file
semantic failure, missing/cyclic/escaping includes, invalid UTF-8, pending journal,
reserved root, human output and option refusal. Each subprocess compares complete
file bytes and directory inventory before/after, including unrelated files.
Platform-specific OS missing-file text is obtained from an independent filesystem
metadata error; no parser, formatter or CLI output generates expected reports.
The parent owns compilation/runtime acceptance. This source note does not imply
Linux coverage, full mixed editing, GUI adoption or an atomic disk snapshot.
## Initial execution and narrow test corrections
Parent-run source `f2324822936374c5b9092518b986375233dc7882` passed all five
`mixed_source_files` external tests and all six `mixed_cli` executable tests.
The mixed test import nevertheless produced `hidden_glob_reexports` because
`std::process::Command` shadowed the public core `Command` re-export used by the
independent model helper. The local name is now `ProcessCommand`; behavior and
expectations are unchanged.
The initial existing `source_cli` suite completed 10 passes and two failures:
`complete_alarm_example_preview_then_write_and_fixed_point` compared the whole
ID-stripped output against an unchanged schema7 header, and
`rename_shows_missing_identity_and_separate_source_migration` expected target
schema7. Ordinary source has already selected schema8. The correction preserves
the original alarm fixture, explicitly asserts its schema7 prefix and compares
all stripped bytes against exactly one header-token replacement to schema8.
It also asserts the preview's 7→8 migration report. The 40 identities, unchanged
preview, complete saved text and repeated-save fixed-point assertions remain.
The rename fixture still originates at schema5; only its expected migration
target and added diff line change to schema8. No production changes accompany
these evaluator corrections.
Original logs/receipts remain under the integration owner's
`.local/analysis-mixed-cli-gates-20260925/`, including
`mixed-cli-integration-suite-source_cli-f232482-1.*`. This records actual initial
failures rather than claiming they passed; corrected-source reruns and final
acceptance remain the parent's responsibility.
@@ -0,0 +1,63 @@
# Public mixed read implementation
Base7b06b8ea, selected root contract bcddaba3. Public
language::check_mixed_bundle returns opaque non-Clone CheckedMixed borrowing the
complete original SourceBundle, with only immutable project/warnings accessors.
Its private resolved field retains syntax, expression/occurrence vectors, ledger
and admission evidence; there is no index, output, save or extraction authority.
The shared parser mixed hooks now compile in production. Ordinary callers still
select None. The fresh constructor binds the actual bundle and exact ProjectLimits,
derives existing internal policy once, selects Some(Borrowed) with zero carry and
both producers, then consumes syntax through complete resolution/admission.
The borrowed guard retains the actual source reference even when test-only
Pending/Retained variants are absent. No duplicate live policy/model is added.
Public schema/UUID requirement wording replaces private-staging wording only;
all limiter phase/category/span transport remains inherited.
Private legacy index/update/write, ordinary-index/rename/emission/acceptance and
fixture/pending constructors remain test-only. Shared parse/lower code remains
private. Necessary retained fields have individual documented non-test dead-code
allowances; no module-wide suppression, fake read, or new public lint-only API.
Exact production compiler lint findings remain a parent gate, not inferred clean
from source inspection. Existing298 tests are not edited.
One internal public_read test invokes the actual production parse helper and
observes borrowedC0, both producer inventories, every inherited/derived policy
field (including262144 entries independent of model records1), full empty-Project
and warning parity, Usage/numeric parity and unchanged input. Three rustdoc blocks
provide a compiling real usage example plus source-drop and source-mutation
compile-fail cases with later result use. Independent worker owns the non-test
mixed_source8_read integration target and its separate complete-model oracles.
Established docs/language.rst gains a bounded staged-read note without changing
its broader conformance/version obligations. Existing docutils HTML5 generation
with halt/report level2 regenerated language.html successfully. An initial local
RST generation refused the new section formatting; the section separator and
blank line were corrected before successful regeneration. No Rust compiler,
Cargo test, CLI, debugger or publication was run by this worker.
Parent owns pinned compilation, all old/new runtime tests, doctests, strict
Clippy, formatting disposition and production Parser stack regression checks.
DEPTH_128_VALIDATED=false, existing depth policies, logical-accounting exclusions,
full-pipeline limitation and deferred Linux qualification remain unchanged.
## First non-test compile correction
Parent's integration-list build at ddf823a stopped with E0063: the semantic review
row-key reader's Parser initializer still gated mixed:None under cfg(test).
That initializer now supplies None in production too, preserving its ordinary
facet-reading behavior. Source inventory found the other shared Parser initializers
in language.rs and mixed.rs already coherent; language::legacy uses its own
separate Parser type. No API, test or runtime policy changed. The failed build
record remains preserved; no compiler was run for this repair by the worker.
## Non-test integration result and warning cleanup
Parent's actual non-test integration suite at 8555e417 passed all ten tests with
the independent complete-model and diagnostic assertions unchanged. Compilation
reported an unused Budget::limits observer and a redundant private language self
import shadowing the integration helper's public glob export. The observer is
now cfg(test), matching all real callers; the integration import drops only self.
No semantic implementation, policy, fixture or expected result changed. Parent
explicitly authorized that one test import-line edit after its independent worker
finished. This is warning cleanup, not a claim that later lint/full/doctest gates
already passed. No compiler was run by this worker.