rojo/.cursor/plans/luau-style_ref_paths_845083e1.plan.md
Joe b8c3520d25 Add Luau-style ref paths migration plan and update related tests
- Introduced a new migration plan for converting `Rojo_Ref_*` attribute paths to Luau require-by-string style, enhancing portability with relative paths.
- Updated existing test snapshots to reflect the new path formats, ensuring consistency across various scenarios.
- Enhanced the `compute_relative_ref_path` and `resolve_ref_path` functions to support the new path semantics, improving path resolution logic.
- Refactored related code in `change_processor`, `syncback`, and `snapshot` modules to accommodate the new reference path handling.

These changes improve the robustness and maintainability of reference path management in the project.
2026-02-18 04:17:51 -06:00

48 KiB

name overview todos isProject
Luau-style ref paths Migrate Rojo_Ref_* attribute paths from bare absolute paths to Luau require-by-string style: @self/, ./, ../, and @game/. Prefer relative paths for maximum copy/paste portability. Update all syncback, forward sync, and two-way sync codepaths, plus comprehensive tests.
id content status
core-compute Add compute_relative_ref_path() to rojo_ref.rs + unit tests for all prefix selection scenarios completed
id content status
resolve-fn Add resolve_ref_path() to tree.rs that handles @game/, @self/, ./, ../ prefixes and .. mid-path completed
id content status
forward-sync Update patch_compute.rs and patch_apply.rs to pass source_ref when resolving Rojo_Ref_* paths completed
id content status
syncback-placeholders Redesign placeholder system in ref_properties.rs to encode (source, target) pairs, compute relative paths in post-processing completed
id content status
syncback-final Update fs_snapshot.rs fix_ref_paths and syncback/mod.rs post-processing to produce relative substitutions completed
id content status
two-way-write Update api.rs syncback_updated_properties and added_paths to write relative ref paths completed
id content status
change-processor Rewrite update_ref_paths_after_rename in change_processor.rs to resolve-then-recompute instead of string prefix replacement completed
id content status
ref-path-index Change RefPathIndex.populate_from_dir to populate_from_tree, store resolved absolute paths, update serve_session.rs call site completed
id content status
update-existing-tests Fix all existing test fixtures and assertions to use new prefix format completed
id content status
new-test-compute Add comprehensive compute_relative_ref_path unit tests (all scenarios from plan) completed
id content status
new-test-resolve Add comprehensive resolve_ref_path unit tests (all prefix types, .., edge cases) completed
id content status
new-test-roundtrip Add syncback round-trip integration tests with relative refs (@self, ./, ../, @game) completed
id content status
new-test-twoway Add two-way sync integration tests (ref writes, renames, dedup cleanup with relative paths) completed
id content status
new-test-edge Add edge case tests (cross-service, ancestor refs, slugified names, dedup suffixes, same-batch add+ref) completed
id content status
cleanup Remove dead escape/unescape functions, update documentation comments throughout completed
false

Luau-Style Ref Paths

Current State

Rojo_Ref_* attributes store bare absolute paths from DataModel's children:

{ "Rojo_Ref_PrimaryPart": "Workspace/TestModel/Part1.model.json5" }

Target State

Use Luau require-by-string semantics with four prefix types:

// Descendant of source instance
{ "Rojo_Ref_PrimaryPart": "@self/Part1.model.json5" }

// Sibling (child of parent)
{ "Rojo_Ref_Value": "./Sibling.model.json5" }

// Navigate up then down (within same service)
{ "Rojo_Ref_Attachment0": "../Attachments/Att1.model.json5" }

// Cross-service or target is ancestor of source
{ "Rojo_Ref_Value": "@game/ReplicatedStorage/Shared/Module.luau" }

Prefix Selection Algorithm

Given source and target absolute ref paths:

  1. target == source --> @self (self-reference, rare)
  2. target is descendant of source --> @self/<relative_path>
  3. LCA is DataModel root (cross-service, common_prefix_len == 0) --> @game/<target_abs>
  4. target is ancestor of source (remaining is empty after LCA) --> @game/<target_abs>
  5. Otherwise (same service, different branches):
  • ups == 1 (LCA is parent) --> ./<remaining>
  • ups >= 2 --> "../" * (ups - 1) + <remaining>

This maximizes portability: subtrees can be copied/moved within a service without breaking refs.

Worked examples:

Source Target LCA Result
Workspace/A/Script Workspace/A/Script/Child (descendant) @self/Child
Workspace/A/Script Workspace/A/Part.model.json5 Workspace/A ./Part.model.json5
Workspace/A/Script Workspace/B/Part.model.json5 Workspace ../B/Part.model.json5
Workspace/A/B/Script Workspace/C/Part.model.json5 Workspace ../../C/Part.model.json5
Workspace/A/Script ReplicatedStorage/Module.luau root @game/ReplicatedStorage/Module.luau
Workspace/A/B/Script Workspace/A (ancestor) @game/Workspace/A

Resolution Algorithm

Per the Roblox require-by-string docs:

