diff --git a/.github/workflows/hydra-gates-package.yml b/.github/workflows/hydra-gates-package.yml index f83df319..f9e73a5e 100644 --- a/.github/workflows/hydra-gates-package.yml +++ b/.github/workflows/hydra-gates-package.yml @@ -366,19 +366,34 @@ jobs: || { echo "::error::${_na} not-applicable + ${_unrun} unrun gates are counted, but only ${_named} said so on their own line. A gate is disappearing from the per-gate output while still being tallied."; exit 1; } echo "OK — exit 0, coverage stated, waivers stated, and ran=${_ran} + na=${_na} + unrun=${_unrun} = declared=${_decl}, each named." - - name: "Unresolvable base ref must exit 99 with no green" + # WHY THIS STEP NOW NAMES ITS SCOPE. + # + # Until ADR-020 was superseded, --scope-to-diff was the DEFAULT, so + # "unresolvable base" and "no scope at all" were the same condition and + # this step could leave the scope unstated. They are now two different + # conditions and they have two different correct answers, so the step + # that asserts the first must say which one it is testing. + # + # This is the diff-scoped half, and it is UNCHANGED: with + # --scope-to-diff, the base IS the scope, so an unresolvable base leaves + # nothing to inspect and refusing is the only honest verdict. Every + # assertion below is byte-for-byte the one that guarded this before the + # default moved; only the invocation names the mode it was always about. + # Verified against the fixture: exit 99, the "NOTHING WAS CHECKED" line, + # zero `[gate-N]` lines. + - name: "Unresolvable base ref, diff scope: must exit 99 with no green" run: | set -eu cd /work/fixture set +e - OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture 2>&1)" + OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture --scope-to-diff 2>&1)" RC=$? set -e printf '%s\n' "${OUT}" echo "--- assertions ---" [ "${RC}" -eq 99 ] \ - || { echo "::error::expected exit 99 for an unresolvable base, got ${RC}"; exit 1; } + || { echo "::error::expected exit 99 for an unresolvable base under --scope-to-diff, got ${RC}"; exit 1; } printf '%s' "${OUT}" | grep -q "NOTHING WAS CHECKED" \ || { echo "::error::did not state that nothing was checked"; exit 1; } printf '%s' "${OUT}" | grep -qiE "ALL .* (GATES )?(PASSED|GREEN)" \ @@ -386,3 +401,45 @@ jobs: printf '%s' "${OUT}" | grep -qE '^\[gate-[0-9]+\]' \ && { echo "::error::gate lines were emitted for a run that could not be scoped"; exit 1; } echo "OK — exit 99, no green, no gate lines." + + # THE FULL-SCOPE HALF — the new default, and the harder one to get right. + # + # Here the base is NOT the scope. The whole tracked tree is, and it is + # readable whatever the caller typed, so 59 gates have a real verdict and + # refusing would throw those away to punish a bad input. The five DELTA + # gates — 16, 29, 47, 48, 61 — are the ones that lose their subject. + # + # So the property this step defends is not "the run failed". It is: THE + # FIVE MUST NOT PASS, AND THE RUN MUST SAY THE BASE WAS UNUSABLE. A green + # here is legitimate and is a green over 59 gates, not over 64 — which is + # precisely the distinction the COVERAGE accounting exists to carry. + # + # Note the last assertion is the one with teeth: it fails if any of the + # five prints PASS. Without it, a future change that quietly restored a + # confident PASS-over-an-empty-diff for those gates would satisfy every + # other clause here. + - name: "Unresolvable base ref, full scope: the five delta gates must not pass" + run: | + set -eu + cd /work/fixture + set +e + OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture 2>&1)" + RC=$? + set -e + printf '%s\n' "${OUT}" + + echo "--- assertions ---" + printf '%s' "${OUT}" | grep -q "does not resolve" \ + || { echo "::error::the run did not say the named base was unusable — a typo'd base must never pass silently"; exit 1; } + printf '%s' "${OUT}" | grep -qE '^\[gate-[0-9]+\]' \ + || { echo "::error::no gate reported at all — full scope must still read the whole tree when the base is unusable"; exit 1; } + _bad="" + for g in 16 29 47 48 61; do + printf '%s\n' "${OUT}" | grep -qE "^\[gate-${g}\] [a-z0-9-]+: (NOT APPLICABLE|SKIPPED)" \ + || _bad="${_bad} ${g}" + printf '%s\n' "${OUT}" | grep -qE "^\[gate-${g}\] [a-z0-9-]+: PASS" \ + && { echo "::error::gate-${g} is a DELTA gate and PASSED with no usable base — it passed over an empty diff, which is the defect this whole contract exists to prevent"; exit 1; } + done + [ -z "${_bad}" ] \ + || { echo "::error::delta gate(s)${_bad} did not report NOT APPLICABLE by name with no usable base"; exit 1; } + echo "OK — base reported unusable, the tree was still read, and gates 16/29/47/48/61 each declined by name." diff --git a/hydra-gates/ADR-020-SUPERSEDED.md b/hydra-gates/ADR-020-SUPERSEDED.md new file mode 100644 index 00000000..7efb3d76 --- /dev/null +++ b/hydra-gates/ADR-020-SUPERSEDED.md @@ -0,0 +1,133 @@ + + +# ADR-020 is superseded: the gates scan the whole tree by default + +**Status:** accepted · **Date:** 2026-08-12 · **Decided by:** Ruben van der Linde +**Supersedes:** ADR-020 (diff-scoped gates) · **Applies to:** all 18 fleet apps at once +**Canonical ADR home:** the numbered ADR registry lives in `hydra/openspec/`, not in +this repository. This file is the decision record for the **gate package**, and it is +what `run-hydra-gates.sh` and `bin/hydra-gates` cite. Allocating a superseding ADR +number in `hydra/openspec/` is a follow-up, not a precondition. + +--- + +## The decision + +**The Hydra gates scan the ENTIRE tracked codebase by default. Diff scoping is now +opt-in (`--scope-to-diff` / `--diff` / `HYDRA_GATE_SCOPE=diff`).** + +Until now it was the other way round: `bin/hydra-gates` defaulted to +`--scope-to-diff` and `--full` was the audit-only escape hatch. + +In Ruben's words: + +> if a gate changes or is added we want the next push to beta to fail unless the old +> code is fixed to the new standard. This will force developers to take updates to +> the gates along in their new releases. + +## What ADR-020 decided, and why it was right at the time + +ADR-020 scoped every gate to the PR's diff so that **inherited debt could never block +a PR**. That was a deliberate, defensible trade: a gate that blocks legitimate work +gets switched off, and a switched-off control is worth less than a loud one. It is why +`enable-hydra-gates` could be turned on repo by repo at all. + +## Why it is being reversed + +Diff scoping means a gate only ever judges code written **after** the gate landed. +The consequences are structural, not incidental: + +1. **Tightening a gate has no effect on existing code.** The new rule applies to the + next hunk anyone happens to touch. Nobody is ever asked to bring old code up to the + new standard, so the standard is only aspirational for everything already shipped. +2. **The debt is unowned and unmeasured.** It is visible only to a `--full` audit that + nothing gates on, so it accumulates without a moment at which anyone must look at it. +3. **The empty-diff failure modes are endless.** This package's own history is mostly a + list of them: `#242`, `#240`, `#258`, `#268`, `#276`, `#347`, `#361`, `#364`, `#371`, + `#374`. Every one is a variation on *a gate that had nothing to look at printed the + same word as a gate that looked and found nothing.* Full scope removes the empty set + in the common case. (It does **not** remove the fall-through — see below.) + +## What this costs, stated up front + +**The first `development → beta` run after this lands will surface the entire backlog +at once.** The last fleet-wide wide-scope measurement was **~3,900 findings**. That is +the point of the change, and it is also the whole risk in it: 18 repos change verdict +simultaneously. + +Two things follow, and they are part of the decision rather than caveats to it: + +- **This is a sequencing problem, not a correctness one.** Every finding it surfaces + was already true. Nothing about the code changed. +- **The number is a FLOOR, not a total.** It predates several gate fixes that make + gates stricter, and several gates are still `no-fixture-yet`. Quote it as a floor. + +## What did NOT change: delta gates keep their base + +Five gates ask what a **change** did, and a whole tree cannot answer that: + +| gate | name | the question | +|---|---|---| +| 16 | spec-coverage | which methods did this change add or modify without an `@spec` anchor? | +| 29 | gitignore-then-commit | did this change add an ignore rule over already-tracked files? | +| 47 | security-change-has-tests | did this change touch security code without touching a test? | +| 48 | csrf-cochange | did this change REMOVE `@NoCSRFRequired`? | +| 61 | listener-work-placement | did this change add a post-event listener doing work in the wrong plane? | + +Keying those on the file scope would have **silently retired all five on every PR in +the fleet** the moment this default flipped — trading the gates that protect the change +in front of you for coverage they cannot use. So the scope is now **two independent, +named inputs**: + +| input | controls | default | +|---|---|---| +| `SCOPE_TO_DIFF` / `HYDRA_GATE_SCOPE` | which files the **state** gates open | `full` | +| the resolved **delta base** (`--base`, `$HYDRA_GATE_BASE_REF`, auto-detect, `github.event.before`) | what the **delta** gates compare against | resolved whenever possible | + +A PR therefore gets **whole-tree state coverage AND every delta gate**. A +`workflow_dispatch` has no base, and those five report `NOT APPLICABLE` **by name, with +a reason** — never `PASS`, and never counted as one. + +This also closes a defect the old conflation caused: *"I ran it with no base" was never +a scope.* gate-19 swept the whole tree while gate-16 fell back to a hardcoded +`origin/development` and printed `PASS` over nothing (`#361`), and gate-61 declined +citing a diff the run had never computed (`#347`) — one package answering one question +two ways. `BASE_REF` now starts **empty**; a base is stated or it does not exist. + +**An unresolvable base is no longer fatal at full scope.** On a diff-scoped run it still +exits 99, because without a base there is no scope at all. On a full run it costs five +gates out of 64, so refusing would discard 59 real verdicts to punish one bad input. + +## What did NOT change: the empty-scope fall-through is still a bug + +Full scope removes the empty set in the common case. It does **not** make the +fall-through safe, and treating it as the fix would have been the mistake: +`--scope-to-diff` still exists, and a repo can genuinely ship no `src/`. + +So the fall-through is fixed **at the fall-through** (`_skip_empty_scope` in +`run-hydra-gates.sh`), across **eighteen** gates — the sixteen `#374` enumerates plus +**14** and **20**, found by sweeping the table rather than from the issue. And the +property is now enforced by +`scripts/lib/test_gate_empty_scope_never_passes.sh` **ARM 6**, gate-agnostically over +the whole package, instead of by seven gates named one at a time. + +## Rollout + +1. **Do not merge this alongside anything else.** It changes the verdict for 18 repos + simultaneously; the next fleet measurement must be attributable to exactly one cause. +2. **Capture a wide-scope baseline per app first.** The standing `development → beta` + PR already runs a wide-scope job and its log carries the full table — no + `workflow_dispatch` needed, and dispatching one cancels that very run. +3. **Expect `--require-full-coverage` to be the loudest change**, not the gate findings: + a `PASS` that was really an unopened scope now reports `NOT APPLICABLE`, so + `COVERAGE: N of 64` drops in some repos. That is the line getting *more* honest, not + coverage getting worse. + +## How to get the old behaviour + +```bash +hydra-gates --scope-to-diff --base origin/development # ADR-020, explicitly +HYDRA_GATE_SCOPE=diff hydra-gates # the same, via the environment +``` + +Both print `SCOPE-MODE: diff` and say what they are not judging. diff --git a/hydra-gates/README.md b/hydra-gates/README.md index 94ba1005..a7f20adc 100644 --- a/hydra-gates/README.md +++ b/hydra-gates/README.md @@ -194,22 +194,50 @@ wrong. --- -## Diff scoping and the base ref +## Scope: full by default (ADR-020 is superseded) -The gates are diff-scoped per ADR-020: a PR is judged on what it changed, not on -what it inherited. This matters — openbuild fails 16 gates on a full-repo run -today and passes when scoped to a real diff. `composer gates:full` gives the -audit view and is deliberately not what `check:strict` runs. +**The gates scan the ENTIRE tracked tree by default. Diff scoping is opt-in.** -Diff scoping is only as trustworthy as the base ref, and a base that resolves to -nothing produces a report of zero failures that is indistinguishable from a -clean one. So: +ADR-020 scoped every gate to the PR's diff so inherited debt could never block a +PR. That is reversed, deliberately — see +[ADR-020-SUPERSEDED.md](ADR-020-SUPERSEDED.md) for the decision, the reasoning, +and what it costs (the first `development → beta` run after it lands surfaces +the fleet backlog at once; the last wide-scope measurement was ~3,900 findings). + +The scope is **two independent, named inputs**, and both are printed every run: + +| input | controls | default | how to set it | +|---|---|---|---| +| **file scope** | which files the **state** gates open | `full` | `--scope-to-diff` / `--diff`, or `HYDRA_GATE_SCOPE=full\|diff` | +| **delta base** | what the five **delta** gates compare against | resolved whenever possible | `--base REF` / `$HYDRA_GATE_BASE_REF` / auto-detect / `github.event.before` | + +Every run emits exactly one machine-readable `[hydra-gates] SCOPE-MODE: full|diff` +line. **Read that, never the prose** — inferring the scope from whether a base was +printed, or from a gate's own wording, has been wrong at least once each. + +Gates **16, 29, 47, 48 and 61** ask what a *change* did and cannot be answered by a +checkout. With a base they run at any file scope; with none they report +`NOT APPLICABLE` **by name, with a reason** — never `PASS`, and never counted as one. + +To get the old behaviour explicitly: + +```bash +hydra-gates --scope-to-diff --base origin/development +HYDRA_GATE_SCOPE=diff hydra-gates +``` + +### The base ref + +A base that resolves to nothing produces a report of zero failures that is +indistinguishable from a clean one. So: - The base is resolved from a stated precedence chain and **printed** every run: `--base` → `$HYDRA_GATE_BASE_REF` → `origin/HEAD` → `origin/development` → `origin/main` → `origin/master`. -- **An unresolvable base stops the run with exit 99.** It is never treated as an - empty diff, and no green is printed. +- **On a diff-scoped run, an unresolvable base stops the run with exit 99.** It is + never treated as an empty diff, and no green is printed. **At full scope it is + NOT fatal** — it costs the five delta gates and nothing else, so refusing would + discard 59 real verdicts to punish one bad input. The five say so by name. - **A base you named explicitly is never silently replaced.** Substituting a different one would scope the run to something you did not ask for and would not read about. @@ -381,7 +409,7 @@ reporting that it did nothing did nothing. But only two of the three fail: | verdict | meaning | counts against coverage? | |---|---|---| -| `NOT APPLICABLE` | the gate's subject matter does not exist in this repo or this diff — no `src/` at all, or a diff with no composer file under ADR-020 scoping | **no** | +| `NOT APPLICABLE` | the gate's subject matter does not exist in this repo, or (on an opt-in diff-scoped run) in this diff — no `src/` at all, no composer file in the change set; **or the gate is a DELTA gate and this run has no base** | **no** | | `SKIPPED (structural)` | the subject matter EXISTS and nothing produced the gate's input — e.g. a repo that registers integration leaves but ships no parity check | yes | | `SKIPPED (wiring)` | the gate's own machinery is missing — a helper script, a tool not on PATH | yes | diff --git a/hydra-gates/bin/hydra-gates b/hydra-gates/bin/hydra-gates index 39eb866e..f9ad3746 100755 --- a/hydra-gates/bin/hydra-gates +++ b/hydra-gates/bin/hydra-gates @@ -19,14 +19,48 @@ # for helpers that are not there. We resolve the link chain to the real # package root first. # -# 2. AN EXPLICIT, STATED BASE REF (ADR-020). -# The gates are diff-scoped. A base ref that silently scopes to nothing -# reports zero failures, which is indistinguishable from clean. We resolve -# the base from an explicit precedence chain, PRINT which base won, and +# 2. AN EXPLICIT, STATED BASE REF. +# A base ref that silently scopes to nothing reports zero failures, which +# is indistinguishable from clean. We resolve the base from an explicit +# precedence chain, PRINT which base won, and — on a diff-scoped run — # refuse to run when none resolves. We never fall back to HEAD~1: on a # squash-merged mainline HEAD~1 is the previous release, and on a fresh # shallow clone it may not exist at all. # +# =========================================================================== +# THE SCOPE IS FULL BY DEFAULT. ADR-020 IS SUPERSEDED. +# =========================================================================== +# +# Until now this wrapper defaulted to `--scope-to-diff` and `--full` was the +# opt-in. That is reversed: the gates scan the ENTIRE tracked tree unless the +# caller asks for a diff with `--scope-to-diff` / `--diff`. +# +# The reason, in Ruben's words: "if a gate changes or is added we want the next +# push to beta to fail unless the old code is fixed to the new standard. This +# will force developers to take updates to the gates along in their new +# releases." Under diff scoping a tightened gate only ever judged code written +# after it landed, so the fleet's inherited debt was permanently invisible and +# permanently unowned. +# +# WHAT THIS COSTS, STATED HERE RATHER THAN DISCOVERED: the first +# `development → beta` run after this lands surfaces the whole backlog at once. +# The last fleet-wide wide-scope measurement was ~3,900 findings. See +# hydra-gates/ADR-020-SUPERSEDED.md for the full record and the sequencing. +# +# THE BASE REF DOES NOT GO AWAY. Five gates ask what a CHANGE did — 16 +# spec-coverage, 29 gitignore-then-commit, 47 security-change-has-tests, +# 48 csrf-cochange, 61 listener-work-placement — and a whole tree cannot answer +# that. So this wrapper still resolves a base and still forwards it, on a full +# run as well as a diff-scoped one, and the runner keys those five on whether a +# base was resolved rather than on the file scope. A PR therefore keeps every +# delta gate AND gains whole-tree state coverage; a workflow_dispatch has no +# base and those five report NOT APPLICABLE, by name, which is not a pass. +# +# AND AN UNRESOLVABLE BASE IS NO LONGER FATAL AT FULL SCOPE. On a diff-scoped +# run it still exits 99 — without a base there is no scope at all. On a full +# run it costs five gates and nothing else, so refusing would throw away 59 +# real verdicts to punish one bad input. +# # 3. A COVERAGE ASSERTION ON THE GREEN. # The runner used to end with "ALL 63 GATES GREEN" whether or not 63 gates # ran. Measured on openbuild, only 59 of 61 emitted a line — gates 24 and @@ -57,9 +91,17 @@ # hydra-gates [--app-dir DIR] [--base REF] [--full] [--require-full-coverage] # # --app-dir DIR repo to scan (default: current directory) -# --base REF diff base (default: see resolution order below) -# --full scan the whole repo instead of the diff. Reports -# inherited debt; use for audits, not for gating. +# --base REF the DELTA base — what the five delta gates compare +# against, and the file scope too when +# --scope-to-diff is given (default: see the +# resolution order below) +# --full scan the whole repo. THIS IS NOW THE DEFAULT; the +# flag is kept because every caller in the fleet +# already passes it, and it now names the default +# rather than switching to it. +# --scope-to-diff, --diff the old ADR-020 behaviour, now OPT-IN: judge only +# the files this change touched. Inherited debt in +# untouched files is not judged. # --require-full-coverage treat "an APPLICABLE gate did not run" as a # failure. Gates that reported NOT APPLICABLE — their # subject matter is absent from this repo or this @@ -129,7 +171,19 @@ fi # --------------------------------------------------------------------------- APP_DIR="" BASE_REF_ARG="" -SCOPE_DIFF=1 +# FULL SCOPE BY DEFAULT — the reversal. `$HYDRA_GATE_SCOPE` is the env form and +# an unrecognised value is a hard stop, because "which scope did that run use?" +# is the question every verdict below is an answer to, and a run whose scope +# nobody can name is not evidence about anything. +case "${HYDRA_GATE_SCOPE:-full}" in + full) SCOPE_DIFF=0 ;; + diff) SCOPE_DIFF=1 ;; + *) + echo "[hydra-gates] FATAL: \$HYDRA_GATE_SCOPE='${HYDRA_GATE_SCOPE}' is not 'full' or 'diff'." >&2 + echo "[hydra-gates] Refusing to guess a scope. NOTHING WAS CHECKED." >&2 + exit 99 + ;; +esac REQUIRE_FULL_COVERAGE=0 AXE_ENABLED="${HYDRA_GATE_AXE_ENABLED:-0}" @@ -140,6 +194,7 @@ while [ $# -gt 0 ]; do --base) BASE_REF_ARG="${2:-}"; shift 2 ;; --base=*) BASE_REF_ARG="${1#--base=}"; shift ;; --full) SCOPE_DIFF=0; shift ;; + --scope-to-diff|--diff) SCOPE_DIFF=1; shift ;; --require-full-coverage) REQUIRE_FULL_COVERAGE=1; shift ;; --axe-enabled) AXE_ENABLED=1; shift ;; -h|--help) sed -n '3,70p' "${_self}" | sed 's/^# \{0,1\}//'; exit 0 ;; @@ -308,16 +363,97 @@ if [ "${SCOPE_DIFF}" = "1" ]; then fi fi else - echo "[hydra-gates] Base ref: n/a — --full requested, scanning the entire tree." - echo "[hydra-gates] NOTE: a --full run reports inherited debt and is NOT the gating mode (ADR-020)." + # ── FULL FILE SCOPE (the default) — resolve a DELTA base anyway ───────── + # + # Full scope is about which files the STATE gates open. It says nothing + # about the five DELTA gates, which need a base and would otherwise have + # been silently retired on every PR in the fleet the moment this default + # flipped. So we resolve one with the SAME precedence chain as above and + # forward it; the runner keys those five on whether it arrived. + # + # EVERY FAILURE HERE IS NON-FATAL. That is the difference from the branch + # above. There, no base means no scope and exit 99 is the only honest + # answer. Here it costs five gates out of 64, so refusing would discard 59 + # real verdicts to punish one bad input — and a run that reports nothing is + # worth less than a run that reports most things and names what it could + # not reach. + echo "[hydra-gates] File scope: FULL — the entire tracked tree (the default; ADR-020 is superseded)." + if command -v git > /dev/null 2>&1 && _git rev-parse --git-dir > /dev/null 2>&1; then + _db_source="" + if [ -n "${BASE_REF_ARG}" ]; then + if _resolves "${BASE_REF_ARG}"; then + BASE_REF="${BASE_REF_ARG}"; _db_source="--base" + else + echo "[hydra-gates] Delta base: '${BASE_REF_ARG}' was named with --base but does not resolve — ignoring it." + fi + elif [ -n "${HYDRA_GATE_BASE_REF:-}" ]; then + if _resolves "${HYDRA_GATE_BASE_REF}"; then + BASE_REF="${HYDRA_GATE_BASE_REF}"; _db_source="\$HYDRA_GATE_BASE_REF" + else + echo "[hydra-gates] Delta base: \$HYDRA_GATE_BASE_REF='${HYDRA_GATE_BASE_REF}' does not resolve — ignoring it." + fi + else + # Same chain as the diff branch, and @{upstream} is deliberately + # NOT in it for the reason spelled out there: a feature branch's + # tracking ref is itself, so the diff is empty exactly when CI runs. + _origin_head="$(_git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + for _cand in "${_origin_head}" origin/development origin/main origin/master; do + [ -n "${_cand}" ] || continue + if _resolves "${_cand}"; then + BASE_REF="${_cand}"; _db_source="auto-detected" + break + fi + done + fi + + # A base that IS HEAD is the mainline-push shape, and it makes the + # delta gates' diff empty by construction — the same disguise the diff + # branch spends a screen of comment on. Re-scope to the push's own + # previous tip; if that cannot be resolved, drop the base rather than + # hand the delta gates a comparison of HEAD with itself. + if [ -n "${BASE_REF}" ] \ + && [ "$(_git rev-parse "${BASE_REF}" 2>/dev/null)" = "$(_git rev-parse HEAD 2>/dev/null)" ]; then + # shellcheck source=scripts/lib/resolve-push-base.sh + # shellcheck disable=SC1091 # resolved at runtime from the package root + . "${PKG_ROOT}/scripts/lib/resolve-push-base.sh" + _head_sha="$(_git rev-parse HEAD 2>/dev/null)" + if _push_base="$(hydra_resolve_push_base "${APP_DIR}" "${_head_sha}")"; then + BASE_REF="${_push_base}"; _db_source="github.event.before (push)" + else + echo "[hydra-gates] Delta base: the resolved base IS HEAD and the push's previous tip could not be used either." + BASE_REF=""; _db_source="" + fi + fi + + if [ -n "${BASE_REF}" ]; then + BASE_SOURCE="${_db_source}" + echo "[hydra-gates] Delta base: ${BASE_REF} (${BASE_SOURCE}) = $(_git rev-parse --short "${BASE_REF}" 2>/dev/null)" + echo "[hydra-gates] Gates 16, 29, 47, 48 and 61 judge that change set; every other gate reads the whole tree." + else + echo "[hydra-gates] Delta base: NONE. Gates 16, 29, 47, 48 and 61 will report NOT APPLICABLE by name." + echo "[hydra-gates] That is not a pass and is not counted as one. The other 59 gates are unaffected." + fi + else + echo "[hydra-gates] Delta base: NONE — git is unavailable or this is not a git repository." + echo "[hydra-gates] Gates 16, 29, 47, 48 and 61 will report NOT APPLICABLE by name." + fi fi # --------------------------------------------------------------------------- # 5. Run the gates. The runner owns the gate logic; we only capture. # --------------------------------------------------------------------------- set -- "${APP_DIR}" +# The BASE and the FILE SCOPE are forwarded as two separate, named inputs. A +# base is forwarded whenever one resolved — including on a full run, where the +# five delta gates are the only things that read it. `--scope-to-diff` is +# forwarded only when the caller asked for it. +if [ -n "${BASE_REF}" ]; then + set -- --base "${BASE_REF}" "$@" +fi if [ "${SCOPE_DIFF}" = "1" ]; then - set -- --scope-to-diff --base "${BASE_REF}" "$@" + set -- --scope-to-diff "$@" +else + set -- --full "$@" fi # Forwarded, not re-derived. The runner is the only thing that reads gate-33's # report; this wrapper just has to stop swallowing the caller's declaration. diff --git a/hydra-gates/scripts/lib/test_gate16_spec_coverage_scope.sh b/hydra-gates/scripts/lib/test_gate16_spec_coverage_scope.sh index 71fe8891..1b8727b1 100644 --- a/hydra-gates/scripts/lib/test_gate16_spec_coverage_scope.sh +++ b/hydra-gates/scripts/lib/test_gate16_spec_coverage_scope.sh @@ -142,14 +142,46 @@ fi # =========================================================================== echo -echo "== arm 3 — FULL scope, same tree as arm 1 (.github#361) ==" +echo "== arm 3 — FULL file scope, NO delta base (.github#361 / #374) ==" # =========================================================================== -_outf="$(gf_run_wrapper "${WORK}/inherited" "${WORK}/log-full" --full)" +# +# REWRITTEN FOR THE ADR-020 REVERSAL (hydra-gates/ADR-020-SUPERSEDED.md). +# +# This arm used to run `--full` against a tree that `gf_mark_base` had given a +# `refs/remotes/origin/development` ref, and assert NOT APPLICABLE — because +# `--full` used to mean BOTH "read the whole tree" AND "compute no diff". +# +# Those are now two separate inputs, and that conflation was the bug: a full +# file scope says nothing about whether a base exists. gate-16 is a DELTA gate; +# what it needs is a BASE, not a narrowed file list. So the arm splits: +# +# 3a full file scope, NO base -> NOT APPLICABLE, with a reason (below) +# 3b full file scope, WITH base -> it JUDGES the change set, and still does +# NOT sweep the legacy surface (further down) +# +# 3b is the one that matters most: it is the only assertion standing between +# this package and the false RED `#361`'s fix was written to avoid — reporting +# every legacy `@spec` gap in the fleet because the file scope went wide. +# +# The tree is arm 1's, rebuilt WITHOUT `gf_mark_base`, so nothing in it +# resolves as a base. Building it explicitly rather than deleting the ref keeps +# the two arms independent. +gf_build_repo "${WORK}/inherited-nobase" "${SRC}" +gf_commit_all "${WORK}/inherited-nobase" "base: app carrying inherited @spec debt" +printf '\n- unrelated doc tweak\n' >> "${WORK}/inherited-nobase/docs/CHANGELOG.md" +gf_commit_paths "${WORK}/inherited-nobase" "docs: unrelated change" docs/CHANGELOG.md + +_outf="$(gf_run_wrapper "${WORK}/inherited-nobase" "${WORK}/log-full" --full)" -if printf '%s' "${_outf}" | grep -qF 'Base ref: n/a — --full requested'; then - _ok "the --full run states it computed no diff" +if printf '%s' "${_outf}" | grep -qF 'SCOPE-MODE: full'; then + _ok "the run announces its file scope in one machine-readable line" +else + _bad "the run printed no 'SCOPE-MODE: full' line; the rest of this arm is unsafe to interpret because nothing states which scope produced it" +fi +if printf '%s' "${_outf}" | grep -qF 'Delta base: NONE'; then + _ok "the run states it resolved NO delta base" else - _bad "the --full run did not announce itself as unscoped; the rest of this arm is unsafe to interpret" + _bad "the run did not state that it has no delta base — a delta gate's NOT APPLICABLE below would then be unattributable to any input" fi _vf="$(gf_verdict "${_outf}" 16)" @@ -158,10 +190,10 @@ case "${_vf}" in _ok "gate-16 on --full reports NOT APPLICABLE instead of PASSing over a scope it never read" ;; *PASS*) - _bad ".github#361 is LIVE: gate-16 printed PASS on a --full run that computed no diff, over a tree whose two untagged methods the positive control just named. 0 inspected, and this PASS counts toward 'N of N applicable gates ran'." + _bad ".github#361 is LIVE: gate-16 printed PASS on a run with NO delta base, over a tree whose two untagged methods the positive control just named. 0 inspected, and this PASS counts toward 'N of N applicable gates ran'." ;; *FAIL*) - _bad ".github#361 was fixed by SWEEPING THE WHOLE TREE. That is the wrong contract (ADR-020) and a false RED in every repo in the fleet — it reports inherited @spec debt the author never touched. Expected NOT APPLICABLE. Got: ${_vf:0:140}" + _bad ".github#361 was fixed by SWEEPING THE WHOLE TREE. gate-16 is a DELTA gate: widening its FILE scope does not give it a change set, it just reports inherited @spec debt the author never touched — a false RED in every repo in the fleet, and exactly what the ADR-020 reversal must NOT do to this gate. Expected NOT APPLICABLE. Got: ${_vf:0:140}" ;; "") _bad "gate-16 emitted no verdict at all on the --full arm" ;; *) _bad "gate-16 on --full gave an unrecognised verdict: ${_vf:0:140}" ;; @@ -201,12 +233,66 @@ else fi # And it must be counted as not-applicable, not as a silent no-show. -if printf '%s' "${_outf}" | grep -qE '^\[hydra-gates\][[:space:]]+gate-16 spec-coverage'; then +if printf '%s' "${_outf}" | grep -qE '^\[hydra-gates\] NOT APPLICABLE: .*\b16\b'; then _ok "gate-16 is named in the NOT APPLICABLE block of the coverage summary" else _bad "gate-16 did not appear in the coverage summary's NOT APPLICABLE block — it is being counted as a gate that simply did not run, which --require-full-coverage would fail on" fi +# =========================================================================== +echo +echo "== arm 3b — FULL file scope WITH a delta base (the ADR-020 reversal) ==" +# =========================================================================== +# +# THE ARM THAT DID NOT EXIST, AND THE ONE THE REVERSAL MAKES NECESSARY. +# +# Full file scope is now the DEFAULT, so this is the shape every PR in the +# fleet runs in: the whole tree open to the state gates, and a real base for +# the five delta gates. Two things must hold simultaneously, and they pull in +# opposite directions: +# +# 1. gate-16 must still JUDGE THE CHANGE — otherwise flipping the default +# silently retired @spec enforcement fleet-wide, which is a straight +# downgrade dressed as an improvement. +# 2. gate-16 must still NOT NAME THE LEGACY SURFACE — otherwise the wide file +# scope leaked into a delta gate and every repo goes red on inherited debt +# nobody touched. That is `#361`'s false-RED, arriving by a new route. +# +# Arm 1 already proves (2) under `--scope-to-diff`. It proves nothing about the +# default any more, because the default changed. +_outfb="$(gf_run_wrapper "${WORK}/newwork" "${WORK}/log-full-base" --full)" + +if printf '%s' "${_outfb}" | grep -qE '^\[hydra-gates\] Delta base: [^N]'; then + _ok "the full-scope run resolved a delta base and named it" +else + _bad "the full-scope run resolved NO delta base though the fixture has refs/remotes/origin/development — arm 3b would then be measuring arm 3a again and proving nothing" +fi + +_vfb="$(gf_verdict "${_outfb}" 16)" +case "${_vfb}" in + *FAIL*) + _ok "gate-16 still JUDGES the change at full file scope — the delta gate survived the reversal: ${_vfb:0:90}" + ;; + *"NOT APPLICABLE"*) + _bad "gate-16 went NOT APPLICABLE at full file scope DESPITE a resolved base. This is the regression the reversal must not cause: keying a delta gate on the FILE scope retires it on every PR in the fleet. Got: ${_vfb:0:160}" + ;; + *PASS*) + _bad "gate-16 PASSed at full file scope over a diff that ADDS an untagged method. It had a base and did not use it. Got: ${_vfb:0:160}" + ;; + *) _bad "gate-16 gave an unrecognised verdict on the full+base arm: ${_vfb:0:160}" ;; +esac + +if grep -qF 'NewWorkController.php' "${WORK}/log-full-base/hydra-gate-spec-coverage.log" 2>/dev/null; then + _ok "gate-16 names the NEWLY ADDED file at full file scope" +else + _bad "gate-16 did not name NewWorkController.php at full file scope — it produced a verdict without evidence" +fi +if grep -qF 'LegacyDebtController.php' "${WORK}/log-full-base/hydra-gate-spec-coverage.log" 2>/dev/null; then + _bad "the full-scope run wrote INHERITED legacy @spec debt into gate-16's log. The wide file scope has leaked into a delta gate — this is #361's false RED arriving by a new route, and it would redden every repo in the fleet on code nobody touched." +else + _ok "gate-16 does NOT sweep the inherited legacy surface even at full file scope — the delta contract survived the reversal" +fi + echo echo "== summary ==" echo " passed: ${_pass_n}" diff --git a/hydra-gates/scripts/lib/test_gate_5661_empty_scope_is_not_a_pass.sh b/hydra-gates/scripts/lib/test_gate_5661_empty_scope_is_not_a_pass.sh index 8eebb22f..d08ff950 100755 --- a/hydra-gates/scripts/lib/test_gate_5661_empty_scope_is_not_a_pass.sh +++ b/hydra-gates/scripts/lib/test_gate_5661_empty_scope_is_not_a_pass.sh @@ -252,11 +252,27 @@ for _g in ${_GATES}; do esac done +# A REASON MUST NAME ITS SUBJECT *AND* BE SUBSTANTIVE. Both halves are needed: +# the token list alone is satisfiable by a bare citation with no explanation, +# and a length test alone is satisfiable by any sentence at all. +# +# `delta-scoped` joined the token list with the ADR-020 reversal +# (hydra-gates/ADR-020-SUPERSEDED.md). gate-61 is delta-scoped BY POLICY rather +# than by nature — `check_listener_placement.py --all` answers the same question +# over a whole tree perfectly well, and the runner already runs it that way as +# an advisory — so its reason can no longer honestly cite ADR-020 as the live +# rule. It now names the policy instead, which is the more testable claim of +# the two. The token list may be EXTENDED for a reason that got more precise; +# it must never be relaxed to a wildcard. for _g in ${_GATES}; do - if grep -qE "^\[gate-${_g}\][^:]*: NOT APPLICABLE — .+(ADR-020|no lib/|no tests/e2e|no lib/AppInfo)" "${_scoped}"; then - _ok "gate-${_g} states WHY it was not applicable" + _reason_line="$(grep -E "^\[gate-${_g}\][^:]*: NOT APPLICABLE — " "${_scoped}" | head -1)" + _reason="${_reason_line#*NOT APPLICABLE — }" + if printf '%s' "${_reason_line}" \ + | grep -qE "(ADR-020|delta-scoped|no lib/|no tests/e2e|no lib/AppInfo)" \ + && [ "${#_reason}" -ge 40 ]; then + _ok "gate-${_g} states WHY it was not applicable (${#_reason} chars, names its subject)" else - _bad "gate-${_g}'s NOT APPLICABLE line carries no reason — a bare declaration is how a gate disappears quietly" + _bad "gate-${_g}'s NOT APPLICABLE line carries no substantive reason (${#_reason} chars) — a bare declaration is how a gate disappears quietly. Got: ${_reason_line:0:160}" fi done diff --git a/hydra-gates/scripts/lib/test_gate_crashed_checker_is_not_a_finding.sh b/hydra-gates/scripts/lib/test_gate_crashed_checker_is_not_a_finding.sh index 84da58d4..94a6b1c6 100755 --- a/hydra-gates/scripts/lib/test_gate_crashed_checker_is_not_a_finding.sh +++ b/hydra-gates/scripts/lib/test_gate_crashed_checker_is_not_a_finding.sh @@ -121,11 +121,36 @@ _out="${_tmp}/run.txt" --scope-to-diff --base "${_root}" . > "${_out}" 2>&1 ) -_v17=$(grep -oE '^\[gate-17\] [^:]+: [A-Z]+( \([a-z]+\))?' "${_out}" | head -1 | sed 's/^[^:]*: //') +# NOTE the `( [A-Z]+)*`: the verdict word is not always one token. Under the +# original single-token pattern "NOT APPLICABLE" parsed as "NOT" and every +# comparison below failed on the STRING rather than on the behaviour — the same +# trap test_gate_empty_scope_never_passes.sh records in its own `_verdict`. +_v17=$(grep -oE '^\[gate-17\] [^:]+: [A-Z]+( [A-Z]+)*( \([a-z]+\))?' "${_out}" | head -1 | sed 's/^[^:]*: //') if grep -qE '^\[gate-17\][^:]*: FAIL — 0 ' "${_out}"; then _bad "gate-17 reported 'FAIL — 0 pass-through method(s)' — a crash rendered as a finding count" elif [ "${_v17}" = "PASS" ] || [ "${_v17}" = "FAIL" ]; then _ok "gate-17 produced a real verdict (${_v17}) over a ${_scope_bytes}-byte scope list" +elif [ "${_v17}" = "NOT APPLICABLE" ]; then + # ADDED WITH .github#374, AND IT IS A STRONGER ASSERTION THAN THE TWO ABOVE. + # + # The diff here is `root...HEAD`, and ThingController.php lives in the ROOT + # commit — so the change set is 3,000 filler `.ts` files and not one PHP + # file under lib/. `NOT APPLICABLE` is therefore the honest verdict, and it + # only became reachable when gate-17 stopped calling a `# count=0` over an + # unopened scope a PASS. + # + # But it must not become a way to DODGE this arm, whose whole subject is + # E2BIG: a scope list over MAX_ARG_STRLEN used to stop python3 from starting + # at all. So the marker is required. gate-17 can only reach its + # empty-scope branch from a path where the checker exited cleanly, so a + # `# count=` in the log is proof the interpreter ran over the + # ${_scope_bytes}-byte list. If E2BIG returns there is no marker, and the + # gate reports SKIPPED (wiring) — handled below, and never NOT APPLICABLE. + if grep -q '^# count=' "${_logs}/hydra-gate-redundant-controller.log" 2>/dev/null; then + _ok "gate-17 reported NOT APPLICABLE over an empty PHP scope, and its checker DID complete over the ${_scope_bytes}-byte scope list (terminal '# count=' marker present)" + else + _bad "gate-17 reported NOT APPLICABLE but its log carries no terminal '# count=' marker — the checker never finished, so this is a CRASH wearing an empty-scope verdict. That is the E2BIG defect returning through a new door." + fi elif [ "${_v17}" = "SKIPPED (wiring)" ]; then _ok "gate-17 reported SKIPPED (wiring) — honest, though the scope file should have avoided the crash" else diff --git a/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh b/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh index b7b88014..026cf419 100755 --- a/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh +++ b/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh @@ -404,6 +404,235 @@ for _g in 12 13; do fi done +echo +# =========================================================================== +# ARM 6 — THE PROPERTY, ACROSS THE WHOLE PACKAGE, NOT SEVEN GATES BY NAME +# =========================================================================== +# +# Everything above names its gates. That is how this suite covered 12, 13, 19, +# 25, 33, 62 and 63 — SEVEN of 64 — while gates 14, 17, 18, 20, 21, 22, 34-44 +# and 52 carried the identical defect for months (.github#374). A property +# enforced gate-by-gate is enforced by whoever remembered to add a line. +# +# So this arm asserts the property ITSELF, gate-agnostically: +# +# OVER A TREE THAT CARRIES REAL, PLANTED DEFECTS, NO GATE MAY REPORT `PASS` +# ON A RUN WHOSE SCOPE EXCLUDES EVERY ONE OF THEM. +# +# The fixture is the shape #374 was measured on: a tree with a planted defect +# for a broad slice of the package, and a docs-only second commit. Any gate +# that says PASS there said it having opened nothing. +# +# ⚠️ THE ALLOWLIST IS THE LOAD-BEARING PART, AND IT MAY ONLY SHRINK. +# A gate on it is one whose PASS over this diff is HONEST — it computed a real +# answer about a real change set. Adding a gate to it to make this arm green is +# how the defect comes back, so each entry states what it computed. +echo +echo "-- ARM 6: the property, across the package --" + +_wide="${_tmp}/wide" +mkdir -p "${_wide}/lib/Controller" "${_wide}/lib/Settings" "${_wide}/appinfo" \ + "${_wide}/src/views" "${_wide}/templates" +cat > "${_wide}/lib/Controller/ThingController.php" <<'PHP' +objectService->findAll([])); + } + public function orphan(): JSONResponse + { + return new JSONResponse($this->objectService->findObjects([])); + } +} +PHP +printf ' "${_wide}/appinfo/routes.php" +printf '{"schemas":[{"slug":"thing","notifications":{"onCreate":{"subject":"x"}}}]}\n' \ + > "${_wide}/lib/Settings/register.json" +printf '<<<<<<< HEAD\nconst a = 1;\n=======\nconst a = 2;\n>>>>>>> other\n' \ + > "${_wide}/src/conflicted.js" +cat > "${_wide}/src/views/Probe.vue" <<'VUE' + + + + +VUE +printf '\n
hi
\n\n' > "${_wide}/templates/admin.php" +printf '{"name":"fx","menu":[],"pages":[]}\n' > "${_wide}/src/manifest.json" +( + cd "${_wide}" || exit 1 + git init -q . + git add -A + git -c user.email=t@t -c user.name=t commit -qm "the planted tree" + printf 'docs only\n' > README.md + git add README.md + git -c user.email=t@t -c user.name=t commit -qm docs +) >/dev/null 2>&1 + +_wide_full="${_tmp}/wide-full.txt" +_wide_logs="${_tmp}/wide-logs"; mkdir -p "${_wide_logs}" +( + cd "${_wide}" || exit 1 + HYDRA_GATE_LOG_DIR="${_wide_logs}" bash "${_runner}" . > "${_wide_full}" 2>&1 +) +_wide_diff="${_tmp}/wide-diff.txt" +_wide_logs2="${_tmp}/wide-logs2"; mkdir -p "${_wide_logs2}" +( + cd "${_wide}" || exit 1 + HYDRA_GATE_LOG_DIR="${_wide_logs2}" bash "${_runner}" --scope-to-diff --base HEAD~1 . \ + > "${_wide_diff}" 2>&1 +) + +# THE POSITIVE CONTROL RUNS FIRST AND EVERYTHING ELSE IS MEANINGLESS WITHOUT +# IT. If the full run finds nothing, the fixture is broken and "no gate passed +# over the empty scope" would be satisfied by a runner that gates nothing. +_wide_fails=$(grep -cE '^\[gate-[0-9]+\][^:]*: FAIL' "${_wide_full}" || true) +_wide_fails="${_wide_fails:-0}" +if [ "${_wide_fails}" -ge 8 ]; then + _ok "positive control: the planted tree FAILS ${_wide_fails} gate(s) at full scope" +else + _bad "positive control BROKEN: the planted tree fails only ${_wide_fails} gate(s) at full scope — the fixture no longer plants what this arm assumes, so every assertion below proves nothing" +fi + +# Gates whose PASS over THIS diff is a real answer to a real question. +# Each computed something; none of them is passing over an unopened scope. +# 4 composer-audit — no composer file in this tree at all; it declines by +# subject matter, not by scope (it reports na, listed +# here only so a future change of that verdict is not +# silently swallowed) +# 15 dashboard-antipattern — its helper reads the WHOLE tree and the diff +# filter is applied to its FINDINGS, so it did open the +# manifest and the .vue tree. See the note below. +# 16 spec-coverage — a DELTA gate with a real base: it diffed HEAD~1..HEAD +# and found no changed method. That is a computed zero. +# 23 or-abstraction-anti-patterns — never diff-scoped; it lints all of lib/. +# 47 security-change-has-tests — DELTA, real base: it classified the docs-only +# hunks and found no security change. Computed. +# 48 csrf-cochange — DELTA, real base: it looked for a removed attribute in +# the diff and found none. Computed. +_ARM6_ALLOWED=" 4 15 16 23 47 48 " + +_wide_bad="" +while IFS= read -r _g; do + [ -z "${_g}" ] && continue + case "${_ARM6_ALLOWED}" in + *" ${_g} "*) continue ;; + esac + _wide_bad="${_wide_bad}${_g} " +done < <(grep -E '^\[gate-[0-9]+\][^:]*: PASS' "${_wide_diff}" \ + | grep -oE '^\[gate-[0-9]+\]' | grep -oE '[0-9]+' | sort -un) + +if [ -z "${_wide_bad}" ]; then + _ok "no gate reports PASS over a diff that excludes every planted defect (the .github#374 property, package-wide)" +else + _bad "gate(s) ${_wide_bad}reported PASS over a scope that excludes every planted defect — this is the .github#374 defect. Each of them printed the same word as a gate that read the whole tree and found it clean. Fix the gate's fall-through (see _skip_empty_scope in run-hydra-gates.sh); do NOT add it to _ARM6_ALLOWED unless you can state what it computed." +fi + +# ANTI-WIDENING. ARM 6 is satisfiable by making every gate skip always, so the +# same tree at FULL scope must still produce those findings — asserted by name, +# so "the gate went quiet" cannot pass as "the gate went green". +_wide_missing="" +for _g in 14 17 21 22 34 35 36 38 40 41 42 43 44; do + grep -qE "^\[gate-${_g}\][^:]*: FAIL" "${_wide_full}" || _wide_missing="${_wide_missing}${_g} " +done +if [ -z "${_wide_missing}" ]; then + _ok "anti-widening: every one of those gates still FAILS the planted tree at full scope" +else + _bad "gate(s) ${_wide_missing}no longer FAIL the planted tree at FULL scope — the empty-scope fix has widened into a permanent skip, which is the strictly worse defect" +fi + +# =========================================================================== +# ARM 7 — A RELATIVE APP-DIR PATH MUST PRODUCE THE SAME VERDICTS (.github#374) +# =========================================================================== +# +# `run-hydra-gates.sh:238` never absolutised APP_DIR before its `cd`, and +# gate-17 is the only gate that hands `${APP_DIR}` to its checker afterwards — +# so the scan root resolved a SECOND time, against the app dir itself, and the +# checker read `relapp/relapp`. It found nothing, printed its terminal +# `# count=0`, and gate-17 reported PASS. Same tree, absolute path: FAIL — 1. +# +# ⚠️ THIS IS THE ARM THE REST OF THE PACKAGE STRUCTURALLY CANNOT HAVE. +# `test_gate_acceptance_matrix.sh` builds every fixture path from `${PKG_ROOT}`, +# so its driver can only ever reproduce the SAFE invocation. A suite that can +# only express the safe call cannot test the unsafe one, and that is worth more +# than the bug: the invocation this runner's own header documents for humans +# (`./scripts/run-hydra-gates.sh [options] [app-dir]`) was untestable here. +# +# So this arm deliberately `cd`s to the PARENT and passes a BARE RELATIVE NAME. +echo +echo "-- ARM 7: a relative app-dir path --" + +_rel_parent="${_tmp}/relparent" +mkdir -p "${_rel_parent}/relapp/lib/Controller" "${_rel_parent}/relapp/appinfo" +cat > "${_rel_parent}/relapp/lib/Controller/ThingController.php" <<'PHP' +objectService->findAll([])); + } +} +PHP +printf ' "${_rel_parent}/relapp/appinfo/routes.php" +( + cd "${_rel_parent}/relapp" || exit 1 + git init -q . + git add -A + git -c user.email=t@t -c user.name=t commit -qm init +) >/dev/null 2>&1 + +_rel_abs_out="${_tmp}/rel-abs.txt" +_rel_rel_out="${_tmp}/rel-rel.txt" +_rel_logs="${_tmp}/rel-logs"; mkdir -p "${_rel_logs}" +_rel_logs2="${_tmp}/rel-logs2"; mkdir -p "${_rel_logs2}" +( + cd "${_rel_parent}" || exit 1 + HYDRA_GATE_LOG_DIR="${_rel_logs}" bash "${_runner}" "${_rel_parent}/relapp" \ + > "${_rel_abs_out}" 2>&1 +) +( + # THE WHOLE POINT: parent directory, bare relative name, no leading `./`. + cd "${_rel_parent}" || exit 1 + HYDRA_GATE_LOG_DIR="${_rel_logs2}" bash "${_runner}" relapp > "${_rel_rel_out}" 2>&1 +) + +# Positive control FIRST: the absolute invocation must catch the plant, or the +# comparison below is between two meaningless numbers. +if grep -qE '^\[gate-17\][^:]*: FAIL' "${_rel_abs_out}"; then + _ok "positive control: gate-17 FAILS the planted pass-through wrapper via an ABSOLUTE path" +else + _bad "positive control BROKEN: gate-17 did not catch the planted ADR-022 wrapper even via an absolute path — got: $(_verdict "${_rel_abs_out}" 17)" +fi + +_rel_abs_v="$(_verdict "${_rel_abs_out}" 17)" +_rel_rel_v="$(_verdict "${_rel_rel_out}" 17)" +if [ "${_rel_abs_v}" = "${_rel_rel_v}" ]; then + _ok "gate-17 returns the same verdict ('${_rel_rel_v}') for a relative and an absolute app-dir" +else + _bad "gate-17 verdict DEPENDS ON HOW THE CALLER SPELLED THE PATH: absolute '${_rel_abs_v}' vs relative '${_rel_rel_v}'. This is .github#374 — APP_DIR is not absolutised before the cd, so the checker resolves it a second time against the app dir." +fi + +# And the invariant that makes it impossible rather than merely fixed: the run +# must STATE the absolute path it resolved. A silent fix is one refactor from +# regressing with nothing to notice it. +if grep -qE '^\[hydra-gates\] App dir: /.* \(absolute\)$' "${_rel_rel_out}"; then + _ok "the run states the ABSOLUTE app dir it resolved, so a future relative path cannot reach a checker unannounced" +else + _bad "the run does not print an absolute 'App dir:' line — nothing in the output distinguishes a relative invocation from an absolute one" +fi + echo if [ "${_failures}" -eq 0 ]; then echo "test_gate_empty_scope_never_passes.sh: ALL PASS" diff --git a/hydra-gates/scripts/lib/test_gate_route_auth.sh b/hydra-gates/scripts/lib/test_gate_route_auth.sh index 6cb58932..d0ee57c6 100644 --- a/hydra-gates/scripts/lib/test_gate_route_auth.sh +++ b/hydra-gates/scripts/lib/test_gate_route_auth.sh @@ -348,7 +348,24 @@ if _run "${_REPO}" --scope-to-diff --base "${_BASE}"; then # this repository, so "PASS" would have claimed a clean bill of health for # four endpoints whose attributes this run cannot see at all. _expect_gate 5 "NOT APPLICABLE" "AppHost app, dependency-only diff: gate-5 clean (the scholiq case)" - _expect_gate 14 PASS "AppHost app, dependency-only diff: gate-14 clean" + # ⚠️ RECLASSIFIED 2026-08-12 from PASS to NOT APPLICABLE (.github#374), + # for the SAME reason 6a reclassified gate-5 on 2026-08-08 — and this line + # is why that reclassification was only half done. + # + # gate-14 filters routes through `_in_scope` one route at a time and then + # asked `_rr_fail -eq 0`. On this diff every route is filtered out, so zero + # findings over zero inspected routes printed `PASS` — indistinguishable + # from a repo whose every route was cross-checked and found reachable. The + # comment 40 lines up already states the argument in full: *"a PASS here is + # scoping, not absence" is a fact the verdict PASS does not state and NOT + # APPLICABLE does.* It was applied to gate-5 and not to its neighbour. + # + # Measured on a fixture carrying a real unrouted controller method: full + # scope FAIL — 1, docs-only diff PASS. The controls that keep this honest + # are 6b and 6c above (unchanged) plus the four `_expect_gate 14 FAIL` + # assertions earlier in this file — gate-14 must still raise a genuinely + # unreachable route the moment one is in scope. + _expect_gate 14 "NOT APPLICABLE" "AppHost app, dependency-only diff: gate-14 reports NO finding (scoped out, not absent)" fi echo diff --git a/hydra-gates/scripts/lib/test_gate_scope_matrix.sh b/hydra-gates/scripts/lib/test_gate_scope_matrix.sh index c65593b6..968c24d6 100644 --- a/hydra-gates/scripts/lib/test_gate_scope_matrix.sh +++ b/hydra-gates/scripts/lib/test_gate_scope_matrix.sh @@ -154,16 +154,39 @@ fi # =========================================================================== echo -echo "== arm 3 — FULL scope, same tree as arm 1 ==" +echo "== arm 3 — FULL file scope, NO delta base ==" # =========================================================================== -# `--full` exists to report inherited debt. The positive control above proves -# the debt is there and findable. Anything other than a FAIL here is the defect. -_out="$(gf_run_wrapper "${WORK}/inherited" "${WORK}/log-full" --full)" +# +# RE-KEYED FOR THE ADR-020 REVERSAL (hydra-gates/ADR-020-SUPERSEDED.md). +# +# This arm ran `--full` against a tree `gf_mark_base` had given a +# `refs/remotes/origin/development` ref, and asserted that the run computed no +# diff — because `--full` used to mean BOTH "read the whole tree" AND "compute +# no diff". +# +# ⚠️ AND THAT CONFLATION IS THE VERY THING `.github#347` WAS. The invariant this +# file enforces — *a NOT APPLICABLE may not blame a diff on a run that computed +# none* — is exactly right and stays. What was wrong is its TRIGGER: it keyed on +# the FILE SCOPE (`--full`) when the property is about whether A DIFF WAS +# COMPUTED. Those are two inputs now, and the suite was reading the wrong one — +# the same mistake, one layer up, inside the test that catches it. +# +# So arm 3 removes the base rather than the file scope, and arm 3b below covers +# the case that used to be unreachable and is now the COMMON one: full file +# scope WITH a real base, where naming the diff is a TRUE statement. +gf_build_repo "${WORK}/inherited-nobase" "${SRC}" +gf_commit_all "${WORK}/inherited-nobase" "base: app carrying an inherited post-event listener" +printf '\n- unrelated doc tweak\n' >> "${WORK}/inherited-nobase/docs/CHANGELOG.md" 2>/dev/null \ + || printf 'docs\n' > "${WORK}/inherited-nobase/README.md" +gf_commit_all "${WORK}/inherited-nobase" "docs: unrelated change" -if printf '%s' "${_out}" | grep -qF 'Base ref: n/a — --full requested'; then - _ok "the --full run states it computed no diff" +_out="$(gf_run_wrapper "${WORK}/inherited-nobase" "${WORK}/log-full")" + +if printf '%s' "${_out}" | grep -qF 'SCOPE-MODE: full' \ + && printf '%s' "${_out}" | grep -qF 'Delta base: NONE'; then + _ok "the run states BOTH its file scope (full) and that it resolved no delta base" else - _bad "the --full run did not announce itself as unscoped; the rest of this arm is unsafe to interpret" + _bad "the run did not state both 'SCOPE-MODE: full' and 'Delta base: NONE'; the rest of this arm is unsafe to interpret because nothing pins which inputs produced it" fi # @@ -204,7 +227,60 @@ fi # =========================================================================== echo -echo "== the general property: no NOT APPLICABLE may blame a diff on a --full run ==" +echo "== arm 3b — FULL file scope WITH a delta base (the new common case) ==" +# =========================================================================== +# +# Full file scope is now the DEFAULT, so this is what every PR in the fleet +# runs: the whole tree open to the state gates, a real base for the delta gates. +# Under the old default this combination was unreachable, which is why nothing +# asserted it. +# +# Naming the diff here is a TRUE statement — one was computed — so the invariant +# below must NOT fire on it. What has to hold instead is the falsifiability +# condition that made `#347` undetectable for weeks: **if a reason names a diff, +# the run must have printed the base it diffed against**, so a reader can check +# the claim rather than trust it. +_outb="$(gf_run_wrapper "${WORK}/inherited" "${WORK}/log-full-base")" + +if printf '%s' "${_outb}" | grep -qE '^\[hydra-gates\] Delta base: [^N]'; then + _ok "the full-scope run resolved a delta base and NAMED it" +else + _bad "the full-scope run resolved no delta base though the fixture has refs/remotes/origin/development — arm 3b would then be re-measuring arm 3 and proving nothing" +fi + +_vb="$(gf_verdict "${_outb}" 61)" +case "${_vb}" in + *"NOT APPLICABLE"*) + _ok "gate-61 still declines at full file scope — the deliberate delta-scoping survived the reversal" + ;; + *FAIL*) + _bad "gate-61 SWEPT THE TREE once full scope became the default. That was tried and reverted before: the builder runs unscoped, so this surfaces the whole registration backlog as blocking findings on every build. Got: ${_vb:0:160}" + ;; + *) _bad "gate-61 gave an unrecognised verdict at full scope with a base: ${_vb:0:160}" ;; +esac + +# A reason may name a diff ONLY on a run that computed one. Asserted from the +# other side here, so the pair pins both directions. +if printf '%s' "${_vb}" | grep -qF 'the diff against'; then + if printf '%s' "${_outb}" | grep -qE '^\[hydra-gates\] Delta base: [^N]'; then + _ok "gate-61 names the diff it was actually given, on a run that printed that base — a checkable claim, not #347's unfalsifiable one" + else + _bad ".github#347 IS LIVE BY A NEW ROUTE: gate-61 names a diff on a run that announced no delta base" + fi +fi + +# The backlog SIZE must be stated here too. This is now the common path, and +# without it '0 of 1' and '0 of 45' print identically on every PR in the fleet — +# the exact ambiguity the advisory sweep was added to remove on the other path. +if printf '%s' "${_vb}" | grep -qE 'ADVISORY.*[0-9]+ registration\(s\) carrying [0-9]+ finding\(s\)'; then + _ok "gate-61 states the size of the backlog it did not inspect at FULL scope too, not only when there is no base" +else + _bad "gate-61's full-scope skip does not state how many registrations went unread. This is the common case now, so the ambiguity the advisory sweep removed on the no-base path has simply moved to the path everyone runs: ${_vb:0:200}" +fi + +# =========================================================================== +echo +echo "== the general property: no NOT APPLICABLE may blame a diff on a run that COMPUTED NONE ==" # =========================================================================== # Generic, gate-agnostic. Catches the next gate that does this. # diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index d1beb77b..73c35068 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -40,13 +40,32 @@ # for apps following the standard Conduction NC app layout: lib/ + appinfo/ # + optional src/ + tests/. # +# SCOPE: FULL BY DEFAULT. ADR-020 IS SUPERSEDED — see +# hydra-gates/ADR-020-SUPERSEDED.md for the decision and what it costs. +# +# The scope is TWO independent, named inputs, and both are printed every run: +# +# FILE SCOPE which files the STATE gates open. Defaults to the entire +# tracked tree. `--scope-to-diff` narrows it to the change. +# DELTA BASE what the five DELTA gates (16, 29, 47, 48, 61) compare +# against. Resolved independently of the file scope, so a +# full-scope run on a PR still judges the change. +# # Options: -# --scope-to-diff [BASE] — Phase G: only scan files changed vs BASE -# (default origin/development). Inherited debt -# in unchanged files is ignored. Required for -# reviewer/security post-flight enforcement; -# optional for builder (build mode runs full). -# --base BRANCH — override the diff base (default origin/development) +# --full — scan the whole tree. THIS IS THE DEFAULT; the +# flag names it rather than switching to it. +# --scope-to-diff, --diff — the old ADR-020 behaviour, now OPT-IN: judge only +# the files this change touched. Inherited debt in +# unchanged files is not judged. +# --base BRANCH — the delta base. Also the file-scope base when +# --scope-to-diff is given. NO DEFAULT: the old +# hardcoded `origin/development` is the shared root +# cause of .github#347 and #361 — a base nobody +# supplied, silently diffing a branch against +# itself, and printing PASS. +# $HYDRA_GATE_SCOPE — `full` | `diff`, the env form of the two flags. +# Anything else stops the run: a run whose scope +# nobody can name is not evidence about anything. # # When the base resolves to the SAME COMMIT as HEAD — which is what every push # to a mainline branch looks like — the scope is taken from the push's own @@ -200,8 +219,45 @@ else echo "[hydra-gates] Set HYDRA_GATES_PKG_SHA to make the comparison sound." fi +# --------------------------------------------------------------------------- +# THE SCOPE IS TWO INPUTS, NOT ONE, AND BOTH ARE NAMED AND PRINTED. +# +# `SCOPE_TO_DIFF` used to be the only scope control, and it decided two +# unrelated things at once: +# +# FILE SCOPE which files the STATE gates open ("is the tree valid?") +# DELTA BASE what the DELTA gates compare against ("did this change do X +# without Y?") +# +# Conflating them is why "I ran it with no base" was never a scope. On a full +# run gate-19 swept the whole tree while gate-16 fell back to a hardcoded +# `origin/development` and reported PASS over nothing (`.github#361`), and +# gate-61 declined citing a diff the run had never computed (`.github#347`) — +# the same package answering the same question two different ways. +# +# So they are separate now: +# +# SCOPE_TO_DIFF=0 FULL SCOPE — the default. Every state gate reads the whole +# tracked tree. This is the reversal of ADR-020; see +# hydra-gates/ADR-020-SUPERSEDED.md for why, and for what it +# costs. +# SCOPE_TO_DIFF=1 the old ADR-020 behaviour, now OPT-IN via --scope-to-diff. +# HAVE_DELTA_BASE set independently of the above. A delta gate runs when +# this is 1 and reports NOT APPLICABLE — never PASS — when +# it is 0. A full-scope run on a PR still HAS a base, so +# gates 16/29/47/48/61 keep working; a workflow_dispatch has +# none, and they say so by name. +# +# `BASE_REF` starts EMPTY, deliberately. The old `origin/development` default +# is the exact shape SHARED-LESSONS records twice: a base nobody supplied, +# silently diffing a branch against itself, and printing PASS. If a base is +# wanted it is stated — by --base, by $HYDRA_GATE_BASE_REF, or by the +# auto-detection below — and the winner is printed. SCOPE_TO_DIFF=0 -BASE_REF="origin/development" +BASE_REF="" +# 1 once a usable base has been resolved AND its changed-file set computed. +# Read by the DELTA gates (16, 29, 47, 48, 61) instead of SCOPE_TO_DIFF. +HAVE_DELTA_BASE=0 APP_DIR="" # Treat "a declared gate did not run" as a failure (exit 98). Off by default so # a Tier-0 app is not blocked by gates it has no surface for; on, the run @@ -225,9 +281,29 @@ REQUIRE_FULL_COVERAGE="${HYDRA_GATE_REQUIRE_FULL_COVERAGE:-0}" # to guess "unverified" in both cases, which is why --require-full-coverage was # unusable in every repo in the fleet. AXE_ENABLED="${HYDRA_GATE_AXE_ENABLED:-0}" +# THE SCOPE IS AN EXPLICIT, NAMED INPUT — never inferred from whether a base +# happens to be set. `$HYDRA_GATE_SCOPE` is the env form of the flags below; an +# unrecognised value is a hard stop rather than a silent fall-back to either +# mode, because "which scope did that run use?" is the one question every +# verdict in this file is an answer to. +case "${HYDRA_GATE_SCOPE:-full}" in + full) SCOPE_TO_DIFF=0 ;; + diff) SCOPE_TO_DIFF=1 ;; + *) + echo "[hydra-gates] FATAL: \$HYDRA_GATE_SCOPE='${HYDRA_GATE_SCOPE}' is not 'full' or 'diff'." >&2 + echo "[hydra-gates] Refusing to guess a scope. A run whose scope nobody can name is not" >&2 + echo "[hydra-gates] evidence about anything. NOTHING WAS CHECKED." >&2 + exit 99 + ;; +esac +BASE_REF="${HYDRA_GATE_BASE_REF:-}" while [ $# -gt 0 ]; do case "$1" in - --scope-to-diff) SCOPE_TO_DIFF=1; shift ;; + --scope-to-diff|--diff) SCOPE_TO_DIFF=1; shift ;; + # Explicit and redundant, and kept because it is what every caller in + # the fleet already passes for an audit run. It now names the DEFAULT + # rather than switching to it. + --full) SCOPE_TO_DIFF=0; shift ;; --require-full-coverage) REQUIRE_FULL_COVERAGE=1; shift ;; --axe-enabled) AXE_ENABLED=1; shift ;; --base) BASE_REF="$2"; shift 2 ;; @@ -236,7 +312,61 @@ while [ $# -gt 0 ]; do esac done APP_DIR="${APP_DIR:-$(pwd)}" +# --------------------------------------------------------------------------- +# ABSOLUTISE THE APP DIR *BEFORE* THE `cd`, AND NEVER LET A RELATIVE PATH REACH +# A CHECKER (.github#374). +# +# This line used to be a bare `cd "${APP_DIR}"`, leaving ${APP_DIR} holding +# whatever spelling the caller typed. Every gate that only ever works from the +# CURRENT DIRECTORY is unaffected — but gate-17 hands `${APP_DIR}` to +# detect-redundant-controllers.py as its scan root AFTER the `cd`, so a +# relative path resolves a second time, against the app dir itself. +# +# Measured on a fixture holding exactly one ADR-022 pass-through wrapper, same +# tree, same commit, only the path SPELLING changed: +# +# bash run-hydra-gates.sh /abs/path/relapp -> [gate-17] FAIL — 1 +# bash run-hydra-gates.sh relapp -> [gate-17] PASS +# +# The checker was pointed at `relapp/relapp`, found nothing, printed its +# terminal `# count=0`, and the runner correctly read that as "clean". Every +# layer behaved exactly as designed; the scan root was wrong before any of them +# saw it. +# +# `bin/hydra-gates` already absolutises (bin/hydra-gates: `APP_DIR="$(cd …)"`), +# so CI has never been exposed — but the invocation this file's own header +# documents for humans (`./scripts/run-hydra-gates.sh [options] [app-dir]`) is, +# and a gate that reports PASS because of how its caller spelled a path is the +# same defect as a gate that reports PASS over an empty scope. +# +# Resolved ONCE, HERE, so it is impossible to reach a checker with a relative +# path: everything below this line — including every `${APP_DIR}` a gate passes +# on — is absolute by construction. `cd -P` resolves symlinks too, so the value +# a checker receives is the directory it will actually read. +_app_dir_abs="$(cd -P "${APP_DIR}" 2>/dev/null && pwd)" +if [ -z "${_app_dir_abs}" ]; then + # Keep the caller's SPELLING in the message — that is the thing they typed + # and the thing they can fix. Overwriting APP_DIR first would print an + # empty path and blame nothing. + echo "[hydra-gates] ERROR: ${APP_DIR} not accessible" >&2 + exit 99 +fi +APP_DIR="${_app_dir_abs}" cd "${APP_DIR}" 2>/dev/null || { echo "[hydra-gates] ERROR: ${APP_DIR} not accessible" >&2; exit 99; } +echo "[hydra-gates] App dir: ${APP_DIR} (absolute)" + +# ONE MACHINE-READABLE LINE FOR THE FILE SCOPE, EMITTED EXACTLY ONCE. +# +# Downstream must never infer the scope from prose, from whether a base was +# printed, or from a gate's own wording. Every one of those has been wrong at +# least once in this package's history. +if [ "${SCOPE_TO_DIFF}" = "1" ]; then + echo "[hydra-gates] SCOPE-MODE: diff" + echo "[hydra-gates] File scope: the diff only (--scope-to-diff / HYDRA_GATE_SCOPE=diff). Inherited debt in untouched files is NOT judged." +else + echo "[hydra-gates] SCOPE-MODE: full" + echo "[hydra-gates] File scope: the ENTIRE tracked tree — the default. A gate added or tightened after this code was written WILL surface its inherited debt here, and that is the point (hydra-gates/ADR-020-SUPERSEDED.md)." +fi # When scope-to-diff is requested, derive the changed-files set once. # Non-diff branches that need the set: each gate below filters its @@ -456,8 +586,67 @@ if [ "${SCOPE_TO_DIFF}" = "1" ]; then else echo "[hydra-gates] Scope: diff vs ${BASE_REF} — ${_cf_count} changed file(s)" fi + # A diff-scoped run that got this far has a usable base by construction — + # every path that could not produce one exited 99 above. + HAVE_DELTA_BASE=1 else echo "[hydra-gates] Scope: full repo" + # ── A DELTA BASE FOR A FULL-SCOPE RUN ─────────────────────────────────── + # + # Full FILE scope does not mean "no base". The five DELTA gates — + # 16 spec-coverage, 29 gitignore-then-commit, 47 security-change-has-tests, + # 48 csrf-cochange, 61 listener-work-placement — ask questions only a diff + # can answer, and switching the default to full scope would have silently + # retired all five on every PR in the fleet. They are the gates that + # protect the change in front of you, so losing them to gain inherited-debt + # coverage would be a straight downgrade. + # + # So: if the caller named a base, resolve it and hand it to those gates + # while every OTHER gate reads the whole tree. + # + # EVERY FAILURE HERE IS NON-FATAL, and that is the difference from the + # diff-scoped branch above. There, an unusable base means the run cannot be + # scoped and exit 99 is correct. Here, an unusable base costs five gates + # and nothing else — 59 of 64 still have a real verdict — so refusing would + # throw away a good answer to punish a bad input. The five say NOT + # APPLICABLE, by name, with the reason below quoted back at the reader. + if [ -n "${BASE_REF}" ]; then + _db_why="" + _db_head=$(git -c safe.directory='*' rev-parse --verify --quiet 'HEAD^{commit}' 2>/dev/null || true) + _db_base=$(git -c safe.directory='*' rev-parse --verify --quiet "${BASE_REF}^{commit}" 2>/dev/null || true) + if [ -z "${_db_base}" ]; then + _db_why="it does not resolve in this repository" + elif [ "${_db_base}" = "${_db_head}" ]; then + # The mainline-push shape. `bin/hydra-gates` re-scopes this to + # github.event.before before it ever reaches here, so seeing it + # means a direct runner invocation. Comparing HEAD with itself + # yields an empty diff and would make the delta gates report a + # confident PASS over nothing — the exact defect this file spends + # 200 lines guarding against on the other branch. + _db_why="it is the SAME COMMIT as HEAD (${_db_head}), so the diff would be empty by construction" + elif ! git -c safe.directory='*' merge-base "${BASE_REF}" HEAD > /dev/null 2>&1; then + _db_why="it shares NO history with HEAD — the shape of a shallow checkout (fetch-depth: 1)" + else + CHANGED_FILES=$(git -c safe.directory='*' diff --name-only \ + --diff-filter=ACMR "${BASE_REF}...HEAD" 2>/dev/null) \ + && HAVE_DELTA_BASE=1 || _db_why="git could not compute the diff against it" + fi + if [ "${HAVE_DELTA_BASE}" = "1" ]; then + _db_count=$(printf '%s' "${CHANGED_FILES}" | grep -c . 2>/dev/null || true) + echo "[hydra-gates] Delta base: ${BASE_REF} = $(git -c safe.directory='*' rev-parse --short "${BASE_REF}" 2>/dev/null) — ${_db_count:-0} changed file(s)." + echo "[hydra-gates] The DELTA gates (16, 29, 47, 48, 61) judge that change set. Every other gate reads the whole tree." + else + CHANGED_FILES="" + echo "[hydra-gates] Delta base: UNUSABLE — '${BASE_REF}' was named but ${_db_why}." + echo "[hydra-gates] Gates 16, 29, 47, 48 and 61 will report NOT APPLICABLE by name. They are" + echo "[hydra-gates] NOT counted as passing. Every other gate is unaffected and still reads the whole tree." + BASE_REF="" + fi + else + echo "[hydra-gates] Delta base: none — no --base and no \$HYDRA_GATE_BASE_REF." + echo "[hydra-gates] Gates 16, 29, 47, 48 and 61 ask what a CHANGE did and have no change to read." + echo "[hydra-gates] They will report NOT APPLICABLE by name, which is not a pass and does not count as one." + fi fi # Helper — return 0 if $1 (a file path) is in scope (i.e. either we're @@ -1441,6 +1630,54 @@ _skip() { esac } +# --------------------------------------------------------------------------- +# _skip_empty_scope