Skip to content

Derive a plugin class's path from its name - #1540

Merged
fog-workflows[bot] merged 1 commit into
working-1.6from
plugins-adopt-core-layout
Aug 31, 2026
Merged

fog-workflows[bot] merged 1 commit into
working-1.6from
plugins-adopt-core-layout

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

Pairs with FOGProject/fog-plugins#34, which must merge and tag v1.6.23 first. Old core with new plugins, or new core with old plugins, is each a server with no working plugins — which is why the FOG_PLUGINS_VERSION pin moves in this same PR.

The problem

Namespacing left the plugins half-migrated. Every class went into FOG\Plugins\<Plugin>, but the files stayed where they were — a plugin declared FOG\Plugins\LDAP\LDAPManager out of a file called class/ldapmanager.class.php. Core paid for the mismatch: autoload() could not derive a path, so it read all 176 plugin files to learn what each one declared, cached that in pluginmap.*.json, and kept a second suffix-scan pipeline alongside it for discovery.

The fix

fog-plugins v1.6.23 lays a plugin out exactly like core:

<plugin>/src/<Bucket>/<Class>.php   declares   FOG\Plugins\<Segment>\<Bucket>\<Class>

file name equal to class name, strtolower(<Segment>) equal to the plugin's directory. So the path is derivable, and that is the whole of this change (ADR 0035):

Before After
autoload() a plugin class basename map built from a full recursive walk one is_file() per plugin root, nothing read
pluginShortMap() 176 file_get_contents on a cold cache derived from the cached path list
discovery fileitems('.page.php', 'pages') pluginitems('Pages'), the twin of coreitems('Pages')

Net −310 lines in init.php. Retired: classFileList(), _scanClassFiles(), _scanRoots(), _readFileListCache(), _isPluginPath(), the classMap build and lookup, _bridgeNamespaced()'s resolution tail, set_include_path(), spl_autoload_extensions(), and FOGBase::fileitems().

Retiring the suffix pipeline was not optional. _readFileListCache() treats an empty list as a cache miss, and with no suffixed files left to find the list is empty forever — leaving it in place would have cost a full recursive walk of the tree on every request.

What deliberately does not change

  • The plugin directory name. It is plugins.pName, the ?node= value, the <node>.view permission string and the js/ path. No schema migration, no URL change, no RBAC churn.
  • Plugin discovery itself. Plugin::_getDirs() still finds a plugin by config/plugin.config.php — which is now also the definition _scanPluginSource() uses. Without it, a .git checkout's hooks/ directory reads as a pre-1.6 plugin and gets reported as one.
  • Report class names. They keep core's underscored spelling (LDAP_Report). The menu key, the base64 f parameter and the permission node are all derived from that name, so renaming would move a report to a different URL under a different permission node.

Failing loudly

A plugin still on the pre-1.6 layout gets one line naming itself, the directory it kept and the shape it needs — rather than a page that never registers with nothing anywhere saying why. Verified against a real v1.6.22 plugin tree on this box, not a fixture:

FOG autoloader: plugin "slack" uses the pre-1.6 layout (class/) and will not load.
Its classes belong in slack/src/<Bucket>/<Class>.php declaring
FOG\Plugins\<Segment>\<Bucket>\<Class>. See docs/plugin-development.md.

Verification

  • Both phpstan passes clean, unscoped.
  • Full suite green. relationship-filter-in-join.test.php fails locally only because this box's packages/web/lib/plugins is still the v1.6.22 release; it passes once bin/fetch-plugins.sh pulls v1.6.23.
  • 18 mutations run against the new and rewritten gates. One came back green and was a fake gate: autoload()'s bare-core arm no longer needs its return to be a guard, because nothing maps a bare name to a file any more. Rather than leave a check that could not fail, the comment now says the return is explicitness, and the replacement check asserts the diagnostic names the qualified spelling — mutation-verified red.
  • End-to-end probe: all 176 real fog-plugins files resolve, each to its own file, with qualify() answering short names correctly (OUAssociation → FOG\Plugins\OU\Items\OUAssociation, OIDCFlow → FOG\Plugins\OIDC\Util\OIDCFlow).

Documentation

