Skip to content

Add Dev_Loader (DEV_MODE-gated boot) #19

Description

@AnuragVasanwala

Context

The Dev Monitor must be invisible in production. Zero classes loaded, zero hooks registered, zero asset enqueues, zero database reads. The only acceptable cost when {PREFIX}_DEV_MODE is false is a single constant check.

Dev_Loader is the one entry point that bridges plugin boot to the Dev Monitor namespace. The plugin's main Plugin::setup() calls Dev_Loader::maybe_boot(). If the constant is not defined or is false, the call returns immediately. If it is true, the loader registers the panel, the collectors, and the asset enqueues.

This file lands now (Sprint 2) so the collectors that arrive in Sprint 3 have a host to register with.

Expected Outcome

A single boot file plus a unit test verifying the constant gate works in both directions.

Files added:

src/Dev/Dev_Loader.php
tests/Dev/Dev_LoaderTest.php
CHANGELOG.md (Unreleased entry)

Class shape:

namespace RtCamp\WPToolkit\Dev;

use RtCamp\WPToolkit\Traits\Singleton;

class Dev_Loader {
    use Singleton;

    /** @var string Constant name to check, e.g. "MYPLUGIN_DEV_MODE". Set via ::set_gate() before maybe_boot(). */
    private string $gate_constant = '';

    /** @var array<string, object> Registered collectors keyed by id(). */
    private array $collectors = [];

    public function setup(): void {}

    /**
     * Tell the loader which constant gates dev mode for the consuming plugin/theme.
     * Called once at plugin boot, before maybe_boot().
     */
    public function set_gate( string $constant_name ): void;

    /**
     * Boot the Dev Monitor only if the gate constant is defined and truthy.
     * Safe to call from production — short-circuits before loading any collector code.
     */
    public function maybe_boot(): void;

    /**
     * Register a collector. Only callable from inside maybe_boot()'s active path.
     */
    public function register_collector( object $collector ): void;

    /** @return array<string, object> */
    public function get_collectors(): array;
}

Boot behaviour:

// In a consuming plugin's Plugin::setup()
Dev_Loader::get_instance()->set_gate( 'MYPLUGIN_DEV_MODE' );
Dev_Loader::get_instance()->maybe_boot();

maybe_boot() does the following, in order, only if the gate is true:

  1. Loads built-in collectors (none yet — added in Sprint 3).
  2. Calls setup() on each registered collector.
  3. Hooks admin_bar_menu to add the panel toggle.
  4. Hooks wp_footer + admin_footer to render the panel HTML.
  5. Hooks wp_enqueue_scripts + admin_enqueue_scripts for panel assets.

If the gate is false, none of those happen. No collectors load, no hooks register.

Verification commands (all must exit 0):

composer install
composer phpcs src/Dev/Dev_Loader.php tests/Dev/Dev_LoaderTest.php
composer phpstan
composer test -- tests/Dev/Dev_LoaderTest.php

Implementation guidance

Self-contained brief. The single most important property here is zero cost when the gate is off.

Step-by-step

  1. Create src/Dev/Dev_Loader.php using the Singleton trait. Empty setup(): void {} body.
  2. Add the set_gate(string $constant_name): void method. Just stores the name in $this->gate_constant. No defined() check here.
  3. Add maybe_boot(): void. First line: if ( '' === $this->gate_constant || ! defined( $this->gate_constant ) || ! constant( $this->gate_constant ) ) { return; }. After that guard, the rest of the method is free to do work.
  4. Add register_collector(Collector_Interface $collector): void. Type-hint the interface. Store keyed by $collector->id() to allow lookup and prevent duplicate registration.
  5. Add get_collectors(): array returning the stored array.
  6. Inside maybe_boot() after the guard, call $collector->setup() on each registered collector. The actual hook wiring (admin_bar, footer, enqueue) is added in Add Dev Monitor — Workflow Panel, Visual Timeline, Console_Log_Collector #21 — for this PR, just call collector setup.
  7. Write tests:
    • test_maybe_boot_is_noop_when_constant_undefined — set gate_constant to 'NOT_DEFINED_CONST', register a mock collector with a setup spy, call maybe_boot(), assert the spy was never called.
    • test_maybe_boot_is_noop_when_constant_falsy — define a constant to false, same flow, assert spy not called.
    • test_maybe_boot_runs_when_constant_truthy — define the constant to true, register mock, call maybe_boot(), assert spy called once.
    • test_register_collector_rejects_non_interface — pass a \stdClass, assert TypeError (PHP catches this at the type-hint level).
    • test_get_collectors_returns_registered — register 2 mocks with different ids, assert both returned.

