feat(db): add cross-culture name pool collision checking to validate-ron
New --check-name-collisions mode scans all culture-*.ron files and reports given/family names appearing in multiple culture pools. Exits 1 on collision with machine-readable output. Sprint 26 review caught 6 overlaps manually — this automates the check. Ticket: #665 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,20 +3,123 @@
|
||||
#
|
||||
# Usage:
|
||||
# tooling/validate-ron <file.ron> <zone|culture>
|
||||
# tooling/validate-ron --check-name-collisions <content/dir/>
|
||||
#
|
||||
# Examples:
|
||||
# tooling/validate-ron content/global/zone-identity-spec.example.ron zone
|
||||
# tooling/validate-ron content/global/culture-van-maanens-star.example.ron culture
|
||||
# tooling/validate-ron --check-name-collisions content/global/
|
||||
#
|
||||
# --check-name-collisions:
|
||||
# Scans all culture-*.ron files in the given directory. Extracts naming.given_names
|
||||
# and naming.family_names pools from each file. Reports any name that appears in more
|
||||
# than one culture's pool. Exits 1 on collision; exits 0 if no collisions found.
|
||||
# Output is machine-readable (one collision per line).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Name collision mode --------------------------------------------------
|
||||
if [ "${1:-}" = "--check-name-collisions" ]; then
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: tooling/validate-ron --check-name-collisions <directory>"
|
||||
exit 1
|
||||
fi
|
||||
SCAN_DIR="$2"
|
||||
if [ ! -d "$SCAN_DIR" ]; then
|
||||
echo "Error: directory not found: $SCAN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$SCAN_DIR" <<'PYEOF'
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
import glob
|
||||
|
||||
scan_dir = sys.argv[1]
|
||||
pattern = os.path.join(scan_dir, "culture-*.ron")
|
||||
files = sorted(glob.glob(pattern))
|
||||
|
||||
if not files:
|
||||
print(f"No culture-*.ron files found in: {scan_dir}")
|
||||
sys.exit(0)
|
||||
|
||||
def extract_names_from_block(text, field):
|
||||
"""Extract quoted string list from a named RON array field."""
|
||||
# Match: field: [\n "name", "name", ...\n]
|
||||
# Handles multi-line arrays with comments inside.
|
||||
pat = re.compile(
|
||||
r'\b' + re.escape(field) + r'\s*:\s*\[([^\]]*)\]',
|
||||
re.DOTALL
|
||||
)
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
return []
|
||||
block = m.group(1)
|
||||
# Strip line comments before extracting quoted strings
|
||||
block = re.sub(r'//[^\n]*', '', block)
|
||||
block = re.sub(r'/[^\n]*', '', block) # single-slash RON comments
|
||||
return re.findall(r'"([^"]+)"', block)
|
||||
|
||||
# culture_id -> set of names (given + family, tracked separately for reporting)
|
||||
given_by_culture = {}
|
||||
family_by_culture = {}
|
||||
|
||||
for fpath in files:
|
||||
with open(fpath, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
# Extract culture id from the id: "..." field
|
||||
id_m = re.search(r'\bid\s*:\s*"([^"]+)"', text)
|
||||
culture_id = id_m.group(1) if id_m else os.path.basename(fpath)
|
||||
|
||||
given_by_culture[culture_id] = set(extract_names_from_block(text, 'given_names'))
|
||||
family_by_culture[culture_id] = set(extract_names_from_block(text, 'family_names'))
|
||||
|
||||
cultures = list(given_by_culture.keys())
|
||||
collisions_found = False
|
||||
|
||||
# Check given_names collisions
|
||||
all_given_names = {} # name -> list of cultures
|
||||
for culture, names in given_by_culture.items():
|
||||
for name in names:
|
||||
all_given_names.setdefault(name, []).append(culture)
|
||||
|
||||
for name, cultures_with_name in sorted(all_given_names.items()):
|
||||
if len(cultures_with_name) > 1:
|
||||
collisions_found = True
|
||||
print(f"COLLISION given_names: \"{name}\" in {', '.join(sorted(cultures_with_name))}")
|
||||
|
||||
# Check family_names collisions
|
||||
all_family_names = {} # name -> list of cultures
|
||||
for culture, names in family_by_culture.items():
|
||||
for name in names:
|
||||
all_family_names.setdefault(name, []).append(culture)
|
||||
|
||||
for name, cultures_with_name in sorted(all_family_names.items()):
|
||||
if len(cultures_with_name) > 1:
|
||||
collisions_found = True
|
||||
print(f"COLLISION family_names: \"{name}\" in {', '.join(sorted(cultures_with_name))}")
|
||||
|
||||
if not collisions_found:
|
||||
print(f"OK: no name collisions across {len(cultures)} culture(s): {', '.join(sorted(cultures))}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
exit $?
|
||||
fi
|
||||
# --- End name collision mode ----------------------------------------------
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: tooling/validate-ron <file.ron> <zone|culture>"
|
||||
echo " tooling/validate-ron --check-name-collisions <directory>"
|
||||
echo ""
|
||||
echo "Validates a RON file against the Rust struct schema."
|
||||
echo "Schema types:"
|
||||
echo " zone — ZoneSpec (zone identity spec)"
|
||||
echo " culture — CultureProfile (culture profile)"
|
||||
echo ""
|
||||
echo "Name collision check:"
|
||||
echo " --check-name-collisions <dir> Scan culture-*.ron files for shared name pool entries"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user