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:
Joe 2026-02-11 01:31:09 -06:00
parent 2a917bf8e1
commit bb266789c0
14 changed files with 843 additions and 23 deletions

View file

@ -69,6 +69,7 @@ gen_build_tests! {
model_json_name_override,
dedup_suffix_with_meta,
init_meta_name_override,
reserved_name_override,
}
fn run_build_test(test_name: &str) {

View file

@ -8,6 +8,7 @@
//! Produces semantically identical output. This validates that the syncback
//! system correctly reconstructs project structure from binary place files.
use std::collections::HashMap;
use std::{fs, path::Path};
use rbx_dom_weak::{ustr, WeakDom};
@ -284,6 +285,11 @@ roundtrip_tests! {
// Issue regression tests
issue_546,
// Slugified names and metadata overrides
meta_name_override,
model_json_name_override,
dedup_suffix_with_meta,
}
// =============================================================================
@ -337,3 +343,111 @@ fn infer_service_name() {
fn rbxmx_ref() {
run_roundtrip_test("rbxmx_ref");
}
// =============================================================================
// SYNCBACK IDEMPOTENCY TEST
// =============================================================================
/// Build -> syncback -> syncback again. The second syncback must produce
/// zero filesystem changes (i.e., the result is identical to the first).
/// This validates that syncback output is stable and self-consistent.
#[test]
fn syncback_idempotency() {
let _ = env_logger::try_init();
// Use dedup_suffix_with_meta as it exercises slugified names + meta
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");
// 1. Build original -> rbxm
let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm");
// 2. First syncback
let first_dir = tempdir().expect("Failed to create temp dir");
copy_project_dir(&project_path, first_dir.path());
assert!(
run_rojo_syncback_clean(first_dir.path(), &original_rbxm),
"First syncback should succeed"
);
ensure_project_dirs_exist(first_dir.path());
// Snapshot all files after first syncback
let first_snapshot = snapshot_dir(first_dir.path());
// 3. Build from first syncback result -> rbxm
let (_tmp2, first_rbxm) = run_rojo_build(first_dir.path(), "first.rbxm");
// 4. Second syncback (into a copy of the first syncback result)
let second_dir = tempdir().expect("Failed to create temp dir");
copy_project_dir(first_dir.path(), second_dir.path());
assert!(
run_rojo_syncback_clean(second_dir.path(), &first_rbxm),
"Second syncback should succeed"
);
ensure_project_dirs_exist(second_dir.path());
// 5. Snapshot all files after second syncback
let second_snapshot = snapshot_dir(second_dir.path());
// 6. Compare snapshots — must be identical
assert_eq!(
first_snapshot.len(),
second_snapshot.len(),
"File count should be identical between first and second syncback.\n\
First: {:?}\nSecond: {:?}",
first_snapshot.keys().collect::<Vec<_>>(),
second_snapshot.keys().collect::<Vec<_>>(),
);
for (path, first_content) in &first_snapshot {
let second_content = second_snapshot.get(path).unwrap_or_else(|| {
panic!(
"File {:?} exists after first syncback but not after second",
path
)
});
assert_eq!(
first_content, second_content,
"File {:?} changed between first and second syncback",
path
);
}
}
/// Snapshot all files under a directory into a map of relative_path -> contents.
fn snapshot_dir(root: &Path) -> HashMap<String, Vec<u8>> {
let mut result = HashMap::new();
snapshot_dir_inner(root, root, &mut result);
result
}
fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap<String, Vec<u8>>) {
if let Ok(entries) = fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
snapshot_dir_inner(root, &path, map);
} else {
let relative = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if let Ok(content) = fs::read(&path) {
map.insert(relative, content);
}
}
}
}
}
// =============================================================================
// WINDOWS RESERVED NAME ROUNDTRIP TEST
// =============================================================================
/// Instances named after Windows reserved names (CON, PRN) should round-trip
/// correctly: slugified on disk, real name in meta, rebuild produces correct
/// instance names.
#[test]
fn reserved_name_roundtrip() {
run_roundtrip_test("reserved_name_override");
}

View file