resolve_ref_path(path, source_ref, tree):
    @game/rest   --> resolve segments from root (game)
    @self        --> return source_ref (script itself)
    @self/rest   --> resolve segments from source_ref (script)
    ./rest       --> resolve segments from parent(source_ref) (script.Parent)
    ../rest      --> resolve segments from parent(parent(source_ref)) (script.Parent.Parent)
    bare_path    --> resolve segments from root (backward compat fallback)

    During segment walk:
      ".."  --> go to parent of previous component
      other --> find child by filesystem name (case-insensitive)

How ../ chaining works (from Roblox docs):

  • ../X = script.Parent.Parent.X (2 ups total)
  • ../../X = script.Parent.Parent.Parent.X (3 ups -- ../ prefix gives 2, then .. component gives 1 more)
  • ../../../X = 4 ups (prefix gives 2, two .. components give 2 more)

Generation formula (ups = levels from source to LCA):

  • ups=1: "./" + remaining
  • ups>=2: "../" * (ups-1) + remaining

Verification: ups=3 --> "../" * 2 = "../../" + remaining. Parse: ../ prefix = 2 ups, one .. component = 1 more = 3. Correct.

Bare paths (no prefix) are treated as @game/ for backward compatibility with old-format files. New writes never produce bare paths.


Phase 1: Core Path Functions

File: src/rojo_ref.rs

  • Add compute_relative_ref_path(source_abs: &str, target_abs: &str) -> String -- pure string function implementing the prefix selection algorithm above. Both inputs are absolute ref paths (slash-separated filesystem names from root children). Output is the prefixed relative/absolute path.
  • Keep ref_target_path_from_tree() and ref_target_path() for computing absolute paths (they become internal building blocks).
  • The escape/unescape functions (escape_ref_path_segment, split_ref_path, etc.) become dead code since filesystem names can't contain /. Remove or deprecate them.

Phase 2: Path Resolution (Forward Sync)

File: src/snapshot/tree.rs

  • Add resolve_ref_path(&self, path: &str, source_ref: Ref) -> Option<Ref> -- new public function. Parses prefix, sets starting Ref, then walks segments. Handles .. as "go to parent" at any position.
  • get_instance_by_path() remains as internal helper for absolute segment walking (used by resolve_ref_path for the @game/ and bare-path cases).

File: src/snapshot/patch_compute.rs

  • compute_ref_properties() currently calls tree.get_instance_by_path(path). Change to call tree.resolve_ref_path(path, source_ref) where source_ref is the instance that owns the attributes.

File: src/snapshot/patch_apply.rs

  • finalize_patch_application() resolves deferred refs in path_refs_to_rewrite. Currently uses tree.get_instance_by_path(). Change to tree.resolve_ref_path(path, source_ref) where source_ref comes from the MultiMap key.

Phase 3: CLI Syncback (Writing Relative Paths)

File: src/syncback/ref_properties.rs

  • Placeholder redesign: Placeholders must be unique per (source, target) pair since the same target gets different relative paths from different sources. Change from __ROJO_REF_<target>__ to __ROJO_REF_<source>_TO_<target>__.
  • Change placeholder_to_target: HashMap<String, Ref> to placeholder_to_source_and_target: HashMap<String, (Ref, Ref)>.
  • When final_paths IS available, call compute_relative_ref_path(source_path, target_path) directly instead of storing the absolute target path.
  • collect_all_paths() is unchanged (still collects absolute paths for all instances).

File: src/syncback/fs_snapshot.rs

  • fix_ref_paths() substitution map entries are now (placeholder, relative_path). The post-processing step in src/syncback/mod.rs must look up BOTH source and target in ref_path_map, call compute_relative_ref_path(), and produce the substitution.

File: src/syncback/snapshot.rs

  • record_ref_path() is unchanged -- it still records absolute paths in ref_path_map. These are used as inputs to compute_relative_ref_path.

Phase 4: Two-Way Sync (Writing Relative Paths)

File: src/web/api.rs

  • syncback_updated_properties(): Currently computes target absolute path via ref_target_path_from_tree() and stores it directly. Change to also compute source absolute path, then call compute_relative_ref_path(source_abs, target_abs) for the attribute value.
  • added_paths pre-computation: Same change -- compute relative paths from source to the newly-added target.

