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 - #280
Conversation
…ver 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.
…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.
…ave nothing to guard (#282) Follow-up to #280, caught by re-running the 12-repo before/after sweep and comparing FINDING COUNTS rather than verdicts. The verdict-level diff I ran first showed 26 changes and missed this entirely, because the verdict did not move: softwarecatalog was FAIL before and FAIL after — at 23 findings and then at 64. ## What went wrong #280 fixed a real blind spot: gate-50's 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. That was measured at 7 security-relevant reads across 5 repos. The regex I wrote to fix it accepted `[^,()]+`, i.e. anything up to the comma. That is a strictly larger widening than the defect measured, and it takes `$app` and `$this->appName` as well. 47 of softwarecatalog's 41 new findings are entries in an array literal that assembles the admin settings payload: 'sendgridApiKey' => $this->config->getValueString($app, 'email_sendgrid_api_key', ''), No defense is being deactivated in a settings read-out, and there is nothing to guard — you cannot add an empty-check to an entry in an array literal. The finding has no legitimate end state, which is the unclosable-gate shape (#252) and the thing most likely to make people stop reading this gate. ## What this changes The accepted app-id shapes are now exactly the ones measured as blind: a quoted literal, or a class constant (`Application::APP_ID`, `self::APP_ID`, `static::APP_ID`). Measured after: softwarecatalog 23 -> 17 (0 added; the 6 removed are #280's window fix, verified by hand: ArchiMateService.php:1804-1807 and ArchiMateImportService.php:2050-2052 are all guarded by `if ($rawRegisterId !== null && $rawRegisterId !== '' && ...)` or by a same-line `if (getValueString(...) !== '')`) procest 3 -> 0 (all three were the multi-line-window false positives #280 fixed) larpingapp/decidesk/opencatalogi/openconnector/doriath/nldesign — unchanged So against the package before #280, this band now reports strictly fewer gate-50 findings and every removal is a verified false positive, while the 7 constant-app-id reads that were invisible are caught. ## The blind spot is now stated, not silent A read whose app id is a plain VARIABLE remains invisible to this gate. That is not a safe shape — it is an unmeasured one, and separating the settings read-outs from the real scope decisions among them needs a data-flow question a regex cannot ask. Two arms pin the boundary rather than an ideal: C9 settings read-outs with `$app` / `$this->appName` -> PASS, with the number and the reason in the test's own comment C10 the SAME read written with a class constant -> FAIL, so C9 is a boundary and not a hole the gate fell through ## Note on method Comparing verdicts across a sweep is not enough: FAIL -> FAIL hid a 23 -> 64 change. Count, not verdict, is the comparison that would have caught this before the merge.
Correction to a number in this PR's descriptionThe description says the constant-app-id blind spot was measured at "7 security-relevant reads across 5 repos". That is an undercount, and the cause is mine: the loop I measured with enumerated ten repos by hand and pipelinq and hermiq were not in the list. A hand-written repo list decays exactly like a hand-written paths filter — the omission is invisible. Recounted over twelve repos: pipelinq writes
So the direction of the fix is unchanged and the finding it recovers is real, but "7 across 5 repos" should read "~298 reads across 6 repos, of which 38 are unguarded — 38 of them in pipelinq alone." Recording it here rather than leaving the smaller number to be quoted onward. Related: the sweep I used to validate this PR compared verdicts, not counts, and that is how a 23 -> 64 regression on softwarecatalog reached |
…l class read as absent, and a lazy closure read as an eager one Second pass over the 56-64 band, planting in a SECOND repo of a different shape per gate. Four more defects, all measured against gate package 48c88ba. A CRASHED CHECKER REPORTED PASS — gates 56 and 57 -------------------------------------------------- Both invoked their helper as `>> log 2>/dev/null || true` and then derived the verdict from `wc -l` on the log. Stderr discarded, exit status discarded, empty log — so a checker that never started reported PASS. Measured on shillinq with a python3 shim that exits 1 for exactly these two helpers: [gate-56] register-handler-resolution: PASS <- 153 registers [gate-57] orphaned-write-capability: PASS <- 316 services and gate-57 had reported 20 real findings over that same tree on the previous run. That is gate-40's defect verbatim. Both helpers ALWAYS exit 0 when they run, by design (#209 — the count goes to stdout, never into the exit byte), so a non-zero exit can only be a crash and never a finding count. Both now emit SKIPPED (wiring) and keep the stderr on disk. Every gate in this band was re-checked for the count-as-exit-status defect found in gates 19, 26 and 52: none of the nine has it. 56 and 57 put the count on stdout and the runner counts lines; 58/59/60/61/62/63/64 return status codes only. A REAL CLASS READ AS ABSENT — gate 56 -------------------------------------- The PSR-4 path guess handles the conventional layout; the fallback walk over lib/ is what finds a type living where PSR-4 does NOT predict — a DI-bound registration, a type not named after its file. That is the shape gate-30 was caught mis-resolving (`AppHost\Controller\GenericHealth` PSR-4-maps to lib/Controller/AppHost/Controller/… while openregister DI-binds it to lib/AppHost/Controller/). The walk matched `class` only, with at most ONE modifier. So every other declaration form was a FALSE POSITIVE on a type that genuinely exists — and the action `guard-class-not-found` invites is to write the class a second time. Measured, each against a real declaration at a non-conventional path: enum ProbeState: string { … } -> guard-class-not-found interface ProbeContract { … } -> guard-class-not-found trait ProbeTrait { … } -> guard-class-not-found final readonly class ReadonlyProbe -> guard-class-not-found `final readonly` is ordinary PHP 8.2. Only the DECLARATION FORMS widen; the line anchor that keeps docblock prose out is unchanged and asserted in both directions. A LAZY CLOSURE READ AS AN EAGER REFERENCE — gate 64 ---------------------------------------------------- This module's header has always said lazy service closures that merely MENTION an AppHost class are deliberately NOT flagged, because their bodies run at resolution time. Both rules ran over the whole file, so they did not. Measured on launchpad — deliberately a DIFFERENT repo shape from larpingapp. launchpad's whole composition root resolves OpenRegister lazily inside closures (it already does this for AppHost\Observability\ManifestLoader); that is the documented leaf pattern and the reason launchpad is green. A closure body naming Bootstrap reported byte-identically to an eager `Bootstrap::register($context, …)`. The gate would have failed the one repo doing it correctly, for doing it correctly, and the only remedy is to stop writing the lazy form. Anonymous and arrow-function bodies are now blanked before both rules. Named methods are untouched (`public function register(` has an identifier between `function` and `(`), and an eager reference AFTER or BETWEEN closures is still caught — both asserted. GATE 63 ON A CONTROLLER-ONLY DIFF — the reason was an overclaim --------------------------------------------------------------- Every BLOCKING rule in this gate reads src/manifest.json, src/manifest.d/ or src/menu-layout.json. The two rules that touch lib/Settings are WARNs and never fail it. So a PR that changes a settings CONTROLLER, or adds or deletes a lib/Settings/*Admin.php section, lands in the empty-scope branch — and the line read "this PR introduces no settings placement (ADR-079) to judge", which on such a diff is false. The author changed the settings surface; this gate does not adjudicate that half of it. NOT WIDENED, on purpose. Reading the controller was tried and reverted, and check_store_and_settings_surface.py records why: a gate that only RUNS when a manifest changed and then judges code the PR never touched "blocked EVERY manifest-touching PR in that repo, permanently". The verdict stays `na` — the correct category, since no change the author could make puts a manifest into a diff that does not touch one. What changes is that the line now names which half it looked at, and when the diff contains lib/Settings or a settings controller it says so explicitly, so `na` cannot be read as a clearance for the change the author actually made. VERIFIED, BOTH ARMS OF THE #270 CONTRACT ----------------------------------------- * empty ADR-020 scope -> NOT APPLICABLE, exit 0 under --require-full-coverage (measured on the controller-only fixture above) * genuine structural gap -> SKIPPED (structural), exit 98 (test_gate_empty_scope_never_passes.sh ARM 4, gate-33 with --axe-enabled and no report) And gate-60's three states, all asserted: real finding · SKIPPED (wiring) naming the missing dependency · clean pass. FLEET SWEEPS — 21 apps-extra repos, old helper vs new ------------------------------------------------------ gate-56 zero verdict changes gate-64 zero verdict changes; the three pre-existing FAILs (openbuild, procest, scholiq) survive, and the three NOTEs (larpingapp, hermiq, nldesign) are unchanged TESTS ----- test_gate_crashed_checker_is_not_a_finding.sh +2 gates, 5 assertions. Two arms: the plants must FAIL with a working interpreter, and the SAME tree must report SKIPPED (wiring) with a dead one — otherwise "always skip" would pass. Against 48c88ba it reports the two PASSes. test_check_register_handler_resolution.py +8 cases incl. the mutant that restores the pre-fix pattern and requires all four forms to go back to not-found. test_check_apphost_autoload_prelude.py +9 cases, closure and anti-widening arms. REPOS PLANTED IN, by gate -------------------------- 56 shillinq (register-owning, 153 register.d files) + a synthetic DI-bound/non-conventional-path fixture 57 shillinq (316 services) + fixture 58 shillinq (60 e2e files); fleet-wide check that no live networkidle call in any repo lives outside tests/e2e/ — 193 inside, 0 outside 59 docudesk (the repo the gate was written against) + fixture 60 shillinq (233 manifests, fake MDI package) + fixture 61 shillinq (15 post-event registrations) 62 shillinq 63 shillinq + a controller-only fixture 64 larpingapp (eager class_exists composition root, 3 live probes) AND launchpad (lazy-closure composition root, 0 eager references) NOT MINE, REPORTED NOT FIXED ----------------------------- test_gate_45_to_55_acceptance.sh fails 4 gate-53 assertions identically on pristine main (48c88ba) and on this branch — the suite expects blocking behaviour for pre-existing orphans that #250/#260/#280 deliberately made advisory. Out of this band; flagged rather than touched.
The branch was rebased from cdfbd7a onto 48c88ba after four gate-package releases landed mid-session (#272, #275, #276, #280/#282). The rebase gave the same content a new history, which is a force-push, and force-push is blocked on shared branches for good reason. The tree here is IDENTICAL to the rebased HEAD — this commit only re-attaches the old tip as a second parent so the push is a fast-forward.
…could not see the defect they exist to catch (#278) * fix(gates 56-64): six gates passed over an unopened scope, and three could not see the defect they exist to catch Acceptance test applied to all nine gates in the 56-64 band: plant one textbook true positive in a real fleet repo, require the gate to FAIL and NAME it, remove the plant, require the prior verdict back, and require a clean fixture to still pass. Five gates passed unchanged (56, 58, 60, 62, 63). Four did not. AN UNOPENED SCOPE IS NOT A PASS — gates 56, 57, 58, 59, 60, 61 -------------------------------------------------------------- #242/#240 established that a gate must not report PASS over a scope it never opened, and #268 that an empty ADR-020 scope is `na` rather than `structural`. Both were applied to gates 19, 25, 62 and 63 and to nothing else. Measured on shillinq, one docs-only commit, --scope-to-diff: [gate-56] register-handler-resolution: PASS <- 153 registers, 0 opened [gate-57] orphaned-write-capability: PASS <- 316 services, 0 opened [gate-58] e2e-networkidle: PASS <- 60 e2e files, 0 opened [gate-59] unclosable-gate: PASS <- lib/ untouched [gate-60] icon-vocabulary: PASS <- no manifest in the diff [gate-61] listener-work-placement: PASS <- all 15 out of scope [gate-62] store-plane: NOT APPLICABLE (already fixed) [gate-63] settings-surface: NOT APPLICABLE (already fixed) Six gates asserting a verdict about code the run had not looked at, beside two that had already learned not to — and --require-full-coverage cannot see a PASS, so nothing reported that six gates had gone quiet. All six now emit `na` with a reason naming the rule and the count of subjects that exist but were not inspected. GATE 59 COULD NOT RUN A FULL-TREE AUDIT AT ALL ---------------------------------------------- CHANGED_FILES is populated only under --scope-to-diff. Gate 59's guard read `grep -qE '^lib/.*\.php$'` against it unconditionally, so on every unscoped run the guard was false and the gate printed PASS having walked no PHP. That is #240's sentence — "a full-tree audit was the one mode this gate could never reach" — in a gate #240 did not visit. The scoping now applies only when the caller asked for it. Gate 61's unconditional --base is DELIBERATE by contrast (it is about new debt; the fleet's 149-registration backlog is a work-list). Switching it to --all was tried and reverted: the builder runs unscoped, so it would have surfaced the whole backlog as blocking findings on every build. Recorded in the code so the next reader does not repeat it. GATE 59 WAS WRONG IN BOTH DIRECTIONS AT ONCE (#184's shape) ----------------------------------------------------------- It caught the textbook case and then failed on the next four mutations, each reproduced against docudesk: false GREEN a COMMENTED-OUT setter counted as a write. `// TODO: setValueString('app','configuration_version',$v)` closed the finding — and that is the single most likely comment to sit beside a key nobody writes. The gate whose subject is "this guard never closes" was itself closed by a comment promising to close a guard. false GREEN `"key"` in double quotes was invisible on both sides. false POSITIVE read 'key' / write "key" — code that closes its gate correctly reported as never closing it, with no remedy available to the app but changing its quote style. false GREEN a key held in a class constant was invisible on both sides — and a constant is the idiomatic way to write a key used twice, which is the shape a CLOSABLE gate has. Fixed with the established remedies: source_scope.php_mask for the comment regions (offsets preserved; string contents kept, because the key literal is the evidence), both quote styles, and constant resolution that keeps read and write symmetric. The suppression keeps reading raw text because it is authored as a comment — and it now requires the key QUOTED ON THE MARKER'S OWN LINE, because the old four-line window spanned the read and so suppressed whatever was near it rather than what it named. Fleet sweep: 21 repos, zero verdict changes. GATE 57'S MCP SEAM DISSOLVED ON A LEADING BACKSLASH --------------------------------------------------- Both halves of the attribute seam (#200/#215) matched a namespace prefix as `(?:[A-Za-z_]\w*\s*\\+\s*)*` — every segment had to start with a letter. So the fully-qualified spelling, which is how you write a name with no `use` import for it, matched neither: registerServiceAlias('…::app', \OCA\App\Mcp\Impl::class) #[\OCA\OpenRegister\Mcp\Attribute\McpTool(name: 'createLead')] Either miss alone empties the seam and puts every #[McpTool] write method in the app back on the finding list — which is #200 verbatim, a finding whose only remedy is deleting a live, curated MCP write tool. Reproduced on shillinq with the two spellings side by side. The regression test's mutant restores BOTH pre-fix patterns together, because the seam needs both and reverting one alone reads as "the fix changed nothing". Fleet sweep: 21 repos, zero verdict changes — the trap is latent, not live. GATE 64 SAW ONE OF PHP'S THREE WAYS TO NAME A CLASS --------------------------------------------------- Rule 2 required a QUOTED literal, so of these four only the first was a finding; the other three were injected into larpingapp's register() and the gate reported OK for every one: class_exists('OCA\OpenRegister\AppHost\…\GenericHealthController') class_exists(\OCA\OpenRegister\AppHost\…\GenericHealthController::class) use …\GenericHealthController; class_exists(GenericHealthController::class) const AH = 'OCA\OpenRegister\AppHost\…'; class_exists(self::AH) That is #184's lesson in the file where it was learned. All four spellings now resolve through one resolver, so a spelling that escapes it escapes both rules rather than whichever was written second. A bare `use` import stays clean on purpose — `use` is a compile-time alias and does not autoload; flagging it would newly redden four repos for code that works. AND A GREEN GATE-64 WAS NOT EVIDENCE ABOUT THE WIDER PROBE ---------------------------------------------------------- The hard rule is scoped to OCA\OpenRegister\AppHost\, but the autoloader mechanism has nothing to do with AppHost: during register() the whole OCA\OpenRegister\ prefix is absent for any app sorting earlier, so ANY class_exists() on it answers FALSE and everything it guards silently never happens. Measured across apps-extra, no prelude present: larpingapp 3 Event\{DeepLinkRegistration,ObjectCreating,ObjectUpdating} — the last two carry larpingapp's server-authoritative skill-requirement / XP-budget enforcement on character writes, which therefore never registers hermiq 3 flow-node, leaf-provider and shareable-config registration nldesign 1 shareable-config registration Reported as a non-blocking NOTE, printed by the runner, and deliberately not a FAIL: this gate is not diff-scoped, so failing it would block every PR in three repos on code the PR did not touch — the trap check_store_and_settings_surface.py already records. A probe in boot() is NOT noted, because boot() runs after every app has registered. ALSO ---- Gate 64's log path was a hardcoded /tmp/hydra-gate-apphost-autoload-prelude .log — the shared-path non-determinism HYDRA_GATE_LOG_DIR exists to remove, left behind in one gate. TESTS ----- test_check_unclosable_gate.py NEW — gate 59 shipped with no helper suite at all; 23 cases, every arm of the matrix above plus the mutant that proves the comment mask is load-bearing test_gate_5661_empty_scope_is_not_a_pass.sh NEW — 28 assertions over gates 56-61: clean in-scope subject PASSes, empty scope is `na` with a reason and exit 0 under --require-full-coverage, and a planted true positive per gate still FAILs. Against the pre-fix package it reports 13 failures, naming the six PASSes test_check_apphost_autoload_prelude.py +14 cases test_check_orphaned_write_capability.py +5 cases test_check_listener_placement.py EXIT_EMPTY_SCOPE 51 discovered helper suites green (2 quarantined, unchanged). Full-tree run on shillinq before/after: verdicts byte-identical for all nine gates. * fix(gates 56-64): six gates passed over an unopened scope, and three could not see the defect they exist to catch Acceptance test applied to all nine gates in the 56-64 band: plant one textbook true positive in a real fleet repo, require the gate to FAIL and NAME it, remove the plant, require the prior verdict back, and require a clean fixture to still pass. Five gates passed unchanged (56, 58, 60, 62, 63). Four did not. AN UNOPENED SCOPE IS NOT A PASS — gates 56, 57, 58, 59, 60, 61 -------------------------------------------------------------- #242/#240 established that a gate must not report PASS over a scope it never opened, and #268 that an empty ADR-020 scope is `na` rather than `structural`. Both were applied to gates 19, 25, 62 and 63 and to nothing else. Measured on shillinq, one docs-only commit, --scope-to-diff: [gate-56] register-handler-resolution: PASS <- 153 registers, 0 opened [gate-57] orphaned-write-capability: PASS <- 316 services, 0 opened [gate-58] e2e-networkidle: PASS <- 60 e2e files, 0 opened [gate-59] unclosable-gate: PASS <- lib/ untouched [gate-60] icon-vocabulary: PASS <- no manifest in the diff [gate-61] listener-work-placement: PASS <- all 15 out of scope [gate-62] store-plane: NOT APPLICABLE (already fixed) [gate-63] settings-surface: NOT APPLICABLE (already fixed) Six gates asserting a verdict about code the run had not looked at, beside two that had already learned not to — and --require-full-coverage cannot see a PASS, so nothing reported that six gates had gone quiet. All six now emit `na` with a reason naming the rule and the count of subjects that exist but were not inspected. GATE 59 COULD NOT RUN A FULL-TREE AUDIT AT ALL ---------------------------------------------- CHANGED_FILES is populated only under --scope-to-diff. Gate 59's guard read `grep -qE '^lib/.*\.php$'` against it unconditionally, so on every unscoped run the guard was false and the gate printed PASS having walked no PHP. That is #240's sentence — "a full-tree audit was the one mode this gate could never reach" — in a gate #240 did not visit. The scoping now applies only when the caller asked for it. Gate 61's unconditional --base is DELIBERATE by contrast (it is about new debt; the fleet's 149-registration backlog is a work-list). Switching it to --all was tried and reverted: the builder runs unscoped, so it would have surfaced the whole backlog as blocking findings on every build. Recorded in the code so the next reader does not repeat it. GATE 59 WAS WRONG IN BOTH DIRECTIONS AT ONCE (#184's shape) ----------------------------------------------------------- It caught the textbook case and then failed on the next four mutations, each reproduced against docudesk: false GREEN a COMMENTED-OUT setter counted as a write. `// TODO: setValueString('app','configuration_version',$v)` closed the finding — and that is the single most likely comment to sit beside a key nobody writes. The gate whose subject is "this guard never closes" was itself closed by a comment promising to close a guard. false GREEN `"key"` in double quotes was invisible on both sides. false POSITIVE read 'key' / write "key" — code that closes its gate correctly reported as never closing it, with no remedy available to the app but changing its quote style. false GREEN a key held in a class constant was invisible on both sides — and a constant is the idiomatic way to write a key used twice, which is the shape a CLOSABLE gate has. Fixed with the established remedies: source_scope.php_mask for the comment regions (offsets preserved; string contents kept, because the key literal is the evidence), both quote styles, and constant resolution that keeps read and write symmetric. The suppression keeps reading raw text because it is authored as a comment — and it now requires the key QUOTED ON THE MARKER'S OWN LINE, because the old four-line window spanned the read and so suppressed whatever was near it rather than what it named. Fleet sweep: 21 repos, zero verdict changes. GATE 57'S MCP SEAM DISSOLVED ON A LEADING BACKSLASH --------------------------------------------------- Both halves of the attribute seam (#200/#215) matched a namespace prefix as `(?:[A-Za-z_]\w*\s*\\+\s*)*` — every segment had to start with a letter. So the fully-qualified spelling, which is how you write a name with no `use` import for it, matched neither: registerServiceAlias('…::app', \OCA\App\Mcp\Impl::class) #[\OCA\OpenRegister\Mcp\Attribute\McpTool(name: 'createLead')] Either miss alone empties the seam and puts every #[McpTool] write method in the app back on the finding list — which is #200 verbatim, a finding whose only remedy is deleting a live, curated MCP write tool. Reproduced on shillinq with the two spellings side by side. The regression test's mutant restores BOTH pre-fix patterns together, because the seam needs both and reverting one alone reads as "the fix changed nothing". Fleet sweep: 21 repos, zero verdict changes — the trap is latent, not live. GATE 64 SAW ONE OF PHP'S THREE WAYS TO NAME A CLASS --------------------------------------------------- Rule 2 required a QUOTED literal, so of these four only the first was a finding; the other three were injected into larpingapp's register() and the gate reported OK for every one: class_exists('OCA\OpenRegister\AppHost\…\GenericHealthController') class_exists(\OCA\OpenRegister\AppHost\…\GenericHealthController::class) use …\GenericHealthController; class_exists(GenericHealthController::class) const AH = 'OCA\OpenRegister\AppHost\…'; class_exists(self::AH) That is #184's lesson in the file where it was learned. All four spellings now resolve through one resolver, so a spelling that escapes it escapes both rules rather than whichever was written second. A bare `use` import stays clean on purpose — `use` is a compile-time alias and does not autoload; flagging it would newly redden four repos for code that works. AND A GREEN GATE-64 WAS NOT EVIDENCE ABOUT THE WIDER PROBE ---------------------------------------------------------- The hard rule is scoped to OCA\OpenRegister\AppHost\, but the autoloader mechanism has nothing to do with AppHost: during register() the whole OCA\OpenRegister\ prefix is absent for any app sorting earlier, so ANY class_exists() on it answers FALSE and everything it guards silently never happens. Measured across apps-extra, no prelude present: larpingapp 3 Event\{DeepLinkRegistration,ObjectCreating,ObjectUpdating} — the last two carry larpingapp's server-authoritative skill-requirement / XP-budget enforcement on character writes, which therefore never registers hermiq 3 flow-node, leaf-provider and shareable-config registration nldesign 1 shareable-config registration Reported as a non-blocking NOTE, printed by the runner, and deliberately not a FAIL: this gate is not diff-scoped, so failing it would block every PR in three repos on code the PR did not touch — the trap check_store_and_settings_surface.py already records. A probe in boot() is NOT noted, because boot() runs after every app has registered. ALSO ---- Gate 64's log path was a hardcoded /tmp/hydra-gate-apphost-autoload-prelude .log — the shared-path non-determinism HYDRA_GATE_LOG_DIR exists to remove, left behind in one gate. TESTS ----- test_check_unclosable_gate.py NEW — gate 59 shipped with no helper suite at all; 23 cases, every arm of the matrix above plus the mutant that proves the comment mask is load-bearing test_gate_5661_empty_scope_is_not_a_pass.sh NEW — 28 assertions over gates 56-61: clean in-scope subject PASSes, empty scope is `na` with a reason and exit 0 under --require-full-coverage, and a planted true positive per gate still FAILs. Against the pre-fix package it reports 13 failures, naming the six PASSes test_check_apphost_autoload_prelude.py +14 cases test_check_orphaned_write_capability.py +5 cases test_check_listener_placement.py EXIT_EMPTY_SCOPE 51 discovered helper suites green (2 quarantined, unchanged). Full-tree run on shillinq before/after: verdicts byte-identical for all nine gates. * fix(gates 56,57,63,64): a crashed checker read as a clean tree, a real class read as absent, and a lazy closure read as an eager one Second pass over the 56-64 band, planting in a SECOND repo of a different shape per gate. Four more defects, all measured against gate package 48c88ba. A CRASHED CHECKER REPORTED PASS — gates 56 and 57 -------------------------------------------------- Both invoked their helper as `>> log 2>/dev/null || true` and then derived the verdict from `wc -l` on the log. Stderr discarded, exit status discarded, empty log — so a checker that never started reported PASS. Measured on shillinq with a python3 shim that exits 1 for exactly these two helpers: [gate-56] register-handler-resolution: PASS <- 153 registers [gate-57] orphaned-write-capability: PASS <- 316 services and gate-57 had reported 20 real findings over that same tree on the previous run. That is gate-40's defect verbatim. Both helpers ALWAYS exit 0 when they run, by design (#209 — the count goes to stdout, never into the exit byte), so a non-zero exit can only be a crash and never a finding count. Both now emit SKIPPED (wiring) and keep the stderr on disk. Every gate in this band was re-checked for the count-as-exit-status defect found in gates 19, 26 and 52: none of the nine has it. 56 and 57 put the count on stdout and the runner counts lines; 58/59/60/61/62/63/64 return status codes only. A REAL CLASS READ AS ABSENT — gate 56 -------------------------------------- The PSR-4 path guess handles the conventional layout; the fallback walk over lib/ is what finds a type living where PSR-4 does NOT predict — a DI-bound registration, a type not named after its file. That is the shape gate-30 was caught mis-resolving (`AppHost\Controller\GenericHealth` PSR-4-maps to lib/Controller/AppHost/Controller/… while openregister DI-binds it to lib/AppHost/Controller/). The walk matched `class` only, with at most ONE modifier. So every other declaration form was a FALSE POSITIVE on a type that genuinely exists — and the action `guard-class-not-found` invites is to write the class a second time. Measured, each against a real declaration at a non-conventional path: enum ProbeState: string { … } -> guard-class-not-found interface ProbeContract { … } -> guard-class-not-found trait ProbeTrait { … } -> guard-class-not-found final readonly class ReadonlyProbe -> guard-class-not-found `final readonly` is ordinary PHP 8.2. Only the DECLARATION FORMS widen; the line anchor that keeps docblock prose out is unchanged and asserted in both directions. A LAZY CLOSURE READ AS AN EAGER REFERENCE — gate 64 ---------------------------------------------------- This module's header has always said lazy service closures that merely MENTION an AppHost class are deliberately NOT flagged, because their bodies run at resolution time. Both rules ran over the whole file, so they did not. Measured on launchpad — deliberately a DIFFERENT repo shape from larpingapp. launchpad's whole composition root resolves OpenRegister lazily inside closures (it already does this for AppHost\Observability\ManifestLoader); that is the documented leaf pattern and the reason launchpad is green. A closure body naming Bootstrap reported byte-identically to an eager `Bootstrap::register($context, …)`. The gate would have failed the one repo doing it correctly, for doing it correctly, and the only remedy is to stop writing the lazy form. Anonymous and arrow-function bodies are now blanked before both rules. Named methods are untouched (`public function register(` has an identifier between `function` and `(`), and an eager reference AFTER or BETWEEN closures is still caught — both asserted. GATE 63 ON A CONTROLLER-ONLY DIFF — the reason was an overclaim --------------------------------------------------------------- Every BLOCKING rule in this gate reads src/manifest.json, src/manifest.d/ or src/menu-layout.json. The two rules that touch lib/Settings are WARNs and never fail it. So a PR that changes a settings CONTROLLER, or adds or deletes a lib/Settings/*Admin.php section, lands in the empty-scope branch — and the line read "this PR introduces no settings placement (ADR-079) to judge", which on such a diff is false. The author changed the settings surface; this gate does not adjudicate that half of it. NOT WIDENED, on purpose. Reading the controller was tried and reverted, and check_store_and_settings_surface.py records why: a gate that only RUNS when a manifest changed and then judges code the PR never touched "blocked EVERY manifest-touching PR in that repo, permanently". The verdict stays `na` — the correct category, since no change the author could make puts a manifest into a diff that does not touch one. What changes is that the line now names which half it looked at, and when the diff contains lib/Settings or a settings controller it says so explicitly, so `na` cannot be read as a clearance for the change the author actually made. VERIFIED, BOTH ARMS OF THE #270 CONTRACT ----------------------------------------- * empty ADR-020 scope -> NOT APPLICABLE, exit 0 under --require-full-coverage (measured on the controller-only fixture above) * genuine structural gap -> SKIPPED (structural), exit 98 (test_gate_empty_scope_never_passes.sh ARM 4, gate-33 with --axe-enabled and no report) And gate-60's three states, all asserted: real finding · SKIPPED (wiring) naming the missing dependency · clean pass. FLEET SWEEPS — 21 apps-extra repos, old helper vs new ------------------------------------------------------ gate-56 zero verdict changes gate-64 zero verdict changes; the three pre-existing FAILs (openbuild, procest, scholiq) survive, and the three NOTEs (larpingapp, hermiq, nldesign) are unchanged TESTS ----- test_gate_crashed_checker_is_not_a_finding.sh +2 gates, 5 assertions. Two arms: the plants must FAIL with a working interpreter, and the SAME tree must report SKIPPED (wiring) with a dead one — otherwise "always skip" would pass. Against 48c88ba it reports the two PASSes. test_check_register_handler_resolution.py +8 cases incl. the mutant that restores the pre-fix pattern and requires all four forms to go back to not-found. test_check_apphost_autoload_prelude.py +9 cases, closure and anti-widening arms. REPOS PLANTED IN, by gate -------------------------- 56 shillinq (register-owning, 153 register.d files) + a synthetic DI-bound/non-conventional-path fixture 57 shillinq (316 services) + fixture 58 shillinq (60 e2e files); fleet-wide check that no live networkidle call in any repo lives outside tests/e2e/ — 193 inside, 0 outside 59 docudesk (the repo the gate was written against) + fixture 60 shillinq (233 manifests, fake MDI package) + fixture 61 shillinq (15 post-event registrations) 62 shillinq 63 shillinq + a controller-only fixture 64 larpingapp (eager class_exists composition root, 3 live probes) AND launchpad (lazy-closure composition root, 0 eager references) NOT MINE, REPORTED NOT FIXED ----------------------------- test_gate_45_to_55_acceptance.sh fails 4 gate-53 assertions identically on pristine main (48c88ba) and on this branch — the suite expects blocking behaviour for pre-existing orphans that #250/#260/#280 deliberately made advisory. Out of this band; flagged rather than touched.
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
python3on PATH that exits 1 on every call, on a tree carrying realfindings:
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/nulldiscarded status and traceback)gate-51/54/55 PASS (
|| truediscarded 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=Non stdout and exitsboolean; 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_dirand 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 withtransition:and no reduced-motion fallback, in the samefile gate-43 FAILED on in the same run.
nais the one verdict that removes agate 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_REwas an unanchored alternation of string literals, and it waswrong 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
@NoAdminRequiredagain, paired with areal 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]wasinvisible. 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
returnrather than anifwas 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,
EventRosterleft registered in src/registry.js — reported PASS. Direction 1of 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 == keyon a schema property is a real gate-51 defect — the rendereruses
prop.title || key, so the user sees the raw technical key. Measuredacross 10 repos: 148 occurrences, ALL of them in softwarecatalog, where they
are VNG-standardised element names (
identifier,type,name) that mustnot 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 withPASS, 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.