CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/intent-integrity-kit

Closing the intent-to-code chasm - specification-driven development with BDD verification chain

Overall
score

96%

Does it follow best practices?

Validation for skill structure

Overview
Skills
Evals
Files

pre-commit-hook.shskills/iikit-core/scripts/bash/

#!/usr/bin/env bash
# IIKIT-PRE-COMMIT
# Git pre-commit hook for assertion integrity enforcement
# Prevents committing tampered test-specs.md assertions
#
# This is a thin wrapper that sources testify-tdd.sh at runtime
# to reuse existing functions (compute_assertion_hash, verify_assertion_hash, etc.)
#
# Installation: Automatically installed by init-project.sh
# Manual: cp pre-commit-hook.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit

# ============================================================================
# PATH DETECTION — find the scripts directory at runtime
# ============================================================================

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [[ -z "$REPO_ROOT" ]]; then
    # Not a git repo — should not happen in a hook, but be safe
    exit 0
fi

SCRIPTS_DIR=""
CANDIDATE_PATHS=(
    "$REPO_ROOT/.claude/skills/iikit-core/scripts/bash"
    "$REPO_ROOT/.tessl/tiles/tessl-labs/intent-integrity-kit/skills/iikit-core/scripts/bash"
    "$REPO_ROOT/.codex/skills/iikit-core/scripts/bash"
)

for candidate in "${CANDIDATE_PATHS[@]}"; do
    if [[ -f "$candidate/testify-tdd.sh" ]]; then
        SCRIPTS_DIR="$candidate"
        break
    fi
done

if [[ -z "$SCRIPTS_DIR" ]]; then
    echo "[iikit] Warning: IIKit scripts not found — skipping assertion integrity check" >&2
    exit 0
fi

# ============================================================================
# FAST PATH — exit immediately if nothing relevant is staged
# ============================================================================

STAGED_FEATURE_FILES=$(git diff --cached --name-only 2>/dev/null | grep -E 'tests/features/.*\.feature$') || true
STAGED_TEST_SPECS=$(git diff --cached --name-only 2>/dev/null | grep 'test-specs\.md$') || true
STAGED_CODE_FILES=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(py|js|ts|jsx|tsx|go|java|rs|cs|rb|kt)$' | grep -vE '^(\.tessl/|\.claude/|\.codex/|\.gemini/|\.opencode/|node_modules/)') || true

if [[ -z "$STAGED_FEATURE_FILES" ]] && [[ -z "$STAGED_TEST_SPECS" ]] && [[ -z "$STAGED_CODE_FILES" ]]; then
    exit 0
fi

# ============================================================================
# SOURCE FUNCTIONS — load testify-tdd.sh (which sources common.sh)
# ============================================================================

# testify-tdd.sh has a main block that only runs when $# > 0,
# so sourcing it just loads the functions
source "$SCRIPTS_DIR/testify-tdd.sh"

# ============================================================================
# TDD DETERMINATION — check constitution for TDD requirements
# ============================================================================

CONSTITUTION_FILE="$REPO_ROOT/CONSTITUTION.md"
TDD_DETERMINATION="unknown"
if [[ -f "$CONSTITUTION_FILE" ]]; then
    TDD_DETERMINATION=$(get_tdd_determination "$CONSTITUTION_FILE")
fi

# ============================================================================
# TDD MANDATORY WARNING — when TDD required but testify never run
# ============================================================================

if [[ "$TDD_DETERMINATION" == "mandatory" ]] && [[ -n "$STAGED_CODE_FILES" ]]; then
    # Check if ANY feature directory has .feature files
    ANY_FEATURES=false
    for feat_dir in "$REPO_ROOT"/specs/[0-9][0-9][0-9]-*/; do
        [[ ! -d "$feat_dir" ]] && continue
        if [[ -d "$feat_dir/tests/features" ]]; then
            FCOUNT=$(find "$feat_dir/tests/features" -maxdepth 1 -name "*.feature" -type f 2>/dev/null | wc -l | tr -d ' ')
            [[ "$FCOUNT" -gt 0 ]] && ANY_FEATURES=true && break
        fi
        # Also check legacy test-specs.md
        [[ -f "$feat_dir/tests/test-specs.md" ]] && ANY_FEATURES=true && break
    done

    if ! $ANY_FEATURES; then
        echo "" >&2
        echo "[iikit] WARNING: TDD is mandatory (per CONSTITUTION.md) but no .feature files or test-specs.md found." >&2
        echo "[iikit]   Run /iikit-05-testify before implementing features." >&2
        echo "" >&2
    fi
fi

# ============================================================================
# BDD RUNNER ENFORCEMENT — when .feature files exist, require proper BDD setup
# ============================================================================
# Triggered when code files are staged and specs/NNN/tests/features/*.feature
# files exist in the repo. Committing .feature files alone (testify phase) is
# unaffected — only code/test commits trigger this gate.

if [[ -n "$STAGED_CODE_FILES" ]]; then
    BDD_BLOCKED=false
    BDD_BLOCK_MESSAGES=()

    # Discover all feature directories containing .feature files
    for feat_dir in "$REPO_ROOT"/specs/[0-9][0-9][0-9]-*/; do
        [[ ! -d "$feat_dir" ]] && continue
        FEATURES_DIR="$feat_dir/tests/features"
        [[ ! -d "$FEATURES_DIR" ]] && continue

        # Check that at least one .feature file exists
        FEATURE_COUNT=$(find "$FEATURES_DIR" -maxdepth 1 -name "*.feature" -type f 2>/dev/null | wc -l | tr -d ' ')
        [[ "$FEATURE_COUNT" -eq 0 ]] && continue

        FEAT_NAME=$(basename "$feat_dir")
        PLAN_FILE="$feat_dir/plan.md"

        # ── Gate 1: Step definitions directory must exist with at least one file ──
        STEP_DEFS_DIR="$feat_dir/tests/step_definitions"
        if [[ ! -d "$STEP_DEFS_DIR" ]] || [[ -z "$(ls -A "$STEP_DEFS_DIR" 2>/dev/null)" ]]; then
            BDD_BLOCKED=true
            BDD_BLOCK_MESSAGES+=("BLOCKED: specs/$FEAT_NAME — missing step definitions")
            BDD_BLOCK_MESSAGES+=("  Expected: specs/$FEAT_NAME/tests/step_definitions/ with at least one file")
            BDD_BLOCK_MESSAGES+=("  .feature files exist but no step definitions wire them to code.")
            BDD_BLOCK_MESSAGES+=("  Run /iikit-08-implement to generate step definitions.")
            continue
        fi

        # ── Gate 2: BDD runner dependency present in project dep files ──
        FRAMEWORK_RESULT=$(detect_framework "$PLAN_FILE" 2>/dev/null)
        FRAMEWORK=$(echo "$FRAMEWORK_RESULT" | cut -d'|' -f1)

        if [[ -n "$FRAMEWORK" ]]; then
            if ! DEP_FILE=$(check_bdd_dependency "$FRAMEWORK" "$REPO_ROOT"); then
                BDD_BLOCKED=true
                BDD_BLOCK_MESSAGES+=("BLOCKED: specs/$FEAT_NAME — BDD runner dependency '$FRAMEWORK' not found")
                BDD_BLOCK_MESSAGES+=("  Add '$FRAMEWORK' to your project dependencies.")
                continue
            fi
        fi
        # If framework undetectable, skip gate 2 (can't enforce what we can't identify)

        # ── Gate 3: verify-steps.sh dry-run passes ──
        VERIFY_STEPS="$SCRIPTS_DIR/verify-steps.sh"
        if [[ -x "$VERIFY_STEPS" ]] || [[ -f "$VERIFY_STEPS" ]]; then
            VERIFY_OUTPUT=$(bash "$VERIFY_STEPS" --json "$FEATURES_DIR" "$PLAN_FILE" 2>/dev/null) || true
            VERIFY_STATUS=$(echo "$VERIFY_OUTPUT" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 2>/dev/null) || true

            if [[ "$VERIFY_STATUS" == "BLOCKED" ]]; then
                BDD_BLOCKED=true
                BDD_BLOCK_MESSAGES+=("BLOCKED: specs/$FEAT_NAME — BDD step verification failed (undefined/pending steps)")
                BDD_BLOCK_MESSAGES+=("  Run verify-steps.sh to see which steps need definitions.")
            fi
            # DEGRADED = tool not on PATH; gate 2 already caught the dep file, so just warn
            if [[ "$VERIFY_STATUS" == "DEGRADED" ]]; then
                echo "[iikit] Warning: specs/$FEAT_NAME — BDD dry-run degraded (runner tool not on PATH)" >&2
            fi
        fi
    done

    if [[ "$BDD_BLOCKED" == true ]]; then
        echo "" >&2
        echo "+-------------------------------------------------------------+" >&2
        echo "|  IIKIT PRE-COMMIT: BDD RUNNER ENFORCEMENT FAILED           |" >&2
        echo "+-------------------------------------------------------------+" >&2
        echo "" >&2
        for msg in "${BDD_BLOCK_MESSAGES[@]}"; do
            echo "[iikit] $msg" >&2
        done
        echo "" >&2
        echo "[iikit] .feature files exist — code commits require proper BDD wiring." >&2
        echo "[iikit] To bypass (NOT recommended): git commit --no-verify" >&2
        echo "" >&2
        exit 1
    fi
