Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -1,373 +0,0 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type classSpellbookSpec struct {
|
||||
Path string
|
||||
Key string
|
||||
Class string
|
||||
Levels map[int][]string
|
||||
}
|
||||
|
||||
type classSpellbookPlan struct {
|
||||
Spec classSpellbookSpec
|
||||
Column string
|
||||
SpellLevels map[string]int
|
||||
}
|
||||
|
||||
func isClassSpellbookPath(path string) bool {
|
||||
slashed := filepath.ToSlash(path)
|
||||
return strings.Contains(slashed, "/data/classes/spellbooks/") && strings.HasSuffix(strings.ToLower(slashed), ".json")
|
||||
}
|
||||
|
||||
func validateClassSpellbookShape(path string, obj map[string]any, report *ValidationReport) {
|
||||
if _, ok := obj["class"]; !ok {
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires class"})
|
||||
}
|
||||
if _, ok := obj["levels"]; !ok {
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires levels"})
|
||||
}
|
||||
}
|
||||
|
||||
func validateClassSpellbooks(dataDir string, report *ValidationReport) {
|
||||
collected, err := collectSpellbookValidationDatasets(dataDir)
|
||||
if err != nil {
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
||||
Severity: SeverityError,
|
||||
Path: dataDir,
|
||||
Message: fmt.Sprintf("validate class spellbooks: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
_, diagnostics := classSpellbookPlans(dataDir, collected)
|
||||
report.Diagnostics = append(report.Diagnostics, diagnostics...)
|
||||
if len(diagnostics) == 0 {
|
||||
warnSpellModulesForManagedSpellbookColumns(dataDir, collected, report)
|
||||
}
|
||||
}
|
||||
|
||||
func collectSpellbookValidationDatasets(dataDir string) ([]nativeCollectedDataset, error) {
|
||||
datasets, err := discoverNativeDatasets(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]nativeCollectedDataset, 0, 2)
|
||||
for _, dataset := range datasets {
|
||||
if dataset.Name != "classes/core" && dataset.Name != "spells" {
|
||||
continue
|
||||
}
|
||||
collected, err := collectNativeDataset(dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, collected)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyClassSpellbooks(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
||||
dataDir := filepath.Join(sourceDir, "data")
|
||||
plans, diagnostics := classSpellbookPlans(dataDir, collected)
|
||||
if len(diagnostics) > 0 {
|
||||
return nil, fmt.Errorf("class spellbook validation failed: %s", diagnosticsTextForError(diagnostics))
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
out := make([]nativeCollectedDataset, len(collected))
|
||||
copy(out, collected)
|
||||
spellsIndex := -1
|
||||
for index := range out {
|
||||
if out[index].Dataset.Name == "spells" {
|
||||
spellsIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if spellsIndex == -1 {
|
||||
return nil, fmt.Errorf("class spellbooks require canonical dataset %q", "spells")
|
||||
}
|
||||
|
||||
spells := out[spellsIndex]
|
||||
columns := append([]string(nil), spells.Columns...)
|
||||
rows := make([]map[string]any, 0, len(spells.Rows))
|
||||
for _, row := range spells.Rows {
|
||||
rows = append(rows, cloneRowMap(row))
|
||||
}
|
||||
rowByKey := rowsByKey(rows)
|
||||
|
||||
for _, plan := range plans {
|
||||
column := plan.Column
|
||||
if _, ok := canonicalColumn(columns, column); !ok {
|
||||
columns = append(columns, column)
|
||||
ensureRowsExposeColumns(rows, columns)
|
||||
}
|
||||
for _, row := range rows {
|
||||
row[column] = nullValue
|
||||
}
|
||||
for spellKey, level := range plan.SpellLevels {
|
||||
row := rowByKey[spellKey]
|
||||
if row == nil {
|
||||
return nil, fmt.Errorf("%s: unknown spell %q", plan.Spec.Path, spellKey)
|
||||
}
|
||||
row[column] = level
|
||||
}
|
||||
}
|
||||
|
||||
spells.Columns = columns
|
||||
spells.Rows = rows
|
||||
out[spellsIndex] = spells
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func classSpellbookPlans(dataDir string, collected []nativeCollectedDataset) ([]classSpellbookPlan, []Diagnostic) {
|
||||
specs, diagnostics := loadClassSpellbookSpecs(filepath.Join(dataDir, "classes", "spellbooks"))
|
||||
if len(specs) == 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
classes := collectedDatasetByName(collected, "classes/core")
|
||||
spells := collectedDatasetByName(collected, "spells")
|
||||
if classes == nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: `class spellbooks require canonical dataset "classes/core"`})
|
||||
return nil, diagnostics
|
||||
}
|
||||
if spells == nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: `class spellbooks require canonical dataset "spells"`})
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
classRows := rowsByKey(classes.Rows)
|
||||
spellRows := rowsByKey(spells.Rows)
|
||||
seenColumns := map[string]string{}
|
||||
plans := make([]classSpellbookPlan, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
classRow := classRows[spec.Class]
|
||||
if classRow == nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("unknown class %q", spec.Class)})
|
||||
}
|
||||
column := ""
|
||||
if classRow != nil {
|
||||
column, _ = classRow["SpellTableColumn"].(string)
|
||||
column = strings.TrimSpace(column)
|
||||
if column == "" || column == nullValue {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("class %q has no SpellTableColumn", spec.Class)})
|
||||
}
|
||||
}
|
||||
if column != "" && column != nullValue {
|
||||
folded := strings.ToLower(column)
|
||||
if previous, ok := seenColumns[folded]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("SpellTableColumn %q is already managed by %s", column, previous)})
|
||||
} else {
|
||||
seenColumns[folded] = spec.Path
|
||||
}
|
||||
}
|
||||
|
||||
spellLevels := map[string]int{}
|
||||
for _, level := range sortedIntKeys(spec.Levels) {
|
||||
for _, spellKey := range spec.Levels[level] {
|
||||
if spellRows[spellKey] == nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("unknown spell %q", spellKey)})
|
||||
continue
|
||||
}
|
||||
if previous, ok := spellLevels[spellKey]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("spell %q appears at both level %d and level %d", spellKey, previous, level)})
|
||||
continue
|
||||
}
|
||||
spellLevels[spellKey] = level
|
||||
}
|
||||
}
|
||||
|
||||
if column != "" && column != nullValue {
|
||||
plans = append(plans, classSpellbookPlan{Spec: spec, Column: column, SpellLevels: spellLevels})
|
||||
}
|
||||
}
|
||||
return plans, diagnostics
|
||||
}
|
||||
|
||||
func warnSpellModulesForManagedSpellbookColumns(dataDir string, collected []nativeCollectedDataset, report *ValidationReport) {
|
||||
plans, diagnostics := classSpellbookPlans(dataDir, collected)
|
||||
if len(diagnostics) > 0 || len(plans) == 0 {
|
||||
return
|
||||
}
|
||||
managed := map[string]classSpellbookPlan{}
|
||||
for _, plan := range plans {
|
||||
managed[strings.ToLower(plan.Column)] = plan
|
||||
}
|
||||
paths, err := collectModulePaths(filepath.Join(dataDir, "spells", "modules"))
|
||||
if err != nil {
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("collect spell modules for spellbook warnings: %v", err)})
|
||||
return
|
||||
}
|
||||
for _, path := range paths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
warnSpellbookManagedColumnsInValue(dataDir, path, obj["entries"], managed, report)
|
||||
warnSpellbookManagedColumnsInValue(dataDir, path, obj["overrides"], managed, report)
|
||||
warnSpellbookManagedColumnsInValue(dataDir, path, obj["rows"], managed, report)
|
||||
}
|
||||
}
|
||||
|
||||
func warnSpellbookManagedColumnsInValue(dataDir, path string, value any, managed map[string]classSpellbookPlan, report *ValidationReport) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range sortedKeys(typed) {
|
||||
row, ok := typed[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
warnSpellbookManagedColumnsInRow(dataDir, path, row, managed, report)
|
||||
}
|
||||
case []any:
|
||||
for _, raw := range typed {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
warnSpellbookManagedColumnsInRow(dataDir, path, row, managed, report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warnSpellbookManagedColumnsInRow(dataDir, path string, row map[string]any, managed map[string]classSpellbookPlan, report *ValidationReport) {
|
||||
for field, value := range row {
|
||||
plan, ok := managed[strings.ToLower(field)]
|
||||
if !ok || isNullLikeValue(value) {
|
||||
continue
|
||||
}
|
||||
spellbookPath := displayTopdataPath(dataDir, plan.Spec.Path)
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
||||
Severity: SeverityWarning,
|
||||
Path: path,
|
||||
Message: fmt.Sprintf(
|
||||
"spell module authors class spell column %q managed by class spellbook %s; use %s instead",
|
||||
plan.Column,
|
||||
spellbookPath,
|
||||
spellbookPath,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func displayTopdataPath(dataDir, path string) string {
|
||||
topdataDir := filepath.Dir(dataDir)
|
||||
rel, err := filepath.Rel(filepath.Dir(topdataDir), path)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func loadClassSpellbookSpecs(dir string) ([]classSpellbookSpec, []Diagnostic) {
|
||||
paths, err := collectModulePaths(dir)
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{{Severity: SeverityError, Path: dir, Message: err.Error()}}
|
||||
}
|
||||
specs := make([]classSpellbookSpec, 0, len(paths))
|
||||
diagnostics := []Diagnostic{}
|
||||
for _, path := range paths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
spec, specDiagnostics := parseClassSpellbookSpec(path, obj)
|
||||
diagnostics = append(diagnostics, specDiagnostics...)
|
||||
if spec.Class != "" && spec.Levels != nil {
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
}
|
||||
return specs, diagnostics
|
||||
}
|
||||
|
||||
func parseClassSpellbookSpec(path string, obj map[string]any) (classSpellbookSpec, []Diagnostic) {
|
||||
diagnostics := []Diagnostic{}
|
||||
spec := classSpellbookSpec{Path: path, Levels: map[int][]string{}}
|
||||
if key, ok := obj["key"].(string); ok {
|
||||
spec.Key = strings.TrimSpace(key)
|
||||
}
|
||||
classKey, ok := obj["class"].(string)
|
||||
if !ok || strings.TrimSpace(classKey) == "" {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires class"})
|
||||
} else {
|
||||
spec.Class = strings.TrimSpace(classKey)
|
||||
if !strings.HasPrefix(spec.Class, "classes:") {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class must use a classes: reference"})
|
||||
}
|
||||
}
|
||||
rawLevels, ok := obj["levels"].(map[string]any)
|
||||
if !ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook levels must be an object"})
|
||||
return spec, diagnostics
|
||||
}
|
||||
for _, rawLevel := range sortedKeys(rawLevels) {
|
||||
level, err := strconv.Atoi(rawLevel)
|
||||
if err != nil || level < 0 {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level key %q must be a nonnegative integer", rawLevel)})
|
||||
continue
|
||||
}
|
||||
rawList, ok := rawLevels[rawLevel].([]any)
|
||||
if !ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d must be an array", level)})
|
||||
continue
|
||||
}
|
||||
for index, rawSpell := range rawList {
|
||||
spellKey, ok := rawSpell.(string)
|
||||
if !ok || strings.TrimSpace(spellKey) == "" {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d spell %d must be a spells: reference", level, index)})
|
||||
continue
|
||||
}
|
||||
spellKey = strings.TrimSpace(spellKey)
|
||||
if !strings.HasPrefix(spellKey, "spells:") {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d spell %d must use a spells: reference", level, index)})
|
||||
continue
|
||||
}
|
||||
spec.Levels[level] = append(spec.Levels[level], spellKey)
|
||||
}
|
||||
}
|
||||
return spec, diagnostics
|
||||
}
|
||||
|
||||
func collectedDatasetByName(collected []nativeCollectedDataset, name string) *nativeCollectedDataset {
|
||||
for index := range collected {
|
||||
if collected[index].Dataset.Name == name {
|
||||
return &collected[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowsByKey(rows []map[string]any) map[string]map[string]any {
|
||||
out := make(map[string]map[string]any, len(rows))
|
||||
for _, row := range rows {
|
||||
key, _ := row["key"].(string)
|
||||
if key != "" {
|
||||
out[key] = row
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedIntKeys(values map[int][]string) []int {
|
||||
keys := make([]int, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func diagnosticsTextForError(diags []Diagnostic) string {
|
||||
messages := make([]string, 0, len(diags))
|
||||
for _, diag := range diags {
|
||||
messages = append(messages, diag.Message)
|
||||
}
|
||||
return strings.Join(messages, "; ")
|
||||
}
|
||||
Reference in New Issue
Block a user