From bf787a6458504a597cc3fbb4995921872d3a0573 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 1 Jul 2026 10:02:14 +0000 Subject: [PATCH] Skip area version-only extract churn (#28) ## Summary - skip overwriting existing `.are.json` files when only the root GFF `Version` differs - keep real extraction changes writing normally - add regression coverage for version-only area extraction ## Test Plan - `nix develop --command make check` - sample extraction in a clean temp `sow-module` clone using the patched `crucible` binary Reviewed-on: https://git.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/28 Reviewed-by: xtul Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- internal/pipeline/extract.go | 35 ++++++++++ internal/pipeline/pipeline_test.go | 107 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/internal/pipeline/extract.go b/internal/pipeline/extract.go index 40d78c1..a87ffbc 100644 --- a/internal/pipeline/extract.go +++ b/internal/pipeline/extract.go @@ -501,6 +501,9 @@ func writeManagedFile(path string, data []byte) (writeState, error) { if bytes.Equal(existing, data) { return writeSkipped, nil } + if areaGFFJSONEqualIgnoringRootVersion(path, existing, data) { + return writeSkipped, nil + } if err := os.WriteFile(path, data, 0o644); err != nil { return writeSkipped, fmt.Errorf("overwrite %s: %w", path, err) } @@ -516,6 +519,38 @@ func writeManagedFile(path string, data []byte) (writeState, error) { return writeNew, nil } +func areaGFFJSONEqualIgnoringRootVersion(path string, existing, extracted []byte) bool { + if !strings.HasSuffix(strings.ToLower(filepath.Base(path)), ".are.json") { + return false + } + + var left, right gff.Document + if err := json.Unmarshal(existing, &left); err != nil { + return false + } + if err := json.Unmarshal(extracted, &right); err != nil { + return false + } + removeRootField(&left.Root, "Version") + removeRootField(&right.Root, "Version") + + leftRaw, err := json.Marshal(left) + if err != nil { + return false + } + rightRaw, err := json.Marshal(right) + if err != nil { + return false + } + return bytes.Equal(leftRaw, rightRaw) +} + +func removeRootField(s *gff.Struct, label string) { + s.Fields = slices.DeleteFunc(s.Fields, func(field gff.Field) bool { + return field.Label == label + }) +} + func cleanupStaleFiles(p *project.Project, desired map[string]struct{}, scope extractionScope) (int, []error) { candidates := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles)) if scope.Source && safeCleanupRoot(p.SourceDir(), p.Root) { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index bc211c8..424365a 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -134,6 +134,113 @@ func TestBuildThenExtract(t *testing.T) { } } +func TestExtractSkipsAreaWhenOnlyRootVersionChanges(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "src", "areas")) + 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": "Test Module" + } + ] + } +} +`) + mustWriteFile(t, filepath.Join(root, "src", "areas", "area001.are.json"), `{ + "file_type": "ARE ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Version", + "type": "DWord", + "value": 2 + }, + { + "label": "Tag", + "type": "CExoString", + "value": "area001" + } + ] + } +} +`) + + 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) + } + + mustWriteFile(t, filepath.Join(root, "src", "areas", "area001.are.json"), `{ + "file_type": "ARE ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Version", + "type": "DWord", + "value": 1 + }, + { + "label": "Tag", + "type": "CExoString", + "value": "area001" + } + ] + } +} +`) + if err := p.Scan(); err != nil { + t.Fatalf("rescan: %v", err) + } + + result, err := Extract(p) + if err != nil { + t.Fatalf("extract: %v", err) + } + if result.Overwritten != 0 { + t.Fatalf("Version-only area extraction must not overwrite, got %d overwritten", result.Overwritten) + } + + document := readGFFJSON(t, filepath.Join(root, "src", "areas", "area001.are.json")) + if got, want := fieldValue(t, document.Root, "Version"), gff.DWordValue(1); got != want { + t.Fatalf("expected existing Version %#v to remain, got %#v", want, got) + } +} + func TestExtractReadsHAKAssets(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src"))