0014 — Patch production code that require_once()s files under tests/¶
Status¶
Accepted (2026-06).
Context and Problem¶
The core bundle excludes every */tests/* directory from the ZIP (ADR 0011 /
PR #141, scripts/build-moodle-bundle.sh), which halves the download and the
MEMFS extraction cost. That exclusion assumes the runtime never reads test code
— a safe assumption for PHPUnit/Behat suites, but Moodle 5.0+ ships an
upstream architectural violation: production code require_once()s a file
that lives under tests/.
Concretely, opening /admin/plugins.php fatals with:
Trigger chain:
/admin/plugins.phpbuilds the admin tree, which includesadmin/settings/analytics.php.- That page calls
\core_analytics\manager::get_all_prediction_processors()(analytics/classes/manager.php), which instantiates everymlbackendplugin, includingmlbackend_python. - Declaring
\mlbackend_python\processor(lib/mlbackend/python/classes/processor.php:30) runsrequire_once($CFG->dirroot . '/analytics/tests/classes/mlbackend_helper_trait.php');anduse mlbackend_helper_trait;in the class body. - The trait file was excluded by
*/tests/*→ fatal.
The trait (@category test, namespace core_analytics\tests) declares two
methods. Only is_mlbackend_python_configured() is used by the class itself
(in the constructor), and only inside an
if (defined('BEHAT_SITE_RUNNING') || (defined('PHPUNIT_TEST') && PHPUNIT_TEST)) && …
guard — never reached in the playground (short-circuited away). generate_courses()
is test-only (uses phpunit_util). mlbackend_python cannot function in WASM at
all (no python/exec(), no remote ML server); the class only needs to load
cleanly so the analytics admin page can enumerate prediction processors.
A repo-wide scan found processor.php is the only production consumer of
this trait across the affected branches (5.0, 5.01, 5.02, main; 4.04/4.05 do not
have it). Other production→tests/ references exist but are latent —
unreachable in the playground:
cache/classes/factory.php→cache/tests/fixtures/lib.php(test-mode only).admin/tool/generator/.../runner.phpandadmin/tool/behat/.../get_entity_generator.php→lib/tests/behat/…,admin/tests/behat/…,course/tests/behat/…(Behat data-generator scenarios only).
Options Considered¶
- Re-add the referenced
tests/files to the bundle (allowlist / build-time scan inbuild-moodle-bundle.sh) + a tripwire. General, but re-introduces the per-branch count-parity bookkeeping and keeps shipping test code. - Revert the
*/tests/*exclusion. Throws away ~52 MB / ~6,100 files to paper over a handful of upstream bugs. Rejected. - Patch
processor.phpto drop the test-trait dependency and inline the one method the class uses. Chosen. - Full-file copy of
processor.phpintopatches/shared/. Rejected: would pin a copy that silently drifts from upstream across six branches.
Decision¶
A guarded in-place edit in scripts/patch-moodle-source.sh (the repo's dominant
patch pattern: a python3 str.replace block that raise SystemExits if a
needle is missing). The block:
- Removes
require_once($CFG->dirroot . '/analytics/tests/classes/mlbackend_helper_trait.php');. - Removes the file-level
use core_analytics\tests\mlbackend_helper_trait;. - Replaces the class-body
use mlbackend_helper_trait;with an inline copy ofis_mlbackend_python_configured()(the only method the class itself calls).
It is gated by grep -q "analytics/tests/classes/mlbackend_helper_trait", so it
applies only where the dependency exists (no-op on 4.04/4.05) and is idempotent
(the needle is gone after the first run; the surviving mention is a \-separated
reference inside a doc comment, which the /-separated guard does not match).
generate_courses() is intentionally not inlined — it is test-only and
references phpunit_util, which does not exist at runtime.
The latent cache/behat references are left untouched: they are unreachable in
the playground, and patching dead code would add maintenance surface for no
runtime benefit.
Consequences¶
Positive¶
/admin/plugins.phpand the analytics admin settings page load cleanly on every affected branch, with the*/tests/*exclusion (and its ~52 MB saving) fully preserved.- The patch is surgical and version-tolerant: it edits only three localized
lines that are byte-identical across 5.0/5.01/5.02/main, and the
raise SystemExitmakes any future upstream reformatting fail the build loudly instead of shipping a broken bundle. - The build pipeline re-applies it automatically:
build-moodle-bundle.shrunspatch-moodle-source.shon every build, and the snapshot fingerprint hashes that script, so editing it invalidates the snapshot cache.
Negative / Risks¶
mlbackend_python's prediction methods are now subtly diverged from upstream (the trait'sgenerate_courses()is absent). Irrelevant in the playground — the backend cannot run — but noted for anyone porting this elsewhere.- If a new Moodle release adds another production→
tests/require_once, the bundle will fatal the same way at a different entry point. There is no generic tripwire for this class of bug yet (see Review Criteria).
Implementation Notes¶
scripts/patch-moodle-source.sh: newPROCESSORPHPpath var (respecting the${PUB}5.1+public/prefix) and the guarded Python block.- No
npm run build-workerneeded (nosrc/blueprint/**or worker JS change). - Verify by rebuilding the bundle (
make bundle) —make up-localdoes not reproduce the bug because it serves the full checkout withtests/present. - Watch for the
*/trap: a/** … */PHP doc comment must not contain the glob literal*/tests/*(the*/closes the comment early).
Review Criteria¶
- If a future Moodle branch fatals on a different
[dirroot]/…/tests/…require, generalize this into a build-time detector inbuild-moodle-bundle.shthat greps production PHP forrequire/includeoftests/…\.phpand asserts each referenced path is present in the bundle (a suffix match against the zip listing avoidsdirroot/libdirresolution). - Revisit if
mlbackend_pythonis ever removed from the bundle outright, which would make this patch unnecessary. - Revisit the latent
cache/behatreferences if any playground feature ever reaches Behat data generators or the test cache factory at runtime.