From af4d23ea48b1be7d3c7717156ee9eb9660fdbb78 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 2 Apr 2026 20:15:01 +0200 Subject: [PATCH] Remove validation errors for missing .nss files --- internal/pipeline/compare.go | 67 ++++++++++++++++++++--- internal/pipeline/pipeline_test.go | 79 ++++++++++++++++++++++++++++ internal/validator/validator.go | 2 +- internal/validator/validator_test.go | 54 +++++++++++++++++++ 4 files changed, 193 insertions(+), 9 deletions(-) diff --git a/internal/pipeline/compare.go b/internal/pipeline/compare.go index b8efa8d..e00bcf3 100644 --- a/internal/pipeline/compare.go +++ b/internal/pipeline/compare.go @@ -9,6 +9,7 @@ import ( "path/filepath" "slices" "strings" + "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" @@ -29,10 +30,6 @@ type resourceExpectation struct { func Compare(p *project.Project) (CompareResult, error) { modulePath := filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod") - moduleArchive, err := readArchive(modulePath) - if err != nil { - return CompareResult{}, err - } var hakPaths []string if len(p.Inventory.AssetFiles) > 0 { @@ -42,6 +39,14 @@ func Compare(p *project.Project) (CompareResult, error) { return CompareResult{}, err } } + if err := ensureBuildIsCurrent(p, modulePath, hakPaths); err != nil { + return CompareResult{ModulePath: modulePath, HAKPaths: hakPaths}, err + } + + moduleArchive, err := readArchive(modulePath) + if err != nil { + return CompareResult{}, err + } expected, err := expectedResources(p) if err != nil { @@ -142,7 +147,7 @@ func expectedResources(p *project.Project) (map[string]resourceExpectation, erro if err != nil { return nil, fmt.Errorf("read %s: %w", abs, err) } - name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))) extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".") out[resourceKey(name, extension)] = resourceExpectation{ Key: resourceKey(name, extension), @@ -157,7 +162,7 @@ func expectedResources(p *project.Project) (map[string]resourceExpectation, erro if err != nil { return nil, fmt.Errorf("read %s: %w", abs, err) } - name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))) extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".") out[resourceKey(name, extension)] = resourceExpectation{ Key: resourceKey(name, extension), @@ -169,6 +174,52 @@ func expectedResources(p *project.Project) (map[string]resourceExpectation, erro return out, nil } +func ensureBuildIsCurrent(p *project.Project, modulePath string, hakPaths []string) error { + archivePaths := make([]string, 0, 1+len(hakPaths)) + archivePaths = append(archivePaths, modulePath) + archivePaths = append(archivePaths, hakPaths...) + + oldestArchive := time.Time{} + for _, path := range archivePaths { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat archive %s: %w", path, err) + } + if oldestArchive.IsZero() || info.ModTime().Before(oldestArchive) { + oldestArchive = info.ModTime() + } + } + + sourcePaths := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles)) + for _, rel := range p.Inventory.SourceFiles { + sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) + } + for _, rel := range p.Inventory.ScriptFiles { + sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) + } + for _, rel := range p.Inventory.AssetFiles { + sourcePaths = append(sourcePaths, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) + } + + newestSource := time.Time{} + newestPath := "" + for _, path := range sourcePaths { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat source %s: %w", path, err) + } + if newestSource.IsZero() || info.ModTime().After(newestSource) { + newestSource = info.ModTime() + newestPath = path + } + } + + if !newestSource.IsZero() && newestSource.After(oldestArchive) { + return fmt.Errorf("built archives are older than source file %s; run build-module or build-haks before compare", newestPath) + } + return nil +} + func readArchive(path string) (erf.Archive, error) { input, err := os.Open(path) if err != nil { @@ -191,7 +242,7 @@ func archiveIndex(archive erf.Archive) map[string]erf.Resource { continue } - key := resourceKey(resource.Name, extension) + key := resourceKey(strings.ToLower(resource.Name), extension) switch extension { case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw", "are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl": @@ -214,7 +265,7 @@ func archiveIndex(archive erf.Archive) map[string]erf.Resource { } func resourceKey(name, extension string) string { - return name + "." + strings.TrimPrefix(strings.ToLower(extension), ".") + return strings.ToLower(name) + "." + strings.TrimPrefix(strings.ToLower(extension), ".") } func manifestHAKPaths(buildDir string) ([]string, error) { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index cac2964..bb32b42 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -87,6 +87,9 @@ func TestBuildThenExtract(t *testing.T) { if err := p.Scan(); err != nil { t.Fatalf("scan after extract: %v", err) } + if _, err := BuildModule(p); err != nil { + t.Fatalf("rebuild after extract: %v", err) + } compareResult, err := Compare(p) if err != nil { @@ -773,6 +776,82 @@ func TestExtractNormalizesResourceNamesToLowercase(t *testing.T) { } } +func TestCompareFailsWhenBuildIsStale(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + 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" + } + ] + } +} +`) + + 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", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Mod_Name", + "type": "CExoString", + "value": "Updated Module" + } + ] + } +} +`) + if err := p.Scan(); err != nil { + t.Fatalf("rescan: %v", err) + } + + _, err = Compare(p) + if err == nil { + t.Fatal("expected stale compare error") + } + if !strings.Contains(err.Error(), "built archives are older than source file") { + t.Fatalf("unexpected compare error: %v", err) + } +} + func mustMkdir(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { diff --git a/internal/validator/validator.go b/internal/validator/validator.go index ca08515..dc87a1f 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -260,7 +260,7 @@ func validateReferences(report *Report, document loadedDocument, resources, scri return } if _, exists := scripts[strings.ToLower(value)]; !exists { - report.add(document.Path, fmt.Sprintf("missing script reference %q from field %q", value, field.Label), SeverityError) + report.add(document.Path, fmt.Sprintf("missing script reference %q from field %q", value, field.Label), SeverityWarning) } case field.Label == "Conversation": key := strings.ToLower(value) + ".dlg" diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go index e4e6b3c..5dbe3f0 100644 --- a/internal/validator/validator_test.go +++ b/internal/validator/validator_test.go @@ -97,6 +97,60 @@ func TestValidateProjectWarnsForUppercaseResourceNames(t *testing.T) { } } +func TestValidateProjectWarnsForMissingScriptReferences(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "dialogs")) + 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", "dialogs", "merchant.dlg.json"), `{ + "file_type": "DLG ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Script", + "type": "ResRef", + "value": "open_store" + } + ] + } +} +`) + + 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) + } + + report := ValidateProject(p) + if report.HasErrors() { + t.Fatalf("expected warnings only, got errors: %#v", report.Diagnostics) + } + if report.WarningCount() == 0 { + t.Fatalf("expected missing-script warning, got %#v", report.Diagnostics) + } +} + func mustMkdir(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil {