История коммитов

.
feat(modules): autoload the classes of a module
A module installed into a site cannot put its namespace in the root
composer.json: that file belongs to the release, and an upgrade overwrites
it — along with the list of what the site installed. So a module says what
to load in its own manifest, and ModuleAutoloader registers it at boot,
before the container is compiled out of those very classes.

The prefixes go into the ClassLoader of Composer rather than an autoloader
of our own: it is already registered, it already resolves PSR-4, and one
loader means one order of resolution. Modules of the release stay in the
root composer.json, where an optimised classmap can cover them, and declare
no autoload at all.

Registration follows enabled(), so a module that is switched off loses its
classes too. Without that its controllers would still be constructible and
"switched off" would only be a word in a listing.

A module may also point at a vendor/autoload.php of its own — the only way
the dependencies it ships get loaded, since Composer of the site knows
nothing about them. One it forgot to ship is a broken package, not a
stopped boot: the modules after it are still registered.

The autoload field is the one deferred in the manifest commit until
something read it. This is that something.
.
refactor(modules): load services, routes and migrations from the registry
The last of the globs over modules/ are gone. Services and routes come from
the modules the registry loads, migration sources from the ones it has
installed, and ModuleContext asks it for the alias and the directory of the
module a route belongs to.

Installed rather than loaded is the rule for migrations, and the difference
matters: switching a module off leaves its tables in the database, so its
source stays in the journal. A module merely dropped into the directory is
not there at all — nothing of it has run.

A switched-off module now answers 404 instead of 500: it has no routes to
reach a controller that is no longer a service.

FileSystemLanguageFilesManager keeps its glob on purpose. Removing a
language has to take the dictionaries of every module lying on disk, the
switched-off and the never-installed included, or the language comes half
back the moment such a module is switched on.

Two things this turned up:

The core depended on a module. CronCleanupForumFilesCommand lived in
system/src/Console/Commands and took a use case of the forum, so switching
the forum off failed to compile the container. It moves into the module as
Forum\Application\Console\CleanupOrphanFilesCommand — same command name,
same schedule — and .agents/architecture.md now says why the core may not
reference a module and where such a command belongs.

Safe mode meant "system modules only", which helped nobody: the admin panel
autowires services of forum, consent, guestbook and registration, so
dropping them takes the panel down as well. It now means "the modules of
the release only", which is the situation it exists for — a third-party
module takes the site down and there is no way in to remove it. Those four
modules still cannot be switched off; that is for the dependency graph to
refuse politely, later.
.
feat(modules): the registry of installed modules
Until now a module was whatever lay in the directory: the container loaded
the services of every one it found, and being listed in the configuration
decided nothing but a Twig namespace. There was no way to say that a module
is installed, and none at all to switch one off.

The registry answers that, from three things that routinely disagree: what
is on disk, what this release ships (bundled, in modules.global.php) and
what the site has done since (the generated modules.local.php). A module of
the release counts as installed until the site says otherwise, so a working
site needs no state file at all; a third-party module counts as installed
only once it is recorded, because files in a directory are not migrations
that have run.

Nothing here raises. A module whose files were deleted and a module whose
alias another one already holds become broken, with a sentence saying why,
and are left out of the loading — a directory must not be able to take a
site down. The installed module keeps the alias against one just dropped
in; refusing that is the business of installing, which comes later.

The factory is static, like PSRContainerFactory and for the same reason:
the list decides whose services.php the container compiles, so it cannot
come out of the container. It reads bundled straight from the file rather
than through config(), so a cached container does not freeze the modules of
the release it was compiled under.

Also here: MODULES_SAFE_MODE, which loads the system modules only;
module:list and module:sync; the state file, written whole through a
temporary file; and the installer, which now records what it put in.
Johncms\Modules\Modules is gone.

Only the template lookup asks the registry so far — routes and services
still come from a glob, so a module switched off by hand answers with its
routes and no templates. That is the next step.
.
feat(modules): describe every module with a manifest
A directory is a module because it carries module.php. The manifest holds
what the system has to know before anything of the module is loaded: the
key (vendor/name, the directory and the package name), the alias (the flat
name a Twig namespace, a gettext domain and a migration source are made of),
what to call it, its version and whether it may be switched off at all.

The loader refuses rather than skips. A key that does not match the
directory it lies in, a key that is not vendor/name, an alias holding a
slash, a field of the wrong type, a file returning something other than an
array — each is answered with the file name and the reason. A module
dropped because of a typo would be routes, tables and permissions that
silently stop existing, and the failure would surface somewhere else.

