sow-topdata moved skills into a tree (skills/core, skills/specs, skillcats, skillspecranks), which broke validate/build: the generated feat families hardcoded dataset "skills". Resolution is now data-driven: the family spec's child_source names the dataset, child slugs come from the row key's own namespace, and the wiki loader accepts both the flat and tree layouts (topdata main still uses the flat one until the overhaul merges). Verified against the skill-grouping-overhaul topdata branch: validate/build/wiki all clean with zero lock churn, focus feats stay on their adopted rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #43 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2993 lines
85 KiB
Go
2993 lines
85 KiB
Go
package topdata
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.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)
|
|
validateClassFeatMasterfeatAccessibility(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 reportTopdataContractError(path string, err error, report *ValidationReport) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
|
|
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" {
|
|
reportTopdataContractError(path, unsupportedObjectKeysError("base.json root", obj, baseOrPlainRootKeys...), report)
|
|
validateRowsFile(path, obj, report, true)
|
|
return
|
|
}
|
|
if inModules {
|
|
reportTopdataContractError(path, unsupportedObjectKeysError("module root", obj, canonicalModuleRootKeys...), report)
|
|
_, hasColumns := obj["columns"]
|
|
_, hasEntries := obj["entries"]
|
|
_, hasOverrides := obj["overrides"]
|
|
_, hasRows := obj["rows"]
|
|
if hasColumns {
|
|
validateColumnsFile(path, obj, report)
|
|
}
|
|
if hasEntries {
|
|
validateEntriesFileWithColumns(path, obj, report, false)
|
|
}
|
|
if hasOverrides {
|
|
validateOverridesFileWithColumns(path, obj, report, false)
|
|
}
|
|
if hasRows {
|
|
validateRowsFileWithColumns(path, obj, report, false, false)
|
|
}
|
|
if !hasColumns && !hasEntries && !hasOverrides && !hasRows {
|
|
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 {
|
|
reportTopdataContractError(path, unsupportedObjectKeysError("plain rows root", obj, baseOrPlainRootKeys...), report)
|
|
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) {
|
|
reportTopdataContractError(path, unsupportedObjectKeysError("base.json root", obj, baseOrPlainRootKeys...), report)
|
|
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) {
|
|
validateRowsFileWithColumns(path, obj, report, requireColumns, true)
|
|
}
|
|
|
|
func validateRowsFileWithColumns(path string, obj map[string]any, report *ValidationReport, requireColumns bool, validateColumns 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 && validateColumns {
|
|
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) {
|
|
validateEntriesFileWithColumns(path, obj, report, true)
|
|
}
|
|
|
|
func validateEntriesFileWithColumns(path string, obj map[string]any, report *ValidationReport, validateColumns bool) {
|
|
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 && validateColumns {
|
|
validateColumnsFile(path, obj, report)
|
|
}
|
|
}
|
|
|
|
func validateOverridesFile(path string, obj map[string]any, report *ValidationReport) {
|
|
validateOverridesFileWithColumns(path, obj, report, true)
|
|
}
|
|
|
|
func validateOverridesFileWithColumns(path string, obj map[string]any, report *ValidationReport, validateColumns bool) {
|
|
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 && validateColumns {
|
|
validateColumnsFile(path, obj, report)
|
|
}
|
|
}
|
|
|
|
func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationReport) {
|
|
reportTopdataContractError(path, unsupportedObjectKeysError("global.json root", obj, globalRootKeys...), report)
|
|
if _, ok := obj["columns"]; ok {
|
|
validateColumnsFile(path, obj, report)
|
|
}
|
|
if _, ok := obj["entries"]; ok {
|
|
validateEntriesFileWithColumns(path, obj, report, false)
|
|
}
|
|
if _, ok := obj["overrides"]; ok {
|
|
validateOverridesFileWithColumns(path, obj, report, false)
|
|
}
|
|
if _, ok := obj["defaults"]; ok {
|
|
validateGlobalDefaults(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)
|
|
}
|
|
_, _ = parseGlobalConditionGroups(injection, globalConditionParseOptions{
|
|
Label: fmt.Sprintf("global injection %d", index),
|
|
Report: func(err error) {
|
|
reportTopdataContractError(path, err, report)
|
|
},
|
|
})
|
|
}
|
|
validateRowCollection(path, obj, rows, report)
|
|
}
|
|
|
|
func topdataColumnsForGlobalValidation(path string, obj map[string]any) ([]string, bool) {
|
|
basePath := filepath.Join(filepath.Dir(path), "base.json")
|
|
baseData, err := loadJSONObject(basePath)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
datasetName := filepath.ToSlash(filepath.Base(filepath.Dir(path)))
|
|
columns, err := parseColumns(baseData, datasetName)
|
|
if err != nil {
|
|
return nil, true
|
|
}
|
|
modulePaths, err := collectModulePaths(filepath.Join(filepath.Dir(path), "modules"))
|
|
if err == nil {
|
|
for _, modulePath := range modulePaths {
|
|
moduleData, err := loadJSONObject(modulePath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
columns, err = extendColumns(columns, moduleData, datasetName, modulePath)
|
|
if err != nil {
|
|
return columns, true
|
|
}
|
|
}
|
|
}
|
|
columns, err = extendColumns(columns, obj, datasetName, path)
|
|
if err != nil {
|
|
return columns, true
|
|
}
|
|
return columns, true
|
|
}
|
|
|
|
func validateGlobalDefaults(path string, obj map[string]any, report *ValidationReport) {
|
|
rawDefaults, ok := obj["defaults"]
|
|
if !ok {
|
|
return
|
|
}
|
|
defaults, ok := rawDefaults.([]any)
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: "global defaults must be a JSON array",
|
|
})
|
|
return
|
|
}
|
|
columns, hasBaseDataset := topdataColumnsForGlobalValidation(path, obj)
|
|
if !hasBaseDataset {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: "global defaults require a sibling base.json dataset",
|
|
})
|
|
return
|
|
}
|
|
for index, rawDefault := range defaults {
|
|
defaultRule, ok := rawDefault.(map[string]any)
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d must be an object", index),
|
|
})
|
|
continue
|
|
}
|
|
reportTopdataContractError(path, unsupportedObjectKeysError(fmt.Sprintf("global default %d", index), defaultRule, globalDefaultRuleKeys...), report)
|
|
validateGlobalDefaultMatch(path, index, defaultRule["match"], report)
|
|
rawValues, ok := defaultRule["values"]
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d values is required", index),
|
|
})
|
|
continue
|
|
}
|
|
values, ok := rawValues.(map[string]any)
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d values must be an object", index),
|
|
})
|
|
continue
|
|
}
|
|
for field, value := range values {
|
|
if _, ok := canonicalColumn(columns, field); !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d values.%s is not a known column", index, field),
|
|
})
|
|
}
|
|
validateGlobalDefaultValue(path, index, field, value, report)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateGlobalDefaultMatch(path string, index int, rawMatch any, report *ValidationReport) {
|
|
if rawMatch == nil {
|
|
return
|
|
}
|
|
if text, ok := rawMatch.(string); ok {
|
|
if strings.EqualFold(strings.TrimSpace(text), "all") {
|
|
return
|
|
}
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d match must be all or an object", index),
|
|
})
|
|
return
|
|
}
|
|
match, ok := rawMatch.(map[string]any)
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d match must be all or an object", index),
|
|
})
|
|
return
|
|
}
|
|
for _, key := range unsupportedObjectKeys(match, globalDefaultMatchKeys...) {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d match.%s is not supported (unsupported key; supported keys: %s)", index, key, strings.Join(globalDefaultMatchKeys, ", ")),
|
|
})
|
|
}
|
|
source, ok := match["source"].(string)
|
|
if !ok || strings.TrimSpace(source) == "" {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d match.source is required", index),
|
|
})
|
|
return
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case string(nativeRowSourceBase), string(nativeRowSourceEntries), string(nativeRowSourceOverride):
|
|
default:
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d match.source is not supported", index),
|
|
})
|
|
}
|
|
}
|
|
|
|
func validateGlobalDefaultValue(path string, index int, field string, value any, report *ValidationReport) {
|
|
obj, ok := value.(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
rawFormat, hasFormat := obj["format"]
|
|
if !hasFormat {
|
|
return
|
|
}
|
|
reportTopdataContractError(path, unsupportedObjectKeysError(fmt.Sprintf("global default %d values.%s", index, field), obj, globalDefaultFormatKeys...), report)
|
|
format, ok := rawFormat.(string)
|
|
if !ok {
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d values.%s format must be a string", index, field),
|
|
})
|
|
return
|
|
}
|
|
if err := validateTopDataRowFormat(format); err != nil {
|
|
message := strings.TrimPrefix(err.Error(), "default ")
|
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
|
Severity: SeverityError,
|
|
Path: path,
|
|
Message: fmt.Sprintf("global default %d values.%s %s", index, field, message),
|
|
})
|
|
}
|
|
}
|
|
|
|
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, false)
|
|
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{
|
|
// skill families: the source dataset is data-driven (family spec), only
|
|
// the accessibility predicate is required
|
|
{FamilyKey: "skillfocus", Predicate: "accessible"},
|
|
{FamilyKey: "greaterskillfocus", 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 (requirement.Dataset != "" && 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 := sourceKey
|
|
if idx := strings.Index(slug, ":"); idx >= 0 {
|
|
slug = slug[idx+1:]
|
|
}
|
|
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 skipTopPackageAsset(rel) {
|
|
return nil // not a NWN ResType (script, doc, _work dir, ...): never packed, never checked
|
|
}
|
|
base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
|
|
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, false)
|
|
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
|
|
}
|