mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Add audit plan for slugify branch pass 3 and implement critical fixes
- Introduced a comprehensive audit plan for the slugify branch, detailing findings from a holistic review of 110 files, including 1 critical bug and 2 correctness issues. - Completed todos addressing model JSON renaming, syncback updates, and test enhancements for roundtrip fidelity. - Added tests for reserved names and ensured correct handling of slugified instances, preserving compound extensions in model files. - Refactored change processing logic to correctly manage the `name` field in model files, enhancing metadata handling and ensuring compliance with round-trip identity standards.
This commit is contained in:
parent
2a917bf8e1
commit
bb266789c0
14 changed files with 843 additions and 23 deletions
|
|
@ -247,6 +247,49 @@ impl JobThreadContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Upsert the `name` field inside a `.model.json5` / `.model.json` file,
|
||||
/// suppressing filesystem events.
|
||||
fn upsert_model_name_field(&self, model_path: &Path, real_name: &str) {
|
||||
self.suppress_path(model_path);
|
||||
if let Err(err) = crate::syncback::meta::upsert_model_name(model_path, real_name) {
|
||||
self.unsuppress_path(model_path);
|
||||
log::error!(
|
||||
"Failed to upsert name in model file {}: {}",
|
||||
model_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.model.json5` / `.model.json` file,
|
||||
/// suppressing filesystem events.
|
||||
fn remove_model_name_field(&self, model_path: &Path) {
|
||||
use crate::syncback::meta::RemoveNameOutcome;
|
||||
self.suppress_path(model_path);
|
||||
match crate::syncback::meta::remove_model_name(model_path) {
|
||||
Ok(RemoveNameOutcome::NoOp) => {
|
||||
self.unsuppress_path(model_path);
|
||||
}
|
||||
Ok(RemoveNameOutcome::FieldRemoved) => {
|
||||
// File was rewritten — suppress_path already covers it.
|
||||
}
|
||||
Ok(RemoveNameOutcome::FileDeleted) => {
|
||||
// Model files shouldn't be deleted (they have className etc),
|
||||
// but handle for completeness.
|
||||
self.unsuppress_path(model_path);
|
||||
self.suppress_path_remove(model_path);
|
||||
}
|
||||
Err(err) => {
|
||||
self.unsuppress_path(model_path);
|
||||
log::error!(
|
||||
"Failed to remove name from model file {}: {}",
|
||||
model_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.meta.json5` file, suppressing filesystem
|
||||
/// events. If the file becomes empty after removal, deletes it entirely.
|
||||
fn remove_meta_name_field(&self, meta_path: &Path) {
|
||||
|
|
@ -785,7 +828,7 @@ impl JobThreadContext {
|
|||
.unwrap_or("");
|
||||
let known_suffixes = [
|
||||
".server", ".client", ".plugin", ".local",
|
||||
".legacy",
|
||||
".legacy", ".model",
|
||||
];
|
||||
let script_suffix = known_suffixes
|
||||
.iter()
|
||||
|
|
@ -912,22 +955,39 @@ impl JobThreadContext {
|
|||
effective_meta_base = old_base;
|
||||
}
|
||||
|
||||
// Always update the meta name field when the
|
||||
// Always update the name field when the
|
||||
// instance name changed, even if the path didn't
|
||||
// change (e.g. "Foo/Bar" → "Foo|Bar" both slugify
|
||||
// to "Foo_Bar"). Use deduped_new_name because
|
||||
// dedup may have appended ~N.
|
||||
let current_meta = parent.join(format!(
|
||||
"{}.meta.json5",
|
||||
effective_meta_base
|
||||
));
|
||||
if deduped_new_name != *new_name {
|
||||
self.upsert_meta_name_field(
|
||||
¤t_meta,
|
||||
new_name,
|
||||
);
|
||||
//
|
||||
// For .model.json5/.model.json files, the name
|
||||
// field lives INSIDE the model file, not in
|
||||
// adjacent .meta.json5.
|
||||
if script_suffix == ".model" {
|
||||
let model_file = overridden_source_path
|
||||
.as_deref()
|
||||
.unwrap_or(path.as_path());
|
||||
if deduped_new_name != *new_name {
|
||||
self.upsert_model_name_field(
|
||||
model_file, new_name,
|
||||
);
|
||||
} else {
|
||||
self.remove_model_name_field(model_file);
|
||||
}
|
||||
} else {
|
||||
self.remove_meta_name_field(¤t_meta);
|
||||
let current_meta = parent.join(format!(
|
||||
"{}.meta.json5",
|
||||
effective_meta_base
|
||||
));
|
||||
if deduped_new_name != *new_name {
|
||||
self.upsert_meta_name_field(
|
||||
¤t_meta,
|
||||
new_name,
|
||||
);
|
||||
} else {
|
||||
self.remove_meta_name_field(¤t_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,9 @@ pub fn validate_file_name<S: AsRef<str>>(name: S) -> anyhow::Result<()> {
|
|||
|
||||
/// Known script suffixes that appear between the base name and file extension.
|
||||
/// For example, in `MyScript.server.luau`, `.server` is the suffix.
|
||||
const KNOWN_SCRIPT_SUFFIXES: &[&str] = &[".server", ".client", ".plugin", ".local", ".legacy"];
|
||||
const KNOWN_SCRIPT_SUFFIXES: &[&str] = &[
|
||||
".server", ".client", ".plugin", ".local", ".legacy", ".model",
|
||||
];
|
||||
|
||||
/// Strips a known script suffix from a file stem.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -46,6 +46,50 @@ pub enum RemoveNameOutcome {
|
|||
FileDeleted,
|
||||
}
|
||||
|
||||
/// Upsert the `name` field inside a `.model.json5` / `.model.json` file.
|
||||
///
|
||||
/// Parses the existing JSON, sets/replaces the `name` key, and rewrites.
|
||||
/// Unlike `upsert_meta_name`, this modifies the model file in-place.
|
||||
pub fn upsert_model_name(model_path: &Path, real_name: &str) -> anyhow::Result<()> {
|
||||
let bytes = fs::read(model_path)?;
|
||||
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => anyhow::bail!("model file is not a JSON object: {}", model_path.display()),
|
||||
};
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(real_name.to_string()),
|
||||
);
|
||||
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
|
||||
fs::write(model_path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.model.json5` / `.model.json` file.
|
||||
///
|
||||
/// Unlike meta files, model files are never deleted when they become "empty"
|
||||
/// (they always have at least `className`). Returns `RemoveNameOutcome` for
|
||||
/// consistency with the meta helpers.
|
||||
pub fn remove_model_name(model_path: &Path) -> anyhow::Result<RemoveNameOutcome> {
|
||||
if !model_path.exists() {
|
||||
return Ok(RemoveNameOutcome::NoOp);
|
||||
}
|
||||
let bytes = match fs::read(model_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Ok(RemoveNameOutcome::NoOp),
|
||||
};
|
||||
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => return Ok(RemoveNameOutcome::NoOp),
|
||||
};
|
||||
if obj.remove("name").is_none() {
|
||||
return Ok(RemoveNameOutcome::NoOp);
|
||||
}
|
||||
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
|
||||
fs::write(model_path, &content)?;
|
||||
Ok(RemoveNameOutcome::FieldRemoved)
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.meta.json5` file.
|
||||
///
|
||||
/// If the file becomes an empty object after removal, deletes it entirely.
|
||||
|
|
|
|||
|
|
@ -1072,7 +1072,18 @@ impl ApiService {
|
|||
if existing_path.is_dir() {
|
||||
// Update init.meta.json5 if needed
|
||||
if !added.properties.is_empty() {
|
||||
self.write_init_meta_json(existing_path, added, None)?;
|
||||
// Preserve the name field if the directory slug differs
|
||||
// from the instance name (due to slugification/dedup).
|
||||
let dir_name = existing_path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
let instance_name = if dir_name != added.name {
|
||||
Some(added.name.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.write_init_meta_json(existing_path, added, instance_name)?;
|
||||
log::info!(
|
||||
"Syncback: Updated existing {} at {}/init.meta.json5",
|
||||
class_name,
|
||||
|
|
@ -1090,7 +1101,19 @@ impl ApiService {
|
|||
}
|
||||
} else {
|
||||
// It's a standalone file (e.g., .model.json5)
|
||||
let content = self.serialize_instance_to_model_json(added, None)?;
|
||||
// Preserve the name field if the filesystem slug differs
|
||||
// from the instance name (due to slugification/dedup).
|
||||
let stem = existing_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let base = crate::syncback::strip_script_suffix(stem);
|
||||
let instance_name = if base != added.name {
|
||||
Some(added.name.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let content = self.serialize_instance_to_model_json(added, instance_name)?;
|
||||
self.suppress_path(existing_path);
|
||||
fs::write(existing_path, &content).with_context(|| {
|
||||
format!("Failed to write file: {}", existing_path.display())
|
||||
|
|
@ -1393,6 +1416,26 @@ impl ApiService {
|
|||
return false;
|
||||
}
|
||||
log::info!("Syncback: Removed directory at {}", path.display());
|
||||
|
||||
// Also remove adjacent dir-level meta in the parent if it
|
||||
// exists (e.g. parent/DirName.meta.json5), matching the
|
||||
// init-file case below.
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Some(dir_name) = path.file_name().and_then(|f| f.to_str()) {
|
||||
let dir_meta = parent.join(format!("{}.meta.json5", dir_name));
|
||||
if dir_meta.exists() {
|
||||
self.suppress_path_remove(&dir_meta);
|
||||
if let Err(err) = fs::remove_file(&dir_meta) {
|
||||
log::warn!(
|
||||
"Failed to remove dir-level meta file {}: {}",
|
||||
dir_meta.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue