Add CI pipeline documentation and enhance syncback architecture

- Introduced a new CI pipeline documentation file outlining steps for auto-fixing formatting, running linters, building, and testing.
- Documented the Rojo syncback architecture, detailing improvements to prevent duplicate file creation and format mismatches.
- Updated syncback logic to ensure existing instances are updated instead of creating duplicates, enhancing project file management.
- Added tests for encoding instances and handling nested children to improve reliability in syncback operations.
This commit is contained in:
Joe 2026-02-01 18:18:31 -06:00
parent edc09741bf
commit b368eb2401
46 changed files with 3688 additions and 112 deletions

View file

@ -156,5 +156,154 @@ return function()
folder:Destroy()
end)
end)
describe("children encoding", function()
it("should include children array even when empty", function()
local moduleScript = Instance.new("ModuleScript")
moduleScript.Name = "TestModule"
moduleScript.Source = "return {}"
local encoded = encodeInstance(moduleScript, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.children).to.be.ok()
expect(type(encoded.children)).to.equal("table")
expect(#encoded.children).to.equal(0)
moduleScript:Destroy()
end)
it("should encode ModuleScript with children", function()
local parent = Instance.new("ModuleScript")
parent.Name = "ParentModule"
parent.Source = "return {}"
local child1 = Instance.new("ModuleScript")
child1.Name = "ChildModule1"
child1.Source = "return 1"
child1.Parent = parent
local child2 = Instance.new("ModuleScript")
child2.Name = "ChildModule2"
child2.Source = "return 2"
child2.Parent = parent
local encoded = encodeInstance(parent, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.children).to.be.ok()
expect(#encoded.children).to.equal(2)
-- Verify children are properly encoded
local childNames = {}
for _, child in encoded.children do
childNames[child.name] = true
expect(child.className).to.equal("ModuleScript")
expect(child.properties.Source).to.be.ok()
end
expect(childNames["ChildModule1"]).to.equal(true)
expect(childNames["ChildModule2"]).to.equal(true)
parent:Destroy()
end)
it("should encode nested children recursively", function()
local root = Instance.new("Folder")
root.Name = "Root"
local level1 = Instance.new("Folder")
level1.Name = "Level1"
level1.Parent = root
local level2 = Instance.new("ModuleScript")
level2.Name = "Level2"
level2.Source = "return 'nested'"
level2.Parent = level1
local encoded = encodeInstance(root, "PARENT_ID")
expect(encoded).to.be.ok()
expect(#encoded.children).to.equal(1)
expect(encoded.children[1].name).to.equal("Level1")
expect(#encoded.children[1].children).to.equal(1)
expect(encoded.children[1].children[1].name).to.equal("Level2")
expect(encoded.children[1].children[1].className).to.equal("ModuleScript")
root:Destroy()
end)
it("should skip children with duplicate names", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child1 = Instance.new("ModuleScript")
child1.Name = "DuplicateName"
child1.Source = "return 1"
child1.Parent = parent
local child2 = Instance.new("ModuleScript")
child2.Name = "DuplicateName" -- Same name!
child2.Source = "return 2"
child2.Parent = parent
local child3 = Instance.new("ModuleScript")
child3.Name = "UniqueName"
child3.Source = "return 3"
child3.Parent = parent
local encoded = encodeInstance(parent, "PARENT_ID")
expect(encoded).to.be.ok()
-- Should only have 1 child (UniqueName), duplicates skipped
expect(#encoded.children).to.equal(1)
expect(encoded.children[1].name).to.equal("UniqueName")
parent:Destroy()
end)
it("should return empty children array when all children deleted", function()
-- Simulate: parent had children, now they're all gone
local parent = Instance.new("ModuleScript")
parent.Name = "ParentModule"
parent.Source = "return {}"
-- No children parented
local encoded = encodeInstance(parent, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.children).to.be.ok()
expect(#encoded.children).to.equal(0)
parent:Destroy()
end)
it("should encode Script with Folder child", function()
-- This tests the init.server.luau + child directory scenario
local script = Instance.new("Script")
script.Name = "EventService"
script.Source = "-- Event service"
local events = Instance.new("Folder")
events.Name = "Events"
events.Parent = script
local event1 = Instance.new("ModuleScript")
event1.Name = "AcidRainEvent"
event1.Source = "return {}"
event1.Parent = events
local encoded = encodeInstance(script, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.className).to.equal("Script")
expect(#encoded.children).to.equal(1)
expect(encoded.children[1].name).to.equal("Events")
expect(encoded.children[1].className).to.equal("Folder")
expect(#encoded.children[1].children).to.equal(1)
expect(encoded.children[1].children[1].name).to.equal("AcidRainEvent")
script:Destroy()
end)
end)
end)
end

View file

@ -222,7 +222,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject]
local previousId = nil
while parentObject do
while parentObject and parentId do
if knownAncestors[parentId] then
-- We've already added this ancestor
previousId = parentId

View file

@ -175,13 +175,21 @@ local function shouldDeleteChild(virtualInstance, childInstance)
return false
end
local function diff(instanceMap, virtualInstances, rootId)
local function diff(instanceMap, virtualInstances, rootId, serverInfo)
local patch = {
removed = {},
added = {},
updated = {},
}
-- Build a lookup table for visible services (for ignoreHiddenServices check)
local visibleServicesSet: { [string]: boolean } = {}
if serverInfo and serverInfo.ignoreHiddenServices and serverInfo.visibleServices then
for _, serviceName in ipairs(serverInfo.visibleServices) do
visibleServicesSet[serviceName] = true
end
end
-- Count of locations with duplicate-named siblings
local skippedDuplicateCount = 0
@ -403,6 +411,20 @@ local function diff(instanceMap, virtualInstances, rootId)
continue
end
-- Skip hidden services when ignoreHiddenServices is enabled.
-- Services are direct children of game (DataModel), and if they're
-- not in the visible services list, they weren't synced back to disk,
-- so we shouldn't mark them for deletion during forward sync.
if serverInfo and serverInfo.ignoreHiddenServices and instance == game then
if not visibleServicesSet[childInstance.Name] then
Log.trace(
"Skipping hidden service {} (ignoreHiddenServices is enabled)",
childInstance.Name
)
continue
end
end
-- This is an existing instance not present in the virtual DOM.
-- We can mark it for deletion unless the user has asked us not
-- to delete unknown stuff. Scripts are always eligible for deletion

View file

@ -1,7 +1,4 @@
return function()
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet)
@ -164,15 +161,7 @@ return function()
assert(isEmpty(patch.updated))
end)
--[[
Because rbx_dom_lua resolves non-canonical properties to their canonical
variants, this test does not work as intended.
Instead, heat_xml is diffed with Heat, the canonical property variant,
and a patch trying to assign to heat_xml is generated. This is
incorrect, but will require more invasive changes to fix later.
]]
itFIXME("should ignore unreadable properties", function()
it("should ignore unreadable properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
@ -194,8 +183,6 @@ return function()
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
Log.warn("{:#?}", patch)
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))

View file

@ -21,6 +21,18 @@ local function getProperty(instance, propertyName)
})
end
-- If the property name we were given is not the canonical name, treat it
-- as unknown. This handles serialization-only aliases like `heat_xml`
-- which resolve to `Heat` - we should skip these and let the canonical
-- property name be used instead.
if descriptor.name ~= propertyName then
return false,
Error.new(Error.UnknownProperty, {
className = instance.ClassName,
propertyName = propertyName,
})
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
return false,
Error.new(Error.UnreadableProperty, {

View file

@ -41,9 +41,9 @@ function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
return result
end
function Reconciler:diff(virtualInstances, rootId)
function Reconciler:diff(virtualInstances, rootId, serverInfo)
Timer.start("Reconciler:diff")
local success, result = diff(self.__instanceMap, virtualInstances, rootId)
local success, result = diff(self.__instanceMap, virtualInstances, rootId, serverInfo)
Timer.stop()
return success, result

View file

@ -612,7 +612,7 @@ function ServeSession:__computeInitialPatch(serverInfo)
Log.trace("Computing changes that plugin needs to make to catch up to server...")
self:setLoadingText("Finding differences between server and Studio...")
local success, catchUpPatch =
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game)
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, serverInfo)
if not success then
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)