Skip to content

Bucket the last 52 flat-namespace classes into src/ - #1528

Merged
fog-workflows[bot] merged 2 commits into
working-1.6from
namespace-discovery-classes
Aug 30, 2026
Merged

Bucket the last 52 flat-namespace classes into src/#1528
fog-workflows[bot] merged 2 commits into
working-1.6from
namespace-discovery-classes

Conversation

@mastacontrola

@mastacontrola mastacontrola commented Aug 30, 2026

Copy link
Copy Markdown
Member

Pages, hooks, reports and events were the only core classes left in a flat
namespace FOG;, sitting under lib/ with a class_alias trailer each. They are
PSR-4 files under src/{Pages,Hooks,Reports,Events} now, declaring
FOG\Pages\HostManagement, FOG\Hooks\BootItem, FOG\Reports\Audit_Report and
FOG\Events\HostList, with the 52 aliases deleted. There is no flat FOG\
namespace in core any more, and every lib/ subdirectory except router/ and
plugins/ is empty of code.

Why ADR 0013 said this was impossible, and why it wasn't

ADR 0013 recorded that these could not move because "PSR-4 does not do discovery".
That was true and was not the constraint. The constraint was that the three
discovery sites derived a bare class name from basename($file) and resolved it
from the global namespace, so the files needed an alias — and the alias was exactly
what the rest of the migration was retiring. Discovery had to learn a second file
shape first; nobody had taught it one. That is a property of the loader, not of
PSR-4. The ADR carries the correction.

How discovery works now

Each site reads two sources and merges them: FOGBase::coreitems($bucket) for core,
and the existing fileitems() walk for plugins, which keep the
<plugin>/<dir>/<name>.<type>.php shape and the global namespace (ADR 0009).

coreitems() filters Initiator::srcFileList(), which is already built and already
cached, so it adds no directory walk to a request. FOGBase::classFromDiscoveredFile()
then derives the name — stripping the discovery extension when the file carries one
and .php otherwise — and hands it to qualify(), which spans both shapes with no
branch on provenance: it maps a name src/ declares onto its FQCN and passes anything
else through untouched.

startClassFromFiles() takes the extension rather than a negative string length, since
it can no longer assume one shape.

The one thing that was nearly an authorization change

A report's filename used to be lowercase, and three things read it as such: the menu
label, the base64 f URL parameter, and the keys of Authorization::REPORT_NODES
which is a permission gate. The PSR-4 filename is Audit_Report.php, so
loadCustomReports() now lowercases what it used to get lowercased for free. Without
that one call the same report answers to a different URL and a different permission
node
than it did before. That is a silent authorization change, not a cosmetic one,
and tests/usertracking-permission-split.test.php now pins the real transform rather
than a hardcoded list.

Verification

Booted a pre-move and a post-move tree against the live database and diffed what
discovery actually found — report labels, page registration, core hook and event
resolution, and both listener registries. Byte-identical.

The probe was mutation-tested, not assumed: making coreitems() return [] takes
reports 17 → 4 and pages 1 → 0. That same mutation exposed a hole in the first version
of the probe — every core hook ships $active = false, so the listener registry is
identical whether core hook discovery works or finds nothing — and the probe now
resolves those classes directly.

  • Test suite: 238 passed, 0 failed, matching the pre-change baseline exactly.
  • Both phpstan passes: [OK] No errors.

tests/psr4-bridge.test.php gains a second probe file for the bridge's
case-insensitive prefix. The assertion it replaces, in autoload.test.php, was a fake
gate: it ran after the class had already been loaded, so PHP's own case-insensitive
class table answered and the bridge was never consulted. Mutating strncasecmp to
strncmp did not fail it. The new one does.

tests/stale-class-file-list.test.php had a real bug of the same family — it passed
-strlen('.hook.php') where the parameter is now the extension itself.

Notes for reviewers

  • The phpstan baselines were repointed by hand — 190 path: lines and 81 message
    FQCNs — rather than regenerated, which would have swept up unrelated drift. One
    pre-existing stale entry for inventory_report.report.php is deliberately left alone.
  • bin/psr4-scan.php buckets the four kinds by ancestry rather than 52 table rows.
    The rule order matters: ReportManagement before FOGPage, since a report's chain
    reaches both.
  • The 13 report classes gained use FOG\Pages\ReportManagement;. They extends ReportManagement unqualified, which used to resolve through the bridge.

Downstream — this needs FOGProject/fog-plugins#32

Merge this first, then the plugins PR, then cut a fog-plugins release.

Out-of-tree plugins spelling \FOG\ReportManagement need \FOG\Pages\ReportManagement.
Eight bundled reports in fog-plugins do exactly that, and they fatal the moment someone
opens the report once this lands. The bridge refuses the flat spelling with an error
naming the correct FQCN rather than resolving it, so the failure is legible — but it is
still a failure.

Note that fog-plugins' own tests/core-references-are-qualified.test.php does not
catch this and cannot: it is a syntax gate asking whether a class reference carries a
leading backslash, not whether the name resolves. It stayed green through the whole
breakage.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VTEiBnDpNrgzu1tSqt1318

Pages, hooks, reports and events were the only core classes left in a flat
`namespace FOG;`, sitting under lib/ with a class_alias trailer each. They are
PSR-4 files under src/{Pages,Hooks,Reports,Events} now, declaring
FOG\Pages\HostManagement, FOG\Hooks\BootItem, FOG\Reports\Audit_Report and
FOG\Events\HostList, with the 52 aliases deleted. There is no flat FOG\
namespace in core any more, and every lib/ subdirectory except router/ and
plugins/ is empty of code.

ADR 0013 said these could not move because "PSR-4 does not do discovery".
That was true and was not the constraint. The constraint was that the three
discovery sites derived a BARE class name from basename($file) and resolved it
from the global namespace, so the files needed an alias -- and the alias was
what the rest of the migration was retiring. Discovery had to learn a second
file shape first; it had not been taught one. That is a property of the
loader, not of PSR-4.

So each site now reads two sources and merges them: FOGBase::coreitems($bucket)
for core, and the existing fileitems() walk for plugins, which keep the
<plugin>/<dir>/<name>.<type>.php shape and the global namespace (ADR 0009).
coreitems() filters Initiator::srcFileList(), already built and already cached,
so it adds no walk to a request. FOGBase::classFromDiscoveredFile() then
derives the name -- stripping the discovery extension when the file carries one
and .php otherwise -- and hands it to qualify(), which spans both shapes with
no branch on provenance: it maps a name src/ declares onto its FQCN and passes
anything else through untouched.

startClassFromFiles() takes the extension rather than a negative string length,
since it can no longer assume one shape.

Reports needed one deliberate save. A report's filename was lowercase and three
things read it as such: the menu label, the base64 `f` URL parameter, and the
keys of Authorization::REPORT_NODES, which is a permission gate. The PSR-4
filename is Audit_Report.php, so loadCustomReports() now lowercases what it
used to get lowercased for free. Without that call the same report answers to a
different URL and a different permission node than before -- a silent
authorization change, not a cosmetic one.

Verified by booting a pre-move and a post-move tree against the live database
and diffing what discovery actually found: report labels, page registration,
core hook and event resolution, and both listener registries are
byte-identical. Mutating coreitems() to return [] takes reports 17 -> 4 and
pages 1 -> 0, so the probe bites. That mutation also exposed a hole in the
first version of it: every core hook ships $active = false, so the listener
registry is identical whether core hook discovery works or finds nothing, and
the probe now resolves those classes directly.

bin/psr4-scan.php buckets the four kinds by ancestry rather than 52 table rows,
ReportManagement before FOGPage since a report's chain reaches both. The
phpstan baselines were repointed by hand -- 190 paths and 81 message FQCNs --
rather than regenerated, which would have swept up unrelated drift.

tests/psr4-bridge.test.php gains a second probe file for the bridge's
case-insensitive prefix. The assertion it replaces, in autoload.test.php, was a
fake gate: it ran after the class had already been loaded, so PHP's own
case-insensitive class table answered and the bridge was never consulted.
Mutating strncasecmp to strncmp did not fail it. The new one does.

Out-of-tree plugins spelling \FOG\ReportManagement need
\FOG\Pages\ReportManagement; fog-plugins carries that change for the eight
bundled reports. The bridge refuses the flat spelling with an error naming the
correct FQCN rather than resolving it, so the failure is legible.

Co-Authored-By: Claude <noreply@anthropic.com>
@mastacontrola
mastacontrola force-pushed the namespace-discovery-classes branch from f54301f to dfca303 Compare August 30, 2026 21:17
@fog-workflows
fog-workflows Bot added this pull request to the merge queue Aug 30, 2026
Merged via the queue into working-1.6 with commit 1b22eb0 Aug 30, 2026
9 checks passed
@mastacontrola
mastacontrola deleted the namespace-discovery-classes branch August 30, 2026 21:32
osiktech pushed a commit to osiktech/fogproject that referenced this pull request Sep 9, 2026
FOGProjectGH-1528 moved the last 52 flat-namespace core classes into
src/{Pages,Hooks,Reports,Events}, so \FOG\ReportManagement became
\FOG\Pages\ReportManagement. Eight bundled plugin reports extend it and fatal
with "Class FOG\ReportManagement not found" the moment someone opens one --
the autoloader refuses the flat spelling and logs the correct FQCN rather than
resolving it, so the failure is legible, but the report page is gone.

fog-plugins v1.6.21 carries the eight edits. Without this bump an install
still fetches v1.6.20 against a core that cannot load it, so the fix exists
and does not reach anybody: bin/fetch-plugins.sh reads the pinned tag straight
out of this constant.

Co-Authored-By: Claude <noreply@anthropic.com>
osiktech pushed a commit to osiktech/fogproject that referenced this pull request Sep 9, 2026
configureHttpd()'s retirement sweep matched *.class.php only. FOGProjectGH-1528 retired
52 more files the same way -- every core page, hook, report and event moved
from lib/{pages,hooks,reports,events}/<lowercase>.<type>.php to
src/<Bucket>/<Class>.php -- so on FOG_copy_back_old=yes all 52 survive the
upgrade.

They are not the harmless case the existing comment describes. A stale
*.class.php is inert because autoload() answers a bare name out of src/ first.
A stale *.report.php is FOUND: ReportManagement::loadCustomReports() merges
core's src/Reports with the fileitems() walk that picks up plugin reports, and
that walk reaches lib/reports/ too. Every core report is then discovered twice
and the Reports menu renders each of them twice.

Reproduced on a live 1.6 install rather than argued: restoring the 52
pre-FOGProjectGH-1528 files into a deployed webroot took the discovered report list from
17 entries to 30, 13 of them duplicates. Page and report rendering stayed
green throughout, which is why this needed the discovery probe to see at all.

The keep-if-still-shipped test is unchanged and is what keeps the wider match
safe -- lib/router/ still ships its .class.php files and they are matched,
tested and kept exactly as before.

tests/oldcopy-retires-moved-classes.test.sh gains three checks: a retired file
of each of the four kinds is removed, a discovery-extension file the release
still ships is spared, and a bundled plugin's report one level deeper is left
to the fog-plugins release. Both mutations fail it -- narrowing the find back
to *.class.php, and dropping the keep test, which is the obvious wrong fix.

Its extractor also had to change. It grabbed the loop out of functions.sh and
stopped at the first `done < <(find ` line; the find is a multi-line -o list
now, so that terminator would cut the snippet mid-command. It stops on
-print0 instead.

Co-Authored-By: Claude <noreply@anthropic.com>
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