Complete implementation of the 3-pass matching algorithm for ambiguous path handling

- Finalized the transition from a duplicate-name blacklist system to a comprehensive 3-pass matching algorithm for both syncback and forward operations, enhancing the handling of ambiguous paths.
- Integrated a new `matching.lua` module to facilitate instance matching during hydration, utilizing unique name matching, class name narrowing, and structural fingerprinting.
- Updated various components, including `ChangeBatcher`, `Reconciler`, and `snapshot` modules, to leverage the new matching logic, ensuring robust file management and improved performance.
- Removed legacy duplicate handling code and streamlined the encoding process for instances with duplicate names.

These changes significantly enhance the clarity and efficiency of file operations, paving the way for improved project management and user experience.
This commit is contained in:
Joe 2026-02-17 02:43:25 -06:00
parent cc77a2cd8a
commit d6162ac210
21 changed files with 2507 additions and 846 deletions

View file

@ -1,6 +1,6 @@
use std::{
borrow::Cow,
collections::{BTreeMap, HashMap, VecDeque},
collections::{BTreeMap, HashMap, HashSet, VecDeque},
path::Path,
};
@ -21,7 +21,7 @@ use crate::{
},
snapshot_middleware::Middleware,
syncback::{
filter_properties, inst_path, name_needs_slugify, slugify_name, strip_middleware_extension,
filter_properties, inst_path, name_needs_slugify, slugify_name,
FsSnapshot, SyncbackReturn, SyncbackSnapshot,
},
variant_eq::variant_eq,
@ -584,30 +584,87 @@ pub fn syncback_project<'sync>(
project_node_property_syncback_no_path(snapshot, new_inst, node);
}
// Detect duplicate children using case-insensitive comparison.
// Project KEYS are unique by JSON definition; only filesystem children
// under project nodes may collide. Duplicates are skipped with a warning
// for now -- the dedup system (Phase 1c/1d) will handle them properly.
let mut new_duplicate_names: HashSet<String> = HashSet::new();
{
let mut seen: HashSet<String> = HashSet::new();
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
let lower = child.name.to_lowercase();
if !seen.insert(lower.clone()) {
new_duplicate_names.insert(lower);
}
}
}
if !new_duplicate_names.is_empty() {
let parent_path = inst_path(snapshot.new_tree(), new_inst.referent());
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
if new_duplicate_names.contains(&child.name.to_lowercase()) {
log::warn!(
"Skipping duplicate-named child '{}' under ProjectNode '{}' -- \
cannot reliably sync yet. Full path: {}/{}",
child.name,
parent_path,
parent_path,
child.name
);
}
}
}
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
if new_child_map.insert(&child.name, child).is_some() {
let parent_path = inst_path(snapshot.new_tree(), new_inst.referent());
anyhow::bail!(
"Instances that are direct children of an Instance that is made by a project file \
must have a unique name.\nThe child '{}' is duplicated in the place file.\n\
Full path: {}/{}", child.name, parent_path, child.name
);
if !new_duplicate_names.contains(&child.name.to_lowercase()) {
new_child_map.insert(&child.name, child);
}
}
let mut old_duplicate_names: HashSet<String> = HashSet::new();
{
let mut seen: HashSet<String> = HashSet::new();
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
let lower = child.name().to_lowercase();
if !seen.insert(lower.clone()) {
old_duplicate_names.insert(lower);
}
}
}
if !old_duplicate_names.is_empty() {
let parent_path = inst_path(snapshot.old_tree(), old_inst.id());
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
if old_duplicate_names.contains(&child.name().to_lowercase()) {
log::warn!(
"Skipping duplicate-named child '{}' under ProjectNode '{}' -- \
cannot reliably sync yet. Full path: {}/{}",
child.name(),
parent_path,
parent_path,
child.name()
);
}
}
}
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
if old_child_map.insert(child.name(), child).is_some() {
let parent_path = inst_path(snapshot.old_tree(), old_inst.id());
anyhow::bail!(
"Instances that are direct children of an Instance that is made by a project file \
must have a unique name.\nThe child '{}' is duplicated on the file system.\n\
Full path: {}/{}", child.name(), parent_path, child.name()
);
if !old_duplicate_names.contains(&child.name().to_lowercase()) {
old_child_map.insert(child.name(), child);
}
}
@ -654,33 +711,27 @@ pub fn syncback_project<'sync>(
// All of the children in this loop are by their nature not in the
// project, so we just need to run syncback on them.
//
// Track taken names (bare slugs) per parent directory so siblings
// under the same parent deduplicate against each other.
// Track taken names (full filesystem name components, lowercased)
// per parent directory so siblings under the same parent deduplicate
// against each other using filesystem-level collision detection.
let mut taken_names_per_dir: std::collections::HashMap<
std::path::PathBuf,
std::collections::HashSet<String>,
> = std::collections::HashMap::new();
// Pre-seed taken_names from old children's actual filesystem dedup keys
// so that new-only children correctly dedup against existing siblings.
// We derive keys from relevant_paths (the real filename on disk) rather
// than slugifying instance names, because an old instance may already
// have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare
// slug wouldn't capture.
// Only in incremental mode: in clean mode, old_ref is forced to None
// for all children, so every child is treated as new and pre-seeding
// would cause false collisions (spurious ~1 suffixes).
// Pre-seed taken_names from old children's actual filesystem names
// (full filename including extension) so that new-only children
// correctly dedup against existing siblings.
if snapshot.data.is_incremental() {
if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) {
let taken = taken_names_per_dir.entry(parent_path.clone()).or_default();
for old_child in old_child_map.values() {
if let Some(path) = old_child.metadata().relevant_paths.first() {
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
let middleware =
old_child.metadata().middleware.unwrap_or(Middleware::Dir);
let dedup_key =
strip_middleware_extension(filename, middleware).to_lowercase();
taken.insert(dedup_key);
// Use the full filename (including extension) as the
// dedup key so that "Foo" (dir) and "Foo.server.luau"
// (file) do NOT collide.
taken.insert(filename.to_lowercase());
}
} else {
// Fallback (shouldn't happen for old instances with disk presence)