Skip to content

fix(gate-14): stop flattening namespaced route names; understand a DI-bound fully-qualified controller - #217

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-14-fully-qualified-route-names
Aug 8, 2026
Merged

fix(gate-14): stop flattening namespaced route names; understand a DI-bound fully-qualified controller#217
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-14-fully-qualified-route-names

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What this fixes

gate-14 route-reachability reported 6 findings on opencatalogi origin/development (9e63d9f3), all false:

lib/Controller/OCAOpenCatalogiAppHostControllerGenericDashboardController.php route='OCAOpenCatalogiAppHostControllerGenericDashboard#catchAll' rule=controller-class-not-found
… same for GenericDashboard#page, GenericHealth#index, GenericMetrics#index, GenericPreferences#getPreference, GenericPreferences#setPreference

Two defects, stacked. The outer one hid the inner one.

1. read without -r — every namespaced route name was flattened

Both route gates read appinfo/routes.php through while IFS='#' read ctrl method. read performs backslash removal, so

route name in the file what the resolver actually received
OCA\OpenCatalogi\AppHost\Controller\GenericDashboard OCAOpenCatalogiAppHostControllerGenericDashboard
Settings\LlmSettings SettingsLlmSettings
$ printf 'OCA\\Foo\\Bar#page\n' | while IFS='#' read    a b; do echo "[$a]"; done
[OCAFooBar]
$ printf 'OCA\\Foo\\Bar#page\n' | while IFS='#' read -r a b; do echo "[$a]"; done
[OCA\Foo\Bar]

Consequence: _ctrl_path_from_name's Settings\Foo -> lib/Controller/Settings/FooController.php branch — documented since the day it was written — had never once been reached from either gate. openregister's 55 Settings\… routes all resolved to non-existent flattened paths.

2. A fully-qualified route name was not a shape any helper understood

Un-flattened, opencatalogi's names are FQCNs under the app's own namespace, bound in lib/AppInfo/Application.php:

// appinfo/routes.php
['name' => 'OCA\OpenCatalogi\AppHost\Controller\GenericDashboard#page', 'url' => '/', 'verb' => 'GET'],

// lib/AppInfo/Application.php
$context->registerService(
    'OCA\\OpenCatalogi\\AppHost\\Controller\\GenericDashboardController',
    static fn ($c) => new GenericDashboardController(appName: self::APP_ID, request: $c->get('OCP\\IRequest'))
);

That binding is the only thing that can make the route work. From /var/www/html/lib/private/AppFramework/App.php in a live NC 33 container (docker exec procest-cilocal-nc awk '/public static function main/,/^\t}/' …):

