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
162
.cursor/plans/audit_slugify_pass_3_b11f65a1.plan.md
Normal file
162
.cursor/plans/audit_slugify_pass_3_b11f65a1.plan.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
---
|
||||
name: Audit slugify pass 3
|
||||
overview: Holistic audit of the entire `slugify` branch diff against master (110 files, +6315/-1188 lines, 21 commits). Found 1 critical bug, 2 correctness issues, and 8 test gaps. All prior plan fixes verified in code. Legacy cleanup confirmed complete.
|
||||
todos:
|
||||
- id: c1-model-rename
|
||||
content: "CRITICAL: Fix model JSON rename in change_processor.rs -- add .model to suffix list, update name inside model file instead of adjacent meta"
|
||||
status: completed
|
||||
- id: cc1-model-update-name
|
||||
content: Fix syncback_update_existing_instance (api.rs:1093) dropping name field when rewriting .model.json5 files, and write_init_meta_json (api.rs:1075) dropping name field for directories
|
||||
status: completed
|
||||
- id: cc2-dir-removal-meta
|
||||
content: Fix remove_instance_at_path directory case to clean up grandparent-level DirName.meta.json5
|
||||
status: completed
|
||||
- id: test-roundtrip-slugified
|
||||
content: "Add roundtrip test: instances with forbidden chars through syncback -> rebuild -> compare"
|
||||
status: completed
|
||||
- id: test-model-rename
|
||||
content: "Add two-way sync test: rename a .model.json5 instance, verify compound extension preserved and name updated"
|
||||
status: completed
|
||||
- id: test-idempotency
|
||||
content: "Add syncback idempotency integration test: syncback twice = zero changes"
|
||||
status: completed
|
||||
- id: test-3-collision
|
||||
content: Add 3+ instance collision test (X/Y, X:Y, X|Y all slug to X_Y)
|
||||
status: completed
|
||||
- id: test-reserved-names
|
||||
content: Add Windows reserved name integration test (CON, PRN round-trip)
|
||||
status: completed
|
||||
- id: test-dir-rename
|
||||
content: "Add two-way sync test: directory/init-file rename with forbidden chars"
|
||||
status: completed
|
||||
- id: test-case-collision
|
||||
content: Add case-insensitive collision integration test (Foo vs foo)
|
||||
status: completed
|
||||
- id: test-meta-empty-deletion
|
||||
content: Strengthen rename_slugified_to_clean test to verify meta file is deleted (not just name removed) when it becomes empty
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Audit: slugify Branch -- Pass 3 (Holistic)
|
||||
|
||||
## Scope
|
||||
|
||||
Full diff against `master`: 110 files, +6,315 / -1,188 lines across 21 commits. Every changed file reviewed. All 4 prior plan files cross-referenced against codebase.
|
||||
|
||||
## Quality Standard
|
||||
|
||||
> **Round-trip identity**: Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree**. Any deviation is a bug.
|
||||
|
||||
---
|
||||
|
||||
## Prior Plan Verification
|
||||
|
||||
All claims from prior plans verified in code -- no discrepancies:
|
||||
|
||||
- **fix_audit_findings**: C1 (meta gating), C2 (removal meta path), C3 (deny_unknown_fields), CC1 (determinism), CC2 (init removal), DR1 (shared meta helper), DR2 (meta path helper) -- all confirmed in code.
|
||||
- **fix_audit_pass_2**: CC2 refactor (`remove_instance_at_path`), preseed fix, rename dedup, dir sort, csv instigating_source, over-suppression, comment fix -- all confirmed.
|
||||
- **fix_stem-level_dedup**: `return-dedup-key`, `fix-dir`, `fix-project`, `fix-api` -- all confirmed. Note: `fix-tests` is still marked `in_progress` in the plan but tests do exist; the plan status is stale.
|
||||
|
||||
---
|
||||
|
||||
## Areas Confirmed CLEAN
|
||||
|
||||
Thoroughly re-audited via the full diff and found correct:
|
||||
|
||||
- **Forward sync** (all middleware): `decode_path_name` fully removed. All middleware correctly resolves names from metadata `name` field or filename stem. `specified_name` set on `InstanceMetadata` in every middleware. `is_empty()` updated for `name`. `instigating_source` preserved in `csv.rs`.
|
||||
- **Reverse sync / syncback**: `slugify_name()`, `deduplicate_name()`, `name_for_inst()` all correct. Pre-seeding uses `relevant_paths` + `strip_middleware_extension()`. Child ordering sorted alphabetically in both `dir.rs` and `project.rs`. `dedup_key.to_lowercase()` applied at ALL insertion sites.
|
||||
- **Two-way sync adds**: `syncback_added_instance` groups adds by parent, seeds `sibling_slugs` from tree children via `instigating_source` paths, uses `strip_middleware_extension`. Meta files written when slug differs. Meta path matches script file path via `adjacent_meta_path()`.
|
||||
- **Two-way sync removals**: `remove_instance_at_path` handles directories, init files (parent dir removal + grandparent meta), and regular files (adjacent meta). Old dead `syncback_removed_instance` deleted.
|
||||
- **Meta lifecycle**: C1 fix in place -- meta updates OUTSIDE `if new_path != *path` for both init and regular paths. Shared module (`syncback::meta`) used by `change_processor.rs`. `suppress_path_remove` exists. `RemoveNameOutcome` handled correctly with `(1, 0)` counts for `FileDeleted`.
|
||||
- **Legacy cleanup**: Zero references to old encoding system in source, tests, plugin, schema, or snapshots. `src/path_encoding.rs` deleted. `pub mod path_encoding` removed from `lib.rs`. `decode_windows_invalid_chars` removed from `InstanceContext` and `serve_session.rs`.
|
||||
- **Dedup key consistency**: All insertion sites use `.to_lowercase()`. Cross-format collision handled.
|
||||
- **SyncbackRules**: No `deny_unknown_fields`.
|
||||
- **JSON schema**: `encodeWindowsInvalidChars` removed from `vscode-rojo/schemas/`.
|
||||
- **Snapshot files**: All `.snap` files updated. No stale `decode_windows_invalid_chars` or `%ENCODED%` patterns.
|
||||
- `**json_model_legacy_name` snapshot change**: Instance name changed from `Expected Name` to `Overridden Name` -- correct, this is the intended behavior change (the `Name`/`name` field is now respected instead of ignored).
|
||||
- **Test fixture migration**: All `%ENCODED%` filenames in `syncback_encoded_names/` correctly replaced with slugified names + meta files.
|
||||
- **Existing test updates**: All ~12 two-way sync tests with encoded patterns correctly updated to slugified equivalents.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: Model JSON rename loses `.model` compound extension
|
||||
|
||||
**File**: [src/change_processor.rs](src/change_processor.rs) lines 786-868
|
||||
|
||||
The rename handler's suffix list:
|
||||
|
||||
```786:789:src/change_processor.rs
|
||||
let known_suffixes = [
|
||||
".server", ".client", ".plugin", ".local",
|
||||
".legacy",
|
||||
];
|
||||
```
|
||||
|
||||
Does NOT include `.model`. For `What_Model.model.json5` renamed to `Why?Model`:
|
||||
|
||||
- `stem` = `"What_Model.model"`, `extension` = `"json5"`, `script_suffix` = `""` (no match)
|
||||
- `old_base` = `"What_Model.model"` (includes `.model`)
|
||||
- New filename = `"Why_Model.json5"` -- `.model` LOST
|
||||
|
||||
**Impact**: File type changes from `.model.json5` (JSON instance) to `.json5` (data module). Instance class, properties, hierarchy silently lost. The meta update then writes to `Why_Model.meta.json5` (wrong location for model files). Direct round-trip violation.
|
||||
|
||||
**Fix**: Add `.model` to the suffix list. Then detect `.model.json5`/`.model.json` files and update the `name` field INSIDE the model file (like `syncback::meta::upsert_meta_name` but targeting the JSON root's `name` key) instead of writing adjacent `.meta.json5`.
|
||||
|
||||
---
|
||||
|
||||
## CORRECTNESS: `syncback_update_existing_instance` drops `name` field
|
||||
|
||||
**File**: [src/web/api.rs](src/web/api.rs) lines 1091-1098
|
||||
|
||||
When updating an existing `.model.json5` via two-way sync (plugin sends updated properties), the code rewrites the entire model file:
|
||||
|
||||
```1093:1093:src/web/api.rs
|
||||
let content = self.serialize_instance_to_model_json(added, None)?;
|
||||
```
|
||||
|
||||
`None` for `instance_name` means the `name` field is NOT written. If the model file previously had `"name": "Hey/Bro"`, the name is silently lost on rewrite. Same issue exists at line 1075 for directory updates via `write_init_meta_json(existing_path, added, None)`.
|
||||
|
||||
**Fix**: Compute `meta_name_field` by comparing the slug of the existing filesystem name against `added.name`. If they differ, pass `Some(&added.name)`. This matches the logic in `syncback_instance_to_path_with_stats`.
|
||||
|
||||
---
|
||||
|
||||
## CORRECTNESS: `remove_instance_at_path` directory case skips grandparent meta
|
||||
|
||||
**File**: [src/web/api.rs](src/web/api.rs) lines 1389-1396
|
||||
|
||||
When `path.is_dir()` (plain Folder), `remove_dir_all` is called but no adjacent `DirName.meta.json5` at the parent level is cleaned up. The init-file case (lines 1423-1438) correctly handles this.
|
||||
|
||||
**Severity**: LOW. Grandparent-level `DirName.meta.json5` only exists from manual creation or the change_processor rename path (which renames it). Standard syncback uses `init.meta.json5` inside the directory.
|
||||
|
||||
**Fix**: After `remove_dir_all`, add grandparent meta cleanup matching the init-file path.
|
||||
|
||||
---
|
||||
|
||||
## TEST COVERAGE GAPS
|
||||
|
||||
Prioritized by risk:
|
||||
|
||||
1. **Roundtrip test with slugified names** -- `syncback_roundtrip.rs` exists but no fixtures exercise slugified names. None of `meta_name_override`, `model_json_name_override`, `dedup_suffix_with_meta` are in the roundtrip test list.
|
||||
2. **Model file rename two-way sync** -- No test for renaming a `.model.json5` instance. Would catch the critical bug above.
|
||||
3. **Syncback idempotency integration** -- Run syncback twice, assert zero changes on second run. Unit tests exist but no full-pipeline test.
|
||||
4. **3+ instance collision** -- Only 2-instance collision tested (`add_two_colliding_instances_deduplicates`). Need `X/Y`, `X:Y`, `X|Y` -> `X_Y`, `X_Y~1`, `X_Y~2`.
|
||||
5. **Windows reserved name integration** -- Unit tests for `CON`/`PRN` slugification exist. No build or syncback integration test.
|
||||
6. **Directory rename with forbidden chars** -- Two-way sync tests cover file renames but not init-file/directory renames.
|
||||
7. **Case-insensitive collision** -- Code handles it (`.to_lowercase()` everywhere). No integration test.
|
||||
8. **Meta file empty deletion** -- `rename_slugified_to_clean` test checks meta doesn't have `name` field but doesn't verify the meta file itself is deleted when it becomes empty (the `RemoveNameOutcome::FileDeleted` path).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
| Category | Count | Details |
|
||||
| ------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Critical bugs | 1 | Model JSON rename loses `.model` extension |
|
||||
| Correctness | 2 | `syncback_update_existing_instance` drops `name` field; directory removal skips grandparent meta |
|
||||
| Missing tests | 8 | Roundtrip, model rename, idempotency, 3+ collision, reserved names, dir rename, case collision, meta empty deletion |
|
||||
| Prior plans | OK | All claims verified in code, no discrepancies |
|
||||
| Clean areas | 10 | Forward sync, reverse sync, two-way adds/removals, meta lifecycle, legacy cleanup, dedup consistency, schema, snapshots, test fixtures, test updates |
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
<Item class="DataModel" referent="0">
|
||||
<Properties>
|
||||
<string name="Name">reserved_name_override</string>
|
||||
</Properties>
|
||||
<Item class="ReplicatedStorage" referent="1">
|
||||
<Properties>
|
||||
<string name="Name">ReplicatedStorage</string>
|
||||
</Properties>
|
||||
<Item class="ModuleScript" referent="2">
|
||||
<Properties>
|
||||
<string name="Name">CON</string>
|
||||
<string name="Source"><![CDATA[return "I am CON"
|
||||
]]></string>
|
||||
</Properties>
|
||||
</Item>
|
||||
<Item class="ModuleScript" referent="3">
|
||||
<Properties>
|
||||
<string name="Name">PRN</string>
|
||||
<string name="Source"><![CDATA[return "I am PRN"
|
||||
]]></string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "reserved_name_override",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
return "I am CON"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "CON"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
return "I am PRN"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "PRN"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue