From f83f3bf45b172862b62b74803de57108d9958d11 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 21:57:06 -0700 Subject: [PATCH] Add explicit read-only mixed source8 CLI checking --- crates/oa-cli/src/lib.rs | 77 ++- crates/oa-cli/src/main.rs | 49 +- crates/oa-cli/tests/mixed_cli.rs | 490 ++++++++++++++++++ docs/source-cli.md | 40 ++ .../analysis-mixed-cli-interface-20260925.md | 37 ++ 5 files changed, 663 insertions(+), 30 deletions(-) create mode 100644 crates/oa-cli/tests/mixed_cli.rs create mode 100644 harness/analysis-mixed-cli-interface-20260925.md diff --git a/crates/oa-cli/src/lib.rs b/crates/oa-cli/src/lib.rs index d7b71537..bf2ed51a 100644 --- a/crates/oa-cli/src/lib.rs +++ b/crates/oa-cli/src/lib.rs @@ -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 { let mut positional = Vec::new(); @@ -433,19 +479,28 @@ pub fn arguments(args: &[std::ffi::OsString]) -> Result { } } 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, }) } diff --git a/crates/oa-cli/src/main.rs b/crates/oa-cli/src/main.rs index 15b58024..9b1c220a 100644 --- a/crates/oa-cli/src/main.rs +++ b/crates/oa-cli/src/main.rs @@ -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 { 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 { diff --git a/crates/oa-cli/tests/mixed_cli.rs b/crates/oa-cli/tests/mixed_cli.rs new file mode 100644 index 00000000..44210f9b --- /dev/null +++ b/crates/oa-cli/tests/mixed_cli.rs @@ -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>> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>>) { + 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) { + 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::(&output.stdout).unwrap(), + self.report(exit, diagnostics) + ); + } + fn report(&self, exit: i32, diagnostics: Vec) -> 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) { + 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::(&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::(&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":[]}) + ); +} diff --git a/docs/source-cli.md b/docs/source-cli.md index 5b7e63ef..8ce8bd45 100644 --- a/docs/source-cli.md +++ b/docs/source-cli.md @@ -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. diff --git a/harness/analysis-mixed-cli-interface-20260925.md b/harness/analysis-mixed-cli-interface-20260925.md new file mode 100644 index 00000000..cf23be98 --- /dev/null +++ b/harness/analysis-mixed-cli-interface-20260925.md @@ -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.