Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10490b13b | ||
|
|
f232482293 | ||
|
|
3fd87bd8be | ||
|
|
f83f3bf45b |
+66
-11
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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":[]})
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,8 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let source = include_str!(
|
||||
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
);
|
||||
assert!(source.starts_with("arcadia 7;"));
|
||||
let migrated_source = source.replacen("arcadia 7;", "arcadia 8;", 1);
|
||||
fixture.put("project.arcadia", source);
|
||||
let (code, check) = fixture.cli(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
@@ -76,6 +78,8 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let (code, preview) = fixture.cli(&["stabilize"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(preview["identities_to_stabilize"], 40);
|
||||
assert_eq!(preview["migration"]["from_schema"], 7);
|
||||
assert_eq!(preview["migration"]["to_schema"], 8);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert!(
|
||||
@@ -94,7 +98,10 @@ fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
for (id, _) in project.identities() {
|
||||
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
||||
}
|
||||
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
||||
assert_eq!(
|
||||
stripped, migrated_source,
|
||||
"only identity clauses and the schema 7→8 header token may change"
|
||||
);
|
||||
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(again["changes"], serde_json::json!([]));
|
||||
@@ -306,9 +313,9 @@ fn rename_shows_missing_identity_and_separate_source_migration() {
|
||||
let report = candidate.report();
|
||||
assert_eq!(report.identities_to_stabilize, 2);
|
||||
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 8);
|
||||
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 8;"));
|
||||
assert!(report.changes[0].after.contains("oa_entity crew"));
|
||||
assert_eq!(fixture.read("project.arcadia"), old);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
|
||||
@@ -5,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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
------------------------
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user