@ -5197,7 +5197,8 @@ fn rename_clean_to_slugified() {
}
/// Renaming a slugified instance back to a clean name should remove the
/// meta `name` field (or the entire meta file if it had no other fields).
/// meta `name` field. When the meta file had no other fields, the file
/// itself must be deleted (the `RemoveNameOutcome::FileDeleted` path).
#[test]
fn rename_slugified_to_clean() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -5231,15 +5232,360 @@ fn rename_slugified_to_clean() {
"What_Module.meta.json5 should be gone after rename",
);
// The new meta file should either not exist (clean name needs no
// name override) or, if it exists, should NOT contain a "name" field.
if new_meta.exists() {
let meta_content = fs::read_to_string(&new_meta).unwrap();
// The meta file had only a "name" field, so it should be deleted
// entirely (not just emptied). This validates the FileDeleted path.
poll_not_exists(
&new_meta,
"CleanName.meta.json5 should NOT exist (name-only meta → deleted)",
);
});
}
// ---------------------------------------------------------------------------
// Tests: Model JSON rename (compound extension preservation)
// ---------------------------------------------------------------------------
/// Renaming a .model.json5 instance must preserve the `.model` compound
/// extension and update the `name` field inside the model file (not in
/// adjacent .meta.json5).
#[test]
fn rename_model_json_preserves_compound_extension() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
let src = session.path().join("src");
let old_path = src.join("What_Model.model.json5");
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
// Rename to another name needing slugification
send_update(
&session,
&session_id,
InstanceUpdate {
id: model_id,
changed_name: Some("Why?Model".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
// The .model compound extension MUST be preserved
let new_path = src.join("Why_Model.model.json5");
poll_file_exists(&new_path, "Why_Model.model.json5 should exist after rename");
poll_not_exists(
&old_path,
"What_Model.model.json5 should be gone after rename",
);
// The WRONG path (lost .model extension) must NOT exist
let wrong_path = src.join("Why_Model.json5");
assert_not_exists(
&wrong_path,
"Why_Model.json5 (without .model) must NOT exist",
);
// The name field must be INSIDE the model file, not in adjacent meta.
// JSON5 serialization uses unquoted keys, so check for key without
// surrounding double-quotes.
let model_content = fs::read_to_string(&new_path).unwrap();
assert!(
model_content.contains("name") && model_content.contains("Why?Model"),
"Model file should contain a name field with \"Why?Model\", got: {}",
model_content
);
// No adjacent .meta.json5 should be created for model files
let wrong_meta = src.join("Why_Model.meta.json5");
assert_not_exists(
&wrong_meta,
"Adjacent .meta.json5 should NOT be created for model files",
);
});
}
/// Renaming a .model.json5 instance to a clean name should remove the
/// `name` field from inside the model file.
#[test]
fn rename_model_json_to_clean_name() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
let src = session.path().join("src");
let old_path = src.join("What_Model.model.json5");
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
send_update(
&session,
&session_id,
InstanceUpdate {
id: model_id,
changed_name: Some("CleanModel".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
let new_path = src.join("CleanModel.model.json5");
poll_file_exists(
&new_path,
"CleanModel.model.json5 should exist after rename",
);
poll_not_exists(&old_path, "What_Model.model.json5 should be gone");
// The model file should NOT have a "name" key when the
// filesystem name matches the instance name.
// JSON5 uses unquoted keys (`name:` not `"name":`), so we
// check for the key pattern at line start.
let model_content = fs::read_to_string(&new_path).unwrap();
let has_name_key = model_content
.lines()
.any(|l| l.trim().starts_with("name:") || l.trim().starts_with("\"name\":"));
assert!(
!has_name_key,
"CleanModel.model.json5 should not have a name key, got: {}",
model_content
);
let has_classname = model_content
.lines()
.any(|l| l.trim().starts_with("className:") || l.trim().starts_with("\"className\":"));
assert!(
has_classname,
"Model file should still have className, got: {}",
model_content
);
});
}
// ---------------------------------------------------------------------------
// Tests: 3+ instance collision deduplication
// ---------------------------------------------------------------------------
/// Adding 3 instances that all slug to the same name (X/Y, X:Y, X|Y → X_Y)
/// should produce X_Y.luau, X_Y~1.luau, X_Y~2.luau with correct meta files.
#[test]
fn add_three_colliding_instances_deduplicates() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_read = session.get_api_read(info.root_instance_id).unwrap();
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
let names = ["X/Y", "X:Y", "X|Y"];
let mut added_map = HashMap::new();
for (i, name) in names.iter().enumerate() {
let mut props = HashMap::new();
props.insert(
"Source".to_string(),
Variant::String(format!("-- instance {}", i + 1)),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: name.to_string(),
class_name: "ModuleScript".to_string(),
properties: props,
children: vec![],
},
);
}
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: added_map,
updated: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
let src = session.path().join("src");
let base = src.join("X_Y.luau");
let dedup1 = src.join("X_Y~1.luau");
let dedup2 = src.join("X_Y~2.luau");
assert!(
base.exists() && dedup1.exists() && dedup2.exists(),
"All three slugified files should exist.\n\
X_Y.luau: {}, X_Y~1.luau: {}, X_Y~2.luau: {}",
base.exists(),
dedup1.exists(),
dedup2.exists()
);
// All three should have meta files with the original names
let base_meta = src.join("X_Y.meta.json5");
let dedup1_meta = src.join("X_Y~1.meta.json5");
let dedup2_meta = src.join("X_Y~2.meta.json5");
assert_file_exists(&base_meta, "Meta for base slug");
assert_file_exists(&dedup1_meta, "Meta for ~1 dedup");
assert_file_exists(&dedup2_meta, "Meta for ~2 dedup");
// Each meta should contain one of the original names
let all_metas = [
fs::read_to_string(&base_meta).unwrap(),
fs::read_to_string(&dedup1_meta).unwrap(),
fs::read_to_string(&dedup2_meta).unwrap(),
];
for meta in &all_metas {
let has_name =
meta.contains("\"X/Y\"") || meta.contains("\"X:Y\"") || meta.contains("\"X|Y\"");
assert!(
!meta_content.contains("\"name\""),
"CleanName.meta.json5 should not have a \"name\" field, got: {}",
meta_content
has_name,
"Meta should contain one of X/Y, X:Y, X|Y, got: {}",
meta
);
}
});
}
// ---------------------------------------------------------------------------
// Tests: Directory rename with forbidden chars
// ---------------------------------------------------------------------------
/// Renaming a directory-format script to a name with forbidden chars should
/// rename the directory using the slugified name and write a meta file with
/// the real name.
#[test]
fn rename_directory_format_to_slugified_name() {
run_serve_test("syncback_format_transitions", |session, _redactions| {
let (session_id, dir_module_id) =
get_format_transitions_instance(&session, "DirModuleWithChildren");
let src = session.path().join("src");
let old_dir = src.join("DirModuleWithChildren");
assert!(old_dir.is_dir(), "Old directory should exist before rename");
// Rename to a name with forbidden chars
send_update(
&session,
&session_id,
InstanceUpdate {
id: dir_module_id,
changed_name: Some("Dir/With:Chars".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
let new_dir = src.join("Dir_With_Chars");
poll_not_exists(&old_dir, "Old directory should be gone after rename");
// Poll for the new directory
let start = Instant::now();
loop {
if new_dir.is_dir() {
break;
}
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
panic!(
"New directory Dir_With_Chars should exist after rename (timed out after {}ms)",
API_POLL_TIMEOUT_MS
);
}
thread::sleep(Duration::from_millis(50));
}
// init.luau should be inside the renamed directory
let init_file = new_dir.join("init.luau");
assert_file_exists(&init_file, "init.luau inside renamed directory");
// Children should be present in the renamed directory
let child_a = new_dir.join("ChildA.luau");
assert_file_exists(&child_a, "ChildA.luau inside renamed directory");
// For init-file directories, the name is stored inside the
// directory in init.meta.json5, not adjacent to it.
let init_meta = new_dir.join("init.meta.json5");
poll_file_exists(
&init_meta,
"init.meta.json5 inside Dir_With_Chars should exist with name override",
);
let meta_content = fs::read_to_string(&init_meta).unwrap();
assert!(
meta_content.contains("Dir/With:Chars"),
"init.meta.json5 should contain real name \"Dir/With:Chars\", got: {}",
meta_content
);
});
}
// ---------------------------------------------------------------------------
// Tests: Case-insensitive collision
// ---------------------------------------------------------------------------
/// Adding two instances whose names differ only in case (Foo and foo) should
/// be deduplicated, since they slug to the same lowercase key.
#[test]
fn add_case_insensitive_colliding_instances() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_read = session.get_api_read(info.root_instance_id).unwrap();
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
let mut added_map = HashMap::new();
let mut props1 = HashMap::new();
props1.insert(
"Source".to_string(),
Variant::String("-- upper".to_string()),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: "CaseTest".to_string(),
class_name: "ModuleScript".to_string(),
properties: props1,
children: vec![],
},
);
let mut props2 = HashMap::new();
props2.insert(
"Source".to_string(),
Variant::String("-- lower".to_string()),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: "casetest".to_string(),
class_name: "ModuleScript".to_string(),
properties: props2,
children: vec![],
},
);
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: added_map,
updated: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
let src = session.path().join("src");
// One should get the base name, the other should get ~1
// The exact casing depends on processing order, but both files
// must exist with distinct names.
let base = src.join("CaseTest.luau");
let base_lower = src.join("casetest.luau");
let dedup = src.join("CaseTest~1.luau");
let dedup_lower = src.join("casetest~1.luau");
let base_exists = base.exists() || base_lower.exists();
let dedup_exists = dedup.exists() || dedup_lower.exists();
assert!(
base_exists && dedup_exists,
"Both case-insensitive colliding instances should exist as separate files.\n\
CaseTest.luau: {}, casetest.luau: {}, CaseTest~1.luau: {}, casetest~1.luau: {}",
base.exists(),
base_lower.exists(),
dedup.exists(),
dedup_lower.exists()
);
});
}