From 9747384838931a3f21971b63ae18c0c5984d6cc1 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 4 Aug 2026 08:51:12 +0000 Subject: [PATCH] fix(extract): keep committed palette skeletons out of stale cleanup (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract skips writing *palcus.itp back to source (build projects them from blueprints, #50), but the skip branch never marked the skeleton target as desired, so cleanupStaleFiles deleted the committed source/palettes/*.itp.json on every extract — shipping modules with empty custom palettes. Fix marks the skipped target desired; regression test builds a module with a committed skeleton and asserts extract leaves it in place. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: https://git.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/96 Co-authored-by: vickydotbat --- internal/pipeline/extract.go | 8 ++- internal/pipeline/pipeline_test.go | 82 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/internal/pipeline/extract.go b/internal/pipeline/extract.go index a5d00a8..cde1743 100644 --- a/internal/pipeline/extract.go +++ b/internal/pipeline/extract.go @@ -126,8 +126,14 @@ func extractArchiveResources(p *project.Project, archive erf.Archive, desired ma } // *palcus.itp are Toolset-generated palette projections; the module // build regenerates them from source blueprints, so extraction never - // writes them back into source. + // writes them back into source. The committed skeleton still has to + // survive stale cleanup, so mark its target as desired. if ext == "itp" && isPaletteProjectionResref(resource.Name) { + effective := p.EffectiveConfig() + target, err := extractionTarget(p, "paths.source", effective.Paths.Source, p.SourceDir(), sourceSubdir(ext), strings.ToLower(resource.Name)+"."+ext+".json") + if err == nil { + desired[target] = struct{}{} + } skippedCount++ continue } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 23c20b7..6b3ace3 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -3312,6 +3312,88 @@ func TestExtractOverwritesAndRemovesStaleFiles(t *testing.T) { } } +func TestExtractKeepsPaletteSkeletonsThroughStaleCleanup(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "src", "palettes")) + mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod" + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + } +} +`) + + mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Mod_Name", + "type": "CExoString", + "value": "Original Module" + } + ] + } +} +`) + skeletonPath := filepath.Join(root, "src", "palettes", "creaturepalcus.itp.json") + mustWriteFile(t, skeletonPath, `{ + "file_type": "ITP ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "MAIN", + "type": "List", + "value": [] + } + ] + } +} +`) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + if err := p.ValidateLayout(); err != nil { + t.Fatalf("validate layout: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("scan: %v", err) + } + + if _, err := BuildModule(p); err != nil { + t.Fatalf("build module: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("rescan before extract: %v", err) + } + + result, err := Extract(p) + if err != nil { + t.Fatalf("extract: %v", err) + } + if result.Removed != 0 { + t.Fatalf("expected no removed files, got %d", result.Removed) + } + if _, err := os.Stat(skeletonPath); err != nil { + t.Fatalf("expected palette skeleton to survive extract, stat err=%v", err) + } +} + func TestExtractMergesConfiguredGFFJSONFieldsAndLists(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module"))