#!/usr/bin/env bash
#
# no-blind-phpunit - Agora - unit 001, task T-214(b).
#
# What it enforces, in one sentence covering both halves: THE PHPUNIT GATE IS
# ARMED HERE, AND ITS ARMING IS DECIDED IN THIS REPOSITORY AND NOWHERE ELSE.
#
#   1. every PHPUnit invocation in CI carries --fail-on-empty-test-suite, and
#      nothing quietly disarms it;
#   2. every variable in PINNED_VARS is DEFINED, by us, with the value this
#      gate needs - NOT defining it leaves the gate's behaviour to somebody
#      else's default (moved here 2026-09-19, T-1903; see that section's own
#      comment for the failure that produced it).
#
# HALF 2 ARRIVED FROM tests/bin/no-ci-allow-dev, where T-1701 had parked it
# under a constraint that does not exist. That script's subject is a variable
# that must never be DEFINED; this script's subject is the phpunit gate, which
# is exactly what the pinned variable decides. The move is D-028's ordinary
# "one manifest to regenerate whenever a shared script legitimately changes",
# not a forbidden edit - the misreading that blocked it for six days is
# corrected in that script's header, in CLAUDE.md and in the task row.
#
# Why this exists: nine of ten green CI runs executed ZERO tests and nobody
# noticed for two days, because nothing ever checked that the test run had
# done anything (I-007, in the one place it had not yet been applied). A
# parallel lane (T-214a) restores execution in
# .github/workflows/phpunit.yml; this script makes the absence of the guard
# LOUD, permanently, so the regression cannot silently return.
#
# An "invocation" is a line that either:
#   - runs phpunit as a command: `phpunit`, `bin/phpunit`, `vendor/bin/phpunit`
#     or `$COMPOSER_BIN_DIR/phpunit`, as a word (not a YAML job key like
#     `phpunit:`, which is never followed by whitespace or end of line); or
#   - assigns `_PHPUNIT_EXTRA` (drupalci's own file never spells "phpunit" as
#     a bare command - it hands flags to the upstream job through this
#     variable instead).
#
# Five findings:
#   1. an invocation with no --fail-on-empty-test-suite on its own line;
#   2. any occurrence of --do-not-fail-on-empty-test-suite anywhere in scope
#      (the literal negation of the guard);
#   3. _PHPUNIT_CONCURRENT set to '1';
#   4. a PINNED_VARS variable ABSENT from the file that must pin it;
#   5. a PINNED_VARS variable present carrying a value this gate does not want.
#
# FINDINGS 3, 4 AND 5 ARE THREE CHECKS AND THREE MESSAGES, NEVER ONE. Finding
# 3 fires on a value somebody WROTE. Finding 4 fires on a value nobody wrote -
# the state that actually happened (I-116), and the one finding 3 is
# structurally blind to, because there is no line for it to match. Finding 5
# names the value found and the value required. They also cover different
# ground: 3 sweeps the whole scope, .github/workflows/*.yml included, while 4
# and 5 interrogate the one named file that must carry the pin. One message
# for two causes is a message that stops being read.
#
# Finding 3 is not obvious and is a real trap: on drupalcode,
# `_PHPUNIT_EXTRA` is handed to the `phpunit` binary only while
# `_PHPUNIT_CONCURRENT` is '0'. This sentence used to add "(its default)", and
# that parenthesis is DELETED rather than reworded: upstream's
# include.drupalci.variables.yml on main reads `value: '1'` as of 2026-09-19,
# so '0' is not the default and has not been since roughly 2026-09-08. Finding
# 4 below exists because of that flip, and a comment still calling '0' the
# default would have argued against the check three screens down from it. Set
# `_PHPUNIT_CONCURRENT` to '1' and `_PHPUNIT_EXTRA`
# goes to `run-tests.sh` instead, where --fail-on-empty-test-suite is not a
# valid option - the guard silently stops existing. Someone enabling
# concurrency for speed would remove the guard without ever touching the
# line that sets it.
#
# Contract (shared by every invariant in tests/bin/, see no-ci-allow-dev):
#   scope:    the exact set of files inspected
#   scanned:  N files, N must be > 0
#   required: N pinned variables demanded, and N must be > 0
#   correctly pinned: N of those found carrying the required value
#   findings: M, each printed as  path:line  reason
#   exit 0 if and only if M == 0
#
# TWO conditions are FATAL here, not ordinary findings (I-028 / I-031):
# `files scanned == 0` and `phpunit invocations == 0`. A soft "0 findings"
# over an empty scope would look identical to a clean run over a real one -
# exactly the false-green shape this script exists to prevent. Both guards
# default their input (`[ "${X:-0}" -eq 0 ]`, because a blank left-hand side
# makes `[` exit 2 with "integer expression expected" and the branch is
# simply not taken - I-031) and print NO summary line when they fire: the
# closing metrics line is a claim about a scan that happened, and a FATAL
# means it didn't.
#
# The scan reads the WORKING TREE with `find`, never `git grep` (I-018):
# `git grep` only sees tracked files, so an offender written and never
# `git add`ed would pass invisibly.
#
# House rules paid for the hard way (T-321 has now swept the older scripts for
# these; this one was born clean). The CANONICAL copy of the block lives in the
# header of tests/bin/no-ci-allow-dev - this restatement is kept, not replaced by
# a pointer, because it is the worked example the canonical block was derived
# from. Keep the two in step:
#   - `wc -l`, never `grep -c`, for any value that feeds a numeric
#     comparison. `grep -c` prints blank on rc >= 2, and `[ "" -eq 0 ]` exits
#     2 ("integer expression expected") - the branch is not taken and
#     execution walks past the guard (I-031).
#   - a `grep` rc >= 2 is an ERROR, never "no match" (I-027). Every scanning
#     site here treats it as fatal, naming the file and the pattern.
#   - no `-F` combined with `-i` in one grep (GNU grep 3.0 on this host
#     SIGABRTs, rc 134). Every pattern below is matched case-sensitively
#     without `-i`, so the combination never arises.
#
# Usage: tests/bin/no-blind-phpunit   (run from anywhere; it cd's to the repo root)