The repository is the opposite: a directory with no manifest is not a
module and is passed over, so an unpacked archive or a leftover of the
one-level layout cannot stop the boot.

Nothing reads any of this yet — the registry that will is the next step.
The fields it does not need yet are absent on purpose: requirements arrive
with the compatibility check, autoload with the module autoloader, assets
with their publishing. A field nobody reads is dead code repeated in 21
files.
.
refactor(modules): move the bundled modules under a vendor directory
modules/<module>/ becomes modules/johncms/<module>/. A module gets a vendor
of its own, so two authors may publish a module of the same name without
colliding — the ground the installable-module work needs before anything
else can be built on it.

Only paths move. The template namespace (@forum), the translation domain
(d__('forum', …)) and the migration source (--source=forum) are the name
alone and stay what they were: nothing in a template, a dictionary or the
migrations table is rewritten, and a site upgrading from 9.9 keeps the
journal it already has.

What that costs elsewhere:

* the glob patterns of the container, the router, the migration sources,
the language file manager and i18n:translate look two levels deep;
* TemplatePathRegistry is given module keys and takes the namespace as the
basename of one, so a theme still overrides templates/<name>;
* the _module attribute of a route now carries the key (johncms/forum).
ModuleContext resolves the directory from it and the domain from its
basename — the route has to say where the dictionaries lie, and the name
alone no longer does;
* paths in composer.json, phpstan-baseline.neon, translate.xml.dist,
crowdin.yml, phpunit.xml.dist, phpcs.xml.dist, modules.global.php and
ide-twig.json.

CHANGELOG and the documentation submodule carry the upgrade instruction:
the old one-level directories are not picked up, but have to be deleted by
hand after unpacking.
.
ci(tests): build the assets before the functional suite
The suite renders real pages, and every layout asks Vite for the entry point of its theme. A runner has no compiled bundle, so each of them failed on the missing manifest — 43 of 72 tests. The job builds the assets the way the Node one does.
.
test(functional): check a route's permission from an account that holds it
The suite could only drive requests as a guest, so a gate was pinned by the refusal alone; an account now says which permission it holds and the way in is covered too. Runs the functional suite in CI, and writes down how a test is written in .agents/testing.md.
.
test(functional): run the functional suite against SQLite instead of the local stand
The suite used to drive requests against the database of the local stand: without one every test skipped itself, tests looked for an account the installation happened to have, and each of them had to remove what it wrote. It now boots the application against SQLite in memory, builds the schema with the migrations of the core and of every module, seeds the system roles, and rolls back the transaction of every test.

FunctionalUserFactory writes the accounts, so a test says which permissions its visitor holds instead of borrowing a role from an installation, and Fixture fills in the NOT NULL columns the legacy tables are full of. The comments pages create what each of them asks for and are really rendered now, rather than skipping for want of a row.
.
feat(database): let the schema adapter write to a database with global index names
Index names come from 9.x, where a table names its key after the column it covers, so a dozen tables have an index called user_id. MySQL keeps such names per table; SQLite keeps them per database, so the baseline migrations could not be run on it at all. SchemaPlatform collects what differs between databases — the word index and the scope of an index name — and prefixes the table where the namespace is shared. MySQL is unaffected.
.
refactor(auth): split the upgrade to roles by what may be repeated
The four commands an administrator had to run in the right order become two, told apart
by whether running them again is safe.

auth:sync-roles creates the built-in roles this version declares and grants them the
default permissions they are missing. Both halves only add, so it is meant to be run
after every update - a release or a module that declares a new role or permission has no
other way of reaching a site that is already installed. Only a fresh installation used to
get this, from the installer.

auth:migrate-legacy-access does what has to happen exactly once: it seeds the roles,
gives every member of staff the one their access level stood for, and turns the mod_*
settings into permissions. The second half revokes as well as grants - it brings the
roles back to what those settings say - so repeating it would undo whatever was arranged
in the panel afterwards.

Whether there is anything left to do is read off the database: while users.rights is
there the conversion is unfinished, and once the column has gone it can never be needed
again. That replaces the flag in a local config file, so OneTimeTaskTracker and
one_time_tasks.local.php are gone, and the guard of auth:drop-legacy-columns is now the
thing it actually cares about - nobody left with an access level and no role.

This also restores something the schema move had dropped: auth:upgrade-schema used to
seed the built-in roles, and after it was deleted nothing did that for an upgrading site.
It would have failed on the first command of the bridge, telling the administrator to run
a command that no longer exists.