File: src/change_processor.rs

  • update_ref_paths_after_rename(): Currently does string prefix replacement on absolute paths. With relative paths on disk, the approach changes:
    1. Find affected files via RefPathIndex (still indexed by absolute target path)
    2. For each file, parse JSON, resolve each Rojo_Ref_* value to absolute (using source instance's absolute path)
    3. If resolved absolute matches old prefix, compute new absolute, then recompute relative path from source
    4. Write updated relative path back
  • This requires knowing each file's source instance absolute path. Add a helper that derives this from the file's filesystem path + tree metadata (instigating source lookup).

Phase 5: RefPathIndex Updates

*File: src/rojo_ref.rs* (RefPathIndex)

The index stores resolved absolute paths as keys (not the on-disk relative strings). This preserves the efficient prefix-based lookup.

  • populate_from_dir() --> Change to populate_from_tree(tree: &RojoTree). Walk instances in the tree, read Rojo_Ref_* attributes, resolve relative paths to absolute using the source instance's position, index the absolute path. Called after initial tree build in src/serve_session.rs line 177-181.
  • add() / remove(): Callers pass resolved absolute paths (same as before).
  • find_by_prefix() / update_prefix(): Unchanged (operate on absolute keys).
  • update_ref_paths_in_file() in src/syncback/meta.rs: Rewrite to parse + resolve + recompute instead of string prefix replacement.

Phase 6: Tests

Update Existing Tests

All test fixtures and assertions with bare absolute ref paths need updating to use the new prefix format.

Key files:


Test Specification: compute_relative_ref_path(source_abs, target_abs)

Every test below shows the two absolute path inputs and the exact expected output string.

@self -- Target is the source itself or a descendant

# Source Target Expected Why
1 Workspace/Model Workspace/Model @self Self-reference
2 Workspace/Model Workspace/Model/Handle.model.json5 @self/Handle.model.json5 Direct child
3 Workspace/Model Workspace/Model/SubFolder/Part.model.json5 @self/SubFolder/Part.model.json5 Nested descendant
4 Workspace/Model Workspace/Model/A/B/C/D.luau @self/A/B/C/D.luau Deeply nested descendant (4 levels)
5 Workspace/Tool Workspace/Tool/Handle.model.json5 @self/Handle.model.json5 Tool with Handle (common Roblox pattern)
6 Workspace/Model Workspace/Model/Init @self/Init Child that is a directory (init-style)
7 Workspace/Gun Workspace/Gun/Sounds/Fire.model.json5 @self/Sounds/Fire.model.json5 Tool referencing nested sound
8 Workspace/Model Workspace/Model/Hey_Bro.server.luau @self/Hey_Bro.server.luau Slugified child name
9 Workspace/Model Workspace/Model/Data~2 @self/Data~2 Dedup'd child

./ -- Target is a sibling (shares same parent, 1 level up)

# Source Target Expected Why
10 Workspace/Folder/ScriptA.server.luau Workspace/Folder/ScriptB.server.luau ./ScriptB.server.luau Sibling script
11 Workspace/Folder/ScriptA.server.luau Workspace/Folder/Config.luau ./Config.luau Sibling module
12 Workspace/Folder/ScriptA.server.luau Workspace/Folder/SubFolder ./SubFolder Sibling directory
13 Workspace/Folder/ScriptA.server.luau Workspace/Folder/SubFolder/Deep.luau ./SubFolder/Deep.luau Sibling then descend
14 Workspace/Folder/ObjectValue.model.json5 Workspace/Folder/Target.model.json5 ./Target.model.json5 ObjectValue.Value pointing to sibling
15 Workspace/Beams/Beam.model.json5 Workspace/Beams/Att1.model.json5 ./Att1.model.json5 Beam.Attachment0 to sibling attachment
16 Workspace/Beams/Beam.model.json5 Workspace/Beams/Att2.model.json5 ./Att2.model.json5 Beam.Attachment1 to another sibling
17 Workspace/Folder/A.luau Workspace/Folder/Hey_Bro.server.luau ./Hey_Bro.server.luau Sibling with slugified name
18 Workspace/Folder/A.luau Workspace/Folder/Data~2 ./Data~2 Sibling with dedup suffix
19 Workspace/Folder/A.luau Workspace/Folder/Data~3.model.json5 ./Data~3.model.json5 Sibling model with dedup suffix

../ -- Navigate up 2+ levels then down (within same service)

# Source Target Expected Why
20 Workspace/A/Script.server.luau Workspace/B/Part.model.json5 ../B/Part.model.json5 Parent's sibling's child (2 ups)
21 Workspace/A/Script.server.luau Workspace/B ../B Parent's sibling directory (2 ups)
22 Workspace/A/B/Script.server.luau Workspace/C/Part.model.json5 ../../C/Part.model.json5 Grandparent's sibling's child (3 ups)
23 Workspace/A/B/C/Script.luau Workspace/D/E/Part.model.json5 ../../../D/E/Part.model.json5 4 ups within same service
24 Workspace/A/B/C/D/Script.luau Workspace/E/Part.model.json5 ../../../../E/Part.model.json5 5 ups within same service
25 Workspace/Models/Car/Body.model.json5 Workspace/Models/Truck/Body.model.json5 ../Truck/Body.model.json5 Peer model in sibling folder
26 Workspace/A/Deep/Script.luau Workspace/A/Other.luau ../Other.luau Up from deep, target is cousin (2 ups)
27 Workspace/A/B/Beam.model.json5 Workspace/A/C/Att.model.json5 ../C/Att.model.json5 Beam referencing attachment in uncle folder
28 Workspace/Systems/Combat/Hitbox.server.luau Workspace/Systems/Audio/HitSound.model.json5 ../Audio/HitSound.model.json5 Cross-system ref within same parent
29 Workspace/Map/Zone1/Spawns/Spawn1.model.json5 Workspace/Map/Zone1/Props/Tree.model.json5 ../Props/Tree.model.json5 Same zone, different category
30 Workspace/Map/Zone1/Spawns/Spawn1.model.json5 Workspace/Map/Zone2/Flag.model.json5 ../../Zone2/Flag.model.json5 Cross-zone ref

@game/ -- Cross-service or target is ancestor

# Source Target Expected Why
31 Workspace/Script.server.luau ReplicatedStorage/Module.luau @game/ReplicatedStorage/Module.luau Classic cross-service require pattern
32 Workspace/Script.server.luau ServerStorage/Data.luau @game/ServerStorage/Data.luau Server script to ServerStorage
33 ServerScriptService/Main.server.luau ReplicatedStorage/Shared/Utils.luau @game/ReplicatedStorage/Shared/Utils.luau SSS to RS (common pattern)
34 StarterGui/ScreenGui/Button.client.luau ReplicatedStorage/UI/Theme.luau @game/ReplicatedStorage/UI/Theme.luau Client UI to shared module
35 Workspace/A/B/C/Script.luau Lighting @game/Lighting Ref to a service itself
36 Workspace/A/B/C/Script.luau Workspace @game/Workspace Ref to own service (ancestor)
37 Workspace/A/B/Script.luau Workspace/A @game/Workspace/A Ref to own grandparent (ancestor)
38 Workspace/A/B/C/Script.luau Workspace/A/B @game/Workspace/A/B Ref to own great-grandparent
39 Workspace/Model ReplicatedStorage/Assets/Model.model.json5 @game/ReplicatedStorage/Assets/Model.model.json5 Cross-service model ref
40 Workspace/Beam.model.json5 Lighting/Atmosphere.model.json5 @game/Lighting/Atmosphere.model.json5 Workspace to Lighting
41 ReplicatedStorage/A.luau ReplicatedStorage @game/ReplicatedStorage Ref to own service (ancestor)
42 Workspace/Script.luau SoundService/BGM.model.json5 @game/SoundService/BGM.model.json5 To SoundService

Special names: slugified, dedup'd, init-style

# Source Target Expected Why
43 Workspace/Folder/A.luau Workspace/Folder/Hey_Bro.server.luau ./Hey_Bro.server.luau Sibling with slug (real name "Hey/Bro")
44 Workspace/Folder/A.luau Workspace/Other/CON_.luau ../Other/CON_.luau Windows reserved name (slugified)
45 Workspace/A.luau Workspace/Data~2 ./Data~2 Dedup'd sibling (Folder)
46 Workspace/A.luau Workspace/Data~3.model.json5 ./Data~3.model.json5 Dedup'd model sibling
47 Workspace/A.luau Workspace/Data~2/Child.luau ./Data~2/Child.luau Into dedup'd folder
48 Workspace/Model Workspace/Model/Scripts @self/Scripts Child is init-style directory
49 Workspace/Scripts Workspace/Scripts/Foo @self/Foo Source is init-style, child is too
50 Workspace/MyFolder_ Workspace/MyFolder_/Child.luau @self/Child.luau Source name has trailing underscore (from slug)

Tricky edge cases

# Source Target Expected Why
51 Workspace Workspace/Model.model.json5 @self/Model.model.json5 Service referencing own child
52 Workspace ReplicatedStorage/Module.luau @game/ReplicatedStorage/Module.luau Service to service (cross-service)
53 Workspace Workspace @self Service self-ref
54 Workspace Lighting @game/Lighting Service to different service
55 Workspace/A Workspace/AB ./AB Prefix ambiguity: "A" is NOT prefix of "AB" in segment terms
56 Workspace/Foo.server.luau Workspace/Foo.luau ./Foo.luau Same stem different extension = different instances
57 Workspace/A/B/C/D/E/F/Script.luau Workspace/A/B/C/D/E/G/Target.luau ../G/Target.luau Deep nesting, close cousins
58 Workspace/A/B/C/D/E/F/Script.luau Workspace/X/Target.luau ../../../../../X/Target.luau Deep nesting, far apart but same service

Test Specification: resolve_ref_path(path, source_ref, tree)

Build a RojoTree for each test. The tree structure is shown in comments. Each test verifies that the path resolves to the correct Ref (or None).

Prefix resolution

Tree for tests 59-78 (instance hierarchy with filesystem names in parens):

DataModel (root)
  Workspace
    ModelA (Model, fs: "ModelA")
      Script (Script server, fs: "Script.server.luau")
      Handle (Part, fs: "Handle.model.json5")
      SubFolder (Folder, fs: "SubFolder")
        Deep (Part, fs: "Deep.model.json5")
    ModelB (Folder, fs: "ModelB")
      Part (Part, fs: "Part.model.json5")
  ReplicatedStorage
    Module (ModuleScript, fs: "Module.luau")
    Shared (Folder, fs: "Shared")
      Utils (ModuleScript, fs: "Utils.luau")
  Lighting

Key relationships: Script and Handle are siblings under ModelA. ModelA has children (Script, Handle, SubFolder). ModelA and ModelB are siblings under Workspace.

# Path Source Expected Why
59 @self/Handle.model.json5 ModelA Handle @self/ starts at ModelA, child Handle
60 @self/SubFolder/Deep.model.json5 ModelA Deep @self/ nested descent from ModelA
61 @self Script Script Self-reference (no path after prefix)
62 ./Handle.model.json5 Script Handle ./ starts at Script.Parent = ModelA, child Handle
63 ./SubFolder/Deep.model.json5 Script Deep ./ starts at ModelA, descend through SubFolder
64 ./Script.server.luau Handle Script ./ from Handle.Parent = ModelA, child Script
65 ../ModelB/Part.model.json5 Script Part ../ starts at Script.Parent.Parent = Workspace, then ModelB/Part
66 ../../ReplicatedStorage/Module.luau Script Module ../ starts at Workspace; remaining ../ReplicatedStorage/Module.luau goes .. up to DataModel root, then descends. Valid path (just not one we'd generate -- we'd use @game/ instead)
67 @game/ReplicatedStorage/Module.luau Script Module @game/ absolute from root
68 @game/Workspace/ModelA/Handle.model.json5 Script Handle @game/ works even when target is nearby
69 @game/Lighting Script Lighting @game/ to a service
70 Workspace/ModelA/Handle.model.json5 Script Handle Bare path (no prefix) = backward compat, resolves like @game/
71 @game/Workspace/ModelA/NonExistent Script None Target doesn't exist
72 ./NonExistent.luau Script None Sibling doesn't exist

.. mid-path

# Path Source Expected Why
73 @self/SubFolder/../Handle.model.json5 ModelA Handle Start at ModelA, into SubFolder, .. back to ModelA, child Handle
74 ./SubFolder/../Script.server.luau Handle Script ./ at ModelA, into SubFolder, .. back to ModelA, child Script
75 ./SubFolder/../../ModelB/Part.model.json5 Script Part ./ at ModelA, into SubFolder, .. to ModelA, .. to Workspace, child ModelB/Part

Case-insensitive matching

# Path Source Expected Why
76 @self/handle.model.json5 ModelA Handle Lowercase "handle" matches "Handle"
77 ./SCRIPT.SERVER.LUAU Handle Script All-caps matches filesystem name
78 @game/workspace/modela/Handle.model.json5 Script Handle Lowercase service and folder names match

Filesystem name vs instance name fallback

Tree with metadata:

DataModel
  Workspace
    MyFolder (fs_name: "MyFolder", instance_name: "MyFolder")
      Slugged (fs_name: "Hey_Bro.server.luau", instance_name: "Hey/Bro")
      Normal.luau (fs_name: "Normal.luau", instance_name: "Normal")
# Path Source Expected Why
79 ./Hey_Bro.server.luau Normal Slugged Matches by filesystem name
80 @game/Workspace/MyFolder/Hey_Bro.server.luau Normal Slugged Absolute with fs name

Test Specification: Syncback Round-Trip

Each test: build a WeakDom with specific instances and Ref properties, run syncback, verify the on-disk Rojo_Ref_* attribute values, then rebuild from disk and verify the Ref properties resolve to the same targets.

RT-1: Model with PrimaryPart (@self/)

Workspace
  TestModel (Model)
    PrimaryPart → Handle
    Handle (Part)
    Other (Part)

Expected on disk (Workspace/TestModel/init.meta.json5):

{ "className": "Model", "attributes": { "Rojo_Ref_PrimaryPart": "@self/Handle.model.json5" } }

Rebuild: TestModel.PrimaryPart == Handle. Round-trip identical.

RT-2: Cross-service ObjectValue (@game/)

Workspace
  OV (ObjectValue)
    Value → Target
ReplicatedStorage
  Target (ModuleScript, source="return {}")

Expected on disk (Workspace/OV.model.json5):

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "@game/ReplicatedStorage/Target.luau" } }

RT-3: Beam with attachments in same parent (./)

Workspace
  Beams (Folder)
    Beam (Beam)
      Attachment0 → Att1
      Attachment1 → Att2
    Att1 (Attachment)
    Att2 (Attachment)

Expected on disk (Workspace/Beams/Beam.model.json5):

{
  "className": "Beam",
  "attributes": {
    "Rojo_Ref_Attachment0": "./Att1.model.json5",
    "Rojo_Ref_Attachment1": "./Att2.model.json5"
  }
}

RT-4: Beam with attachments in sibling subfolder (./)

Workspace
  Beams (Folder)
    Beam (Beam)
      Attachment0 → Att1
    Attachments (Folder)
      Att1 (Attachment)

Beam and Attachments are siblings under Beams. Source = Workspace/Beams/Beam.model.json5. Target = Workspace/Beams/Attachments/Att1.model.json5. LCA = Workspace/Beams. ups = 1 --> ./.

Expected on disk (Workspace/Beams/Beam.model.json5):

{
  "className": "Beam",
  "attributes": { "Rojo_Ref_Attachment0": "./Attachments/Att1.model.json5" }
}

RT-5: Beam with attachments across branches (../)

Workspace
  GroupA (Folder)
    Beam (Beam)
      Attachment0 → Att1
  GroupB (Folder)
    Att1 (Attachment)

Expected on disk (Workspace/GroupA/Beam.model.json5):

{ "className": "Beam", "attributes": { "Rojo_Ref_Attachment0": "../GroupB/Att1.model.json5" } }

Source = Workspace/GroupA/Beam.model.json5. Target = Workspace/GroupB/Att1.model.json5. LCA = Workspace. ups = 2. ../GroupB/Att1.model.json5.

RT-6: Mixed prefix types across multiple meta files

Workspace
  Container (Folder)
    Model (Model)
      Handle (Part)         <-- child of Model
      RefToSibling (ObjectValue, Value → Sibling)  <-- child of Model
    Sibling (Part)           <-- child of Container (sibling of Model)
ReplicatedStorage
  SharedModule (ModuleScript)

Two refs produce different prefix types:

  1. Model.PrimaryPart → Handle: Source = Model (Workspace/Container/Model). Handle is a descendant of Model --> @self/Handle.model.json5. Written in Model/init.meta.json5.
  2. RefToSibling.Value → Sibling: Source = RefToSibling (Workspace/Container/Model/RefToSibling.model.json5). Target = Sibling (Workspace/Container/Sibling.model.json5). LCA = Container. ups = 2 --> ../Sibling.model.json5. Written in RefToSibling.model.json5.

Expected (Workspace/Container/Model/init.meta.json5):

{ "className": "Model", "attributes": { "Rojo_Ref_PrimaryPart": "@self/Handle.model.json5" } }

Expected (Workspace/Container/Model/RefToSibling.model.json5):

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "../Sibling.model.json5" } }

Verifies two different ref prefix types coexist in the same directory tree.

RT-7: Refs through dedup'd siblings

Workspace
  Container (Folder)
    Data (Folder, child1)
    Data (Folder, child2)  --> gets dedup'd to Data~2
    Pointer (ObjectValue)
      Value → Data(child2)

Expected (Workspace/Container/Pointer.model.json5):

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "./Data~2" } }

RT-8: Ref to init-style script

Workspace
  Folder
    Pointer (ObjectValue, Value → BigScript)
    BigScript (ModuleScript with children)
      Helper (ModuleScript)

BigScript has children, so it becomes a directory: Folder/BigScript/init.luau. Filesystem name = BigScript.

Expected (Workspace/Folder/Pointer.model.json5):

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "./BigScript" } }

RT-9: Ref to instance with slugified name

Workspace
  Folder
    Pointer (ObjectValue, Value → Target)
    Target (Script, name="Hey:There", server)

"Hey:There" slugifies to "Hey_There". File = Hey_There.server.luau.

Expected (Workspace/Folder/Pointer.model.json5):

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "./Hey_There.server.luau" } }

RT-10: Deep nesting within same service

Workspace
  A (Folder)
    B (Folder)
      C (Folder)
        D (Folder)
          Source (ObjectValue, Value → Target)
    X (Folder)
      Target (Part)

Source = Workspace/A/B/C/D/Source.model.json5. Target = Workspace/A/X/Target.model.json5. LCA = Workspace/A. ups = 4. --> ../../../X/Target.model.json5.

Expected:

{ "className": "ObjectValue", "attributes": { "Rojo_Ref_Value": "../../../X/Target.model.json5" } }

RT-11: Multiple instances referencing same target from different depths

Workspace
  Target (Part)
  Shallow (ObjectValue, Value → Target)
  Deep (Folder)
    Mid (Folder)
      DeepOV (ObjectValue, Value → Target)
  • Shallow: Source = Workspace/Shallow.model.json5, Target = Workspace/Target.model.json5. ups = 1. --> ./Target.model.json5
  • DeepOV: Source = Workspace/Deep/Mid/DeepOV.model.json5, Target = Workspace/Target.model.json5. LCA = Workspace. ups = 3. --> ../../Target.model.json5

Verifies same target gets different paths from different sources.

RT-12: Duplicate-named attachments (dedup + placeholder system)

Workspace
  Beams (Folder)
    Att (Attachment, child1)
    Att (Attachment, child2) --> Att~2
    Att (Attachment, child3) --> Att~3
    Beam (Beam)
      Attachment0 → Att(child2)
      Attachment1 → Att(child3)

Expected (Workspace/Beams/Beam.model.json5):

{
  "className": "Beam",
  "attributes": {
    "Rojo_Ref_Attachment0": "./Att~2.model.json5",
    "Rojo_Ref_Attachment1": "./Att~3.model.json5"
  }
}

Verifies source-aware placeholders produce correct per-source relative paths even with dedup.


Test Specification: Two-Way Sync

TW-1: Ref property change writes relative path

Setup: Serve session with tree:

Workspace
  Model (Model)
    Handle (Part)

Plugin sends: Model.PrimaryPart = Handle (Ref property change).

Verify init.meta.json5 on disk contains:

{ "attributes": { "Rojo_Ref_PrimaryPart": "@self/Handle.model.json5" } }

TW-2: Cross-service ref writes @game/

Setup:

Workspace
  OV (ObjectValue)
ReplicatedStorage
  Target (ModuleScript)

Plugin sends: OV.Value = Target.

Verify OV.model.json5 contains:

{ "attributes": { "Rojo_Ref_Value": "@game/ReplicatedStorage/Target.luau" } }

TW-3: Nil ref removes attribute

Setup: Model already has Rojo_Ref_PrimaryPart: "@self/Handle.model.json5" on disk.

Plugin sends: Model.PrimaryPart = nil.

Verify: Rojo_Ref_PrimaryPart is removed from the meta file.

TW-4: Same-batch add + ref

Plugin sends in one write request:

  • Add: new Part "NewPart" under Model
  • Update: Model.PrimaryPart = NewPart

Verify: meta file contains Rojo_Ref_PrimaryPart with relative path to the newly added instance (should be @self/NewPart.model.json5).

TW-5: Rename target updates refs

Setup:

Workspace
  Folder
    Pointer (ObjectValue, Value → OldName)
    OldName (Part)

On disk: Pointer.model.json5 has "Rojo_Ref_Value": "./OldName.model.json5".

Plugin renames OldName to NewName.

Verify: Pointer.model.json5 updated to "Rojo_Ref_Value": "./NewName.model.json5".

TW-6: Rename source does NOT change its own outgoing refs

Setup:

Workspace
  Folder
    SourceOV (ObjectValue, Value → Target)
    Target (Part)

On disk: SourceOV.model.json5 has "Rojo_Ref_Value": "./Target.model.json5".

Plugin renames SourceOV to RenamedOV.

Verify: RenamedOV.model.json5 still has "Rojo_Ref_Value": "./Target.model.json5" (unchanged -- the relative relationship didn't change).

TW-7: Dedup cleanup rename updates refs

Setup:

Workspace
  Folder
    Data (Folder)
    Data~2 (Folder)
    Pointer (ObjectValue, Value → Data~2)

On disk: Pointer.model.json5 has "Rojo_Ref_Value": "./Data~2".

Delete Data (the base). Dedup cleanup promotes Data~2 --> Data.

Verify: Pointer.model.json5 updated to "Rojo_Ref_Value": "./Data".

TW-8: Base-name promotion with cross-branch ref

Setup:

Workspace
  GroupA
    Pointer (ObjectValue, Value → Target)
  GroupB
    Target (Part)
    Target~2 (Part)

On disk: Pointer.model.json5 has "Rojo_Ref_Value": "../GroupB/Target~2".

Delete Target (base). Cleanup: Target~2 --> Target.

Verify: Pointer.model.json5 updated to "Rojo_Ref_Value": "../GroupB/Target".

TW-9: Multiple referencing files with different relative paths

Setup:

Workspace
  Target (Part)
  ShallowOV (ObjectValue, Value → Target)
  Deep
    DeepOV (ObjectValue, Value → Target)

Rename Target to Renamed.

Verify:

  • ShallowOV.model.json5: "Rojo_Ref_Value": "./Renamed.model.json5" (was ./Target.model.json5)
  • Deep/DeepOV.model.json5: "Rojo_Ref_Value": ../Renamed.model.json5"(was../Target.model.json5`)

Both files updated with correct relative paths from their respective positions.

TW-10: Rename intermediate ancestor

Setup:

Workspace
  Parent
    Child
      Target (Part)
  Pointer (ObjectValue, Value → Target)

On disk: Pointer.model.json5 has "Rojo_Ref_Value": "./Parent/Child/Target.model.json5".

Rename Parent to RenamedParent.

Verify: Pointer.model.json5 updated to "Rojo_Ref_Value": "./RenamedParent/Child/Target.model.json5".


Test Specification: Placeholder System (Syncback)

PH-1: Source-aware placeholders are unique

Build DOM with two ObjectValues pointing to the same target:

Workspace
  Target (Part)
  OV_A (ObjectValue, Value → Target)
  OV_B (ObjectValue, Value → Target)

Call collect_referents without final_paths.

Verify:

  • OV_A gets placeholder __ROJO_REF_<OV_A>_TO_<Target>__
  • OV_B gets placeholder __ROJO_REF_<OV_B>_TO_<Target>__
  • Placeholders are different strings
  • placeholder_to_source_and_target maps each to (source_ref, target_ref)

PH-2: Post-processing produces different relative paths for same target

After walk, ref_path_map contains:

  • OV_A --> Workspace/OV_A.model.json5
  • OV_B --> Workspace/Deep/OV_B.model.json5
  • Target --> Workspace/Target.model.json5

Verify substitutions:

  • OV_A's placeholder --> ./Target.model.json5 (sibling)
  • OV_B's placeholder --> ../Target.model.json5 (2 ups)

PH-3: Duplicate-named targets with source-aware placeholders

Workspace
  Beams
    Att (Attachment, child1)
    Att (Attachment, child2)
    Beam (Beam, Attachment0 → child1, Attachment1 → child2)

Both attachments are named "Att" so they get different Refs but tentatively the same path. Placeholders encode both source (Beam) and target (child1, child2) so they're unique.

After walk with dedup: child1 = Att.model.json5, child2 = Att~2.model.json5.

Verify:

  • Attachment0: ./Att.model.json5
  • Attachment1: ./Att~2.model.json5
  • No chaining corruption

PH-4: With final_paths available, computes relative directly

Call collect_referents WITH final_paths:

OV_A source path: "Workspace/OV_A.model.json5"
Target final path: "Workspace/Target.model.json5"

Verify: No placeholders generated. Path stored directly as ./Target.model.json5.


Test Specification: RefPathIndex with Relative Paths

RI-1: populate_from_tree indexes resolved absolute paths

Tree has:

Workspace/Model/init.meta.json5 with Rojo_Ref_PrimaryPart: "@self/Handle.model.json5"

Source instance absolute path: Workspace/Model. Resolve @self/Handle.model.json5 from Model = Workspace/Model/Handle.model.json5.

Verify: Index contains key Workspace/Model/Handle.model.json5 mapping to the meta file.

RI-2: find_by_prefix still works with absolute keys

After RI-1, call find_by_prefix("Workspace/Model/Handle.model.json5").

Verify: Returns the meta file path.

RI-3: Relative refs from different files pointing to same target

Two files:

  • Workspace/A/Pointer.model.json5 with "Rojo_Ref_Value": "./Target.model.json5" (resolves to Workspace/A/Target.model.json5)
  • Workspace/B/Pointer.model.json5 with "Rojo_Ref_Value": "../A/Target.model.json5" (resolves to Workspace/A/Target.model.json5)

Verify: Both files indexed under key Workspace/A/Target.model.json5.

RI-4: Rename updates files with recomputed relative paths

After RI-3, rename Workspace/A/Target.model.json5 to Workspace/A/Renamed.model.json5.

Verify:

  • Workspace/A/Pointer.model.json5 updated to "Rojo_Ref_Value": "./Renamed.model.json5"
  • Workspace/B/Pointer.model.json5 updated to "Rojo_Ref_Value": "../A/Renamed.model.json5"
  • Index key updated from Workspace/A/Target.model.json5 to Workspace/A/Renamed.model.json5

Test Specification: fix_ref_paths (fs_snapshot.rs)

FP-1: Source-aware substitution with different relative paths

Snapshot has two meta files:

/test/ShallowOV.model.json5:
  { "attributes": { "Rojo_Ref_Value": "__ROJO_REF_shallow_TO_target__" } }

/test/Deep/DeepOV.model.json5:
  { "attributes": { "Rojo_Ref_Value": "__ROJO_REF_deep_TO_target__" } }

Substitutions:

  • __ROJO_REF_shallow_TO_target__ --> ./Target.model.json5
  • __ROJO_REF_deep_TO_target__ --> ../Target.model.json5

Verify: Each file gets its own correct relative path.

FP-2: Non-ref lines are never touched

Meta file has a properties.Description containing "Rojo_Ref_" as a string value.

Verify: Only lines with actual Rojo_Ref_ attribute keys are substituted.

FP-3: Non-meta files are never touched

A .luau file happens to contain a placeholder string in a comment.

Verify: File is not modified.

FP-4: Multiple refs in same file with mixed prefixes

{
  "attributes": {
    "Rojo_Ref_PrimaryPart": "__ROJO_REF_model_TO_handle__",
    "Rojo_Ref_Value": "__ROJO_REF_model_TO_target__"
  }
}

Substitutions produce @self/Handle.model.json5 and @game/ReplicatedStorage/Target.luau.

Verify: Both substituted correctly in same file.


Test Specification: Backward Compatibility

BC-1: Old bare paths resolve correctly

Meta file on disk: { "attributes": { "Rojo_Ref_PrimaryPart": "Workspace/Model/Handle.model.json5" } }.

Verify: Forward sync resolves this to the correct Handle instance (bare path treated as @game/).

BC-2: Old bare paths still work during transition

Build from a project that has old-format Rojo_Ref_* values. Refs should resolve correctly. No errors or warnings that break the build.

BC-3: Syncback overwrites old format with new

Project has old bare paths on disk. Run syncback. Verify all Rojo_Ref_* values are rewritten with the correct prefix (@self/, ./, ../, or @game/).


Test Specification: Format Transition (syncback_format_transitions)

FT-1: Old absolute --> new relative on re-syncback

Project has "Rojo_Ref_PrimaryPart": "Workspace/Model/Handle.model.json5" (old format).

Run syncback with same rbxl.

Verify: File updated to "Rojo_Ref_PrimaryPart": "@self/Handle.model.json5".

FT-2: Mixed old and new formats in same project

Some files have old bare paths, others have already been updated to new format. Run syncback.

Verify: ALL files now use new format. No old bare paths remain.