package topdata import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "slices" "strconv" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type Severity string const ( SeverityError Severity = "error" SeverityWarning Severity = "warning" ) type Diagnostic struct { Severity Severity Path string Message string } type ValidationReport struct { Files int DataFiles int TLKFiles int Diagnostics []Diagnostic } func (r ValidationReport) SummaryLines(maxPaths int) []string { type key struct { severity Severity message string } groups := map[key][]string{} order := make([]key, 0) for _, diagnostic := range r.Diagnostics { k := key{severity: diagnostic.Severity, message: diagnostic.Message} if _, ok := groups[k]; !ok { order = append(order, k) } groups[k] = append(groups[k], diagnostic.Path) } slices.SortFunc(order, func(a, b key) int { if a.severity != b.severity { if a.severity == SeverityError { return -1 } return 1 } return strings.Compare(a.message, b.message) }) lines := make([]string, 0, len(order)) for _, k := range order { paths := append([]string(nil), groups[k]...) slices.Sort(paths) paths = slices.Compact(paths) lines = append(lines, formatDiagnosticSummaryLine(string(k.severity), k.message, paths, maxPaths)) } return lines } func (r ValidationReport) HasErrors() bool { for _, diagnostic := range r.Diagnostics { if diagnostic.Severity == SeverityError { return true } } return false } func (r ValidationReport) ErrorCount() int { count := 0 for _, diagnostic := range r.Diagnostics { if diagnostic.Severity == SeverityError { count++ } } return count } func (r ValidationReport) WarningCount() int { count := 0 for _, diagnostic := range r.Diagnostics { if diagnostic.Severity == SeverityWarning { count++ } } return count } func formatDiagnosticSummaryLine(prefix, message string, paths []string, maxPaths int) string { if len(paths) == 0 { return fmt.Sprintf("%s: %s", prefix, message) } if maxPaths <= 0 { maxPaths = 3 } display := paths if len(display) > maxPaths { display = display[:maxPaths] } location := strings.Join(display, ", ") if len(paths) > len(display) { location += fmt.Sprintf(", and %d more", len(paths)-len(display)) } return fmt.Sprintf("%s: %s [%s]", prefix, message, location) } type BuildResult struct { Mode string Output2DADir string OutputTLKDir string Files2DA int FilesTLK int WikiOutputDir string WikiPages int WikiStatus string } type PackageResult struct { Mode string Output2DADir string OutputTLKDir string OutputHAKPath string OutputTLKPath string Files2DA int FilesTLK int HAKResources int AssetFiles int WikiOutputDir string WikiPages int WikiStatus string } type CompareResult struct { Mode string Compared2DA int ComparedTLK int NativePass int NotYetCanonical int NativeMismatch int } func ValidateProject(p *project.Project) ValidationReport { report := ValidationReport{} if !p.HasTopData() { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: project.ConfigFile, Message: "topdata is not configured for this project", }) return report } sourceDir := p.TopDataSourceDir() info, err := os.Stat(sourceDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: sourceDir, Message: err.Error(), }) return report } if !info.IsDir() { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: sourceDir, Message: "topdata source must be a directory", }) return report } dataDir := filepath.Join(sourceDir, "data") // Canonical topdata no longer requires a dedicated source tlk directory. // When present, it is only validated for disallowed legacy module input. tlkDir := filepath.Join(sourceDir, "tlk") baseDialog := filepath.Join(sourceDir, "base_dialog.json") statePath := filepath.Join(sourceDir, tlkStateFile) for _, requiredDir := range []string{dataDir} { info, err := os.Stat(requiredDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: requiredDir, Message: err.Error(), }) continue } if !info.IsDir() { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: requiredDir, Message: "must be a directory", }) } } if info, err := os.Stat(tlkDir); err == nil && info.IsDir() { modulesDir := filepath.Join(tlkDir, "modules") legacyModuleFiles := 0 if moduleInfo, moduleErr := os.Stat(modulesDir); moduleErr == nil && moduleInfo.IsDir() { _ = walkJSON(modulesDir, func(path string) { legacyModuleFiles++ report.Files++ report.TLKFiles++ _ = validateJSONFile(path, "tlk", &report) }) } else if moduleErr != nil && !errors.Is(moduleErr, os.ErrNotExist) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: modulesDir, Message: moduleErr.Error(), }) } if legacyModuleFiles > 0 { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: modulesDir, Message: "topdata/tlk/modules is no longer allowed for canonical authored input; inline TLK-in-data is required", }) } if lockInfo, lockErr := os.Stat(filepath.Join(tlkDir, "lock.json")); lockErr == nil && !lockInfo.IsDir() { report.Files++ report.TLKFiles++ _ = validateJSONFile(filepath.Join(tlkDir, "lock.json"), "tlk", &report) } else if lockErr != nil && !errors.Is(lockErr, os.ErrNotExist) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: filepath.Join(tlkDir, "lock.json"), Message: lockErr.Error(), }) } } if _, err := os.Stat(baseDialog); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: baseDialog, Message: "base_dialog.json is missing; dialog migration is not canonical yet", }) } else { report.Files++ if err := validateJSONFile(baseDialog, "base_dialog", &report); err == nil { // count only once for base dialog. } } _ = walkJSON(dataDir, func(path string) { report.Files++ report.DataFiles++ _ = validateJSONFile(path, "data", &report) }) validateTopPackageAssets(sourceDir, dataDir, &report) validateNativeOutputCatalog(dataDir, &report) validateNativeEntryKeyUniqueness(dataDir, &report) validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateGeneratedFeatFamilies(dataDir, &report) validateClassSpellbooks(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report) if _, err := os.Stat(statePath); err == nil { report.Files++ _ = validateJSONFile(statePath, "state", &report) } if !report.HasErrors() { validateNativeAuthoringWarnings(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateNativeBuildability(p, &report) } return report } func validateJSONFile(path, domain string, report *ValidationReport) error { raw, err := os.ReadFile(path) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return err } var payload any validateDuplicateJSONKeys(path, raw, report) if err := json.Unmarshal(raw, &payload); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("invalid JSON: %v", err), }) return err } obj, ok := payload.(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "topdata files must contain a JSON object at the root", }) return errors.New("topdata file root must be object") } switch domain { case "data": validateDataObject(path, obj, report) case "tlk": validateTLKObject(path, obj, report) case "state": validateStateObject(path, obj, report) case "base_dialog": validateBaseDialogObject(path, obj, report) } return nil } func validateDuplicateJSONKeys(path string, raw []byte, report *ValidationReport) { decoder := json.NewDecoder(bytes.NewReader(raw)) if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil { return } } func scanJSONValueForDuplicateKeys(decoder *json.Decoder, path string, report *ValidationReport) error { token, err := decoder.Token() if err != nil { return err } switch delim := token.(type) { case json.Delim: switch delim { case '{': seen := map[string]struct{}{} for decoder.More() { rawKey, err := decoder.Token() if err != nil { return err } key, ok := rawKey.(string) if !ok { return fmt.Errorf("expected object key") } if _, ok := seen[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("duplicate JSON object key %q", key), }) } seen[key] = struct{}{} if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil { return err } } _, err := decoder.Token() return err case '[': for decoder.More() { if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil { return err } } _, err := decoder.Token() return err default: return nil } default: return nil } } func validateDataObject(path string, obj map[string]any, report *ValidationReport) { base := filepath.Base(path) slashed := filepath.ToSlash(path) inModules := strings.Contains(slashed, "/modules/") dataPath := parseTopdataDataPath(path) if invariant, ok := datasetInvariantForPath(dataPath); ok { if dataPath.IsRootBase { validateDatasetInvariantBaseFile(path, obj, invariant, report) return } if dataPath.IsRootLock { validateDatasetInvariantLockFile(path, obj, invariant, report) return } } if strings.Contains(slashed, "/data/feat/generated/") { validateFeatGeneratedFile(path, obj, report) return } if strings.Contains(slashed, "/data/parts/overrides/") { validateOverridesFile(path, obj, report) return } if isClassSpellbookPath(path) { validateClassSpellbookShape(path, obj, report) return } if strings.Contains(slashed, "/racialtypes/registry/races/") { validateRacialtypesRegistryRaceFile(path, obj, report) return } if base == "global.json" { validateGlobalJSONFile(path, obj, report) return } if base == "lock.json" { validateLockObject(path, obj, report) return } if base == "base.json" { validateRowsFile(path, obj, report, true) return } if inModules { if _, hasEntries := obj["entries"]; hasEntries { validateEntriesFile(path, obj, report) return } if _, hasOverrides := obj["overrides"]; hasOverrides { validateOverridesFile(path, obj, report) return } if _, hasRows := obj["rows"]; hasRows { validateRowsFile(path, obj, report, false) return } if _, hasColumns := obj["columns"]; hasColumns { validateColumnsFile(path, obj, report) return } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: path, Message: "module file does not use a recognized canonical shape yet; expected one of columns, entries, overrides, or rows", }) return } if _, hasRows := obj["rows"]; hasRows { validateRowsFile(path, obj, report, false) return } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: path, Message: "unrecognized data file shape; parseable for compatibility, but not canonical yet", }) } type topdataDataPath struct { Segments []string IsRootBase bool IsRootLock bool } func parseTopdataDataPath(path string) topdataDataPath { slashed := filepath.ToSlash(path) marker := "/data/" index := strings.LastIndex(slashed, marker) if index == -1 { return topdataDataPath{} } rel := strings.Trim(slashed[index+len(marker):], "/") if rel == "" { return topdataDataPath{} } segments := strings.Split(rel, "/") return topdataDataPath{ Segments: segments, IsRootBase: len(segments) == 2 && segments[1] == "base.json", IsRootLock: len(segments) == 2 && segments[1] == "lock.json", } } type datasetValidationInvariant struct { Name string KeyPrefix string RequiredColumns []string } var datasetValidationInvariants = map[string]datasetValidationInvariant{ "feat": { Name: "feat", KeyPrefix: "feat:", }, "masterfeats": { Name: "masterfeats", KeyPrefix: "masterfeats:", RequiredColumns: []string{"LABEL", "STRREF", "DESCRIPTION"}, }, } func datasetInvariantForPath(dataPath topdataDataPath) (datasetValidationInvariant, bool) { if len(dataPath.Segments) == 0 { return datasetValidationInvariant{}, false } invariant, ok := datasetValidationInvariants[dataPath.Segments[0]] return invariant, ok } func validateDatasetInvariantBaseFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { validateRowsFile(path, obj, report, true) validateDerivedBaseOutput(path, obj, invariant.Name, report) validateRequiredColumns(path, obj, invariant, report) validateRowsKeyPrefix(path, obj, invariant, report) } func validateRequiredColumns(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { if len(invariant.RequiredColumns) == 0 { return } columns := extractValidationColumns(obj) for _, required := range invariant.RequiredColumns { if _, ok := canonicalColumn(columns, required); ok { continue } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s base.json must include %s in columns", invariant.Name, required), }) } } func validateRowsKeyPrefix(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { if invariant.KeyPrefix == "" { return } rows, ok := obj["rows"].([]any) if !ok { return } for index, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } key, ok := row["key"].(string) if !ok || strings.TrimSpace(key) == "" { continue } if !strings.HasPrefix(key, invariant.KeyPrefix) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s row %d key %q must start with %s", invariant.Name, index, key, invariant.KeyPrefix), }) } } } func validateDatasetInvariantLockFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { validateLockObject(path, obj, report) if invariant.KeyPrefix == "" { return } for key := range obj { if !strings.HasPrefix(key, invariant.KeyPrefix) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s lock key %q must start with %s", invariant.Name, key, invariant.KeyPrefix), }) } } } func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel string, report *ValidationReport) { if output, ok := obj["output"]; !ok { return } else if outputText, ok := output.(string); !ok || strings.TrimSpace(outputText) != nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s output must be %s", datasetLabel, nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase)), }) } } func validateFeatGeneratedFile(path string, obj map[string]any, report *ValidationReport) { family, ok := obj["family"].(string) if !ok || strings.TrimSpace(family) == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "feat generated file must contain a non-empty family string", }) return } if isFamilyExpansionObject(obj) { validateFeatFamilyExpansionFile(path, obj, report) return } switch family { default: _, hasEntries := obj["entries"] _, hasOverrides := obj["overrides"] if !hasEntries && !hasOverrides { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "feat generated file must contain entries and/or overrides", }) return } if hasEntries { validateEntriesFile(path, obj, report) } if hasOverrides { validateOverridesFile(path, obj, report) } } } func validateFeatFamilyExpansionFile(path string, obj map[string]any, report *ValidationReport) { spec, err := parseFamilyExpansionSpec(path, obj) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return } if !strings.HasPrefix(spec.Template, "masterfeats:") { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "compact feat generated template must start with masterfeats:", }) } if spec.NamePrefix == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "family expansion file must contain a non-empty name_prefix", }) } if spec.LabelPrefix == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "family expansion file must contain a non-empty label_prefix", }) } if spec.ConstantPrefix == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "family expansion file must contain a non-empty constant_prefix", }) } if raw, ok := obj["overrides"]; ok { if _, ok := raw.(map[string]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "family expansion overrides must be an object keyed by source dataset key", }) } } } func validateRacialtypesRegistryRaceFile(path string, obj map[string]any, report *ValidationReport) { key, ok := obj["key"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race file must contain a key field", }) } else if _, ok := key.(string); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race key must be a string", }) } core, ok := obj["core"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race file must contain a core object", }) } else if _, ok := core.(map[string]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race core must be a JSON object", }) } if rawOutput, ok := obj["feat_output"]; ok { if _, ok := rawOutput.(string); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race feat_output must be a string", }) } if rawFeats, ok := obj["feats"]; ok { if _, ok := rawFeats.([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race feats must be an array", }) } } else { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race feat_output requires a feats array", }) } return } if _, ok := obj["feats"]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "racialtypes registry race feats requires feat_output", }) } } func validateTLKObject(path string, obj map[string]any, report *ValidationReport) { if filepath.Base(path) == "lock.json" { validateLockObject(path, obj, report) return } validateLegacyDialogEntries(path, obj, report, "TLK") } func validateBaseDialogObject(path string, obj map[string]any, report *ValidationReport) { validateLegacyDialogEntries(path, obj, report, "base_dialog") } func validateLegacyDialogEntries(path string, obj map[string]any, report *ValidationReport, label string) { entries, ok := obj["entries"] if ok { switch entries.(type) { case map[string]any, []any: // accepted compatibility shapes default: report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s entries must be a JSON object or array", label), }) } } if language, ok := obj["language"]; ok { switch language.(type) { case string, float64: // accepted compatibility shapes default: report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s language must be a string or numeric language id when present", label), }) } } if label == "TLK" && !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "TLK file must contain an entries object", }) } } func validateLockObject(path string, obj map[string]any, report *ValidationReport) { ids := map[int]string{} checkDuplicateIDs := !strings.Contains(filepath.ToSlash(path), "/data/itemprops/registry/lock.json") for key, value := range obj { var rowID int switch value.(type) { case float64: // JSON numbers land here. if parsed, err := asInt(value); err == nil { rowID = parsed } default: report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("lock entry %q must be numeric", key), }) } if checkDuplicateIDs { if rowID <= 0 { continue } if other, ok := ids[rowID]; ok && other != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("lock id collision between %q and %q at id %d", other, key, rowID), }) } ids[rowID] = key } } } func validateRowsFile(path string, obj map[string]any, report *ValidationReport, requireColumns bool) { rows, ok := obj["rows"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "rows file must contain a rows array", }) return } if _, ok := rows.([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "rows must be a JSON array", }) } if rowsList, ok := rows.([]any); ok { validateRowCollection(path, obj, rowsList, report) } if _, ok := obj["columns"]; ok { validateColumnsFile(path, obj, report) } else if requireColumns { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "base dataset file must contain columns", }) } } func validateColumnsFile(path string, obj map[string]any, report *ValidationReport) { columns, ok := obj["columns"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "columns file must contain a columns array", }) return } list, ok := columns.([]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "columns must be a JSON array when present", }) return } for _, raw := range list { column, ok := raw.(string) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "columns must contain only strings", }) continue } if isAuthoringOnlyField(column) && !preserveLegacyWikiColumnForPath(path, column) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("deprecated wiki column %q must be moved into meta.wiki", column), }) } } } func validateEntriesFile(path string, obj map[string]any, report *ValidationReport) { entries, ok := obj["entries"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "entries file must contain an entries object", }) return } if _, ok := entries.(map[string]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "entries must be a JSON object", }) } if entryMap, ok := entries.(map[string]any); ok { for key, raw := range entryMap { entry, ok := raw.(map[string]any) if !ok { continue } for field := range entry { if isAuthoringOnlyField(field) && !preserveLegacyWikiColumnForPath(path, field) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("entry %q uses deprecated wiki field %q; move it into meta.wiki", key, field), }) } } validateRowMetadata(path, fmt.Sprintf("entry %q", key), entry, report) validateInlineTextUsage(path, key, entry, report) } } if _, ok := obj["columns"]; ok { validateColumnsFile(path, obj, report) } } func validateOverridesFile(path string, obj map[string]any, report *ValidationReport) { overrides, ok := obj["overrides"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "override file must contain an overrides array", }) return } if _, ok := overrides.([]any); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "overrides must be a JSON array", }) } if overrideList, ok := overrides.([]any); ok { container := map[string]any{"rows": overrideList} validateRowCollection(path, container, overrideList, report) } if _, ok := obj["columns"]; ok { validateColumnsFile(path, obj, report) } } func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationReport) { if _, ok := obj["columns"]; ok { validateColumnsFile(path, obj, report) } if _, ok := obj["entries"]; ok { validateEntriesFile(path, obj, report) } if _, ok := obj["overrides"]; ok { validateOverridesFile(path, obj, report) } if rawPosition, ok := obj["position"]; ok { position, ok := rawPosition.(string) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "global position must be a string", }) } else { switch strings.ToLower(strings.TrimSpace(position)) { case "", "append", "prepend": default: report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "global position must be append or prepend", }) } } } rawInjections, hasInjections := obj["injections"] if !hasInjections { return } injections, ok := rawInjections.([]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "global injections must be a JSON array", }) return } rows := make([]any, 0, len(injections)) for index, rawInjection := range injections { injection, ok := rawInjection.(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("global injection %d must be an object", index), }) continue } row, ok := injection["row"].(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("global injection %d must contain a row object", index), }) } else { rows = append(rows, row) } validateGlobalConditionList(path, fmt.Sprintf("global injection %d require_present", index), injection["require_present"], report) validateGlobalConditionList(path, fmt.Sprintf("global injection %d unless_present", index), injection["unless_present"], report) } validateRowCollection(path, obj, rows, report) } func validateGlobalConditionList(path, label string, raw any, report *ValidationReport) { if raw == nil { return } conditions, ok := raw.([]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s must be a JSON array", label), }) return } for index, rawCondition := range conditions { condition, ok := rawCondition.(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s[%d] must be an object", label, index), }) continue } if field, ok := condition["field"].(string); !ok || strings.TrimSpace(field) == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s[%d].field is required", label, index), }) } if id, ok := condition["id"].(string); !ok || strings.TrimSpace(id) == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s[%d].id is required", label, index), }) } } } func validateStateObject(path string, obj map[string]any, report *ValidationReport) { entries, ok := obj["entries"].(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "TLK state file must contain an entries object", }) return } ids := map[int]string{} for key, raw := range entries { entry, ok := raw.(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("TLK state entry %q must be an object", key), }) continue } idValue, ok := entry["id"] if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("TLK state entry %q is missing id", key), }) continue } id, err := asInt(idValue) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("TLK state entry %q id must be numeric", key), }) continue } if other, ok := ids[id]; ok && other != key { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("TLK state id collision between %q and %q", other, key), }) } ids[id] = key } } func validateNativeOutputCatalog(dataDir string, report *ValidationReport) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: err.Error(), }) return } registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: err.Error(), }) return } outputOwners := map[string]string{} for _, dataset := range datasets { recordNativeOutputOwner(dataset.OutputName, dataset.Name, outputOwners, report) } for _, dataset := range registryDatasets { recordNativeOutputOwner(dataset.Dataset.OutputName, dataset.Dataset.Name, outputOwners, report) } } func validateNativeEntryKeyUniqueness(dataDir string, report *ValidationReport) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("discover native datasets for entry key uniqueness validation: %v", err), }) return } for _, dataset := range datasets { if dataset.Kind != nativeDatasetBase { continue } entryKeyPath := map[string]string{} for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} { paths, err := collectModulePaths(dir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dir, Message: fmt.Sprintf("collect module paths for entry key uniqueness validation: %v", err), }) continue } for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("load module for entry key uniqueness validation: %v", err), }) continue } entries, ok := obj["entries"].(map[string]any) if !ok { continue } for _, key := range sortedKeys(entries) { if key == "" { continue } if previousPath, exists := entryKeyPath[key]; exists { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("duplicate entries key %q declared by %s and %s", key, previousPath, path), }) continue } entryKeyPath[key] = path } } } } } func validateNativeLockAllocation(dataDir string, rowGeneration []project.TopDataRowGenerationConfig, report *ValidationReport) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("discover native datasets for lock allocation validation: %v", err), }) return } datasets = applyTopDataRowGeneration(datasets, rowGeneration) for _, dataset := range datasets { if dataset.Kind != nativeDatasetBase { continue } baseObj, err := loadJSONObject(dataset.BasePath) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataset.BasePath, Message: fmt.Sprintf("load base rows for lock allocation validation: %v", err), }) continue } rawRows, ok := baseObj["rows"].([]any) if !ok || len(rawRows) == 0 { continue } baseBoundaryID := len(rawRows) - 1 baseKeys := map[string]struct{}{} nullBaseIDs := map[int]struct{}{} columns, _ := parseColumns(baseObj, dataset.Name) for index, raw := range rawRows { row, ok := raw.(map[string]any) if !ok { continue } rowID := index if rawID, ok := row["id"]; ok { if parsed, err := asInt(rawID); err == nil { rowID = parsed } } baseBoundaryID = rowID if key, ok := row["key"].(string); ok && key != "" { baseKeys[key] = struct{}{} } if len(columns) > 0 { canonical, err := canonicalizeBaseRow(dataset, columns, row, index) if err == nil && isNativeAllocationNullRow(canonical, columns) { nullBaseIDs[rowID] = struct{}{} } } } explicitModuleIDs := map[string]int{} for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} { paths, err := collectModulePaths(dir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dir, Message: fmt.Sprintf("collect module paths for lock allocation validation: %v", err), }) continue } for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("load module for lock allocation validation: %v", err), }) continue } ids, err := collectExplicitModuleIDs(dataset.Name, path, obj) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) continue } for key, rowID := range ids { explicitModuleIDs[key] = rowID } } } lockData, err := loadLockfile(dataset.LockPath) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataset.LockPath, Message: fmt.Sprintf("load lockfile for allocation validation: %v", err), }) continue } if dataset.Name == "feat" { generatedModules, err := collectGeneratedModules(dataset, lockData) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataset.GeneratedDir, Message: fmt.Sprintf("collect generated feat modules for lock allocation validation: %v", err), }) continue } for _, module := range generatedModules { ids, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: module.Name, Message: err.Error(), }) continue } for key, rowID := range ids { explicitModuleIDs[key] = rowID } } } invalid := 0 for key, rowID := range lockData { if _, ok := baseKeys[key]; ok { continue } if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID { continue } if dataset.RowGenerationMinRow > 0 && rowID < dataset.RowGenerationMinRow { invalid++ continue } if rowID > baseBoundaryID { continue } if dataset.RowGeneration == "first_null_row" { if _, ok := nullBaseIDs[rowID]; ok { if rowID >= dataset.RowGenerationMinRow { continue } } } invalid++ } if invalid > 0 { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: dataset.LockPath, Message: fmt.Sprintf("%d lock entrie(s) do not match configured generated-row allocation and will be regenerated", invalid), }) } } } func validateNativeAuthoringWarnings(dataDir string, rowGeneration []project.TopDataRowGenerationConfig, report *ValidationReport) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { return } datasets = applyTopDataRowGeneration(datasets, rowGeneration) for _, dataset := range datasets { if dataset.Kind != nativeDatasetBase { continue } validateDatasetOverrideTargetWarnings(dataset, report) validateDatasetNormalizedLockWarnings(dataset, report) validateDatasetGhostRowWarnings(dataset, report) } } func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *ValidationReport) { if dataset.BasePath == "" { return } if _, err := os.Stat(dataset.BasePath); err != nil { return } baseObj, err := loadJSONObject(dataset.BasePath) if err != nil { return } rawRows, ok := baseObj["rows"].([]any) if !ok { return } columns, _ := parseColumns(baseObj, dataset.Name) rowIDToKey := map[int]string{} keyToID := map[string]int{} usedIDs := map[int]struct{}{} for index, raw := range rawRows { row, ok := raw.(map[string]any) if !ok { continue } rowID := index if rawID, ok := row["id"]; ok { if parsed, err := asInt(rawID); err == nil { rowID = parsed } } if dataset.RowGeneration == "first_null_row" && len(columns) > 0 && rowID >= dataset.RowGenerationMinRow { canonical, err := canonicalizeBaseRow(dataset, columns, row, index) if err == nil && isNativeAllocationNullRow(canonical, columns) { continue } } usedIDs[rowID] = struct{}{} if key, ok := row["key"].(string); ok && key != "" { rowIDToKey[rowID] = key keyToID[key] = rowID } } nextID := nextAvailableIDAtLeast(usedIDs, dataset.RowGenerationMinRow) allocateNextID := func() int { rowID := nextID usedIDs[rowID] = struct{}{} nextID = nextAvailableIDAtLeast(usedIDs, dataset.RowGenerationMinRow) return rowID } modulePaths, err := collectModulePaths(dataset.ModulesDir) if err != nil { return } for _, path := range modulePaths { obj, err := loadJSONObject(path) if err != nil { continue } if rawEntries, ok := obj["entries"].(map[string]any); ok { for key := range rawEntries { if key == "" { continue } if _, exists := keyToID[key]; exists { continue } rowID := allocateNextID() keyToID[key] = rowID rowIDToKey[rowID] = key } } rawOverrides, ok := obj["overrides"].([]any) if !ok { continue } for index, raw := range rawOverrides { override, ok := raw.(map[string]any) if !ok { continue } keyOnlyTarget := false keyText, hasKeyText := override["key"].(string) if _, hasID := override["id"]; !hasID && hasKeyText && keyText != "" { keyOnlyTarget = true if _, exists := keyToID[keyText]; !exists { canonical := resolveCanonicalDatasetKey(keyText, keyToID) if canonical != keyText { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: path, Message: fmt.Sprintf("override %d targets key %q, but the live canonical key at that point is %q; this key-only override will create or retarget a new row", index, keyText, canonical), }) } else { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: path, Message: fmt.Sprintf("override %d targets key %q, but no live row owns that key at that point; this key-only override will create or retarget a new row", index, keyText), }) } } } rowID := -1 if rawID, ok := override["id"]; ok { parsed, err := asInt(rawID) if err == nil { rowID = parsed } } else if hasKeyText && keyText != "" { if mappedID, exists := keyToID[keyText]; exists { rowID = mappedID } } if rowID < 0 { if keyOnlyTarget { rowID = allocateNextID() keyToID[keyText] = rowID rowIDToKey[rowID] = keyText } continue } currentKey := rowIDToKey[rowID] if overrideRequestsNullRow(override) { if currentKey != "" { delete(keyToID, currentKey) delete(rowIDToKey, rowID) } continue } if rawKey, present := override["key"]; present { switch typed := rawKey.(type) { case nil: if currentKey != "" { delete(keyToID, currentKey) delete(rowIDToKey, rowID) } case string: if typed == "" || strings.TrimSpace(typed) == nullValue { if currentKey != "" { delete(keyToID, currentKey) delete(rowIDToKey, rowID) } continue } if currentKey != "" && currentKey != typed { delete(keyToID, currentKey) } if previousID, exists := keyToID[typed]; exists && previousID != rowID { delete(rowIDToKey, previousID) } keyToID[typed] = rowID rowIDToKey[rowID] = typed } } } } } func validateDatasetNormalizedLockWarnings(dataset nativeDataset, report *ValidationReport) { lockData, err := loadLockfile(dataset.LockPath) if err != nil { return } groups := map[string][]string{} for key := range lockData { prefix, suffix, ok := strings.Cut(key, ":") if !ok || prefix == "" || suffix == "" { continue } groups[prefix+":"+normalizeKeyIdentity(suffix)] = append(groups[prefix+":"+normalizeKeyIdentity(suffix)], key) } for _, keys := range groups { if len(keys) < 2 { continue } slices.Sort(keys) report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: dataset.LockPath, Message: fmt.Sprintf("lockfile keeps multiple normalized-equivalent keys alive: %s", strings.Join(keys, ", ")), }) } } func validateDatasetGhostRowWarnings(dataset nativeDataset, report *ValidationReport) { if dataset.BasePath == "" { return } if _, err := os.Stat(dataset.BasePath); err != nil { return } baseObj, err := loadJSONObject(dataset.BasePath) if err != nil { return } rawRows, ok := baseObj["rows"].([]any) if !ok { return } baseKeyedIDs := map[int]struct{}{} for index, raw := range rawRows { row, ok := raw.(map[string]any) if !ok { continue } if key, ok := row["key"].(string); !ok || key == "" { continue } rowID := index if rawID, ok := row["id"]; ok { if parsed, err := asInt(rawID); err == nil { rowID = parsed } } baseKeyedIDs[rowID] = struct{}{} } collected, err := collectNativeDataset(dataset) if err != nil { return } for _, row := range collected.Rows { if key, ok := row["key"].(string); ok && key != "" { continue } rowID, _ := row["id"].(int) if _, hadBaseKey := baseKeyedIDs[rowID]; !hadBaseKey { continue } nonNullFields := make([]string, 0, 4) for field, value := range row { if field == "id" || field == "key" || field == "meta" { continue } if isEffectivelyNullValue(value) { continue } nonNullFields = append(nonNullFields, field) } if len(nonNullFields) == 0 || len(nonNullFields) > 3 { continue } slices.Sort(nonNullFields) report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: dataset.BasePath, Message: fmt.Sprintf("row id %d has no canonical key but still carries fields %s; this usually indicates a key-only override hit a retired row or a row was partially de-identified", rowID, strings.Join(nonNullFields, ", ")), }) } } func isEffectivelyNullValue(value any) bool { switch typed := value.(type) { case nil: return true case string: return strings.TrimSpace(typed) == "" || strings.TrimSpace(typed) == nullValue default: return false } } type requiredFeatFamily struct { FamilyKey string Dataset string Column string Predicate string } func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) { featDataset, ok, err := discoverNamedNativeDataset(dataDir, "feat") if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("discover feat dataset for generated family validation: %v", err), }) return } if !ok { return } lockData, err := loadLockfile(featDataset.LockPath) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: featDataset.LockPath, Message: fmt.Sprintf("load feat lockfile for generated family validation: %v", err), }) return } ctx, err := newFeatGeneratedContext(featDataset, lockData) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: featDataset.GeneratedDir, Message: fmt.Sprintf("prepare generated family validation: %v", err), }) return } specs, specPaths, err := collectGeneratedFamilySpecs(featDataset.GeneratedDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: featDataset.GeneratedDir, Message: fmt.Sprintf("load generated family specs: %v", err), }) return } for familyKey, spec := range specs { ctx.familySpecs[familyKey] = spec } required := []requiredFeatFamily{ {FamilyKey: "skillfocus", Dataset: "skills", Predicate: "accessible"}, {FamilyKey: "greaterskillfocus", Dataset: "skills", Predicate: "accessible"}, {FamilyKey: "weaponfocus", Dataset: "baseitems", Column: "WeaponFocusFeat"}, {FamilyKey: "weaponspecialization", Dataset: "baseitems", Column: "WeaponSpecializationFeat"}, {FamilyKey: "improvedcritical", Dataset: "baseitems", Column: "WeaponImprovedCriticalFeat"}, {FamilyKey: "overwhelmingcritical", Dataset: "baseitems", Column: "EpicWeaponOverwhelmingCriticalFeat"}, {FamilyKey: "greaterweaponfocus", Dataset: "baseitems", Column: "EpicWeaponFocusFeat"}, {FamilyKey: "greaterweaponspecialization", Dataset: "baseitems", Column: "EpicWeaponSpecializationFeat"}, {FamilyKey: "weaponofchoice", Dataset: "baseitems", Column: "WeaponOfChoiceFeat"}, } for _, requirement := range required { spec, path, ok := generatedFamilySpecByNormalizedKey(specs, specPaths, requirement.FamilyKey) if !ok { continue } if spec.ChildSource.Dataset != requirement.Dataset || spec.ChildSource.Column != requirement.Column || spec.ChildSource.Predicate != requirement.Predicate { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("generated feat family %q must use child source dataset=%q column=%q predicate=%q", requirement.FamilyKey, requirement.Dataset, requirement.Column, requirement.Predicate), }) continue } validateGeneratedFeatFamilyCompleteness(path, spec, ctx, report) } if hasGeneratedFamilySpecs(specs, coreWeaponGeneratedFamilyKeys) { validateBaseitemsWeaponFeatColumnCompleteness(featDataset.GeneratedDir, ctx, report) } validateRequiredProductionGeneratedFamilies(featDataset.GeneratedDir, specs, report) } var coreWeaponGeneratedFamilyKeys = []string{ "weaponfocus", "weaponspecialization", "improvedcritical", "overwhelmingcritical", "greaterweaponfocus", "greaterweaponspecialization", } func hasGeneratedFamilySpecs(specs map[string]familyExpansionSpec, familyKeys []string) bool { for _, familyKey := range familyKeys { if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, familyKey); !ok { return false } } return true } func validateBaseitemsWeaponFeatColumnCompleteness(path string, ctx *featGeneratedContext, report *ValidationReport) { rows, err := ctx.datasetRows("baseitems") if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("validate baseitems weapon feat coverage: %v", err), }) return } coreColumns := []string{ "WeaponFocusFeat", "WeaponSpecializationFeat", "WeaponImprovedCriticalFeat", "EpicWeaponFocusFeat", "EpicWeaponSpecializationFeat", "EpicWeaponOverwhelmingCriticalFeat", } for _, sourceKey := range sortedStringMapKeys(rows) { row := rows[sourceKey] hasAny := false missing := make([]string, 0) for _, column := range coreColumns { value, ok := lookupField(row, column) if !ok || isNullishValue(value) { missing = append(missing, column) continue } hasAny = true } if !hasAny || len(missing) == 0 { continue } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("baseitems row %q declares partial core weapon feat coverage; missing %s", sourceKey, strings.Join(missing, ", ")), }) } } func generatedFamilySpecByNormalizedKey(specs map[string]familyExpansionSpec, specPaths map[string]string, familyKey string) (familyExpansionSpec, string, bool) { normalized := normalizeKeyIdentity(familyKey) for candidateKey, spec := range specs { if normalizeKeyIdentity(candidateKey) == normalized { path := "" if specPaths != nil { path = specPaths[candidateKey] } return spec, path, true } } return familyExpansionSpec{}, "", false } func validateRequiredProductionGeneratedFamilies(path string, specs map[string]familyExpansionSpec, report *ValidationReport) { if !hasGeneratedFamilySpecs(specs, coreWeaponGeneratedFamilyKeys) { return } if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, "weaponofchoice"); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "missing generated feat family \"weaponofchoice\"", }) } } func discoverNamedNativeDataset(dataDir, name string) (nativeDataset, bool, error) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { return nativeDataset{}, false, err } for _, dataset := range datasets { if dataset.Name == name { return dataset, true, nil } } return nativeDataset{}, false, nil } func collectGeneratedFamilySpecs(generatedDir string) (map[string]familyExpansionSpec, map[string]string, error) { paths, err := collectModulePaths(generatedDir) if err != nil { return nil, nil, err } specs := map[string]familyExpansionSpec{} pathsByFamily := map[string]string{} for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { return nil, nil, err } if !isFamilyExpansionObject(obj) { continue } spec, err := parseFamilyExpansionSpec(path, obj) if err != nil { return nil, nil, err } if previous, ok := pathsByFamily[spec.FamilyKey]; ok { return nil, nil, fmt.Errorf("generated family %q is declared by both %s and %s", spec.FamilyKey, previous, path) } specs[spec.FamilyKey] = spec pathsByFamily[spec.FamilyKey] = path } return specs, pathsByFamily, nil } func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSpec, ctx *featGeneratedContext, report *ValidationReport) { sourceRows, err := ctx.datasetRows(spec.ChildSource.Dataset) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return } moduleData, err := buildFamilyExpansionGeneratedModule(path, map[string]any{ "family": spec.Family, "family_key": spec.FamilyKey, "template": spec.Template, "name_prefix": spec.NamePrefix, "label_prefix": spec.LabelPrefix, "constant_prefix": spec.ConstantPrefix, "template_fields": validationStringSliceToAny(spec.TemplateFields), "default_fields": spec.DefaultFields, "apply_after_modules": spec.ApplyAfterModules, "child_ref_field": spec.ChildRefField, "identity_source": spec.IdentitySource, "allow_existing_only": spec.AllowExistingOnly, "auto_prereq_fields": stringMapToAny(spec.AutoPrereqFields), "legacy_family_keys": validationStringSliceToAny(spec.LegacyFamilyKeys), "child_source": map[string]any{ "dataset": spec.ChildSource.Dataset, "column": spec.ChildSource.Column, "predicate": spec.ChildSource.Predicate, }, "overrides": map[string]any{}, }, ctx) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return } expected := map[string]struct{}{} familyAllowlist := spec.AllowExistingOnly && ctx.familyHasExistingRows(spec.FamilyKey) for sourceKey, row := range sourceRows { include, err := familyExpansionSourceEligible(spec, row) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return } if include { if familyAllowlist { slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":") featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: err.Error(), }) return } if !ctx.featKeyExists(featKey) { continue } } expected[sourceKey] = struct{}{} } } actual := map[string]string{} overrides, _ := moduleData["overrides"].([]any) for index, raw := range overrides { override, ok := raw.(map[string]any) if !ok { continue } source, ok := familySourceFromMeta(override["meta"]) if !ok || source == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("generated family %q row %d is missing family source metadata", spec.FamilyKey, index), }) continue } if previousKey, ok := actual[source]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("generated family %q emits source %q more than once (%s and row %d)", spec.FamilyKey, source, previousKey, index), }) } key, _ := override["key"].(string) actual[source] = key } for source := range expected { if _, ok := actual[source]; !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("generated family %q is missing source %q", spec.FamilyKey, source), }) } } for source := range actual { if _, ok := expected[source]; !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("generated family %q includes unexpected source %q", spec.FamilyKey, source), }) } } } func familySourceFromMeta(raw any) (string, bool) { meta, ok := raw.(map[string]any) if !ok { return "", false } family, ok := meta["family"].(map[string]any) if !ok { return "", false } source, ok := family["source"].(string) return source, ok } func validationStringSliceToAny(items []string) []any { if items == nil { return nil } out := make([]any, 0, len(items)) for _, item := range items { out = append(out, item) } return out } func stringMapToAny(items map[string]string) map[string]any { if items == nil { return nil } out := make(map[string]any, len(items)) for key, value := range items { out[key] = value } return out } func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationReport) { assetsDir := filepath.Join(sourceDir, "assets") generated2DADir := filepath.Join(assetsDir, "2da") info, err := os.Stat(assetsDir) if err != nil { if errors.Is(err, os.ErrNotExist) { return } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: assetsDir, Message: err.Error(), }) return } if !info.IsDir() { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: assetsDir, Message: "must be a directory", }) return } projectOutputs, err := discoverNativeOutputCatalog(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: assetsDir, Message: err.Error(), }) return } seen := map[string]string{} err = filepath.WalkDir(assetsDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() && path == generated2DADir { return filepath.SkipDir } if d.IsDir() { return nil } report.Files++ rel, err := filepath.Rel(assetsDir, path) if err != nil { return err } if strings.HasPrefix(filepath.Base(path), ".") { return nil } base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") if _, ok := erf.HAKResourceTypeForExtension(ext); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("unsupported topdata asset HAK resource extension %q", filepath.Ext(path)), }) return nil } key := base + "." + ext if previous, ok := seen[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("duplicate topdata asset resource %q also defined by %s", key, previous), }) return nil } seen[key] = rel if owner, ok := projectOutputs[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("topdata asset resource %q collides with compiled topdata output from %s", key, owner), }) } return nil }) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: assetsDir, Message: err.Error(), }) } } func recordNativeOutputOwner(outputName, owner string, outputOwners map[string]string, report *ValidationReport) { if existing, ok := outputOwners[outputName]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: owner, Message: fmt.Sprintf("duplicate 2da output %q also produced by %s", outputName, existing), }) return } outputOwners[outputName] = owner } func validateNativeBuildability(p *project.Project, report *ValidationReport) { dataDir := filepath.Join(p.TopDataSourceDir(), "data") datasets, err := discoverNativeDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: err.Error(), }) return } registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: err.Error(), }) return } if len(datasets) == 0 && len(registryDatasets) == 0 { return } tempBuild, err := os.MkdirTemp("", "sow-topdata-validate-*") if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: p.TopDataSourceDir(), Message: fmt.Sprintf("create validation temp dir: %v", err), }) return } defer os.RemoveAll(tempBuild) clone := *p clone.Config.TopData.Build = tempBuild lockSnapshot, err := snapshotLockfiles(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("snapshot lockfiles for validation build: %v", err), }) return } defer restoreLockfiles(lockSnapshot) if _, err := buildNativeUnchecked(&clone, NativeBuildOptions{}, nil, nil); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: p.TopDataSourceDir(), Message: fmt.Sprintf("native topdata buildability check failed: %v", err), }) } } type lockfileSnapshot struct { Root string Files map[string][]byte } func snapshotLockfiles(dataDir string) (lockfileSnapshot, error) { snapshot := lockfileSnapshot{ Root: dataDir, Files: map[string][]byte{}, } err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() || filepath.Base(path) != "lock.json" { return nil } raw, err := os.ReadFile(path) if err != nil { return err } snapshot.Files[path] = raw return nil }) return snapshot, err } func restoreLockfiles(snapshot lockfileSnapshot) { seen := map[string]struct{}{} for path, raw := range snapshot.Files { seen[path] = struct{}{} if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { continue } _ = os.WriteFile(path, raw, 0o644) } _ = filepath.WalkDir(snapshot.Root, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil || d.IsDir() || filepath.Base(path) != "lock.json" { return nil } if _, ok := seen[path]; !ok { _ = os.Remove(path) } return nil }) } func validateRowCollection(path string, obj map[string]any, rows []any, report *ValidationReport) { columns := extractValidationColumns(obj) declaredKeys := map[string]int{} checkDuplicateKeys := filepath.Base(path) != "base.json" rowHasIdentity := func(row map[string]any, fallbackID int) (string, bool) { if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" { return key, true } if _, ok := row["id"]; ok { return fmt.Sprintf("id:%d", fallbackID), true } return "", false } for index, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } if checkDuplicateKeys { if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" { if previous, ok := declaredKeys[key]; ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("row %d repeats key %q already declared by row %d", index, key, previous), }) } declaredKeys[key] = index } } for key := range row { if isAuthoringOnlyField(key) && !preserveLegacyWikiColumnForPath(path, key) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("row %d uses deprecated wiki field %q; move it into meta.wiki", index, key), }) } } if _, hasTLK := row["_tlk"]; hasTLK { if _, ok := rowHasIdentity(row, index); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("row %d uses _tlk but has no resolvable identity", index), }) } } validateRowMetadata(path, fmt.Sprintf("row %d", index), row, report) validateInheritanceUsage(path, fmt.Sprintf("row %d", index), row, columns, report) validateGenericInheritanceObject(path, fmt.Sprintf("row %d", index), row, report) validateInlineTextUsage(path, fmt.Sprintf("row %d", index), row, report) } _ = obj } func validateRowMetadata(path, label string, row map[string]any, report *ValidationReport) { rawMeta, ok := lookupField(row, "meta") if !ok { return } if _, err := parseRowMetadata(rawMeta); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s meta: %v", label, err), }) } } func extractValidationColumns(obj map[string]any) []string { rawColumns, ok := obj["columns"].([]any) if !ok { return nil } columns := make([]string, 0, len(rawColumns)) for _, raw := range rawColumns { column, ok := raw.(string) if !ok { continue } columns = append(columns, column) } return columns } func preserveLegacyWikiColumnForPath(path, field string) bool { return strings.HasSuffix(filepath.ToSlash(path), "/data/classes/core/base.json") && strings.EqualFold(strings.TrimSpace(field), "WIKIGENERATE") } func validateInheritanceUsage(path, label string, row map[string]any, columns []string, report *ValidationReport) { spec, ok, err := parseRowInheritanceSpec(row) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s inherit: %v", label, err), }) return } if !ok { return } if _, ok := row[spec.From]; !ok { found := false for key := range row { if strings.EqualFold(key, spec.From) { found = true break } } if !found { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s inherit.from field %q was not found on row", label, spec.From), }) } } for _, field := range spec.Fields { if len(columns) == 0 { continue } if _, ok := canonicalColumn(columns, field); ok { continue } report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s inherit field %q is not a known column", label, field), }) } } func validateGenericInheritanceObject(path, label string, value any, report *ValidationReport) { switch typed := value.(type) { case map[string]any: spec, ok, err := parseGenericInheritanceSpec(typed) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s inherit: %v", label, err), }) } else if ok { if !strings.Contains(spec.Ref, ":") { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s inherit.ref %q must use dataset:key identity", label, spec.Ref), }) } } for key, child := range typed { if key == "inherit" { continue } validateGenericInheritanceObject(path, label+"."+key, child, report) } case []any: for index, child := range typed { validateGenericInheritanceObject(path, fmt.Sprintf("%s[%d]", label, index), child, report) } } } func validateInlineTextUsage(path, label string, obj map[string]any, report *ValidationReport) { for key, value := range obj { if key == "_tlk" { typed, ok := value.(map[string]any) if !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s _tlk must be an object", label), }) continue } for role, roleValue := range typed { if strings.ToLower(role) != "name" && strings.ToLower(role) != "description" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s _tlk role %q is not supported", label, role), }) continue } if _, _, err := parseTLKPayload(roleValue, true); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s _tlk.%s: %v", label, role, err), }) } } continue } allowBare := true if typed, ok := value.(map[string]any); ok { _, hasWrappedTLK := typed["tlk"] if !hasWrappedTLK { if _, hasRef := typed["ref"]; hasRef { if _, hasText := typed["text"]; !hasText { if _, hasKey := typed["key"]; !hasKey { continue } } } allowBare = false } } payload, ok, err := parseTLKPayload(value, allowBare) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s %s: %v", label, key, err), }) continue } if !ok { continue } if payload.Key == "" && payload.Text == "" && payload.Ref == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: fmt.Sprintf("%s %s TLK payload is empty", label, key), }) } } } func Compare(p *project.Project, progress func(string)) (CompareResult, error) { if progress == nil { progress = func(string) {} } output2DA := compiled2DAOutputDir(p) outputTLK := compiledTLKOutputDir(p) if _, err := os.Stat(output2DA); err != nil { return CompareResult{}, fmt.Errorf("topdata build output missing: run build-topdata first") } compared2DA, comparedTLK, nativePass, nativeMismatch, err := compareNativeSelfCheck(p, output2DA, outputTLK, progress) if err != nil { return CompareResult{}, err } return CompareResult{ Mode: "native", Compared2DA: compared2DA, ComparedTLK: comparedTLK, NativePass: nativePass, NotYetCanonical: 0, NativeMismatch: nativeMismatch, }, nil } func BuildReference(p *project.Project, progress func(string)) (BuildResult, error) { return BuildNative(p, progress) } func CompareReference(p *project.Project, progress func(string)) (CompareResult, error) { return Compare(p, progress) } func compareDirs(actualRoot, expectedRoot string) (int, error) { expectedFiles := make([]string, 0) err := filepath.WalkDir(expectedRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(expectedRoot, path) if err != nil { return err } expectedFiles = append(expectedFiles, filepath.ToSlash(rel)) return nil }) if err != nil { return 0, err } slices.Sort(expectedFiles) actualFiles := make([]string, 0) err = filepath.WalkDir(actualRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(actualRoot, path) if err != nil { return err } actualFiles = append(actualFiles, filepath.ToSlash(rel)) return nil }) if err != nil { return 0, err } slices.Sort(actualFiles) if !slices.Equal(actualFiles, expectedFiles) { return 0, fmt.Errorf("topdata output file list differs from reference") } for _, rel := range expectedFiles { actualBytes, err := os.ReadFile(filepath.Join(actualRoot, rel)) if err != nil { return 0, err } expectedBytes, err := os.ReadFile(filepath.Join(expectedRoot, rel)) if err != nil { return 0, err } if !bytes.Equal(actualBytes, expectedBytes) { return 0, fmt.Errorf("topdata output differs from reference: %s", rel) } } return len(expectedFiles), nil } func compareNativeSelfCheck(p *project.Project, actual2DA, actualTLK string, progress func(string)) (int, int, int, int, error) { if progress == nil { progress = func(string) {} } projectOutputs, err := discoverNativeOutputCatalog(filepath.Join(p.TopDataSourceDir(), "data")) if err != nil { return 0, 0, 0, 0, err } if len(projectOutputs) == 0 { return 0, 0, 0, 0, nil } tempBuild, err := os.MkdirTemp("", "sow-topdata-native-compare-*") if err != nil { return 0, 0, 0, 0, fmt.Errorf("create native compare temp dir: %w", err) } defer os.RemoveAll(tempBuild) nativeProject := *p nativeProject.Config.TopData.Build = tempBuild nativeResult, err := buildNativeUnchecked(&nativeProject, NativeBuildOptions{}, nil, nil) if err != nil { return 0, 0, 0, len(projectOutputs), nil } pass := 0 mismatch := 0 for outputName := range projectOutputs { actualBytes, err := os.ReadFile(filepath.Join(actual2DA, outputName)) if err != nil { mismatch++ continue } expectedBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, outputName)) if err != nil { mismatch++ continue } if bytes.Equal(normalize2DAForCompare(outputName, actualBytes), normalize2DAForCompare(outputName, expectedBytes)) { pass++ continue } mismatch++ } actual2DAFiles, err := listRelativeFiles(actual2DA) if err != nil { return 0, 0, 0, 0, err } expected2DAFiles, err := listRelativeFiles(nativeResult.Output2DADir) if err != nil { return 0, 0, 0, 0, err } if !slices.Equal(actual2DAFiles, expected2DAFiles) { mismatch += symmetricDiffCount(actual2DAFiles, expected2DAFiles) } comparedTLK, tlkMismatch, err := compareExactDirFilesByExtension(actualTLK, nativeResult.OutputTLKDir, ".tlk") if err != nil { return 0, 0, 0, 0, err } mismatch += tlkMismatch return len(projectOutputs), comparedTLK, pass, mismatch, nil } func compareNativeCoverage(p *project.Project, actual2DA string, progress func(string)) (int, int, int, error) { actualTLK := compiledTLKOutputDir(p) compared2DA, _, pass, mismatch, err := compareNativeSelfCheck(p, actual2DA, actualTLK, progress) if err != nil { return 0, 0, 0, err } if compared2DA == 0 { return 0, 0, mismatch, nil } return pass, 0, mismatch, nil } func normalize2DAForCompare(outputName string, raw []byte) []byte { text := strings.ReplaceAll(string(raw), "\r\n", "\n") lines := strings.Split(text, "\n") if len(lines) < 3 || !strings.HasPrefix(lines[0], "2DA V2.0") { return raw } headerIndex := 2 columns := strings.Split(lines[headerIndex], "\t") keep := make([]int, 0, len(columns)) filtered := make([]string, 0, len(columns)) for index, column := range columns { if isAuthoringOnlyField(column) { continue } keep = append(keep, index) filtered = append(filtered, column) } var builder strings.Builder builder.WriteString(lines[0]) builder.WriteByte('\n') builder.WriteByte('\n') builder.WriteString(strings.Join(filtered, "\t")) builder.WriteByte('\n') normalizedRows := make([][]string, 0, len(lines)) for _, line := range lines[headerIndex+1:] { if line == "" { continue } fields := strings.Split(line, "\t") if len(fields) == 0 { continue } row := make([]string, 0, len(keep)) for _, index := range keep { if index+1 < len(fields) { row = append(row, fields[index+1]) } else { row = append(row, nullValue) } } normalizedRows = append(normalizedRows, row) } if shouldIgnore2DAPlacement(outputName) { slices.SortFunc(normalizedRows, func(a, b []string) int { return strings.Compare(strings.Join(a, "\x00"), strings.Join(b, "\x00")) }) } for index, row := range normalizedRows { builder.WriteString(strconv.Itoa(index)) for _, value := range row { builder.WriteByte('\t') builder.WriteString(value) } builder.WriteByte('\n') } return []byte(builder.String()) } func shouldIgnore2DAPlacement(outputName string) bool { return strings.HasPrefix(outputName, "cls_feat_") && strings.HasSuffix(outputName, ".2da") } func walkJSON(root string, fn func(path string)) error { return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if strings.HasSuffix(strings.ToLower(d.Name()), ".json") { fn(path) } return nil }) } func countFiles(root string) (int, error) { count := 0 err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { count++ } return nil }) return count, err } type progressWriter struct { progress func(string) } func (w progressWriter) Write(p []byte) (int, error) { text := strings.TrimSpace(string(p)) if text != "" { w.progress(text) } return len(p), nil } func ioDiscardProgress(progress func(string)) *progressWriter { return &progressWriter{progress: progress} } func listRelativeFiles(root string) ([]string, error) { return listRelativeFilesByExtension(root, "") } func listRelativeFilesByExtension(root, extension string) ([]string, error) { files := make([]string, 0) if _, err := os.Stat(root); err != nil { if os.IsNotExist(err) { return files, nil } return nil, err } err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if extension != "" && !strings.EqualFold(filepath.Ext(path), extension) { return nil } rel, err := filepath.Rel(root, path) if err != nil { return err } files = append(files, filepath.ToSlash(rel)) return nil }) if err != nil { return nil, err } slices.Sort(files) return files, nil } func symmetricDiffCount(left, right []string) int { leftSet := map[string]struct{}{} rightSet := map[string]struct{}{} for _, item := range left { leftSet[item] = struct{}{} } for _, item := range right { rightSet[item] = struct{}{} } count := 0 for item := range leftSet { if _, ok := rightSet[item]; !ok { count++ } } for item := range rightSet { if _, ok := leftSet[item]; !ok { count++ } } return count } func compareExactDirFiles(actualRoot, expectedRoot string) (int, int, error) { return compareExactDirFilesByExtension(actualRoot, expectedRoot, "") } func compareExactDirFilesByExtension(actualRoot, expectedRoot, extension string) (int, int, error) { actualFiles, err := listRelativeFilesByExtension(actualRoot, extension) if err != nil { return 0, 0, err } expectedFiles, err := listRelativeFilesByExtension(expectedRoot, extension) if err != nil { return 0, 0, err } mismatch := 0 if !slices.Equal(actualFiles, expectedFiles) { mismatch += symmetricDiffCount(actualFiles, expectedFiles) } shared := make([]string, 0, len(expectedFiles)) for _, rel := range expectedFiles { if slices.Contains(actualFiles, rel) { shared = append(shared, rel) } } for _, rel := range shared { actualBytes, err := os.ReadFile(filepath.Join(actualRoot, rel)) if err != nil { mismatch++ continue } expectedBytes, err := os.ReadFile(filepath.Join(expectedRoot, rel)) if err != nil { mismatch++ continue } if !bytes.Equal(actualBytes, expectedBytes) { mismatch++ } } return len(expectedFiles), mismatch, nil }