// first try $controllerName then go for \OCA\AppName\Controller\$controllerName
try {
    $controller = $container->get($controllerName);
} catch (QueryException $e) {
    if (str_contains($controllerName, '\\Controller\\')) {
        // This is from a global registered app route that is not enabled.
        [/*OC(A)*/, $app, /* Controller/Name*/] = explode('\\', $controllerName, 3);
        throw new HintException('App ' . strtolower($app) . ' is not enabled');
    }
    …
    $controllerName = $appNameSpace . '\\Controller\\' . $controllerName;

The literal lookup happens first, and for a name containing \Controller\ the fallback does not rewrite anything — it throws. RouteParser::buildControllerName() only does underScoreToCamelCase(ucfirst($controller)) . 'Controller', so the backslashes reach the container untouched.

Neither existing helper could see this, each for its own correct reason:

  • _apphost_serves is keyed on the five short slugs Bootstrap::register() aliases (dashboard). The route name is the whole class.
  • _di_binds_controller takes a file path and rebuilds <app_ns>\Controller\… from it. Fed the flattened name it reconstructed OCA\OpenCatalogi\Controller\OCAOpenCatalogiAppHostControllerGenericDashboardController, which matches nothing.

The change

  • read -r in gate-5 and gate-14.
  • _ctrl_path_from_name maps a name fully qualified under the app's own namespace to its PSR-4 path (OCA\<App>\lib/), so a leaf that genuinely ships such a class is opened and judged rather than being unresolvable. It also now turns every namespace separator into a path separator, not just the last — AppHost\Controller\GenericHealth used to produce lib/Controller/AppHost\Controller/GenericHealthController.php, a path with a literal backslash that can never exist.
  • New _di_binds_fq_controller, sharing the needle matcher with _di_binds_controller through a new _app_php_binds_class. The _HYDRA_DI_NEEDLE environment technique is preserved verbatim (awk -v runs escape processing and silently destroys the \\ pairs).

What still FAILS — the invariant is intact

Not a wildcard, and not "a route name with backslashes is unverifiable, skip it" — that would retire reachability for every namespaced route in the fleet, silently. The evidence demanded is exactly what _di_binds_controller already demands: the exact escaped literal within a few lines of a registerService/registerServiceAlias call in lib/AppInfo/Application.php.

An unbound fully-qualified route still fails, and the fixture proves it:

lib/AppHost/Controller/GenericGadgetController.php route='OCA\Fixture\AppHost\Controller\GenericGadget#run' rule=controller-class-not-found

Also scoped to the app's own namespace on purpose: OCA\SomeOtherApp\… is a class this repo does not own and cannot vouch for, so it is left to fail.

Measured before / after — full-tree

opencatalogi origin/development (9e63d9f3) — 64 gate verdicts captured on both arms and diffed; after normalising the per-run /tmp/hydra-gates.XXXX log-dir token, exactly one line differs:

before: [gate-14] route-reachability: FAIL — 6 unrouted method(s) or wrong-target route(s) — see LOGDIR/hydra-gate-route-reachability.log
after:  [gate-14] route-reachability: PASS

gate-5 stays PASS on both arms with the same 6 NOT-JUDGED entries; only their stated reason improves, from … is not present in this repository; controller class UNRESOLVED to served by the OpenRegister AppHost generic controller (ADR-040). Runner exit code 12 → 11.

Fleet, same A/B (origin/main worktree vs this branch):

repo gate-14 before gate-14 after
opencatalogi FAIL — 6 PASS
hermiq FAIL — 6 PASS
openregister FAIL — 53 FAIL — 2

openregister's remaining 2 are a different, still-open false positive (see below). gate-5 on openregister goes 11 → 12 findings — both arms already FAIL, and the extra one is a true finding the gate was blind to because the name was flattened:

lib/Controller/Settings/FileSettingsController.php:323 method=getFileExtractionStats rule=missing-auth-attribute

Can-fail proof

hydra-gates/scripts/lib/test_gate_fq_route_names.sh (13 assertions, auto-discovered by tests/run-helper-suites.sh) with both arms: fq-bound → gate-14 PASS, fq-unbound → gate-14 FAIL.

Stashing only the run-hydra-gates.sh change and re-running the new suite — exit code read directly, not through a pipe:

$ git stash push -- hydra-gates/scripts/run-hydra-gates.sh
$ bash hydra-gates/scripts/lib/test_gate_fq_route_names.sh; echo "EXIT=$?"
FAIL — fq-bound: DI-bound fully-qualified routes are not reachability findings — wanted PASS, got: [gate-14] route-reachability: FAIL — 4 unrouted method(s) or wrong-target route(s)
FAIL — fq-bound: a FLATTENED route name (OCAFixture…) reappeared — read -r regressed
FAIL — fq-bound: Settings\Widget resolves to lib/Controller/Settings/, not the flattened path — unexpected log line: lib/Controller/SettingsWidgetController.php route='SettingsWidget#show' rule=controller-class-not-found
FAIL — fq-bound: gate-5 states the DI-bound generic was NOT JUDGED rather than dropping it — …
FAIL — fq-unbound: gate-14 names the unbound class at its PSR-4 path, with the route name intact — … log was: lib/Controller/OCAFixtureAppHostControllerGenericDashboardController.php route='OCAFixtureAppHostControllerGenericDashboard#catchAll' rule=controller-class-not-found|…
FAIL — fq-unbound: the DI-bound GenericDashboardController is NOT reported alongside the unbound one
FAIL — fq-unbound: the DI-bound GenericHealthController is NOT reported alongside the unbound one
FAIL — fq-unbound: a Settings\ route whose method is absent is reported as method-not-found, not class-not-found
FAIL — fq-unbound: expected 2 findings, got 6
== summary ==
   passed: 4
   failed: 9
EXIT=1

The positive-control arm goes red for the right reason: rule=controller-class-not-found on the bound fixture, on flattened OCAFixture… names — the exact opencatalogi shape. With the change restored it is 13/13 green.

Regression harness, all read directly:

bash hydra-gates/tests/run-helper-suites.sh   -> EXIT=0   (28 passed, 2 quarantined, 0 failed)
bash hydra-gates/tests/test-hydra-gates-bin.sh -> EXIT=0
bash hydra-gates/scripts/lib/test_gate_route_auth.sh -> EXIT=0 (30/30, unchanged)

Known remaining false positive — NOT fixed here

openregister names two routes with a relative namespace and binds them verbatim:

// appinfo/routes.php
['name' => 'AppHost\Controller\GenericHealth#index',  …]
// lib/AppInfo/Application.php
$context->registerService('AppHost\\Controller\\GenericHealthController', …);

_di_binds_fq_controller is deliberately scoped to OCA\<App>\…, so these two are still reported. They were reported before this PR too (as part of the 53), so this is not a regression — but it is the same class of defect one step over, and it wants a decision rather than a silent widening. Flagging rather than fixing.

…lly-qualified controller

MEASURED, on opencatalogi origin/development (9e63d9f3). gate-14
route-reachability reported 6 findings, all false:

  lib/Controller/OCAOpenCatalogiAppHostControllerGenericDashboardController.php
    route='OCAOpenCatalogiAppHostControllerGenericDashboard#catchAll'
    rule=controller-class-not-found

Two defects, stacked, the outer one hiding the inner.

1. `while IFS='#' read ctrl method` — WITHOUT `-r`, in BOTH route gates.
   `read` performs backslash removal, so every namespaced route name arrived
   FLATTENED. `OCA\OpenCatalogi\AppHost\Controller\GenericDashboard` became
   `OCAOpenCatalogiAppHostControllerGenericDashboard`; openregister's 55
   `Settings\…` routes became `SettingsLlmSettings` and friends. That is why
   _ctrl_path_from_name's `Settings\Foo -> lib/Controller/Settings/
   FooController.php` branch — documented since the day it was written — had
   never once been reached from either gate.

2. Un-flattened, the opencatalogi names are fully-qualified classes under the
   app's own namespace, and no exemption helper understood that shape.
   `_apphost_serves` is keyed on the five short slugs Bootstrap aliases;
   `_di_binds_controller` rebuilds `<app_ns>\Controller\…` from a FILE PATH.
   The route is bound in lib/AppInfo/Application.php under the name verbatim
   + `Controller`, which is the only name NC will ever look up for it:
   App::main() does `$container->get($controllerName)` FIRST, and for a name
   containing `\Controller\` the QueryException branch throws
   "App … is not enabled" rather than rewriting the name.

Fix:
  * `read -r` in gate-5 and gate-14.
  * `_ctrl_path_from_name` maps a name fully qualified under the app's own
    namespace to its PSR-4 path (`OCA\<App>\` -> `lib/`), so a leaf that
    genuinely ships such a class is opened and judged rather than
    unresolvable.
  * new `_di_binds_fq_controller`, sharing the needle matcher with
    `_di_binds_controller` via `_app_php_binds_class`. Evidence required is
    unchanged in strength: the exact escaped literal within a few lines of a
    registerService/registerServiceAlias call in lib/AppInfo/Application.php.

NOT a wildcard and NOT a backslash skip. An UNBOUND fully-qualified route
still FAILS — that is the invariant, and test_gate_fq_route_names.sh asserts
both arms. Its positive control was verified by reverting this commit's
run-hydra-gates.sh change: the bound fixture then goes
`[gate-14] route-reachability: FAIL — 4` with `rule=controller-class-not-found`
on flattened names, i.e. the exact opencatalogi shape.

Measured effect, full-tree, gate verdicts otherwise byte-identical:
  opencatalogi  gate-14 FAIL — 6  -> PASS
  hermiq        gate-14 FAIL — 6  -> PASS
  openregister  gate-14 FAIL — 53 -> FAIL — 2   (see PR body)
…ot just the last

Measured on openregister: the route `AppHost\Controller\GenericHealth`
resolved to `lib/Controller/AppHost\Controller/GenericHealthController.php`
— a path with a literal backslash inside it, which cannot exist, so the
branch always answered "missing". Invisible until now because plain `read`
deleted the backslashes before this branch could ever see two of them.
@rubenvdlinde
rubenvdlinde merged commit 7511fb2 into main Aug 8, 2026
29 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gate-14-fully-qualified-route-names branch August 8, 2026 13:40
rubenvdlinde pushed a commit that referenced this pull request Aug 8, 2026
main advanced by four hydra-gates commits (#217, #246, #249, #248) while this
branch was open. No file overlap: this branch touches quality.yml,
quality-resolve-probe.yml, a fixture workflow and two scripts/.
rubenvdlinde added a commit that referenced this pull request Aug 8, 2026
…ing gate that passed on nothing (#264)

Closes #213. Closes #218. Closes #221. Closes #223. Closes #237.

Two defects, five issues, three gates:

(a) A shell/regex detail corrupted the input, so the gate measured
    something other than the code. gate-30's selector alternation is
    lowercase-only, so GenericMetrics/GenericHealth/chatHealth matched
    ZERO and it printed PASS over a 0-byte log; it also still carried the
    `read` without -r that #217 fixed in gates 5 and 14.

(b) The gates modelled ONE registration idiom and flagged every other
    legitimate one: routes supplied by Routes::standard(), a
    Bootstrap::register() call moved into a registrar, a credential
    resolved one frame down, a controller whose name merely contains
    "health".

gate-30 can no longer print PASS without having opened a monitoring
method: every other outcome is a stated NOT APPLICABLE / SKIPPED with
counts, and PASS states how many endpoints it inspected.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
@rubenvdlinde
rubenvdlinde restored the fix/gate-14-fully-qualified-route-names branch August 8, 2026 16:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant