From fe204aa5f41905512dd2d1f60373ad0af214ef7f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 17:17:34 +0100 Subject: [PATCH] feat(ci): add pre-commit FactId validation hook (#393) Grep-based pre-commit check validating fact_id references in content YAML against canonical knowledge catalogs. Runs in advisory mode when catalogs are stubs (exit 0), switches to enforcing mode once populated (exit 1 on unknown fact_ids with file:line output). - tooling/check-fact-ids: core validation script (<2s runtime) - .config/hooks/pre-commit: hook dispatcher for modular checks - Makefile: check-fact-ids + setup-hooks targets, wired into setup - docs/DEVOPS.md: content validation and pre-commit hooks sections Co-Authored-By: Claude Opus 4.6 --- .config/hooks/pre-commit | 28 +++++++++++++ Makefile | 16 ++++++-- docs/DEVOPS.md | 34 ++++++++++++++++ tooling/check-fact-ids | 85 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100755 .config/hooks/pre-commit create mode 100755 tooling/check-fact-ids diff --git a/.config/hooks/pre-commit b/.config/hooks/pre-commit new file mode 100755 index 000000000..6e3843c83 --- /dev/null +++ b/.config/hooks/pre-commit @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Pre-commit hook dispatcher. Runs modular checks from tooling/. +# Installed via: git config core.hooksPath .config/hooks +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +ERRORS=0 + +run_check() { + local script="$1" + local label="$2" + if [ -x "$REPO_ROOT/$script" ]; then + if ! "$REPO_ROOT/$script"; then + ERRORS=$((ERRORS + 1)) + fi + else + echo "pre-commit: skipping $label ($script not found or not executable)" + fi +} + +# --- Checks --- +run_check "tooling/check-fact-ids" "fact_id validation" + +if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "pre-commit: $ERRORS check(s) failed. Commit aborted." + exit 1 +fi diff --git a/Makefile b/Makefile index f7a85f1d8..843ed8efb 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ - db-backup db-install validate-content content-ron + db-backup db-install validate-content content-ron check-fact-ids setup-hooks # --- Configuration --- @@ -32,14 +32,17 @@ help: @echo " make decisions-coverage Decision-to-ticket coverage by domain" @echo " make decisions-active List active decisions" @echo " make decisions-orphan Decisions without implementing tickets" - @echo " make validate-content Validate content YAML against schemas" + @echo " make validate-content Validate content YAML against schemas" + @echo " make check-fact-ids Check fact_id references against knowledge catalogs" @echo " make content-ron Convert content YAML to RON (build-time)" @echo "" + @echo " make setup-hooks Install pre-commit hooks (included in setup)" + @echo "" @echo " GODOT_VERSION=4.6 make setup Override Godot version" # --- Setup --- -setup: setup-rust setup-godot setup-tooling decisions-sync +setup: setup-rust setup-godot setup-tooling setup-hooks decisions-sync @echo "Dev environment ready." setup-rust: @@ -54,6 +57,10 @@ setup-tooling: @command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; } @command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; } +setup-hooks: + @git config core.hooksPath .config/hooks + @echo "Git hooks path set to .config/hooks" + # --- Build --- build: build-server build-client @@ -149,6 +156,9 @@ decisions-orphan: validate-content: @tooling/validate-content +check-fact-ids: + @tooling/check-fact-ids + content-ron: cd tooling/content-converter && cargo build --release tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 7bfdbaf77..7624f5c42 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -95,6 +95,40 @@ CI targets chain lint → build → test sequentially. A failure in any stage st make clean # Remove build artifacts and .cache/ contents ``` +### Content Validation + +```bash +make validate-content # Validate content YAML against JSON schemas +make check-fact-ids # Check fact_id references against knowledge catalogs +``` + +`check-fact-ids` operates in two modes: +- **Advisory** — when knowledge catalogs (`content/global/knowledge/*.yaml`) have no fact definitions yet: lists referenced fact_ids and exits cleanly. +- **Enforcing** — when catalogs are populated: fails on any `fact_id` reference that doesn't match a canonical definition. + +## Pre-commit Hooks + +Git hooks are stored in `.config/hooks/` (version-controlled). Activate them with: + +```bash +make setup # Includes hook installation +make setup-hooks # Just hooks +``` + +Or manually: + +```bash +git config core.hooksPath .config/hooks +``` + +Active checks: + +| Check | Script | Behavior | +|-------|--------|----------| +| fact_id validation | `tooling/check-fact-ids` | Warns if catalogs are stubs; fails on unknown fact_ids when populated | + +The `core.hooksPath` setting uses a relative path (`.config/hooks`) that resolves per worktree, so it works correctly across all worktrees in the repository. + ## Configuration Files The `.config/` directory holds shared configuration for linters, formatters, and CI. Examples of what goes here: diff --git a/tooling/check-fact-ids b/tooling/check-fact-ids new file mode 100755 index 000000000..4fb843755 --- /dev/null +++ b/tooling/check-fact-ids @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Validate fact_id references in content YAML against canonical knowledge catalogs. +# Part of pre-commit checks (ticket #393). Grep-based, targets <2s runtime. +# +# Modes: +# Advisory — when knowledge catalogs have no fact definitions yet (exit 0) +# Enforcing — when catalogs are populated; fails on unknown fact_ids (exit 1) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +KNOWLEDGE_DIR="$REPO_ROOT/content/global/knowledge" +CONTENT_DIR="$REPO_ROOT/content/campaigns" + +# --- Extract canonical fact_ids from knowledge catalogs --- +# Matches YAML lines like: fact_id: some_value or fact_id: "some_value" +# Excludes entity-attributes.yaml (different schema: attribute keys, not fact_ids) +CANONICAL_IDS="" +if [ -d "$KNOWLEDGE_DIR" ]; then + CANONICAL_IDS=$( + grep -rh 'fact_id:\s*' "$KNOWLEDGE_DIR" \ + --include='*.yaml' \ + --exclude='entity-attributes.yaml' \ + | sed 's/.*fact_id:\s*//' \ + | sed 's/\s*#.*//' \ + | sed "s/^[\"']\(.*\)[\"']$/\1/" \ + | sort -u \ + || true + ) +fi + +CANONICAL_COUNT=$(echo "$CANONICAL_IDS" | grep -c '\S' || true) + +# --- Extract referenced fact_ids from campaign content --- +# Covers monologue prerequisites.facts[].fact_id and dialogue knowledge_grant.fact_id +REFERENCED="" +if [ -d "$CONTENT_DIR" ]; then + REFERENCED=$( + grep -rh 'fact_id:\s*' "$CONTENT_DIR" \ + --include='*.yaml' \ + | sed 's/.*fact_id:\s*//' \ + | sed 's/\s*#.*//' \ + | sed "s/^[\"']\(.*\)[\"']$/\1/" \ + || true + ) +fi + +REFERENCED_UNIQUE=$(echo "$REFERENCED" | sort -u | grep '\S' || true) +REF_COUNT=$(echo "$REFERENCED_UNIQUE" | grep -c '\S' || true) + +# --- Compare --- +if [ "$CANONICAL_COUNT" -eq 0 ]; then + echo "check-fact-ids: WARNING — no canonical fact_ids in knowledge catalogs" + echo " Catalogs not yet populated. Check is advisory only." + if [ "$REF_COUNT" -gt 0 ]; then + echo " $REF_COUNT unique fact_ids referenced in content:" + echo "$REFERENCED_UNIQUE" | sed 's/^/ /' + fi + exit 0 +fi + +# Enforcing mode: catalogs have definitions +ERRORS=0 +while IFS= read -r ref_id; do + [ -z "$ref_id" ] && continue + if ! echo "$CANONICAL_IDS" | grep -qxF "$ref_id"; then + echo "ERROR: unknown fact_id '$ref_id' — not in knowledge catalogs" + grep -rn "fact_id:\s*$ref_id" "$CONTENT_DIR" --include='*.yaml' \ + | sed "s|$REPO_ROOT/||" \ + | sed 's/^/ /' + ERRORS=$((ERRORS + 1)) + fi +done <<< "$REFERENCED_UNIQUE" + +if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "check-fact-ids: FAILED — $ERRORS unknown fact_id(s)" + echo " Canonical fact_ids defined in: content/global/knowledge/*.yaml" + echo " Run 'make check-fact-ids' to recheck." + exit 1 +fi + +echo "check-fact-ids: OK — $REF_COUNT references validated against $CANONICAL_COUNT canonical facts" +exit 0