#!/usr/bin/env bash
#
# no-patches - Agora - unit 001, T-302.
#
# Fails if the package patches its dependencies in any way. A site template may
# not carry patches, nor the machinery to apply them: literal marketplace
# requirement, CLAUDE.md rule 1, and asserted upstream by the starter kit's
# `tests/src/Kernel/RequirementsTest.php`, which this script mirrors locally so
# the answer is available without booting Drupal.
#
# RequirementsTest asserts two of the seven vectors below:
#   - no `cweagans/composer-patches` key in `require`
#   - no `patches` key in `extra`
# The other five are the same prohibition arriving through a different door.
#
# Scope is `composer.json` only, parsed with `jq`. Documentation that discusses
# patching is out of scope by construction, so a file that names a patch plugin
# in prose can never be a finding here (I-018: match the DEFINITION, never the
# mention). There is no allow-list.
#
# Contract:
#   scope:    <exact list of files inspected>
#   scanned:  <N> file(s)    - N must be > 0 or this script exits 1
#   findings: <M>            - each printed as  path:line  reason
#   exit 0 iff M == 0
#
# An empty scope, a missing tool or an unreadable file is a FAILURE, never a
# skip (I-007). Every exclusion is printed on every run; there are none today.
#
#
# House rules for tests/bin/ (T-321(c)): `wc -l` never `grep -c` for a compared
# value; numeric guards defaulted ONLY where zero means FAIL; grep rc >= 2 is an
# error, never "no match"; never `-F` with `-i`. The CANONICAL copy of the block,
# with the reasoning and the I-031 asymmetry spelled out, lives in the header of
# tests/bin/no-ci-allow-dev. Read it before adding a counter here.
#
# Usage: tests/bin/no-patches   (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.

# `CDPATH= cd` is an env-var prefix, not an assignment; shellcheck misreads it.
# The idiom matches tests/bin/gate-a-wave1.sh deliberately.
# shellcheck disable=SC1007
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# shellcheck disable=SC1007
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

C_BAD=""; C_OFF=""
if [ -t 1 ]; then C_BAD=$(printf '\033[31m'); C_OFF=$(printf '\033[0m'); fi

fatal() { printf 'FATAL: %s\n' "$1" >&2; exit 1; }

# ------------------------------------------------------- grep's third state --
# T-316 / I-027. `grep` has THREE exit states, not two: 0 = matched, 1 = no
# match, >= 2 = grep itself FAILED (invalid pattern, unreadable file, crash).
# Inferring "no findings" from "no stdout" turns every grep error into a green
# run. The single grep in this script is a LOCATOR, not a scanner - jq decides
# the findings - but a locator that silently fails still hides a broken tool,
# so its exit status is read too, and it runs inside a command substitution
# where `exit` ends only the subshell. Hence the marker file, honoured by
# assert_grep_ok() before anything is summarised.
grep_failed() { # <rc> <pattern> <subject>
  printf 'FATAL: grep failed (exit %s) on %s while matching: %s\n' "$1" "$3" "$2" >&2
  printf 'grep exit %s on %s while matching: %s\n' "$1" "$3" "$2" >> "$GREP_ERR_FILE"
  exit 1
}

assert_grep_ok() {
  [ -s "$GREP_ERR_FILE" ] || return 0
  printf '\nFATAL: the scan did not complete - grep failed:\n' >&2
  sed 's/^/  /' "$GREP_ERR_FILE" >&2
  printf 'Nothing was scanned: this run has NO result, which is not a clean result. (T-316 / I-027)\n' >&2
  exit 1
}

# ---------------------------------------------------------------- preflight --
command -v jq >/dev/null 2>&1 || fatal "required tool not found: jq"

MANIFEST="composer.json"
[ -f "$MANIFEST" ] || fatal "$MANIFEST not found in $REPO_ROOT (empty scope)"
[ -r "$MANIFEST" ] || fatal "$MANIFEST is not readable"
jq -e . "$MANIFEST" >/dev/null 2>&1 || fatal "$MANIFEST is not valid JSON"

FINDINGS_FILE=$(mktemp -t no-patches.XXXXXX) || fatal "cannot create a temp file"
GREP_ERR_FILE=$(mktemp -t no-patches-grep.XXXXXX) || fatal "cannot create a temp file"
trap 'rm -f "$FINDINGS_FILE" "$GREP_ERR_FILE"' EXIT

SCANNED=1
VECTORS=0

# Findings are appended to a file and counted from it at report time, never
# accumulated in a variable that a subshell could silently drop. One finding is
# always exactly one line.
finding() { # <file> <line> <reason>
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "$1" "$2" "$C_OFF" "$3" >> "$FINDINGS_FILE"
}

count_findings() {
  wc -l < "$FINDINGS_FILE" | tr -d '[:space:]'
}

# jq finds the offender, grep -n locates it: jq does not report line numbers.
# grep is NOT piped straight into `head`: the pipeline's exit status would be
# `head`'s, and grep's own status is precisely what has to be read.
line_of() { # <string> <file>
  _out=$(grep -n -F -- "$1" "$2" 2>/dev/null)
  _rc=$?
  # rc 1 = not located, which is the documented `0` answer below. rc >= 2 = grep
  # broke, and a fabricated line number would bury that.
  [ "$_rc" -ge 2 ] && grep_failed "$_rc" "$1" "$2"
  _n=$(printf '%s\n' "$_out" | head -1 | cut -d: -f1)
  if [ -n "${_n:-}" ]; then printf '%s' "$_n"; else printf '0'; fi
}

# ------------------------------------------------- vectors 1-3: extra keys ---
# `extra.patches`, `extra.patches-file` and `extra.enable-patching` are the
# three ways cweagans/composer-patches and its forks are configured.
for key in patches patches-file enable-patching; do
  VECTORS=$((VECTORS + 1))
  if jq -e --arg k "$key" '(.extra // {}) | has($k)' "$MANIFEST" >/dev/null 2>&1; then
    finding "$MANIFEST" "$(line_of "\"$key\"" "$MANIFEST")" \
      "extra.$key is declared; a site template must not patch its dependencies"
  fi
done

# ------------------------------- vectors 4-6: patch plugins as dependencies --
# Matched as an exact `require` / `require-dev` KEY. A package whose name merely
# contains the string is not a finding; a declared dependency on the plugin is.
for plugin in cweagans/composer-patches vaimo/composer-patches szeidler/composer-patch-repository; do
  VECTORS=$((VECTORS + 1))
  if jq -e --arg p "$plugin" '((.require // {}) + (.["require-dev"] // {})) | has($p)' "$MANIFEST" >/dev/null 2>&1; then
    finding "$MANIFEST" "$(line_of "\"$plugin\"" "$MANIFEST")" \
      "$plugin is declared as a dependency; a site template must not depend on a patching plugin"
  fi
done

# ---------------------------------- vector 7: allow-plugins for a patcher ----
# A patch plugin can only run if it is allowed, so an allowed patcher is a
# finding even before the plugin appears in `require` (it may arrive
# transitively). Substring match on the allow-plugins KEY covers the forks.
VECTORS=$((VECTORS + 1))
ALLOWED=$(jq -r '(.config // {})["allow-plugins"] // {} | keys[]?' "$MANIFEST" 2>/dev/null)
if [ -n "$ALLOWED" ]; then
  while IFS= read -r entry; do
    [ -n "$entry" ] || continue
    case "$entry" in
      *composer-patches*)
        finding "$MANIFEST" "$(line_of "\"$entry\"" "$MANIFEST")" \
          "config.allow-plugins allows the patching plugin $entry"
        ;;
    esac
  done <<ALLOWLIST
$ALLOWED
ALLOWLIST
fi

# ------------------------------------------------------------------ report --
# Before any count is printed: a grep that failed inside a subshell left its
# reason here, and `findings: 0` from a scan that did not run is a lie.
assert_grep_ok

printf '\nno-patches - patching of dependencies in the Composer manifest\n\n'
printf 'scope:      %s\n' "$MANIFEST"
printf 'scanned:    %s file(s)\n' "$SCANNED"
printf 'keys:       extra.patches, extra.patches-file, extra.enable-patching, require+require-dev keys, config.allow-plugins keys\n'
printf 'patch vectors checked: %s\n' "$VECTORS"
printf 'exclusions: none - prose about patching is out of scope, not excluded (see the header)\n'

# T-321(b): defaulted because zero means FAIL here - the safe direction of
# I-031. Do not copy the `:-0` to a guard where zero means PASS.
if [ "${SCANNED:-0}" -eq 0 ]; then
  printf 'findings:   n/a\n'
  printf '\nFAILURE: 0 files scanned - an empty scope proves nothing (I-007).\n'
  exit 1
fi

M=$(count_findings)
printf 'findings:   %s\n' "$M"

if [ "$M" -gt 0 ]; then
  printf '\n'
  cat "$FINDINGS_FILE"
  printf '\nFAILURE: %s patching finding(s).\n' "$M"
  exit 1
fi

printf '\nOK: no patch declaration, no patching plugin, no patching plugin allowed.\n'
exit 0