set -u
# No `set -e`: a finding must be recorded and counted, not abort the run.

# ---------------------------------------------------------------- repo root --
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

C_OK=""; C_BAD=""; C_OFF=""; C_DIM=""
if [ -t 1 ]; then
  C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_OFF=$'\033[0m'; C_DIM=$'\033[2m'
fi

FINDINGS=0

finding() { # <file> <line> <reason>
  FINDINGS=$((FINDINGS + 1))
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "${1#./}" "$2" "$C_OFF" "$3"
}

note() { printf '  %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; }

# ------------------------------------------------------- grep's third state --
# I-027: `grep` has THREE exit states - 0 = matched, 1 = no match, >= 2 =
# grep itself FAILED (invalid pattern, unreadable file, crash). A grep error
# is FATAL here: never a warning, never a finding. A finding says "the tree
# contains an offender"; this says "the tree was not searched".
grep_failed() { # <rc> <pattern> <subject>
  printf 'FATAL: grep failed (exit %s) on %s while matching: %s\n' "$1" "$3" "$2"
  printf 'The scan did not complete: this run has NO result, which is not a clean result.\n'
  printf '(I-027)\n'
  exit 1
}

# ---------------------------------------------------------------- patterns --
# Command invocation: an optional path prefix, then "phpunit" as a whole
# word - preceded by start-of-line or whitespace, followed by whitespace or
# end-of-line. The "followed by" half is what excludes the YAML job key
# `phpunit:`, where the very next character is a colon.
#
# ⚠️ A SHARP EDGE, DOCUMENTED RATHER THAN FILED DOWN (2026-08-27, D-040(2)).
# This pattern cannot tell a COMMAND from a REFERENCE to a job, so two lines
# that are perfectly correct CI are reported as unguarded invocations:
#
#     extends: phpunit          # a reference to the upstream job
#   phpunit (PostgreSQL):       # a job NAME in upstream's own variant style
#
# In the first, `phpunit` is preceded by a space and followed by end-of-line;
# in the second it is followed by a space. Both satisfy the ERE. This cost an
# implementer an hour before it was understood, so it is written here instead
# of being rediscovered in a pipeline.
#
# THE WORKAROUND, and it is not a compromise: write `extends: .phpunit-base`
# and name the variant job `phpunit-pgsql`. `.phpunit-base` is what upstream's
# own `phpunit:` job extends, so the inheritance is byte-for-byte the same
# thing; the hyphenated job name is only a name.
#
# WHY THE PATTERN IS NOT LOOSENED TO EXCLUDE `extends:`. The guard exists
# because a suite that executed 0 tests passed as green TWICE (I-007, I-032),
# and every character removed from it is a character a real unguarded
# invocation could hide behind - `extends: phpunit --do-not-...` is not a
# line anyone would write, but the exclusion would also swallow shapes nobody
# has thought of yet. A false positive here costs an hour and is loud; a false
# negative costs a release and is silent. The asymmetry decides it. A
# documented sharp edge beats a loosened guard.
CMD_PATTERN='(^|[[:space:]])(\$COMPOSER_BIN_DIR/|vendor/bin/|bin/)?phpunit([[:space:]]|$)'
# `_PHPUNIT_EXTRA` as a YAML mapping entry or shell assignment.
EXTRA_PATTERN='_PHPUNIT_EXTRA[[:space:]]*[:=]'
# The guard itself, and its literal negation.
FAIL_FLAG='--fail-on-empty-test-suite'
DO_NOT_FAIL_PATTERN='--do-not-fail-on-empty-test-suite'
# `_PHPUNIT_CONCURRENT` as a YAML mapping entry or shell assignment - matched
# first, then its value is read back out and compared in the shell (not in
# the regex): an ERE like `...=.?1.?` would also fire on `'10'`, which is a
# different value entirely.
CONCURRENT_KEY_PATTERN='_PHPUNIT_CONCURRENT[[:space:]]*[:=]'

# -------------------------------------------------------------------- scope --
# Exactly the two places drupalci-driven PHPUnit runs are configured in this
# repository. Not a tree walk: a narrower, named scope, because both files
# are known by path and a broader walk would only invite false positives
# from unrelated YAML.
scan_files() {
  find . \( -path './.gitlab-ci.yml' -o -path './.github/workflows/*.yml' \) \
    -type f -print 2>/dev/null | LC_ALL=C sort
}

# ---------------------------------------------------------------- preflight --
for tool in find grep sort wc sed; do
  command -v "$tool" >/dev/null 2>&1 || \
    finding '<preflight>' '0' "required tool not available: $tool"
done

printf '=========================================================================================================\n'
printf 'no-blind-phpunit - Agora invariant T-214(b)\n'
printf 'repo: %s\n' "$REPO_ROOT"
printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '=========================================================================================================\n'

TMPDIR_RUN=$(mktemp -d 2>/dev/null) || { echo "FATAL: cannot create a temp dir"; exit 1; }
trap 'rm -rf "$TMPDIR_RUN"' EXIT
ALL_LIST="$TMPDIR_RUN/all"
scan_files > "$ALL_LIST"
SCANNED=$(wc -l < "$ALL_LIST" | tr -d ' ')

printf '\nscope:    .gitlab-ci.yml and .github/workflows/*.yml, working tree of %s\n' "$REPO_ROOT"
printf 'scanned:  %s files\n' "${SCANNED:-0}"

if [ "${SCANNED:-0}" -eq 0 ]; then
  printf '\nFATAL: files scanned: 0 - neither .gitlab-ci.yml nor .github/workflows/*.yml was found\n'
  printf 'in the working tree. This run has NO result: the scope did not reach what it\n'
  printf 'claims to police. (I-028 / I-031)\n'
  exit 1
fi

# --------------------------------------------------------------- the scan ---
printf '\ninvocations: phpunit commands and _PHPUNIT_EXTRA assignments\n'

INVOCATIONS=0
GUARDED=0
UNGUARDED=0

while IFS= read -r f; do
  [ -n "$f" ] || continue

  # --- invocation lines: phpunit command OR _PHPUNIT_EXTRA assignment ---
  # A whole-line YAML comment can never be a live invocation or assignment,
  # so it is filtered out here - otherwise prose that merely NAMES phpunit
  # (this very file's own header comment does, to explain the guard) would
  # be miscounted as code. `--do-not-fail-on-empty-test-suite` and
  # `_PHPUNIT_CONCURRENT` below are intentionally NOT filtered this way: the
  # spec asks for "any occurrence", a stricter, paranoid check.
  CMD_HITS_RAW=$(LC_ALL=C grep -nE "$CMD_PATTERN" "$f" 2>/dev/null)
  CMD_RC=$?
  [ "$CMD_RC" -ge 2 ] && grep_failed "$CMD_RC" "$CMD_PATTERN" "$f"
  CMD_HITS=$(printf '%s\n' "$CMD_HITS_RAW" | grep -vE '^[0-9]+:[[:space:]]*#' 2>/dev/null)
  CMD_FILT_RC=$?
  [ "$CMD_FILT_RC" -ge 2 ] && grep_failed "$CMD_FILT_RC" 'comment-line filter' "$f"

  EXTRA_HITS_RAW=$(LC_ALL=C grep -nE "$EXTRA_PATTERN" "$f" 2>/dev/null)
  EXTRA_RC=$?
  [ "$EXTRA_RC" -ge 2 ] && grep_failed "$EXTRA_RC" "$EXTRA_PATTERN" "$f"
  EXTRA_HITS=$(printf '%s\n' "$EXTRA_HITS_RAW" | grep -vE '^[0-9]+:[[:space:]]*#' 2>/dev/null)
  EXTRA_FILT_RC=$?
  [ "$EXTRA_FILT_RC" -ge 2 ] && grep_failed "$EXTRA_FILT_RC" 'comment-line filter' "$f"

  INV_LINES=$(printf '%s\n%s\n' "$CMD_HITS" "$EXTRA_HITS" | cut -d: -f1 | grep -E '^[0-9]+$' | LC_ALL=C sort -nu)

  while IFS= read -r ln; do
    [ -n "$ln" ] || continue
    INVOCATIONS=$((INVOCATIONS + 1))
    LINE_TEXT=$(sed -n "${ln}p" "$f")
    LC_ALL=C grep -qE -- "$FAIL_FLAG" <<< "$LINE_TEXT" 2>/dev/null
    FLAG_RC=$?
    [ "$FLAG_RC" -ge 2 ] && grep_failed "$FLAG_RC" "$FAIL_FLAG" "$f (line $ln)"
    if [ "$FLAG_RC" -eq 0 ]; then
      GUARDED=$((GUARDED + 1))
      note "guarded:   ${f#./}:$ln"
    else
      UNGUARDED=$((UNGUARDED + 1))
      finding "$f" "$ln" 'phpunit invocation with no --fail-on-empty-test-suite'
    fi
  done <<< "$INV_LINES"

  # --- finding 2: the negation, anywhere in the file ---
  NEG_HITS=$(LC_ALL=C grep -nE -- "$DO_NOT_FAIL_PATTERN" "$f" 2>/dev/null)
  NEG_RC=$?
  [ "$NEG_RC" -ge 2 ] && grep_failed "$NEG_RC" "$DO_NOT_FAIL_PATTERN" "$f"
  while IFS= read -r rec; do
    [ -n "$rec" ] || continue
    finding "$f" "${rec%%:*}" "$DO_NOT_FAIL_PATTERN found - this negates the guard"
  done <<< "$NEG_HITS"

  # --- finding 3: _PHPUNIT_CONCURRENT set to '1' ---
  CONC_HITS=$(LC_ALL=C grep -nE "$CONCURRENT_KEY_PATTERN" "$f" 2>/dev/null)
  CONC_RC=$?
  [ "$CONC_RC" -ge 2 ] && grep_failed "$CONC_RC" "$CONCURRENT_KEY_PATTERN" "$f"
  while IFS= read -r rec; do
    [ -n "$rec" ] || continue
    ln="${rec%%:*}"
    txt="${rec#*:}"
    rhs=$(printf '%s' "$txt" | sed -E 's/^.*_PHPUNIT_CONCURRENT[[:space:]]*[:=][[:space:]]*//; s/[[:space:]]*#.*$//; s/[[:space:]]+$//')
    rhs=$(printf '%s' "$rhs" | sed -E "s/^['\"]//; s/['\"]\$//")
    if [ "$rhs" = "1" ]; then
      finding "$f" "$ln" \
        "_PHPUNIT_CONCURRENT: '1' routes _PHPUNIT_EXTRA to run-tests.sh instead of phpunit, where --fail-on-empty-test-suite is not a valid option - the guard silently stops existing (include.drupalci.variables.yml:107-109)"
    fi
  done <<< "$CONC_HITS"
done < "$ALL_LIST"

if [ "${INVOCATIONS:-0}" -eq 0 ]; then
  printf '\nFATAL: phpunit invocations: 0 - scanned %s file(s) but found no phpunit command\n' "${SCANNED:-0}"
  printf 'and no _PHPUNIT_EXTRA assignment. This run has NO result: the scan did not\n'
  printf 'reach what it claims to police. (I-028 / I-031)\n'
  exit 1
fi

# ---------------------------------------------------- pinned gate variables --
# Half 2 of this script's subject - see the header. The flag checked above must
# be PRESENT in our files; the variables here must be PINNED in them, by us.
#
# WHY THIS EXISTS. It is a measured failure, not a precaution. Between
# 2026-09-08 and 2026-09-12 upstream gitlab_templates flipped
# _PHPUNIT_CONCURRENT's default to concurrent. Nobody changed a character in
# this repository. At that setting drupalci hands _PHPUNIT_EXTRA to
# run-tests.sh instead of the phpunit binary and strips every phpunit-only
# option out of it, so --fail-on-empty-test-suite reached NOTHING: pipeline
# 952632 printed "OK (20 tests, 2549 assertions)"; 958565 and every pipeline
# until the pin printed no OK(...) line, no assertion total, and upstream's own
# warning that _PHPUNIT_EXTRA "is intended for the 'phpunit' binary only".
#
# AND THIS SCRIPT WAS GREEN THROUGHOUT - 2 invocations, 2 guarded, 0 findings -
# because it asserted that the FLAG APPEARS in our files, which it did. The
# flag's EFFECT lived in an environment this scope cannot reach. It had even
# anticipated the trap and guarded the wrong shape of it: finding 3 fires when
# _PHPUNIT_CONCURRENT is set to the concurrent value, which nobody ever wrote,
# and never when the variable is simply ABSENT. Asserting that a setting exists
# while the behaviour it buys depends on a default somebody else owns is being
# green about a question never asked (I-116).
#
# ENTRY CRITERION, so this list does not become a junk drawer: a variable
# belongs here when its DEFAULT is owned by somebody else AND that default
# decides whether one of our gates is real. _PHPUNIT_EXTRA does NOT qualify -
# its upstream default is empty, an empty value disarms nothing silently, and
# the scan above already checks the flag we set. _PHPUNIT_CONCURRENT does: its
# default IS a behaviour, and that behaviour moved underneath us.
#
# Entries are `file|variable|required value`, one per line. The list's LENGTH
# is printed and asserted below, for the reason G7, G13 and G14 print their
# deny-term counts: a require-list that has been emptied finds nothing, prints
# the same "0 findings" a clean tree prints, and passes by construction
# (I-028).
PINNED_VARS='.gitlab-ci.yml|_PHPUNIT_CONCURRENT|0'

printf '\npinned gate variables: a value a gate depends on, set HERE and not inherited\n'

REQUIRED=0
PINNED_OK=0
while IFS='|' read -r pv_file pv_name pv_want; do
  [ -n "$pv_file" ] || continue
  REQUIRED=$((REQUIRED + 1))

  if [ ! -r "$pv_file" ]; then
    finding "$pv_file" '0' \
      "must pin $pv_name, but the file is not readable - the check did not run"
    continue
  fi

  # A YAML mapping entry: optional indent, the name, optional space, a colon.
  # A whole-line comment can never match, because `#` would precede the name -
  # so no comment filter is needed here, unlike the scans above.
  PV_HITS=$(LC_ALL=C grep -nE "^[[:space:]]*${pv_name}[[:space:]]*:" "$pv_file" 2>/dev/null)
  PV_RC=$?
  [ "$PV_RC" -ge 2 ] && \
    grep_failed "$PV_RC" "^[[:space:]]*${pv_name}[[:space:]]*:" "$pv_file"

  # House rule 1: `wc -l`, never `grep -c`, for a value that feeds a
  # comparison. A blank left-hand side makes `[` exit 2 and the guard below
  # would simply not be taken (I-031).
  PV_N=$(printf '%s\n' "$PV_HITS" | grep -E '^[0-9]+:' | wc -l | tr -d ' ')

  # Defaulted because zero means FAIL here - the safe direction of I-031.
  if [ "${PV_N:-0}" -eq 0 ]; then
    finding "$pv_file" '0' \
      "$pv_name is NOT pinned here - its value is whatever upstream defaults to, and upstream can change that without touching this repository (T-1701 / I-116)"
    continue
  fi

  PV_LINE=${PV_HITS%%$'\n'*}
  PV_LN=${PV_LINE%%:*}
  PV_RHS=$(printf '%s' "$PV_LINE" | sed -E \
    "s/^[0-9]+:[[:space:]]*${pv_name}[[:space:]]*:[[:space:]]*//; s/[[:space:]]*#.*\$//; s/[[:space:]]+\$//; s/^['\"]//; s/['\"]\$//")

  if [ "$PV_RHS" = "$pv_want" ]; then
    PINNED_OK=$((PINNED_OK + 1))
    note "pinned:    ${pv_file}:${PV_LN}  ${pv_name} = ${PV_RHS}"
  else
    finding "$pv_file" "$PV_LN" \
      "$pv_name is '$PV_RHS' and this gate requires '$pv_want' (T-1701 / I-116)"
  fi
done <<< "$(printf '%s\n' "$PINNED_VARS" | grep -vE '^[[:space:]]*$')"

# I-028 applied to this section's own denominator: an emptied require-list
# would examine nothing and report nothing, which reads exactly like a pass.
# Defaulted because zero means FAIL here.
if [ "${REQUIRED:-0}" -eq 0 ]; then
  finding '<pinned>' '0' \
    'the required-variable list is EMPTY - this section proves nothing (I-028)'
fi
printf '  required: %s - correctly pinned: %s\n' "$REQUIRED" "$PINNED_OK"

# ----------------------------------------------------------------- summary ---
printf '\n---------------------------------------------------------------------------------------------------------\n'
printf 'scope: .gitlab-ci.yml, .github/workflows/*.yml (working tree) · files scanned: %s · phpunit invocations: %s · guarded: %s · unguarded: %s · pinned required: %s · correctly pinned: %s · findings: %s\n' \
  "$SCANNED" "$INVOCATIONS" "$GUARDED" "$UNGUARDED" "$REQUIRED" "$PINNED_OK" "$FINDINGS"

if [ "$FINDINGS" -eq 0 ]; then
  printf '%sno-blind-phpunit: clean%s\n' "$C_OK" "$C_OFF"
  exit 0
fi
printf '%sno-blind-phpunit: %s finding(s)%s\n' "$C_BAD" "$FINDINGS" "$C_OFF"
exit 1
