Skip to content

fix(gates): a gate must never report PASS over a scope it did not open (#242, #240) - #258

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/gates-242-240-empty-scope-skips
Aug 8, 2026
Merged

fix(gates): a gate must never report PASS over a scope it did not open (#242, #240)#258
rubenvdlinde merged 2 commits into
mainfrom
fix/gates-242-240-empty-scope-skips

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #242. Closes #240.

The defect

Gates 19 (e2e-coverage), 25 (contract-coverage), 62 (store-plane) and 63 (settings-surface) diff-scoped themselves inside their own helpers, below the runner's base resolution — and did it unconditionally. The base ref was defaulted to origin/development even when the caller had asked for no scoping at all.

Two consequences, and the second is why it stayed hidden:

  1. A full-tree run — the mode a fleet audit uses — was silently narrowed to a diff against origin/development, which on a mainline checkout is empty.
  2. The verdict for "I inspected nothing" was PASS, not a skip. So --require-full-coverage — the one assertion built to catch gates that did not run — had nothing to catch.

#240 is the clearest illustration: gate-63's log printed gate skipped on the line directly above a verdict that said PASS. Both cannot be true, and PASS is the one every consumer counted.

Measured on openconnector (2026-08-08)

gate as the runner invoked it over the full tree hidden
19 e2e-coverage 5 findings 412 407
25 contract-coverage PASS 32 32

439 findings behind two green lines, in one repo.

The fix

  • the base ref is now the caller's decision and is never defaulted — set means diff-scoped, unset means full-tree audit
  • an empty scope returns a distinct status; the runner maps it to _skip … structural, which counts against coverage and fails --require-full-coverage
  • absent subject matter returns its own status and maps to _skip … na
  • gate-25 scopes on controller files, not "any file" — a docs-only diff opens no controller, and PASS there claimed a wire contract had been read when none was
  • gate-25 returns a status rather than the finding count, adopting the convention gate-19 settled on in fix(gate-19): the finding count was returned as an exit status, and a byte only holds 255 #209; the count is read from stdout

Statuses extend #249's 0/1/2 rather than introducing a second convention: 3 = empty scope, 4 = not applicable.

Proof, both arms

gate-19  SKIPPED (structural) — the diff against 'HEAD~1' touched NO spec file …UNVERIFIED
gate-25  SKIPPED (structural) — the diff against 'HEAD~1' changed NO controller file …
gate-62  SKIPPED (structural) — …
gate-63  SKIPPED (structural) — …
[hydra-gates] --require-full-coverage was set: treating incomplete coverage as failure.
EXIT=98

…while over the full tree the same fixture's planted true positives are still caught (gate-19 and gate-25 both FAIL on one genuine finding each), and 62/63 PASS over a clean manifest rather than skipping themselves into uselessness.

Mutation check — three mutants, all killed

  1. pristine pre-fix runner — every arm red
  2. the Three gates report PASS over an EMPTY DIFF without calling _skip — so --require-full-coverage cannot see them (openconnector: 404 + 32 hidden findings) #242 defect reintroduced on gate-19 — ARM 1 red: the planted true positive stops being caught
  3. anti-widening control, checkers forced to inspect nothing — ARM 1 and ARM 3 red, so the suite cannot be satisfied by skipping everything

That third mutant is the one that matters: without it, "make every empty scope a skip" could be satisfied by skipping unconditionally, and the suite would look fixed while checking nothing.

Fleet impact

The fleet consumes these gates at @main, unpinned, so this reaches all 16 apps on their next run. Repos will see gates 19/25/62/63 move from PASS to either a real finding count or a stated SKIP. That is the point — none of those PASSes were measurements.

🤖 Generated with Claude Code

#242, #240)

Gates 19 (e2e-coverage), 25 (contract-coverage), 62 (store-plane) and 63
(settings-surface) diff-scoped themselves INSIDE their own helpers, below the
runner's base resolution, and did it UNCONDITIONALLY — the base ref was
defaulted to origin/development even when the caller had asked for no scoping
at all.

Two consequences, and the second is why it stayed hidden:

  1. A full-tree run — the mode a fleet audit uses — was silently narrowed to a
     diff against origin/development, which on a mainline checkout is empty.
  2. The verdict for "I inspected nothing" was PASS, not a skip. So
     --require-full-coverage, the one assertion built to catch gates that did
     not run, had nothing to catch.

gate-63 was the clearest case (#240): its log printed "gate skipped" on the
line above a verdict that said PASS. Both cannot be true, and PASS is the one
every consumer counted.

Measured on openconnector 2026-08-08:

  gate-19    5 findings as the runner invoked it  ->  412 over the full tree
  gate-25    PASS as the runner invoked it        ->   32 over the full tree

- the base ref is now the caller's decision and is never defaulted: set means
  diff-scoped, unset means full-tree audit
- an empty scope returns a distinct status and the runner maps it to
  `_skip … structural`, which counts against coverage and fails
  --require-full-coverage
- absent subject matter returns its own status and maps to `_skip … na`
- gate-25 scopes on CONTROLLER files, not "any file": a docs-only diff opened no
  controller, and PASS there claimed a wire contract had been read when none was
- gate-25 now returns a STATUS rather than the finding count, matching the
  convention gate-19 settled on in #209; the count is read from stdout

Mutation-checked, three mutants, all killed:
  1. pristine pre-fix runner — every arm red
  2. the #242 defect reintroduced on gate-19 — ARM 1 red: the planted true
     positive stops being caught
  3. ANTI-WIDENING CONTROL, checkers forced to inspect nothing — ARM 1 and
     ARM 3 red, so the suite cannot be satisfied by skipping everything
Follow-up to the empty-scope work, found by test_check_e2e_coverage.py.

Adding the NOT APPLICABLE verdict introduced a regression of exactly the kind
this issue is about: `openspec/specs` missing and the app dir being unreadable
produce the SAME empty set, and the new code reported both as NOT APPLICABLE.
That would retire the gate on the strength of a typo in a path. A missing
directory is now an ERROR — a failure to look, not an absence of specs.

Three tests updated, and two of them encoded the defect as an expectation:

- test_pass_when_no_spec_files_in_diff asserted PASS for a repo with no specs.
  Renamed and now asserts NOT APPLICABLE: "I inspected nothing" and "I inspected
  everything and it was fine" cannot share a verdict.
- test_diff_scope_only_changed_spec_flagged asserted PASS when the diff touched
  no spec. It now asserts EMPTY SCOPE, and STILL asserts the ADR-020 invariant
  it was written for — an untouched legacy spec is never flagged.
- test_run_gate_raising_is_reported_as_ERROR monkeypatched changed_spec_files,
  which an unscoped run no longer calls. It now sets a base ref, so it exercises
  the path it claims to. Without this it would have passed for a reason
  unrelated to what it checks.

All 105 gate-19 helper tests green; all 33 discovered helper suites green.
@rubenvdlinde
rubenvdlinde merged commit c1f7deb into main Aug 8, 2026
31 checks passed
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
…ver a dead interpreter, and three could not see the defect they exist for (#280)

* fix(gates 45-55): eleven gates passed over an unopened scope, eight over a dead interpreter, and three could not see the defect they exist for

Every gate in this band was given ONE textbook true positive of exactly what
it exists to catch, planted in a real fleet repo, then removed again. Where a
gate could not fail, it was repaired; where it could, the plant is now a
regression test. Measured at package sha 34370f6.

## 1. All eleven reported PASS over a scope they never opened (#242/#240/#258/#268)

On a README-only diff against larpingapp, gates 45-55 printed eleven PASS
lines and the summary read "53 of 53 applicable gates ran". Not one of them
had opened a file. Gates 4/6/7/19/25/28/62/63 have answered the identical
situation with NOT APPLICABLE since #268; this band never adopted it.

Gates 47 and 48 are the sharper case: they can only answer a question about a
CHANGE SET, so on every builder full-repo run in the fleet — no base ref at
all — they printed a co-change verdict they had not formed.

## 2. Eight reported PASS over a crashed interpreter (#147/#249/#262)

A planted defect only fires when the gate runs, so no plant can see this. With
a `python3` on PATH that exits 1 on every call, on a tree carrying real
findings:

  gate-46  PASS — over the 277 unresolved @SPEC findings, across 104 distinct
           targets, it had reported one run earlier on the same files
  gate-47  PASS — on the same diff where it had just reported FAIL
  gate-45/49/50   PASS  (`2>/dev/null` discarded status and traceback)
  gate-51/54/55   PASS  (`|| true` discarded the status)
  gate-52  FAIL — "1 custom-widget finding(s)", a fabricated finding: the
           helper returned its COUNT as its exit status, the same channel
           Python uses for a traceback (#209). The count was also clamped to
           99 to fit in a byte. It now prints `findings=N` on stdout and exits
           boolean; no `findings=` line means the helper died.

gate-54 was the quietest: its advisory WARN half reads the same log, so a dead
helper silenced both halves at once.

## 3. gate-45 was the residue of #272's fix (.github#274)

#272 migrated gates 35/40/42/44 off `[ -d src ]` onto `_a11y_has_markup_dir`
and left the twelfth member of the family behind. On a templates-only app
gate-45 reported NOT APPLICABLE — "this repo ships no frontend" — over a
`<style>` block with `transition:` and no reduced-motion fallback, in the same
file gate-43 FAILED on in the same run. `na` is the one verdict that removes a
gate from coverage accounting.

The regression test was already written and gate-45 was excluded from it by
name, with a comment explaining why. Removing the name from ARM 4's skip list
in test_gate_a11y_markup_scope.sh IS the test; it fails against 34370f6.

## 4. gate-47: prose satisfied it, and a qualified attribute did not

`_ANNOTATION_RE` was an unanchored alternation of string literals, and it was
wrong in both directions from that one regex — the pairing #269 found in
gate-48 and never carried to its sibling.

  FALSE POSITIVE  rewording ONE docblock sentence that merely NAMES the
                  annotation ("becomes `@NoAdminRequired` again, paired with a
                  real ownership check") made the gate demand a test
                  co-change. A gate satisfiable by prose manufactures the
                  appearance of a security review (#191).
  FALSE NEGATIVE  `#[\OCP\AppFramework\Http\Attribute\NoAdminRequired]` was
                  invisible. A commit adding exactly that to a controller —
                  opening an admin-only endpoint to every authenticated user —
                  with no test in the diff reported PASS.

Now position-anchored, by the same rule check_csrf_removal.py already used.

## 5. gate-50: a false positive and a false negative in the same regex

  FALSE NEGATIVE  the app-id argument had to be a QUOTED STRING, so every read
                  written the fleet-standard way — `getValueString(
                  Application::APP_ID, 'listing_register', '')` — was invisible.
                  Identical code with `'larpingapp'` FAILED. Same family as
                  #184. 7 security-relevant reads across 5 repos sit behind a
                  constant today.
  FALSE POSITIVE  the empty-compare guard required a closing paren immediately
                  after the empty string, so the correct compound guard
                  `if ($reg === '' || $sch === '')` was reported as unguarded —
                  twice, on code the gate was asking for. A guard that is a
                  boolean `return` rather than an `if` was rejected too.

Both directions are now asserted, including the opencatalogi#86 shape that
mixes them: one read guarded, the next unguarded two lines later.

## 6. gate-53 did not block the PR that creates larpingapp#286

Reintroducing #286 exactly — the check-in tab deleted from src/manifest.json,
`EventRoster` left registered in src/registry.js — reported PASS. Direction 1
of the registry cross-reference stays advisory for LEGACY orphans, correctly:
the gate cannot tell "wire it" from "delete it". But when the DIFF ITSELF
removed the last reference it can, and that finding now blocks. Pre-existing
orphans are untouched (larpingapp carries one today), so this is prevention,
not a burn-down list nobody can close.

## Verified working, repaired nothing

gate-46 (dangling file, dangling fragment, valid anchor), gate-48 (short and
fully-qualified attribute removal; a comment reword correctly stays green),
gate-49, gate-51 (title, description and nested items.properties independently),
gate-52's ratchet (growth fails, shrink passes), gate-54 (flat, nested and
$ref-carrying), gate-55.

## Deliberately NOT enforced

`title == key` on a schema property is a real gate-51 defect — the renderer
uses `prop.title || key`, so the user sees the raw technical key. Measured
across 10 repos: 148 occurrences, ALL of them in softwarecatalog, where they
are VNG-standardised element names (`identifier`, `type`, `name`) that must
not be renamed. Enforcing it would produce 148 findings with no legitimate end
state in the one repo that has them. Reported rather than gated (#252).

## Divergence to reconcile

gate-45 now answers an empty in-scope set with `na`; gate-40 answers it with
PASS, by a deliberate choice in #272 that cited the invariant test this PR
reworks. The invariant now discriminates on the REASON — the applicability
table's own phrasing must not appear once its prerequisite holds — so both
behaviours are expressible. The family should pick one.

## Testing

New: hydra-gates/scripts/lib/test_gate_45_to_55_acceptance.sh — 31 arms across
six families, discovered by run-helper-suites.sh. Against the package as
merged on main it fails 20 of 31; the 11 that pass are exactly the
anti-widening and no-regression controls. Every mutation asserts its anchor is
present before it plants.

Repos used, chosen for different shapes: larpingapp (register-owning,
manifest-driven, ships registry.js), nldesign (PHP templates, no .vue, no
register), doriath (ships no phpcs SpecTagSniff — the #246 control, held at
81 findings across 46 targets before and after the plant), openconnector
(41 register files).

Full package suite: 52 discovered suites pass, 2 quarantined as documented;
60/60 entry-point invariants.

* fix(gate-50): the fail-mode window started where the call began, not where it ended

The constant-app-id fix in the parent commit made procest's config reads
visible for the first time and immediately produced 3 findings on
lib/Service/AiService.php — all three false positives, and both causes are
ordinary code the window could never have seen:

  multi-line call   PHPCS formats each read across five lines. Two of them
                    plus a blank line put the guard on the ELEVENTH line, one
                    outside a window counted from the line the match BEGAN on.
                    The guard being missed is a textbook
                    `if (empty($registerId) === true || empty($schemaId) === true)
                    { $this->logger->warning(...); return; }` (AiService.php:580, :967).

  same-line guard   `'ai_api_key_set' => ...getValueString(APP_ID, 'ai_api_key', '') !== ''`
                    handles the empty default ON the match line, and the window
                    started after it (AiService.php:710).

The window now anchors to the END of the call expression — parentheses
balanced forward from the `(` — and includes the remainder of that line. A
single-line read keeps exactly the ten lines it always had.

Caught by a before/after sweep of 12 fleet repos: 26 of 121 verdicts changed,
25 of them PASS -> NOT APPLICABLE (the truthfulness correction), and this was
the only one that changed to FAIL. procest is PASS again, correctly.

Three arms added: the multi-line shape, the same-line shape, and the reverse
control — the same multi-line shape with the guard DELETED must still FAIL, so
the window cannot have been widened until the gate finds nothing.

Also: shellcheck SC2181 in gate-45's new status check, and a file-scoped
SC2016 suppression for the acceptance suite, whose PHP fixtures are
single-quoted on purpose.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant