From fe204aa5f41905512dd2d1f60373ad0af214ef7f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 17:17:34 +0100 Subject: [PATCH 1/3] 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 From fa3c473273469d8caeb298ff564db77a15753e85 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 17:17:49 +0100 Subject: [PATCH 2/3] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 962480339..1a9b01d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Pre-commit FactId validation hook (#393) — grep-based check validates fact_id references in content YAML against canonical knowledge catalogs; advisory mode when catalogs are stubs, enforcing mode when populated +- Pre-commit hook infrastructure — `.config/hooks/` with modular dispatcher, `make setup-hooks` target, `core.hooksPath` config for worktree-safe hook installation +- `make check-fact-ids` target for manual fact_id validation - Hoshe QA briefing updated with integration test coverage priorities — test harnesses, dedicated client-server test map, edge case focus - Sprint 5 "Live" team briefings — copy (6 tickets), client (2), CI (1), joint coordination for content-at-scale sprint targeting FRIEND packs, voice patterns, NPC style guide, PC-as-NPC authoring, UI microcopy, FactId validation - Live server mode (`make game`) — single command builds server, launches client with TCP connection, auto-kills server on exit; `make stop` helper for manual cleanup From 163de8bf255ec2beeb3faa3e730940a617533a99 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 17:42:22 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(ci):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20filter=20comments,=20trim=20whitespace,=20warn=20on?= =?UTF-8?q?=20missing=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter YAML comment lines (grep -v '^\s*#') from both extraction pipelines to prevent phantom canonical IDs - Trim trailing whitespace from extracted fact_ids so grep -qxF exact match works reliably - Pre-commit dispatcher now prints explicit warning when a check script is missing instead of silently skipping - Document --no-verify bypass for emergencies in DEVOPS.md Co-Authored-By: Claude Opus 4.6 --- .config/hooks/pre-commit | 3 ++- docs/DEVOPS.md | 6 ++++++ tooling/check-fact-ids | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.config/hooks/pre-commit b/.config/hooks/pre-commit index 6e3843c83..bb5bc7e6c 100755 --- a/.config/hooks/pre-commit +++ b/.config/hooks/pre-commit @@ -14,7 +14,8 @@ run_check() { ERRORS=$((ERRORS + 1)) fi else - echo "pre-commit: skipping $label ($script not found or not executable)" + echo "pre-commit: WARNING — $label skipped ($script not found or not executable)" + echo " Run 'make setup' or check that $script exists and is executable." fi } diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 7624f5c42..8d6c3a0ae 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -129,6 +129,12 @@ Active checks: The `core.hooksPath` setting uses a relative path (`.config/hooks`) that resolves per worktree, so it works correctly across all worktrees in the repository. +To bypass hooks in an emergency: + +```bash +git commit --no-verify -m "fix: emergency hotfix" +``` + ## 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 index 4fb843755..8472b1450 100755 --- a/tooling/check-fact-ids +++ b/tooling/check-fact-ids @@ -22,9 +22,11 @@ if [ -d "$KNOWLEDGE_DIR" ]; then grep -rh 'fact_id:\s*' "$KNOWLEDGE_DIR" \ --include='*.yaml' \ --exclude='entity-attributes.yaml' \ + | grep -v '^\s*#' \ | sed 's/.*fact_id:\s*//' \ | sed 's/\s*#.*//' \ | sed "s/^[\"']\(.*\)[\"']$/\1/" \ + | sed 's/[[:space:]]*$//' \ | sort -u \ || true ) @@ -39,9 +41,11 @@ if [ -d "$CONTENT_DIR" ]; then REFERENCED=$( grep -rh 'fact_id:\s*' "$CONTENT_DIR" \ --include='*.yaml' \ + | grep -v '^\s*#' \ | sed 's/.*fact_id:\s*//' \ | sed 's/\s*#.*//' \ | sed "s/^[\"']\(.*\)[\"']$/\1/" \ + | sed 's/[[:space:]]*$//' \ || true ) fi