replaced explicit masterfeats/feat base+lock path branches

This commit is contained in:
2026-05-13 20:14:27 +02:00
parent 4b55628ac8
commit 4fd0375608
3 changed files with 147 additions and 50 deletions
+18
View File
@@ -146,6 +146,9 @@ This should be test-covered.
### Work Item 2: Audit path-based validator routing ### 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: Classify every explicit route in `validateDataObject()` as one of:
- generic canonical shape handling - generic canonical shape handling
@@ -154,6 +157,21 @@ Classify every explicit route in `validateDataObject()` as one of:
Reduce the explicit route set where possible. 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 ### Work Item 3: Define topdata invariant boundaries
For each current explicit topdata behavior, decide whether it belongs in: For each current explicit topdata behavior, decide whether it belongs in:
+94 -50
View File
@@ -383,22 +383,17 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor
base := filepath.Base(path) base := filepath.Base(path)
slashed := filepath.ToSlash(path) slashed := filepath.ToSlash(path)
inModules := strings.Contains(slashed, "/modules/") inModules := strings.Contains(slashed, "/modules/")
dataPath := parseTopdataDataPath(path)
if strings.HasSuffix(slashed, "/data/masterfeats/base.json") { if invariant, ok := datasetInvariantForPath(dataPath); ok {
validateMasterfeatsBaseFile(path, obj, report) if dataPath.IsRootBase {
return validateDatasetInvariantBaseFile(path, obj, invariant, report)
} return
if strings.HasSuffix(slashed, "/data/masterfeats/lock.json") { }
validateMasterfeatsLockFile(path, obj, report) if dataPath.IsRootLock {
return validateDatasetInvariantLockFile(path, obj, invariant, 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 strings.Contains(slashed, "/data/feat/generated/") { if strings.Contains(slashed, "/data/feat/generated/") {
validateFeatGeneratedFile(path, obj, report) 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) { type topdataDataPath struct {
validateRowsFile(path, obj, report, true) Segments []string
validateDerivedBaseOutput(path, obj, "masterfeats", report) IsRootBase bool
IsRootLock bool
}
columns := extractValidationColumns(obj) func parseTopdataDataPath(path string) topdataDataPath {
for _, required := range []string{"LABEL", "STRREF", "DESCRIPTION"} { slashed := filepath.ToSlash(path)
if _, ok := canonicalColumn(columns, required); !ok { marker := "/data/"
report.Diagnostics = append(report.Diagnostics, Diagnostic{ index := strings.LastIndex(slashed, marker)
Severity: SeverityError, if index == -1 {
Path: path, return topdataDataPath{}
Message: fmt.Sprintf("masterfeats base.json must include %s in columns", required),
})
}
} }
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) rows, ok := obj["rows"].([]any)
if !ok { if !ok {
return return
@@ -482,34 +541,32 @@ func validateMasterfeatsBaseFile(path string, obj map[string]any, report *Valida
if !ok || strings.TrimSpace(key) == "" { if !ok || strings.TrimSpace(key) == "" {
continue continue
} }
if !strings.HasPrefix(key, "masterfeats:") { if !strings.HasPrefix(key, invariant.KeyPrefix) {
report.Diagnostics = append(report.Diagnostics, Diagnostic{ report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError, Severity: SeverityError,
Path: path, 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) validateLockObject(path, obj, report)
if invariant.KeyPrefix == "" {
return
}
for key := range obj { for key := range obj {
if !strings.HasPrefix(key, "masterfeats:") { if !strings.HasPrefix(key, invariant.KeyPrefix) {
report.Diagnostics = append(report.Diagnostics, Diagnostic{ report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError, Severity: SeverityError,
Path: path, 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) { func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel string, report *ValidationReport) {
if output, ok := obj["output"]; !ok { if output, ok := obj["output"]; !ok {
return 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) { func validateFeatGeneratedFile(path string, obj map[string]any, report *ValidationReport) {
family, ok := obj["family"].(string) family, ok := obj["family"].(string)
if !ok || strings.TrimSpace(family) == "" { if !ok || strings.TrimSpace(family) == "" {
+35
View File
@@ -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) { func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))