fi

# ============================================================================
# CONTEXT FILE — read stored hashes (per-feature, derived from test-specs.md path)
# ============================================================================

# ============================================================================
# SLOW PATH — verify staged .feature files and/or test-specs.md
# ============================================================================

BLOCKED=false
BLOCK_MESSAGES=()

# Capture all staged files once for context.json co-staging detection
STAGED_FILES_ALL=$(git diff --cached --name-only 2>/dev/null) || true

# ============================================================================
# .feature file verification (new format)
# Groups staged .feature files by feature directory, computes combined hash
# ============================================================================

if [[ -n "$STAGED_FEATURE_FILES" ]]; then
    # Group staged .feature files by feature directory
    # e.g., specs/001-feature/tests/features/login.feature -> specs/001-feature
    declare -A FEATURE_DIRS_MAP
    while IFS= read -r staged_path; do
        [[ -z "$staged_path" ]] && continue
        # Derive feature dir: specs/NNN/tests/features/x.feature -> specs/NNN
        FEATURES_DIR=$(dirname "$staged_path")                    # tests/features
        TESTS_DIR=$(dirname "$FEATURES_DIR")                      # tests
        FEAT_DIR=$(dirname "$TESTS_DIR")                          # specs/NNN-feature
        FEATURE_DIRS_MAP["$FEAT_DIR"]=1
    done <<< "$STAGED_FEATURE_FILES"

    for FEAT_DIR in "${!FEATURE_DIRS_MAP[@]}"; do
        FEATURES_DIR_ABS="$REPO_ROOT/$FEAT_DIR/tests/features"
        CONTEXT_FILE="$REPO_ROOT/$FEAT_DIR/context.json"
        CONTEXT_REL_PATH="$FEAT_DIR/context.json"

        # Reconstruct the full features directory for hash computation:
        # 1. Start with all committed .feature files from HEAD
        # 2. Overlay any staged changes on top
        # This handles partial staging correctly (only some files staged)
        TEMP_FEATURES_DIR=$(mktemp -d)
        trap "rm -rf $TEMP_FEATURES_DIR" EXIT

        FEATURES_REL="$FEAT_DIR/tests/features"

        # Step 1: Get ALL committed .feature files from HEAD
        COMMITTED_FEATURES=$(git ls-tree --name-only "HEAD:$FEATURES_REL" 2>/dev/null | grep '\.feature$') || true
        while IFS= read -r fname; do
            [[ -z "$fname" ]] && continue
            git show "HEAD:$FEATURES_REL/$fname" > "$TEMP_FEATURES_DIR/$fname" 2>/dev/null || true
        done <<< "$COMMITTED_FEATURES"

        # Step 2: Overlay staged versions (these take precedence over HEAD)
        STAGED_FOR_FEAT=$(echo "$STAGED_FEATURE_FILES" | grep "^$FEAT_DIR/") || true
        while IFS= read -r staged_path; do
            [[ -z "$staged_path" ]] && continue
            BASENAME=$(basename "$staged_path")
            git show ":$staged_path" > "$TEMP_FEATURES_DIR/$BASENAME" 2>/dev/null || continue
        done <<< "$STAGED_FOR_FEAT"

        # Compute combined hash of the full reconstructed directory
        CURRENT_HASH=$(compute_assertion_hash "$TEMP_FEATURES_DIR")
        rm -rf "$TEMP_FEATURES_DIR"

        if [[ "$CURRENT_HASH" == "NO_ASSERTIONS" ]]; then
            continue
        fi

        # Check if context.json is also being staged
        CONTEXT_STAGED=false
        if echo "$STAGED_FILES_ALL" | grep -qF "$CONTEXT_REL_PATH"; then
            CONTEXT_STAGED=true
        fi

        # Read context.json (staged or committed version)
        CONTEXT_STATUS="missing"
        CONTEXT_JSON=""
        if [[ "$CONTEXT_STAGED" == true ]] && [[ -f "$CONTEXT_FILE" ]]; then
            CONTEXT_JSON=$(cat "$CONTEXT_FILE" 2>/dev/null)
        else
            CONTEXT_JSON=$(git show "HEAD:$CONTEXT_REL_PATH" 2>/dev/null) || true
        fi

        if [[ -n "$CONTEXT_JSON" ]] && echo "$CONTEXT_JSON" | jq empty 2>/dev/null; then
            STORED_HASH=$(echo "$CONTEXT_JSON" | jq -r '.testify.assertion_hash // ""' 2>/dev/null || echo "")
            # For .feature files, match by features_dir or presence of file_count
            STORED_DIR=$(echo "$CONTEXT_JSON" | jq -r '.testify.features_dir // ""' 2>/dev/null || echo "")

            if [[ -n "$STORED_HASH" ]] && [[ -n "$STORED_DIR" ]]; then
                if [[ "$STORED_HASH" == "$CURRENT_HASH" ]]; then
                    CONTEXT_STATUS="valid"
                else
                    CONTEXT_STATUS="invalid"
                fi
            fi
        fi

        # Combine results (git notes skipped for directory-based .feature files)
        HASH_STATUS="missing"
        if [[ "$CONTEXT_STAGED" == true ]] && [[ "$CONTEXT_STATUS" == "valid" ]]; then
            HASH_STATUS="valid"
        elif [[ "$CONTEXT_STATUS" == "invalid" ]]; then
            HASH_STATUS="invalid"
        elif [[ "$CONTEXT_STATUS" == "valid" ]]; then
            HASH_STATUS="valid"
        fi

        # Decision logic
        case "$HASH_STATUS" in
            valid)
                ;;
            invalid)
                BLOCKED=true
                BLOCK_MESSAGES+=("BLOCKED: $FEAT_DIR/tests/features/ — .feature assertion integrity check failed")
                BLOCK_MESSAGES+=("  .feature file assertions have been modified since /iikit-05-testify generated them.")
                BLOCK_MESSAGES+=("  Re-run /iikit-05-testify to regenerate .feature files.")
                ;;
            missing)
                if [[ "$TDD_DETERMINATION" == "mandatory" ]]; then
                    echo "[iikit] Warning: $FEAT_DIR/tests/features/ — no stored assertion hash found (TDD is mandatory)" >&2
                    echo "[iikit]   If this is the initial testify commit, this is expected." >&2
                    echo "[iikit]   Otherwise, run /iikit-05-testify to generate integrity hashes." >&2
                fi
                ;;
        esac
    done
