Add explicit read-only mixed source8 CLI checking
This commit is contained in:
+66
-11
@@ -170,6 +170,47 @@ fn disk_error(report: &mut Report, error: SourceFileError) {
|
|||||||
report.fail(3, code, error.to_string());
|
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.
|
/// A candidate owns its original expectations and is consumed by saving.
|
||||||
/// Callers cannot replace its source/model/expectations after preparation.
|
/// Callers cannot replace its source/model/expectations after preparation.
|
||||||
pub struct Prepared {
|
pub struct Prepared {
|
||||||
@@ -406,14 +447,19 @@ fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only mixed checking cannot acquire ordinary edit/write authority.
|
||||||
|
pub enum Dispatch {
|
||||||
|
Editable { operation: Operation, write: bool },
|
||||||
|
CheckMixed,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Arguments {
|
pub struct Arguments {
|
||||||
pub entry: PathBuf,
|
pub entry: PathBuf,
|
||||||
pub operation: Operation,
|
pub dispatch: Dispatch,
|
||||||
pub write: bool,
|
|
||||||
pub json: bool,
|
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> {
|
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||||
let mut positional = Vec::new();
|
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 command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
let operation = match (command, positional.len()) {
|
let dispatch = match (command, positional.len()) {
|
||||||
("check" | "lint", 2) if !write => Operation::Check,
|
("check-mixed", 2) if !write => Dispatch::CheckMixed,
|
||||||
("stabilize", 2) => Operation::Stabilize,
|
("check" | "lint", 2) if !write => Dispatch::Editable {
|
||||||
("rename-alias", 4) => Operation::RenameAlias {
|
operation: Operation::Check,
|
||||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
write,
|
||||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
},
|
||||||
|
("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()),
|
_ => return Err(HELP.into()),
|
||||||
};
|
};
|
||||||
Ok(Arguments {
|
Ok(Arguments {
|
||||||
entry: PathBuf::from(positional[1]),
|
entry: PathBuf::from(positional[1]),
|
||||||
operation,
|
dispatch,
|
||||||
write,
|
|
||||||
json,
|
json,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-19
@@ -1,4 +1,4 @@
|
|||||||
use oa_cli::{Report, arguments, prepare};
|
use oa_cli::{Dispatch, Report, arguments, check_mixed, prepare};
|
||||||
use std::{
|
use std::{
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
path::Path,
|
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
|
d.severity, d.code, d.coordinate_space, d.message
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
writeln!(
|
if report.operation == "check-mixed" {
|
||||||
err,
|
writeln!(
|
||||||
"{}: {} file changes; {} identities to stabilize",
|
err,
|
||||||
report.status,
|
"{}: mixed source8 check; no files changed",
|
||||||
report.changes.len(),
|
report.status
|
||||||
report.identities_to_stabilize
|
)?;
|
||||||
)?;
|
} else {
|
||||||
|
writeln!(
|
||||||
|
err,
|
||||||
|
"{}: {} file changes; {} identities to stabilize",
|
||||||
|
report.status,
|
||||||
|
report.changes.len(),
|
||||||
|
report.identities_to_stabilize
|
||||||
|
)?;
|
||||||
|
}
|
||||||
if let Some(migration) = &report.migration {
|
if let Some(migration) = &report.migration {
|
||||||
writeln!(
|
writeln!(
|
||||||
err,
|
err,
|
||||||
@@ -67,19 +75,22 @@ fn run() -> io::Result<u8> {
|
|||||||
let mut displayed = false;
|
let mut displayed = false;
|
||||||
let report = match arguments(&args) {
|
let report = match arguments(&args) {
|
||||||
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
||||||
Ok(args) => match prepare(&args.entry, args.operation) {
|
Ok(args) => match args.dispatch {
|
||||||
Err(report) => *report,
|
Dispatch::CheckMixed => check_mixed(&args.entry),
|
||||||
Ok(prepared) => {
|
Dispatch::Editable { operation, write } => match prepare(&args.entry, operation) {
|
||||||
if args.write {
|
Err(report) => *report,
|
||||||
if !args.json {
|
Ok(prepared) => {
|
||||||
human_report(prepared.report(), true)?;
|
if write {
|
||||||
displayed = true;
|
if !args.json {
|
||||||
|
human_report(prepared.report(), true)?;
|
||||||
|
displayed = true;
|
||||||
|
}
|
||||||
|
prepared.write()
|
||||||
|
} else {
|
||||||
|
prepared.into_report()
|
||||||
}
|
}
|
||||||
prepared.write()
|
|
||||||
} else {
|
|
||||||
prepared.into_report()
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if json {
|
if json {
|
||||||
|
|||||||
@@ -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, 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 = Command::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 = Command::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":[]})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
For the notation itself, start with [the text-first examples](../examples/text-first/README.md)
|
||||||
and the [language contract](language.rst).
|
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,37 @@
|
|||||||
|
# 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; no
|
||||||
|
runtime claim is made by this note.
|
||||||
|
|
||||||
|
`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.
|
||||||
Reference in New Issue
Block a user