Skip to content

require-qa-ui.sh

.claude/hooks/require-qa-ui.sh

PreToolUse

Gate PR creation on a fresh qa-ui pass for UI diffs (#964). A PR whose diff touches resources/js or resources/css is required to have run qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first. qa-ui/qa-everything write a marker file on completion (see the tail of both SKILL.md files); this hook checks that marker matches the branch + HEAD sha about to be opened as a PR. A docs/backend-only diff is never blocked — it just gets a reminder in the hook's context output. Stand-down branch (#978): a pure visual token/CSS swap on already-storied components is genuinely reviewed by VRT (which captures every storied component/page state per-PR), not by the qa-ui craft checklist — so the gate stands down and allows the PR with a note that VRT is the review surface. A diff qualifies only when EVERY changed file under resources/js|css is an in-place modification (never added / renamed / copied / deleted) that is either a .css file, or an EXISTING .vue whose ONLY fork-point-to-HEAD difference lies inside class / :class attribute VALUES — i.e. blanking those values makes the old and new file byte-identical, so the <script>, the markup structure and the text are all unchanged. Anything else — a new/renamed file, a .ts/.js change, a <script>/markup/text edit — falls through to REQUIRING qa-ui. A false "require" is safe friction; a false "stand down" silently drops review, so the heuristic biases hard to require (a missing perl for the blanking, or any parse it can't do cleanly, lands on require). Registered in settings.json under PreToolUse for mcp__github__create_pull_request. jq parses the hook's stdin JSON.

Source

bash
#!/usr/bin/env bash
#
# PreToolUse hook — gate PR creation on a fresh qa-ui pass for UI diffs (#964).
#
# A PR whose diff touches resources/js or resources/css is required to have run
# qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first.
# qa-ui/qa-everything write a marker file on completion (see the tail of both
# SKILL.md files); this hook checks that marker matches the branch + HEAD sha
# about to be opened as a PR. A docs/backend-only diff is never blocked — it just
# gets a reminder in the hook's context output.
#
# Stand-down branch (#978): a *pure visual token/CSS swap* on already-storied
# components is genuinely reviewed by VRT (which captures every storied
# component/page state per-PR), not by the qa-ui craft checklist — so the gate
# stands down and allows the PR with a note that VRT is the review surface. A
# diff qualifies only when EVERY changed file under resources/js|css is an
# in-place modification (never added / renamed / copied / deleted) that is
# either a `.css` file, or an EXISTING `.vue` whose ONLY fork-point-to-HEAD
# difference lies inside `class` / `:class` attribute VALUES — i.e. blanking
# those values makes the old and new file byte-identical, so the `<script>`,
# the markup structure and the text are all unchanged. Anything else — a
# new/renamed file, a `.ts`/`.js` change, a `<script>`/markup/text edit — falls
# through to REQUIRING qa-ui. A false "require" is safe friction; a false "stand
# down" silently drops review, so the heuristic biases hard to require (a missing
# `perl` for the blanking, or any parse it can't do cleanly, lands on require).
#
# Registered in settings.json under PreToolUse for
# mcp__github__create_pull_request. jq parses the hook's stdin JSON.

set -euo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0

input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"

[ "$tool" = "mcp__github__create_pull_request" ] || exit 0

marker=".claude/.qa-ui-pass"
skip_once=".claude/.qa-ui-skip-once"

# The merge-base of the PR branch and main — the same base `git diff A...B` uses,
# so the file list and blob lookups below all speak of the true fork point.
base="$(git merge-base origin/main HEAD 2>/dev/null || echo origin/main)"

diff_files="$(git diff "$base" HEAD --name-only 2>/dev/null || true)"
touches_ui=0
grep -qE '^(resources/js|resources/css)/' <<<"$diff_files" && touches_ui=1

allow_with_note() {
  jq -n --arg ctx "$1" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
  exit 0
}

deny() {
  jq -n --arg r "$1" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}

if [ "$touches_ui" = 0 ]; then
  allow_with_note "Reminder: this PR's diff is docs/backend-only, so the qa-ui gate does not apply. If it does touch resources/js or resources/css indirectly, run /qa-ui or /qa-everything first."
fi

# Escape hatch — consume the one-shot skip.
if [ -f "$skip_once" ]; then
  rm -f "$skip_once"
  allow_with_note "qa-ui gate skipped via .claude/.qa-ui-skip-once (consumed)."
fi

# Blank the VALUE of every class / :class attribute (single- or multi-line),
# leaving the rest of the file untouched. Comparing the blanked old vs new tells
# us whether the ONLY change was inside class strings. \x27 is a single quote,
# so the whole program stays single-quotable for bash.
strip_classes() {
  perl -0777 -pe 's/(?<![\w-])(:?class=)(["\x27])(.*?)\2/${1}${2}${2}/gs' 2>/dev/null
}

# Is the whole UI diff a pure visual token/CSS swap that VRT already reviews?
# Conservative: any file it can't cleanly classify as pure-visual returns 1.
is_vrt_covered() {
  command -v perl >/dev/null 2>&1 || return 1

  local name_status
  name_status="$(git diff "$base" HEAD --name-status -- resources/js resources/css 2>/dev/null || true)"

  # No UI files at all would be odd (touches_ui was 1) — require, to be safe.
  [ -n "$name_status" ] || return 1

  local status path
  while IFS=$'\t' read -r status path _; do
    [ -n "$status" ] || continue

    # Only an in-place modification can be a pure swap; A/D/R/C/T are structural.
    [ "$status" = "M" ] || return 1

    case "$path" in
      *.css) ;; # a stylesheet change is inherently visual — VRT covers it
      *.vue)
        # Stand down only if blanking class values makes old == new — i.e. the
        # <script>, the markup and the text are all byte-identical.
        if ! diff -q \
          <(git show "$base:$path" 2>/dev/null | strip_classes) \
          <(strip_classes <"$path") >/dev/null 2>&1; then
          return 1
        fi
        ;;
      *) return 1 ;; # .ts / .js / anything else under resources/js is logic
    esac
  done <<<"$name_status"

  return 0
}

if is_vrt_covered; then
  allow_with_note "qa-ui gate stood down (#978): this diff is a pure visual token/CSS swap on existing, already-storied files — no new components, no <script>, markup or text changes. VRT is the review surface for it. Review the VRT report on the PR rather than a qa-ui pass."
fi

branch="$(git branch --show-current)"
head_sha="$(git rev-parse HEAD)"

if [ ! -f "$marker" ]; then
  deny "UI diff without a qa-ui pass — run /qa-ui (or /qa-everything) first. This PR touches resources/js or resources/css, and no ${marker} marker was found."
fi

read -r marker_branch marker_sha < "$marker" || true

if [ "$marker_branch" != "$branch" ] || [ "$marker_sha" != "$head_sha" ]; then
  deny "Stale qa-ui pass — ${marker} was recorded for '${marker_branch:-?}' @ ${marker_sha:-?}, but this PR is '${branch}' @ ${head_sha}. Re-run /qa-ui (or /qa-everything) against the current HEAD first."
fi

exit 0