diff --git a/TOPDATA_HARDENING_CONTRACT.md b/TOPDATA_HARDENING_CONTRACT.md index 587f358..6a67d11 100644 --- a/TOPDATA_HARDENING_CONTRACT.md +++ b/TOPDATA_HARDENING_CONTRACT.md @@ -146,6 +146,9 @@ This should be test-covered. ### Work Item 2: Audit path-based validator routing +Status: implemented for root dataset invariants; family-specific routes remain +where the authored shape is not a generic base/module/lock file. + Classify every explicit route in `validateDataObject()` as one of: - generic canonical shape handling @@ -154,6 +157,21 @@ Classify every explicit route in `validateDataObject()` as one of: Reduce the explicit route set where possible. +Current classification: + +- `masterfeats/base.json`, `masterfeats/lock.json`, `feat/base.json`, and + `feat/lock.json` are now handled by a generic dataset invariant table. These + remain toolkit invariants because the row key namespaces and required columns + are intrinsic to those native datasets, not repository-level YAML choices. +- `feat/generated/*` remains an intrinsic dataset-family route. Generated feat + files support compact family expansion and generated entries/overrides, which + are not generic module shapes. +- `parts/overrides/*` remains a dataset-family route for non-module override + files outside the standard `modules/` tree. +- `racialtypes/registry/races/*` remains a registry-family route because those + files describe race registry records, optional feat-table generation, and core + row payloads rather than generic row/module documents. + ### Work Item 3: Define topdata invariant boundaries For each current explicit topdata behavior, decide whether it belongs in: diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 191db9e..e0c4882 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -383,22 +383,17 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor base := filepath.Base(path) slashed := filepath.ToSlash(path) inModules := strings.Contains(slashed, "/modules/") + dataPath := parseTopdataDataPath(path) - if strings.HasSuffix(slashed, "/data/masterfeats/base.json") { - validateMasterfeatsBaseFile(path, obj, report) - return - } - if strings.HasSuffix(slashed, "/data/masterfeats/lock.json") { - validateMasterfeatsLockFile(path, obj, report) - return - } - if strings.HasSuffix(slashed, "/data/feat/base.json") { - validateFeatBaseFile(path, obj, report) - return - } - if strings.HasSuffix(slashed, "/data/feat/lock.json") { - validateFeatLockFile(path, obj, report) - return + 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) @@ -454,21 +449,85 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor }) } -func validateMasterfeatsBaseFile(path string, obj map[string]any, report *ValidationReport) { - validateRowsFile(path, obj, report, true) - validateDerivedBaseOutput(path, obj, "masterfeats", report) +type topdataDataPath struct { + Segments []string + IsRootBase bool + IsRootLock bool +} - columns := extractValidationColumns(obj) - for _, required := range []string{"LABEL", "STRREF", "DESCRIPTION"} { - if _, ok := canonicalColumn(columns, required); !ok { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: fmt.Sprintf("masterfeats base.json must include %s in columns", required), - }) - } +func parseTopdataDataPath(path string) topdataDataPath { + slashed := filepath.ToSlash(path) + marker := "/data/" + index := strings.LastIndex(slashed, marker) + if index == -1 { + return topdataDataPath{} } + rel := strings.Trim(slashed[index+len(marker):], "/") + if rel == "" { + return topdataDataPath{} + } + segments := strings.Split(rel, "/") + return topdataDataPath{ + Segments: segments, + IsRootBase: len(segments) == 2 && segments[1] == "base.json", + IsRootLock: len(segments) == 2 && segments[1] == "lock.json", + } +} +type datasetValidationInvariant struct { + Name string + KeyPrefix string + RequiredColumns []string +} + +var datasetValidationInvariants = map[string]datasetValidationInvariant{ + "feat": { + Name: "feat", + KeyPrefix: "feat:", + }, + "masterfeats": { + Name: "masterfeats", + KeyPrefix: "masterfeats:", + RequiredColumns: []string{"LABEL", "STRREF", "DESCRIPTION"}, + }, +} + +func datasetInvariantForPath(dataPath topdataDataPath) (datasetValidationInvariant, bool) { + if len(dataPath.Segments) == 0 { + return datasetValidationInvariant{}, false + } + invariant, ok := datasetValidationInvariants[dataPath.Segments[0]] + return invariant, ok +} + +func validateDatasetInvariantBaseFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { + validateRowsFile(path, obj, report, true) + validateDerivedBaseOutput(path, obj, invariant.Name, report) + validateRequiredColumns(path, obj, invariant, report) + validateRowsKeyPrefix(path, obj, invariant, report) +} + +func validateRequiredColumns(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { + if len(invariant.RequiredColumns) == 0 { + return + } + columns := extractValidationColumns(obj) + for _, required := range invariant.RequiredColumns { + if _, ok := canonicalColumn(columns, required); ok { + continue + } + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("%s base.json must include %s in columns", invariant.Name, required), + }) + } +} + +func validateRowsKeyPrefix(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) { + if invariant.KeyPrefix == "" { + return + } rows, ok := obj["rows"].([]any) if !ok { return @@ -482,34 +541,32 @@ func validateMasterfeatsBaseFile(path string, obj map[string]any, report *Valida if !ok || strings.TrimSpace(key) == "" { continue } - if !strings.HasPrefix(key, "masterfeats:") { + if !strings.HasPrefix(key, invariant.KeyPrefix) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: fmt.Sprintf("masterfeats row %d key %q must start with masterfeats:", index, key), + Message: fmt.Sprintf("%s row %d key %q must start with %s", invariant.Name, index, key, invariant.KeyPrefix), }) } } } -func validateMasterfeatsLockFile(path string, obj map[string]any, report *ValidationReport) { +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, "masterfeats:") { + if !strings.HasPrefix(key, invariant.KeyPrefix) { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: fmt.Sprintf("masterfeats lock key %q must start with masterfeats:", key), + Message: fmt.Sprintf("%s lock key %q must start with %s", invariant.Name, key, invariant.KeyPrefix), }) } } } -func validateFeatBaseFile(path string, obj map[string]any, report *ValidationReport) { - validateRowsFile(path, obj, report, true) - validateDerivedBaseOutput(path, obj, "feat", report) -} - func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel string, report *ValidationReport) { if output, ok := obj["output"]; !ok { return @@ -522,19 +579,6 @@ func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel str } } -func validateFeatLockFile(path string, obj map[string]any, report *ValidationReport) { - validateLockObject(path, obj, report) - for key := range obj { - if !strings.HasPrefix(key, "feat:") { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: fmt.Sprintf("feat lock key %q must start with feat:", key), - }) - } - } -} - func validateFeatGeneratedFile(path string, obj map[string]any, report *ValidationReport) { family, ok := obj["family"].(string) if !ok || strings.TrimSpace(family) == "" { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index be5287f..a128eed 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -79,6 +79,41 @@ func TestValidateAndBuildDerivesFeatOutputWhenOmitted(t *testing.T) { } } +func TestValidateProjectRejectsInvalidFeatInvariantContract(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "masterfeats.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION"], + "rows": [ + { + "id": 0, + "key": "masterfeats:test", + "LABEL": "FEAT_TEST", + "FEAT": "100", + "DESCRIPTION": "200" + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"masterfeats:test":0}`+"\n") + + report := ValidateProject(testProject(root)) + if !report.HasErrors() { + t.Fatal("expected validation errors for invalid feat invariants") + } + text := diagnosticsText(report.Diagnostics) + for _, want := range []string{ + "feat output must be feat.2da", + `feat row 0 key "masterfeats:test" must start with feat:`, + `feat lock key "masterfeats:test" must start with feat:`, + } { + if !strings.Contains(text, want) { + t.Fatalf("expected validation message %q, got:\n%s", want, text) + } + } +} + func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))