Reference patterns

<?php
declare(strict_types=1);

namespace RtCamp\WPToolkit\Dev;

use RtCamp\WPToolkit\Dev\Interfaces\Collector_Interface;
use RtCamp\WPToolkit\Traits\Singleton;

class Dev_Loader {
    use Singleton;

    private string $gate_constant = '';

    /** @var array<string, Collector_Interface> */
    private array $collectors = [];

    public function setup(): void {}

    public function set_gate( string $constant_name ): void {
        $this->gate_constant = $constant_name;
    }

    public function maybe_boot(): void {
        if (
            '' === $this->gate_constant
            || ! defined( $this->gate_constant )
            || ! constant( $this->gate_constant )
        ) {
            return;
        }

        foreach ( $this->collectors as $collector ) {
            $collector->setup();
        }

        // Panel + asset wiring lives in Workflow_Panel — added in a later issue.
    }

    public function register_collector( Collector_Interface $collector ): void {
        $this->collectors[ $collector->id() ] = $collector;
    }

    /** @return array<string, Collector_Interface> */
    public function get_collectors(): array {
        return $this->collectors;
    }
}

Edge cases

  • defined() returns true for constants defined to null — the ! constant() check catches that.
  • A collector registered twice (same id()) silently overrides. Document this as expected behaviour, not a bug.
  • The test that defines a constant cannot un-define it within the same test process. Use runInSeparateProcess or unique constant names per test (MYPLUGIN_DEV_MODE_TEST_<rand>).

Pre-PR self-check

  • maybe_boot() returns within the first 3 lines if gate is off (verified by reading the code)
  • All 5 tests pass
  • Type-hint enforces Collector_Interface on register_collector
  • PHPCS, PHPStan level 5, PHPUnit all green
  • CHANGELOG entry under ## Unreleased

Acceptance Criteria

  • Dev_Loader uses the Singleton trait
  • setup() is empty (boot work happens in maybe_boot(), after the gate check)
  • set_gate() accepts a constant name; storing only, no side effects
  • maybe_boot() returns immediately if gate constant is undefined or falsy — verified by test (no hooks fire, no collector setup() runs)
  • maybe_boot() calls every registered collector's setup() when the gate is true — verified by test with a mock collector
  • register_collector() requires the collector to implement Dev\Interfaces\Collector_Interface — type-hint enforced
  • PHPCS passes
  • PHPStan level 5 passes
  • At least 4 unit tests: gate-off-no-boot, gate-on-boots, register-collector-fails-without-interface, get-collectors-returns-registered
  • CHANGELOG.md entry added under ## Unreleased

Notes

  • Depends on Singleton trait merged.
  • Depends on the Dev Monitor interfaces issue (Add Dev Monitor — 7 collector interfaces #17) merged (this loader type-hints Collector_Interface).
  • Built-in collectors are added in Sprint 3. This issue does NOT register any collectors at the loader level — that's the collector's own job.
  • Asset registration (panel CSS/JS) is sketched here but the actual asset files come with the panel issue in Sprint 3.
  • The .distignore rule that excludes src/Dev/ from release artifacts is set in the skeleton, not here.
  • PR target: release/v1.0.0. Branch: v1.0.0/task/dev-loader. Commit subject: feat(dev): add Dev_Loader (DEV_MODE-gated boot).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: P1High — sprint commitmentScope: Dev MonitorDev_Monitor panel + collectors (in src/Dev/, dev-mode-only)Type: TaskSelf-contained unit of work for a milestone

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions