fix(gates): gate-3 spares a contract-imposed unused param; gate-53 compares page identity not route spelling - #434
Conversation
…mpares page identity Two findings that no correct app-side change could close (#339, #340). gate-3 `caller-identity-ignored` reported all three of procest's GuardEvaluatorInterface implementors that legitimately ignore the $userId the interface hands them. Deleting the parameter breaks the contract and every call site in GuardRegistry::evaluateAll(); referencing it pointlessly is dead code written to satisfy a stub detector. A new helper exempts a finding only when BOTH halves hold — a supertype resolvable in this repo declares the same method with the same parameter (mechanical), AND the docblock's @PARAM line for THAT parameter is explicitly marked unused. Either half alone still reports, and an unresolvable supertype is not an exemption. Five arms, four of them controls. gate-53 `removals-invariant` compared raw route STRINGS, but `menu[].route` may hold either a pages[].id or a pages[].route — check (a) accepts both. Two menu entries reaching the same page by different spellings therefore did not count as reaching each other, so retiring one of them, exactly the duplicate-navigation removal ADR-044 §5 sanctions, was reported as an orphaned route. Both sides now resolve to the page id before comparison. An unresolvable reference is left as-is so two broken entries cannot vouch for each other. Measured on procest/opencatalogi/openregister/softwarecatalog/docudesk/larpingapp @ development. gate-3: procest 3 -> 0, the only verdict that moved in a full 60-gate run; 0 -> 0 elsewhere. gate-53: unchanged everywhere (9/0/0/1/0/1 findings before and after) — procest's 8 removals are NOT the id-vs-route shape and remain red, deliberately; see #340 for why they are closable app-side. Refs #339, #340
|
CI settled: 17 success, 17 skipped, 0 failure (counted from the full check-run set via the API, not One more measurement since opening, on the §1 recommendation in the #340 comment (delete the eight menu nodes from procest's own manifest sources, keep the pages, empty Still not merged — the gate-53 half needs the decision in the #340 comment. |
|
Correction to my earlier note, since it is now merged and the record should be right. I reported the local
I A/B'd that one on a fresh clone rather than assume it was noise:
Three clean-tree runs across both sides of the change, all green — plus Nothing about the gate-3 or gate-53 conclusions changes. But the coverage claim for this PR rests on CI's
|
…reads must stay split (#447) #445 (#422) and #434 (#339) both changed the SAME arm of gate-3, three lines apart, so they merged cleanly and the interaction never appeared in a diff. Both are now on main and the behaviour there is CORRECT — but nothing asserts it, and this is a shape that breaks silently. The arm reads two texts in one coordinate system, and only `php_mask` being line- and offset-preserving makes that legal: the body questions -> the MASK _sig / _sig_region / _body / _count ("is $uid referenced in CODE?") the exemption -> the ORIGINAL --file "$f" --line "${_line_no}" (#434's `@param … (unused)` marker, which lives in a COMMENT BY DESIGN) Repoint the exemption at the mask — a one-word edit, and the obvious "consistency" cleanup for anyone who notices the arm reading two different files — and the marker vanishes into blanks. The exemption silently stops working and procest's three GuardEvaluatorInterface implementors go red WITH NO CLOSING ACTION AVAILABLE: deleting the parameter breaks the interface and every call site, referencing it pointlessly is dead code written to satisfy a stub detector. #434's own note says neither is a fix, which is why it added the exemption in the first place. That is gate-17's `@spec exclude` trap, which #444 hit for real in the sibling gate of the same sweep: masking the body slid the reported line into the blanked docblock, `_method_docblock` walked from the wrong place, and a reason-bearing exclusion silently stopped exempting. Caught there by an anti-widening arm. This is the same arm for gate-3. CONTROL 6b — a supertype-declared `authorize(string $uid, …)` whose docblock reads `@param string $uid Current user UID (unused by this implementor).` must still be EXEMPT with the mask in place. MUTATION-CHECKED, so it is a coupling and not a comment: with `--file` pointed at `${_stub_code}` the arm reports `FAIL — 1` (9 passed, 1 failed); restored, 10 passed, 0 failed. Measured against main at 43ecb0c. No production code changes — one arm, in a suite tests/run-helper-suites.sh already discovers. Refs #422, #339, #445, #434. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Closes #339. Partially addresses #340 — see "What #340 actually is" below; that issue needs a decision, not just this patch.
Both issues were filed with the observation that no app-side fix exists. Rule 1 was to test that claim before writing anything, so both were reproduced first, on a fresh
developmentcheckout of six fleet apps.Reproduction — both still live
Full 60-gate run,
hydra-gates@c26f9a3, apps atdevelopment:caller-identity-ignoredremovals-invariant792fe1f4bBoth reproduce exactly as filed, at the same three files and the same eight removals. Both live populations are procest only.
menu-layout.json#removalsis non-empty in 1 of the 18 core apps (surveyed via the API ondevelopment; the probe's positive control is that it returned procest's 8).gate-3 — outcome: the question is right, the detection is incomplete. Fixed.
The rule exists for decidesk#45: a builder-generated
authorize*()stub that accepts the caller and forgets to check it. That is a real defect and the rule should keep finding it.It cannot currently tell that shape apart from a correct strategy implementation. All three procest findings implement
GuardEvaluatorInterface::evaluate(array, array, string $userId); two of the five implementors use the parameter, which is the whole point of those classes. A guard answering "is this required field filled in?" has no business consulting the caller. Deleting the parameter breaks the interface and every call site; referencing it pointlessly is dead code written to satisfy a stub detector. Neither edit is a fix, which is why procest reported instead of closing.New helper
check_caller_identity_exempt.py. A finding is exempt only when both hold:implements/extends, transitively) declares the same method with the same parameter. Fully mechanical; nothing the author asserts. This is what makes the exemption un-sprinklable: an invented service method has no supertype, so a fixer agent cannot escape the rule by adding a docblock line.@paramline for the flagged parameter carries an explicit unused marker.Condition 1 alone would exempt a genuinely gutted implementation (a
RoleGuardthat stopped consulting$userIdis contract-imposed too, and that is the defect). Condition 2 alone is a one-word escape hatch.#339 suggested keying on
@SuppressWarnings(PHPMD.UnusedFormalParameter)and preferred it. I took the stricter of the two options it offered: that annotation is method-wide and cannot name which parameter it excuses. procest'sMandaatGuardcarries it — for a different parameter — while genuinely using$userId. Keying on it would have pre-authorised MandaatGuard's$userIdfor a reason that was never about$userId. The per-@parammarker is per-parameter, and all three findings already carry it, so no app edit is needed.Fail-closed throughout: a missing helper, an unreadable file, or a supertype outside this repository all leave the finding standing.
Acceptance —
test_gate_3_contract_param.sh, 5 arms, 4 of them controlsArms 2–5 each delete exactly one half of the exemption. Revert test: the same suite run against
origin/main's unmodified runner — arm 1 goes red (RequiredFieldGuard.php:16 method=evaluate rule=caller-identity-ignored param=$userId), all four controls stay green, i.e. the controls were never depending on the fix.Effect: procest 3 → 0 across a full 60-gate run, and gate-3 is the only verdict that moved (runner exit 8 → 7). 0 → 0 on the other five apps.
gate-53 — outcome: mixed. One real detection defect fixed here; the issue's own premise does not hold.
The detection defect (fixed)
menu[].routemay hold either apages[].idor apages[].route— check (a) in the same file accepts both, and apps use both spellings.removals-invariantcompared the raw strings, so two menu entries reaching the same page by different spellings did not count as reaching each other. Demonstrated on a two-entry manifest:→
removal 'items-by-path' orphans route '/items' — no surviving menu entry reaches itThat is precisely the "duplicate navigation entry whose page is still reachable" ADR-044 §5 sanctions, reported as a loss. Both sides now resolve to the page id before comparison. An unresolvable reference is left as-is, so two broken entries cannot vouch for each other (check (a) already fails those).
Two arms added to
test_check_manifest_crossref.js, off one fixture pair differing only in whether the second entry survives:Existing
broken/fixture arm (cases-index) unchanged and still red. Whole suite green (28 assertions).Fleet A/B, crossref findings before → after: procest 9→9, opencatalogi 0→0, openregister 0→0, softwarecatalog 1→1, docudesk 0→0, larpingapp 1→1. Nothing moved — this is a latent defect, not a live one, because only procest uses
removalsat all and its eight are not this shape.What #340 actually is — flagged for a decision, not patched
procest's 8 are not closable by this fix and I did not implement the enhancement #340 proposes, for three measured reasons. Detail in the issue comment; summary:
manifest.json/manifest.d/fragments. Deleting the eight menu nodes at source (keeping the pages entries, which is what makes the routes routable) and emptyingremovalsproduces a byte-identical effective manifest — measured — and takes gate-53 from 8 errors to 0. So the finding is closable, behaviour-neutrally, without restoring the anti-pattern or retiring any route.removals-invariantpolices the key, not the invariant. The same menu outcome reached by editing a fragment is completely unchecked — I just did it eight times and got a PASS. The gate penalises the honest, centrally-documented authoring path and ignores the silent one, which is the opposite of what ADR-044 says it is guarding ("the hard risk … is silently losing a page").folderSidebarjoin is genuinely mechanical;viewModeis a name heuristic, not a join; and the sidebar-tab arm is not resolvable in principle —BesluitvormingLeafTabresolves another app's component from the runtimewindow.OCA.OpenRegister.integrationsregistry, and nothing static links it toVoorstellen/Advice/AgendaCompiler. Implementing it would add PASSes for 3 and leave 5 red, which is the failure mode gate-53 removals-invariant equates reachability with the static .menu tree — 8 procest removals whose replacement is a folderSidebar/viewMode/sidebar-tab are reported as functionality loss #340 itself asks to avoid.Also worth recording: ADR-044 §5 says
removalsmay retire only a duplicate navigation entry whose page is still reachable. The gate implements that faithfully. procest's eight are supersessions, not duplicates — so what #340 is really asking for is an amendment to ADR-044, which is an architecture call, not a gate call.Files
hydra-gates/scripts/lib/check_caller_identity_exempt.py(new)hydra-gates/scripts/lib/test_gate_3_contract_param.sh(new)hydra-gates/scripts/run-hydra-gates.sh(gate-3 call site, fail-closed)hydra-gates/scripts/lib/check_manifest_crossref.js(removals-invariant normalisation)hydra-gates/scripts/lib/test_check_manifest_crossref.js(2 arms)source_scope.pyuntouched. No app repository is changed by this PR.