Add explicit read-only mixed source8 CLI checking

This commit is contained in:
2026-09-24 21:57:06 -07:00
parent bab6f96a5b
commit f83f3bf45b
5 changed files with 663 additions and 30 deletions
+66 -11
View File
@@ -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
View File
@@ -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 {
+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, 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":[]})
);
}
+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) 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.