fi

# ============================================================================
# Legacy test-specs.md verification (backward compatibility)
# ============================================================================

while IFS= read -r staged_path; do
    [[ -z "$staged_path" ]] && continue

    # Extract staged version to a temp file (check what's being committed)
    TEMP_FILE=$(mktemp)
    trap "rm -f $TEMP_FILE" EXIT

    if ! git show ":$staged_path" > "$TEMP_FILE" 2>/dev/null; then
        rm -f "$TEMP_FILE"
        continue
    fi

    # Compute hash of the staged version
    CURRENT_HASH=$(compute_assertion_hash "$TEMP_FILE")
    rm -f "$TEMP_FILE"

    # Skip if no assertions in the file
    if [[ "$CURRENT_HASH" == "NO_ASSERTIONS" ]]; then
        continue
    fi

    # Derive per-feature context.json path from the test-specs.md path
    FEATURE_DIR=$(dirname "$(dirname "$staged_path")")
    CONTEXT_FILE="$REPO_ROOT/$FEATURE_DIR/context.json"
    CONTEXT_REL_PATH="$FEATURE_DIR/context.json"

    CONTEXT_STAGED=false
    if echo "$STAGED_FILES_ALL" | grep -qF "$CONTEXT_REL_PATH"; then
        CONTEXT_STAGED=true
    fi

    CONTEXT_STATUS="missing"
    CONTEXT_JSON=""
    if [[ "$CONTEXT_STAGED" == true ]] && [[ -f "$CONTEXT_FILE" ]]; then
        CONTEXT_JSON=$(cat "$CONTEXT_FILE" 2>/dev/null)
    else
        CONTEXT_JSON=$(git show "HEAD:$CONTEXT_REL_PATH" 2>/dev/null) || true
    fi

    if [[ -n "$CONTEXT_JSON" ]] && echo "$CONTEXT_JSON" | jq empty 2>/dev/null; then
        STORED_FILE=$(echo "$CONTEXT_JSON" | jq -r '.testify.test_specs_file // ""' 2>/dev/null || echo "")
        STORED_HASH=$(echo "$CONTEXT_JSON" | jq -r '.testify.assertion_hash // ""' 2>/dev/null || echo "")

        if [[ -n "$STORED_HASH" ]]; then
            if [[ "$STORED_FILE" == *"/$staged_path" ]] || [[ "$STORED_FILE" == "$staged_path" ]]; then
                if [[ "$STORED_HASH" == "$CURRENT_HASH" ]]; then
                    CONTEXT_STATUS="valid"
                else
                    CONTEXT_STATUS="invalid"
                fi
            fi
        fi
    fi

    # Check git notes (tamper-resistant)
    NOTE_STATUS="missing"
    GIT_NOTES_REF="refs/notes/testify"
    NOTE_HASH=""
    for commit_sha in $(git rev-list HEAD -50 2>/dev/null); do
        NOTE_CONTENT=$(git notes --ref="$GIT_NOTES_REF" show "$commit_sha" 2>/dev/null) || continue
        if [[ -n "$NOTE_CONTENT" ]]; then
            NOTE_HASH=$(echo "$NOTE_CONTENT" | awk -v path="$staged_path" '
                /^testify-hash:/ { hash = $2 }
                /^test-specs-file:/ {
                    sub(/^test-specs-file:[[:space:]]*/, "")
                    file = $0
                    if (file == path || index(file, "/" path) == length(file) - length("/" path) + 1) {
                        print hash
                        exit
                    }
                }
                /^---$/ { hash = "" }
            ')
            if [[ -n "$NOTE_HASH" ]]; then
                break
            fi
        fi
    done
    if [[ -n "$NOTE_HASH" ]]; then
        if [[ "$NOTE_HASH" == "$CURRENT_HASH" ]]; then
            NOTE_STATUS="valid"
        else
            NOTE_STATUS="invalid"
        fi
    fi

    HASH_STATUS="missing"
    if [[ "$CONTEXT_STAGED" == true ]] && [[ "$CONTEXT_STATUS" == "valid" ]]; then
        HASH_STATUS="valid"
    elif [[ "$NOTE_STATUS" == "invalid" ]] || [[ "$CONTEXT_STATUS" == "invalid" ]]; then
        HASH_STATUS="invalid"
    elif [[ "$NOTE_STATUS" == "valid" ]] || [[ "$CONTEXT_STATUS" == "valid" ]]; then
        HASH_STATUS="valid"
    fi

    case "$HASH_STATUS" in
        valid)
            ;;
        invalid)
            BLOCKED=true
            BLOCK_MESSAGES+=("BLOCKED: $staged_path — assertion integrity check failed")
            BLOCK_MESSAGES+=("  Assertions have been modified since /iikit-05-testify generated them.")
            BLOCK_MESSAGES+=("  Re-run /iikit-05-testify to regenerate test specifications.")
            ;;
        missing)
            if [[ "$TDD_DETERMINATION" == "mandatory" ]]; then
                echo "[iikit] Warning: $staged_path — no stored assertion hash found (TDD is mandatory)" >&2
                echo "[iikit]   If this is the initial testify commit, this is expected." >&2
                echo "[iikit]   Otherwise, run /iikit-05-testify to generate integrity hashes." >&2
            fi
            ;;
    esac
done <<< "$STAGED_TEST_SPECS"

# ============================================================================
# OUTPUT — report results
# ============================================================================

if [[ "$BLOCKED" == true ]]; then
    echo "" >&2
    echo "+-------------------------------------------------------------+" >&2
    echo "|  IIKIT PRE-COMMIT: ASSERTION INTEGRITY CHECK FAILED        |" >&2
    echo "+-------------------------------------------------------------+" >&2
    echo "" >&2
    for msg in "${BLOCK_MESSAGES[@]}"; do
        echo "[iikit] $msg" >&2
    done
    echo "" >&2
    echo "[iikit] To fix: Re-run /iikit-05-testify to regenerate test specs with valid hashes." >&2
    echo "[iikit] To bypass (NOT recommended): git commit --no-verify" >&2
    echo "" >&2
    exit 1
fi

exit 0

Install with Tessl CLI

npx tessl i tessl-labs/intent-integrity-kit

skills

README.md

tile.json