feat(analysis): stage bounded JSON codec and iterative expression ownership

This commit is contained in:
Codex
2026-09-11 09:14:16 -07:00
parent cdebb49d89
commit 847b1caa7e
11 changed files with 1971 additions and 110 deletions
+150 -96
View File
@@ -2,6 +2,7 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
mod json_codec;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
@@ -132,6 +133,19 @@ pub fn decode_limited(
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
decode_with_report_profile(
bytes,
limits,
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
json_codec::DEPTH_128_VALIDATED,
)
}
fn decode_with_report_profile(
bytes: &[u8],
limits: crate::language::ParseLimits,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
@@ -140,66 +154,43 @@ pub fn decode_with_report_limited(
)));
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
json_codec::preflight(bytes, profile)?;
let version = json_codec::version(bytes)?;
if version.is_some_and(|version| version <= 6) {
let header = json_codec::legacy(bytes)?;
let result = check_legacy_header(&header, version);
json_codec::discard_value(header);
result?;
}
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
{
match version {
Some(1) => {
let old = serde_json::from_slice(bytes)?;
let old = json_codec::legacy(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
Some(6) => migrate_v6(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
Some(7) => migrate_v7(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
Some(5) => migrate_v5(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(6) => migrate_v6(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(7) => migrate_v7(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(8) => DecodeResult {
project: serde_json::from_slice(bytes)?,
project: json_codec::current(bytes, profile, depth_128)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
.into(),
));
}
}
@@ -228,6 +219,38 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
reject_source7_fields(header).map_err(FileError::Invalid)?;
if version.is_some_and(|version| version <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
Ok(())
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
@@ -372,48 +395,49 @@ pub(crate) fn migrate_v5(
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str)
== Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null."
.into(),
);
}
pending.extend(fields.values().rev());
}
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
serde_json::Value::Array(values) => {
pending.extend(values.iter().rev());
}
_ => {}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
@@ -438,14 +462,23 @@ pub(crate) fn migrate_v7(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 7 { return Err(FileError::Invalid("Expected schema_version 7.".into())); }
if project.schema_version != 7 {
return Err(FileError::Invalid("Expected schema_version 7.".into()));
}
reject_pre8_project(&project)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult { project, migration: Some(MigrationReport::data_upgrade(7)) })
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(7)),
})
}
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
if !project.definitions.is_empty() { return Err(FileError::Invalid("Definitions require schema/source 8.".into())); }
if !project.definitions.is_empty() {
return Err(FileError::Invalid(
"Definitions require schema/source 8.".into(),
));
}
v7::reject_elements(project.elements.values())
}
@@ -866,6 +899,9 @@ pub(crate) mod v1 {
}
fn validate_project(p: &Project) -> Result<(), FileError> {
json_codec::document_stack(|| validate_project_inner(p))
}
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
if let Some(i) = validation::validate(p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
@@ -881,7 +917,10 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&std::fs::read(path)?)
decode_with_report(&json_codec::read_bounded(
path,
json_codec::MAX_DOCUMENT_BYTES,
)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
@@ -894,6 +933,21 @@ pub fn save_checked(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
) -> Result<Vec<u8>, FileError> {
save_checked_profile(
path,
p,
expected,
json_codec::Profile::DOCUMENT,
json_codec::DEPTH_128_VALIDATED,
)
}
fn save_checked_profile(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
validate_project(p)?;
let mut bytes = if path
@@ -902,7 +956,7 @@ pub fn save_checked(
{
crate::language::format(p).into_bytes()
} else {
serde_json::to_vec_pretty(p)?
json_codec::encode(p, profile, depth_128)?
};
if !bytes.ends_with(b"\n") {
bytes.push(b'\n');
@@ -915,8 +969,8 @@ pub fn save_checked(
temp.write_all(&bytes)?;
temp.as_file().sync_all()?;
if let Some(expected) = expected {
match std::fs::read(path) {
Ok(current) if current == expected => {}
match json_codec::matches_file(path, expected) {
Ok(true) => {}
Ok(_) => return Err(FileError::Conflict),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
Err(e) => return Err(FileError::Io(e)),