mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Add audit plan for Ambiguous Path Handling feature
- Introduced a comprehensive audit plan detailing critical issues, code quality items, and test coverage gaps identified in the Ambiguous Path Handling feature. - Documented specific todos addressing various correctness and performance issues, including case sensitivity, path formatting, and dead code removal. - Established standards for round-trip identity and CLI syncback parity to ensure consistent behavior across the system. These changes enhance the clarity and accountability of the auditing process, paving the way for improved feature reliability and maintainability.
This commit is contained in:
parent
e313f61515
commit
4cb45cd607
6 changed files with 648 additions and 154 deletions
|
|
@ -16,7 +16,10 @@ use crate::{
|
|||
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
|
||||
},
|
||||
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
|
||||
syncback::{deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix},
|
||||
syncback::{
|
||||
dedup_suffix::{compute_cleanup_action, parse_dedup_suffix, DedupCleanupAction},
|
||||
deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix,
|
||||
},
|
||||
};
|
||||
|
||||
/// Wrapper that displays a path relative to a project root directory.
|
||||
|
|
@ -1069,6 +1072,140 @@ impl JobThreadContext {
|
|||
}
|
||||
}
|
||||
|
||||
// Dedup suffix cleanup: after removals, check if any dedup
|
||||
// groups need suffix renames (base-name promotion, group-to-1).
|
||||
// Must run BEFORE apply_patch_set removes the instances from
|
||||
// the tree, because we need parent/sibling relationships.
|
||||
for &removed_id in &patch_set.removed_instances {
|
||||
let (parent_ref, removed_fs_name) = {
|
||||
let Some(inst) = tree.get_instance(removed_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(source) = &inst.metadata().instigating_source else {
|
||||
continue;
|
||||
};
|
||||
if matches!(source, InstigatingSource::ProjectNode { .. }) {
|
||||
continue;
|
||||
}
|
||||
let parent = inst.parent();
|
||||
let fs_name = tree.filesystem_name_for(removed_id);
|
||||
(parent, fs_name)
|
||||
};
|
||||
|
||||
if parent_ref.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the dedup suffix from the removed instance's FS name
|
||||
let removed_stem = removed_fs_name
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(&removed_fs_name);
|
||||
let (base_stem, _) = match parse_dedup_suffix(removed_stem) {
|
||||
Some((base, n)) => (base.to_string(), Some(n)),
|
||||
None => (removed_stem.to_string(), None),
|
||||
};
|
||||
|
||||
// Find siblings that share the same dedup base stem
|
||||
let Some(parent_inst) = tree.get_instance(parent_ref) else {
|
||||
continue;
|
||||
};
|
||||
let mut remaining_stems: Vec<String> = Vec::new();
|
||||
let mut deleted_was_base = parse_dedup_suffix(removed_stem).is_none();
|
||||
|
||||
for &sibling_ref in parent_inst.children() {
|
||||
if sibling_ref == removed_id {
|
||||
continue;
|
||||
}
|
||||
let sibling_fs = tree.filesystem_name_for(sibling_ref);
|
||||
let sibling_stem = sibling_fs
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(&sibling_fs);
|
||||
let sibling_base = match parse_dedup_suffix(sibling_stem) {
|
||||
Some((b, _)) => b,
|
||||
None => sibling_stem,
|
||||
};
|
||||
if sibling_base.eq_ignore_ascii_case(&base_stem) {
|
||||
remaining_stems.push(sibling_stem.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if remaining_stems.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine extension from the removed instance's FS name
|
||||
let extension = removed_fs_name
|
||||
.find('.')
|
||||
.map(|i| &removed_fs_name[i + 1..]);
|
||||
|
||||
// Get the parent directory path
|
||||
let parent_dir = {
|
||||
let Some(parent) = tree.get_instance(parent_ref) else {
|
||||
continue;
|
||||
};
|
||||
match &parent.metadata().instigating_source {
|
||||
Some(InstigatingSource::Path(p)) => p.clone(),
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let action = compute_cleanup_action(
|
||||
&base_stem,
|
||||
extension,
|
||||
&remaining_stems,
|
||||
deleted_was_base,
|
||||
&parent_dir,
|
||||
);
|
||||
|
||||
match action {
|
||||
DedupCleanupAction::None => {}
|
||||
DedupCleanupAction::RemoveSuffix { from, to }
|
||||
| DedupCleanupAction::PromoteLowest { from, to } => {
|
||||
log::info!(
|
||||
"Dedup cleanup: renaming {} -> {}",
|
||||
self.display_path(&from),
|
||||
self.display_path(&to),
|
||||
);
|
||||
self.suppress_path_any(&from);
|
||||
self.suppress_path(&to);
|
||||
if let Err(e) = fs::rename(&from, &to) {
|
||||
log::warn!(
|
||||
"Dedup cleanup rename failed: {} -> {}: {}",
|
||||
from.display(),
|
||||
to.display(),
|
||||
e
|
||||
);
|
||||
self.unsuppress_path(&to);
|
||||
} else {
|
||||
// Update ref paths if the renamed instance has refs
|
||||
let old_ref_segment = from
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
let new_ref_segment = to
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
if old_ref_segment != new_ref_segment {
|
||||
// Build the old and new ref path prefixes
|
||||
let parent_path =
|
||||
crate::ref_target_path_from_tree(&tree, parent_ref);
|
||||
let old_prefix =
|
||||
format!("{}/{}", parent_path, old_ref_segment);
|
||||
let new_prefix =
|
||||
format!("{}/{}", parent_path, new_ref_segment);
|
||||
self.update_ref_paths_after_rename(
|
||||
&old_prefix,
|
||||
&new_prefix,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect (Ref, new_instigating_source) for instances whose
|
||||
// filesystem path changed (rename / ClassName transition).
|
||||
// Applied after the PatchSet to keep metadata in sync.
|
||||
|
|
|
|||
|
|
@ -229,40 +229,50 @@ fn pass3_similarity(
|
|||
continue;
|
||||
};
|
||||
|
||||
// Build pairs with similarity scores.
|
||||
let mut pairs: Vec<(usize, usize, u32)> = Vec::new();
|
||||
// Narrow by ClassName within this name group.
|
||||
let mut snap_by_class: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
for &si in snap_indices {
|
||||
if snap_matched[si] {
|
||||
continue;
|
||||
}
|
||||
let snap = match &snap_available[si] {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
for &ti in tree_indices {
|
||||
if !tree_available[ti] {
|
||||
continue;
|
||||
}
|
||||
let inst = match tree.get_instance(tree_children[ti]) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let score = compute_forward_similarity(snap, &inst);
|
||||
pairs.push((si, ti, score));
|
||||
if let Some(snap) = &snap_available[si] {
|
||||
snap_by_class
|
||||
.entry(&snap.class_name)
|
||||
.or_default()
|
||||
.push(si);
|
||||
}
|
||||
}
|
||||
let mut tree_by_class: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
for &ti in tree_indices {
|
||||
if !tree_available[ti] {
|
||||
continue;
|
||||
}
|
||||
if let Some(inst) = tree.get_instance(tree_children[ti]) {
|
||||
tree_by_class
|
||||
.entry(inst.class_name().to_string())
|
||||
.or_default()
|
||||
.push(ti);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by score, tiebreak by original order.
|
||||
pairs.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0).then(a.1.cmp(&b.1))));
|
||||
|
||||
// Greedy assignment.
|
||||
for (si, ti, _score) in pairs {
|
||||
if snap_matched[si] || !tree_available[ti] {
|
||||
// Within same-name+class sub-groups, use POSITIONAL matching.
|
||||
// The Nth snapshot child matches the Nth tree child within each
|
||||
// sub-group. This preserves child ordering, which is the strongest
|
||||
// signal for duplicate-named instances (e.g., Parts in a Model).
|
||||
for (class, snap_class_indices) in &snap_by_class {
|
||||
let Some(tree_class_indices) = tree_by_class.get(*class) else {
|
||||
continue;
|
||||
};
|
||||
let match_count = snap_class_indices.len().min(tree_class_indices.len());
|
||||
for idx in 0..match_count {
|
||||
let si = snap_class_indices[idx];
|
||||
let ti = tree_class_indices[idx];
|
||||
if !snap_matched[si] && tree_available[ti] {
|
||||
matched.push((si, ti));
|
||||
snap_matched[si] = true;
|
||||
tree_available[ti] = false;
|
||||
}
|
||||
}
|
||||
matched.push((si, ti));
|
||||
snap_matched[si] = true;
|
||||
tree_available[ti] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,53 +344,84 @@ fn pass3_similarity(
|
|||
continue;
|
||||
};
|
||||
|
||||
let mut pairs: Vec<(Ref, Ref, u32)> = Vec::new();
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
// Narrow by ClassName within this name group.
|
||||
let mut new_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in new_refs {
|
||||
if matched_new.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let score = compute_similarity(
|
||||
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes,
|
||||
);
|
||||
pairs.push((new_ref, old_ref, score));
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in old_refs {
|
||||
if matched_old.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
|
||||
pairs.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2).then_with(|| {
|
||||
let a_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == a.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == b.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_new_idx.cmp(&b_new_idx).then_with(|| {
|
||||
let a_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == a.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == b.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_old_idx.cmp(&b_old_idx)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
for (new_ref, old_ref, _score) in &pairs {
|
||||
if matched_new.contains(new_ref) || matched_old.contains(old_ref) {
|
||||
// Within same-name+class sub-groups: try hash-based matching first,
|
||||
// then fall back to positional matching.
|
||||
for (class, new_class_refs) in &new_by_class {
|
||||
let Some(old_class_refs) = old_by_class.get(class) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// First pass: match by hash equality (strong signal).
|
||||
let mut hash_matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut hash_matched_old: HashSet<Ref> = HashSet::new();
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
for &new_ref in new_class_refs {
|
||||
if matched_new.contains(&new_ref) || hash_matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
if let Some(new_hash) = nh.get(&new_ref) {
|
||||
for &old_ref in old_class_refs {
|
||||
if matched_old.contains(&old_ref) || hash_matched_old.contains(&old_ref)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(old_hash) = oh.get(&old_ref) {
|
||||
if new_hash == old_hash {
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
hash_matched_new.insert(new_ref);
|
||||
hash_matched_old.insert(old_ref);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: positional matching for remaining unmatched.
|
||||
// The Nth unmatched new ref matches the Nth unmatched old ref.
|
||||
let remaining_new_class: Vec<Ref> = new_class_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_new.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
let remaining_old_class: Vec<Ref> = old_class_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_old.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
let match_count = remaining_new_class.len().min(remaining_old_class.len());
|
||||
for idx in 0..match_count {
|
||||
let new_ref = remaining_new_class[idx];
|
||||
let old_ref = remaining_old_class[idx];
|
||||
if !matched_new.contains(&new_ref) && !matched_old.contains(&old_ref) {
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
}
|
||||
}
|
||||
matched.push((*new_ref, *old_ref));
|
||||
matched_new.insert(*new_ref);
|
||||
matched_old.insert(*old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,134 @@ use rbx_dom_weak::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
ref_attribute_name, ref_target_attribute_name, syncback::snapshot::inst_path,
|
||||
ref_attribute_name, ref_target_attribute_name,
|
||||
syncback::{name_needs_slugify, slugify_name},
|
||||
REF_ID_ATTRIBUTE_NAME, REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
|
||||
};
|
||||
|
||||
/// Compute a filesystem-name-compatible path for an instance in a bare WeakDom.
|
||||
///
|
||||
/// Each path segment is a **tentative filesystem name**: the slugified instance
|
||||
/// name plus the extension that the syncback middleware would assign based on
|
||||
/// class and children. This produces paths like `"Workspace/Hey_Bro.server.luau"`
|
||||
/// that are compatible with `get_instance_by_path()` (which resolves using
|
||||
/// filesystem names).
|
||||
///
|
||||
/// For directory-style instances (Folders, services, scripts with children),
|
||||
/// the segment is just the slugified name (no extension).
|
||||
fn tentative_fs_path(dom: &WeakDom, target_ref: Ref) -> String {
|
||||
let root_ref = dom.root_ref();
|
||||
let mut components: Vec<String> = Vec::new();
|
||||
let mut current = target_ref;
|
||||
|
||||
loop {
|
||||
if current == root_ref || current.is_none() {
|
||||
break;
|
||||
}
|
||||
|
||||
let inst = match dom.get_by_ref(current) {
|
||||
Some(i) => i,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let segment = tentative_fs_name(inst);
|
||||
components.push(segment);
|
||||
current = inst.parent();
|
||||
}
|
||||
|
||||
components.reverse();
|
||||
components.join("/")
|
||||
}
|
||||
|
||||
/// Compute the tentative filesystem name for a single instance based on its
|
||||
/// class, RunContext property, and whether it has children.
|
||||
///
|
||||
/// This mirrors the logic in `get_best_middleware()` + `extension_for_middleware()`
|
||||
/// but operates on a bare Instance without SyncbackSnapshot context.
|
||||
fn tentative_fs_name(inst: &Instance) -> String {
|
||||
let slug = if name_needs_slugify(&inst.name) {
|
||||
slugify_name(&inst.name)
|
||||
} else {
|
||||
inst.name.clone()
|
||||
};
|
||||
|
||||
let has_children = !inst.children().is_empty();
|
||||
|
||||
// Directory-style classes never get extensions
|
||||
let is_container = matches!(
|
||||
inst.class.as_str(),
|
||||
"Folder"
|
||||
| "Configuration"
|
||||
| "Tool"
|
||||
| "ScreenGui"
|
||||
| "SurfaceGui"
|
||||
| "BillboardGui"
|
||||
| "AdGui"
|
||||
);
|
||||
|
||||
if is_container || (has_children && is_script_class(inst.class.as_str())) {
|
||||
// Directory representation -- slug only, no extension
|
||||
return slug;
|
||||
}
|
||||
|
||||
let extension = match inst.class.as_str() {
|
||||
"Script" => {
|
||||
if has_children {
|
||||
return slug; // directory
|
||||
}
|
||||
match inst.properties.get(&ustr("RunContext")) {
|
||||
Some(Variant::Enum(e)) => match e.to_u32() {
|
||||
0 => "legacy.luau",
|
||||
1 => "server.luau",
|
||||
2 => "client.luau",
|
||||
3 => "plugin.luau",
|
||||
_ => "legacy.luau",
|
||||
},
|
||||
_ => "legacy.luau",
|
||||
}
|
||||
}
|
||||
"LocalScript" => {
|
||||
if has_children {
|
||||
return slug;
|
||||
}
|
||||
"local.luau"
|
||||
}
|
||||
"ModuleScript" => {
|
||||
if has_children {
|
||||
return slug;
|
||||
}
|
||||
"luau"
|
||||
}
|
||||
"StringValue" => {
|
||||
if has_children {
|
||||
return slug;
|
||||
}
|
||||
"txt"
|
||||
}
|
||||
"LocalizationTable" => {
|
||||
if has_children {
|
||||
return slug;
|
||||
}
|
||||
"csv"
|
||||
}
|
||||
_ => {
|
||||
if has_children {
|
||||
return slug; // directory
|
||||
}
|
||||
"model.json5"
|
||||
}
|
||||
};
|
||||
|
||||
format!("{slug}.{extension}")
|
||||
}
|
||||
|
||||
fn is_script_class(class: &str) -> bool {
|
||||
matches!(
|
||||
class,
|
||||
"Script" | "LocalScript" | "ModuleScript"
|
||||
)
|
||||
}
|
||||
|
||||
pub struct RefLinks {
|
||||
/// Refs that use path-based linking (path is unique).
|
||||
path_links: HashMap<Ref, Vec<PathRefLink>>,
|
||||
|
|
@ -36,7 +160,7 @@ struct IdRefLink {
|
|||
}
|
||||
|
||||
/// Collects all instance paths in a WeakDom before any pruning occurs.
|
||||
/// Returns a map of Ref -> path for all instances.
|
||||
/// Returns a map of Ref -> filesystem-name-compatible path for all instances.
|
||||
pub fn collect_all_paths(dom: &WeakDom) -> HashMap<Ref, String> {
|
||||
let mut paths = HashMap::new();
|
||||
let mut queue = VecDeque::new();
|
||||
|
|
@ -45,7 +169,7 @@ pub fn collect_all_paths(dom: &WeakDom) -> HashMap<Ref, String> {
|
|||
while let Some(inst_ref) = queue.pop_front() {
|
||||
let inst = dom.get_by_ref(inst_ref).unwrap();
|
||||
queue.extend(inst.children().iter().copied());
|
||||
paths.insert(inst_ref, inst_path(dom, inst_ref));
|
||||
paths.insert(inst_ref, tentative_fs_path(dom, inst_ref));
|
||||
}
|
||||
|
||||
paths
|
||||
|
|
@ -85,17 +209,19 @@ pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>)
|
|||
|
||||
if dom.get_by_ref(*target_ref).is_some() {
|
||||
// Target exists in DOM -- always use path-based system.
|
||||
// With dedup suffixes, paths are always unique.
|
||||
let target_path = inst_path(dom, *target_ref);
|
||||
// Use tentative filesystem names so the path is compatible
|
||||
// with get_instance_by_path() resolution.
|
||||
let target_path = tentative_fs_path(dom, *target_ref);
|
||||
path_links.entry(inst_ref).or_default().push(PathRefLink {
|
||||
name: *prop_name,
|
||||
path: target_path,
|
||||
});
|
||||
} else if let Some(external_path) = pre_prune_paths.get(target_ref) {
|
||||
// Target was pruned -- use pre-prune path
|
||||
// Target was pruned -- use pre-prune path (already in
|
||||
// filesystem-name format from collect_all_paths)
|
||||
log::debug!(
|
||||
"Property {}.{} points to pruned instance at '{}', storing as path reference",
|
||||
inst_path(dom, inst_ref),
|
||||
tentative_fs_path(dom, inst_ref),
|
||||
prop_name,
|
||||
external_path
|
||||
);
|
||||
|
|
@ -106,7 +232,7 @@ pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>)
|
|||
} else {
|
||||
log::warn!(
|
||||
"Property {}.{} will be `nil` on disk because the referenced instance does not exist",
|
||||
inst_path(dom, inst_ref),
|
||||
tentative_fs_path(dom, inst_ref),
|
||||
prop_name
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue