Compare commits

...
16 changed files with 1457 additions and 64 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);
+1
View File
@@ -5,6 +5,7 @@ mod semantic_source;
mod source3_bridge;
// Mixed source8 is exposed only through the separate borrowed read-only checker.
mod analysis_source;
pub(crate) use analysis_source::MixedDiscovery;
pub use analysis_source::{CheckedMixed, check_mixed_bundle};
#[path = "migration_v2/source.rs"]
mod v2_source;
@@ -118,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
#[cfg(test)]
mod tests;
pub(crate) use mixed::discovery::MixedDiscovery;
@@ -395,30 +395,15 @@ fn parse_impl<'s>(
let failure = |error| located(path, source, ordinary_error(error));
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
let metadata_before = budget.producer_entries();
let mut parser = Parser {
let (ordinary, state) = parse_unit(
source,
tokens: Vec::new(),
cursor: 0,
value_depth: 0,
limits: limits.model,
mixed: Some(State {
source,
tokens: &tokens,
budget: &mut budget,
model: &mut model,
typed: Vec::new(),
value_ends: BTreeMap::new(),
lexical_owners: Vec::new(),
pending_projection: 0,
reserved_values: 0,
reserved_owners: 0,
}),
};
let ordinary = parser
.unit(path == &bundle.entry, true)
.map_err(|e| located(path, source, e))?;
let state = parser.mixed.take().expect("private parser state");
drop(parser);
&tokens,
&mut budget,
&mut model,
limits.model,
path == &bundle.entry,
)
.map_err(|e| located(path, source, e))?;
// Projected ordinary AST nodes/fields are bounded before constructing a
// second position representation. Count references as slots, not syntax.
let projection_slots = if entry_producers {
@@ -564,3 +549,40 @@ fn ordinary_position_slots(unit: &SourceUnit) -> usize {
mod tests;
pub(super) mod project;
/// Shared grammar setup only. Complete parsing still retains/projects its state;
/// discovery drops that state after moving includes without a projection.
fn parse_unit<'a>(
source: &'a str,
tokens: &'a [lex::Token],
budget: &'a mut Budget,
model: &'a mut ModelCounts,
limits: ParseLimits,
entry: bool,
) -> Result<(SourceUnit, State<'a>), Error> {
let mut parser = Parser {
source,
tokens: Vec::new(),
cursor: 0,
value_depth: 0,
limits,
mixed: Some(State {
source,
tokens,
budget,
model,
typed: Vec::new(),
value_ends: BTreeMap::new(),
lexical_owners: Vec::new(),
pending_projection: 0,
reserved_values: 0,
reserved_owners: 0,
}),
};
let ordinary = parser.unit(entry, true)?;
let state = parser.mixed.take().expect("mixed parser state");
drop(parser);
Ok((ordinary, state))
}
pub(crate) mod discovery;
@@ -0,0 +1,99 @@
//! A separate conservative discovery phase; no model admission or edit positions.
use super::*;
use crate::source_project::{IncludeLocation, SourceLocation, resolve_include};
pub(crate) struct MixedDiscovery {
budget: Budget,
model: ModelCounts,
failed: Option<Vec<SourceDiagnostic>>,
}
impl MixedDiscovery {
pub(crate) fn new(limits: ProjectLimits) -> Result<Self, String> {
let mut budget = Budget::new(source_profile(limits)).map_err(|e| e.message)?;
budget.enable_text_producers(0).map_err(|e| e.message)?;
budget.enable_entry_producers(0, 0).map_err(|e| e.message)?;
Ok(Self {
budget,
model: ModelCounts {
limits,
graph: 0,
records: 0,
includes: 0,
},
failed: None,
})
}
pub(crate) fn inspect(
&mut self,
path: &str,
source: &str,
entry: bool,
) -> Result<Vec<IncludeLocation>, Vec<SourceDiagnostic>> {
if let Some(failed) = &self.failed {
return Err(failed.clone());
}
let result = self.inspect_unit(path, source, entry);
if let Err(failed) = &result {
self.failed = Some(failed.clone());
}
result
}
fn inspect_unit(
&mut self,
path: &str,
source: &str,
entry: bool,
) -> Result<Vec<IncludeLocation>, Vec<SourceDiagnostic>> {
let failure = |error| located(path, source, ordinary_error(error));
// Conservative per-inspected-file reservation, before its scanner/parser.
self.budget
.reserve_metadata(1, Span::default())
.map_err(failure)?;
let tokens = lex::tokens(source, &mut self.budget).map_err(failure)?;
let limits = self.model.limits.model;
let (ordinary, state) = parse_unit(
source,
&tokens,
&mut self.budget,
&mut self.model,
limits,
entry,
)
.map_err(|e| located(path, source, e))?;
// No unit-drop refunds: remaining reservations are cumulative phase work.
// In-parser quoted/UUID/key temporary release behavior is unchanged.
drop(state);
let mut includes = Vec::new();
for (raw, span) in ordinary.includes {
let target = resolve_include(path, &raw).map_err(|message| {
let mut diagnostic = language::diagnostic(
source,
Error {
code: "invalid_path",
offset: span.start,
message,
},
);
diagnostic.span = span;
vec![SourceDiagnostic {
path: path.into(),
diagnostic,
related: Vec::new(),
}]
})?;
includes.push(IncludeLocation {
location: SourceLocation {
path: path.into(),
span,
},
target,
});
}
Ok(includes)
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,144 @@
use super::*;
const MAIN: &str = "arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\ninclude \"part.arcadia\";\n}\n";
const PART: &str = "include \"last.arcadia\";\r\n";
const LAST: &str = "//é\r\n";
#[test]
fn mixed_discovery_accumulates_work_and_reservations_after_unit_drop() {
let limits = ProjectLimits {
model: ParseLimits {
max_source_bytes: 4096,
max_records: 1,
max_graph_objects: 3,
max_step_references: 17,
},
max_files: 7,
max_include_depth: 9,
max_include_edges: 11,
};
let mut phase = MixedDiscovery::new(limits).unwrap();
let profile = phase.budget.limits();
assert_eq!(
(
profile.bytes,
profile.tokens,
profile.entries,
profile.references,
profile.frames,
profile.numeric_units
),
(4096, 4096, 262144, 17, 256, 50_000_000)
);
assert_eq!(phase.budget.producer_text(), Some((0, 0)));
assert_eq!(phase.budget.producer_entries(), Some(0));
let includes = phase.inspect("main.arcadia", MAIN, true).unwrap();
assert_eq!(includes.len(), 1);
assert_eq!(includes[0].target, "part.arcadia");
drop(includes);
assert_eq!(phase.budget.producer_text(), Some((20, 0)));
assert_eq!(phase.budget.producer_entries(), Some(1));
assert_eq!(phase.budget.usage.entries, 2);
let includes = phase.inspect("part.arcadia", PART, false).unwrap();
assert_eq!(includes[0].target, "last.arcadia");
drop(includes);
assert_eq!(phase.budget.producer_text(), Some((32, 0)));
assert!(
phase
.inspect("last.arcadia", LAST, false)
.unwrap()
.is_empty()
);
assert_eq!(
(
phase.budget.usage.bytes,
phase.budget.usage.tokens,
phase.budget.usage.entries,
phase.budget.usage.references
),
(132, 31, 3, 0)
);
assert_eq!(phase.budget.producer_entries(), Some(3));
assert_eq!(phase.budget.producer_text(), Some((32, 0)));
assert_eq!(phase.budget.numeric_units(), 0);
assert_eq!(
(phase.model.records, phase.model.graph, phase.model.includes),
(1, 0, 2)
);
assert_eq!(phase.model.limits.max_files, 7);
assert_eq!(phase.model.limits.max_include_depth, 9);
assert_eq!(phase.model.limits.max_include_edges, 11);
assert_eq!(phase.model.limits.model.max_records, 1);
assert_eq!(phase.model.limits.model.max_graph_objects, 3);
}
#[test]
fn mixed_discovery_cumulative_entry_boundary_and_terminal_original_failure() {
// Deliberate private constructor fixture: fresh policy, never a widened owner.
for cap in [6, 5] {
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
phase.budget = Budget::new(Limits {
entries: cap,
bytes: 4096,
..source_profile(ProjectLimits::default())
})
.unwrap();
phase.budget.enable_text_producers(0).unwrap();
phase.budget.enable_entry_producers(0, 0).unwrap();
phase.inspect("main.arcadia", MAIN, true).unwrap();
phase.inspect("part.arcadia", PART, false).unwrap();
let last = phase.inspect("last.arcadia", LAST, false);
if cap == 6 {
assert!(last.unwrap().is_empty());
} else {
let errors = last.unwrap_err();
assert_eq!(errors[0].path, "last.arcadia");
assert_eq!(errors[0].diagnostic.span, Span { start: 0, end: 1 });
assert!(errors[0].diagnostic.message.contains("entry limit"));
let usage = phase.budget.usage;
assert_eq!(
phase
.inspect("later.arcadia", "malformed", false)
.unwrap_err(),
errors
);
assert_eq!(phase.budget.usage, usage);
}
}
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
let errors = phase
.inspect("broken.arcadia", "arcadia 8; project p \"P\" {}", true)
.unwrap_err();
assert_eq!(
errors[0].diagnostic.message,
"Mixed source8 requires explicit UUIDs."
);
let usage = phase.budget.usage;
assert_eq!(
phase.inspect("later.arcadia", LAST, false).unwrap_err(),
errors
);
assert_eq!(phase.budget.usage, usage);
}
#[test]
fn mixed_discovery_numeric_and_reference_work_is_not_restarted_per_unit() {
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
for (alias, number) in [("f", 2), ("g", 3)] {
let source = format!(
"calculation {alias} \"C\" id \"00000000-0000-0000-0000-{number:012}\" {{ input x id \"00000000-0000-0000-0000-000000000001\" : Number; result Number; body $x + 1; }}"
);
let before = phase.budget.numeric_units();
phase
.inspect(&format!("{alias}.arcadia"), &source, false)
.unwrap();
if number == 2 {
assert!(phase.budget.numeric_units() > 0);
} else {
assert_eq!(phase.budget.numeric_units(), 2 * before);
}
assert_eq!(phase.budget.usage.references, number - 1);
assert_eq!(phase.budget.producer_text().unwrap().1, 0);
}
assert_eq!(phase.model.records, 2);
}
+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,
+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(_))
));
}
+8
View File
@@ -649,6 +649,14 @@ parse/compile/update APIs, CLI commands, desktop and server flows remain separat
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>
+9
View File
@@ -26,6 +26,15 @@ 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.