Tom asked for the docs to land with the code, so they are in here rather than following.

  • New: docs/adr/0035-a-plugin-is-laid-out-like-core.md.
  • Amended, not rewritten: ADR 0013's namespacing amendment (which said in terms "this is namespacing and not a second PSR-4 move"), ADR 0010's task seam and its scan-regex consequence, ADR 0009's "the contracts are conventions" note, and docs/composer-psr4-plan.md's "Plugins do not live under FOG\" section.
  • docs/plugin-development.md: §2, §3, §4.2–4.5, §7a, §9a, §10 and §11 rewritten; the "Discovery still works by filename" subsection is the claim being reversed and is gone; new §11b is the port table for a plugin still on the old layout.
  • docs/PLUGIN_SCHEMA_MIGRATIONS.md: its Key files table still named lib/fog/*.class.php paths that moved in the core PSR-4 work.

A fog-docs PR mirrors the user-facing half separately.

Downstream

No route classes added or removed, so FogApi's hardcoded class list is unaffected.

Namespacing left the plugins half-migrated: every class went into
FOG\Plugins\<Plugin>, but the files stayed where they were, so a plugin
declared FOG\Plugins\LDAP\LDAPManager out of class/ldapmanager.class.php.
Core paid for the mismatch. autoload() could not derive a path, so it
read all 176 plugin files to learn what each declared, cached that in
pluginmap.*.json, and kept a second suffix-scan pipeline alongside it for
discovery.

fog-plugins v1.6.23 lays a plugin out exactly like core --
<plugin>/src/<Bucket>/<Class>.php declaring
FOG\Plugins\<Segment>\<Bucket>\<Class>, file name equal to class name,
strtolower(<Segment>) equal to the plugin directory (ADR 0035). So the
path is derivable, and this is what that buys:

  autoload()          one is_file() per plugin root, nothing read
  pluginShortMap()    derived from paths, not from 176 file reads
  discovery           pluginitems('Pages'), the twin of coreitems('Pages')

Net -310 lines in init.php. Retired: classFileList(), _scanClassFiles(),
_scanRoots(), _readFileListCache(), _isPluginPath(), the classMap build
and lookup, _bridgeNamespaced()'s resolution tail, set_include_path(),
spl_autoload_extensions(), and FOGBase::fileitems().

Retiring the suffix pipeline was not optional. _readFileListCache()
treats an empty list as a cache miss, and with no suffixed files left to
find the list is empty forever -- so leaving it in place would have cost
a full recursive walk of the tree on every request.

The plugin directory name does not move. It is plugins.pName, the
?node= value, the <node>.view permission string and the js/ path, so
there is no schema migration and no RBAC churn.

A plugin still on the pre-1.6 layout is refused with a line naming
itself, the directory it kept and the shape it needs, rather than being
silently absent. Verified against a real v1.6.22 tree, not a fixture.

Plugin discovery itself is untouched: Plugin::_getDirs() still finds a
plugin by config/plugin.config.php, which is also now the definition
_scanPluginSource() uses -- without it, a .git checkout's hooks/
directory reads as a pre-1.6 plugin.

Report classes keep core's underscored names (LDAP_Report). The menu
key, the base64 `f` parameter and the permission node are all derived
from that name, so renaming them would move a report to a different URL
under a different permission node.

Eighteen mutations run against the new and rewritten gates; one came
back green and was a fake gate -- autoload()'s bare-core arm no longer
needs its `return` to be a guard, since nothing maps a bare name to a
file any more. The comment now says that, and the check that replaced it
asserts the diagnostic names the qualified spelling.

Docs: ADR 0035 is new. ADR 0013's namespacing amendment, ADR 0010's task
seam, ADR 0009's "contracts are conventions" note and
docs/composer-psr4-plan.md's "plugins do not live under FOG\" section are
all amended rather than rewritten. docs/plugin-development.md gains a
port table (§11b) and loses the section that taught the rule this
reverses.

FOG_PLUGINS_VERSION moves to v1.6.23 in the same commit deliberately:
old core with new plugins, or new core with old plugins, is each a
server with no working plugins.

Co-Authored-By: Claude <noreply@anthropic.com>
@fog-workflows
fog-workflows Bot enabled auto-merge August 31, 2026 15:04
@fog-workflows
fog-workflows Bot added this pull request to the merge queue Aug 31, 2026
mastacontrola added a commit to FOGProject/fog-docs that referenced this pull request Aug 31, 2026
Mirrors FOGProject/fogproject#1540 and FOGProject/fog-plugins#34. A
plugin's PHP moved out of class/ pages/ hooks/ events/ reports/ tasks/
and into src/<Bucket>/<Class>.php, declaring
FOG\Plugins\<Segment>\<Bucket>\<Class> -- the same PSR-4 arrangement core
already used on itself. The plugin DIRECTORY does not move: it is still
the routing node, the permission string and the js/ path.

plugin-development.md is re-ported from the fogproject copy, so it
carries the rewritten §2, §3, §4.2-4.5, §7a, §9a, §10 and §11, loses the
"Discovery still works by filename" subsection that taught the rule this
reverses, and gains §11b -- the port table for a plugin still on the
pre-1.6 layout.

plugin-schema-migrations.md's Key files table still named
lib/fog/*.class.php paths that moved when core itself went PSR-4; the
reference implementation is now
lib/plugins/location/src/Managers/LocationManager.php.

reports.md is the user-facing half and was teaching a path that no longer
exists. A report is packages/web/src/Reports/<Class>.php, or
<plugin>/src/Reports/<Class>.php inside a plugin, and the underscores in
Fleet_Report are load bearing -- the menu label, the base64 `f` parameter
and the permission node are all derived from that name.

Two adjacent references corrected while here: the storage-node hook guide
pointed at lib/fog/storagegroup.class.php and told authors to write a
*.hook.php in lib/hooks/, and the CSV guide named the Location plugin's
hook by its old filename.

Translations are left alone. scripts/translate.mjs regenerates a page
when its source hash moves, so the French copies refresh themselves once
a provider is configured.

Co-Authored-By: Claude <noreply@anthropic.com>
Merged via the queue into working-1.6 with commit 52eebcb Aug 31, 2026
17 of 19 checks passed
@mastacontrola
mastacontrola deleted the plugins-adopt-core-layout branch August 31, 2026 15:59
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