Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,22 @@ fogproject/
│ │ ├── init.php # Initiator class: autoloader, session, sanitization
│ │ ├── schema.php # DB schema (CREATE TABLE as PHP arrays)
│ │ └── text.php # $foglang[] translation strings
│ ├── lib/
│ │ ├── fog/ # 121 core *.class.php files (models, managers, utilities)
│ │ ├── pages/ # 20 *.page.php UI page classes
│ │ ├── hooks/ # *.hook.php hook classes
│ │ ├── events/ # *.event.php event classes
│ │ ├── reports/ # *.report.php report classes
│ │ ├── db/ # PDODB, DatabaseManager
│ │ ├── router/ # AltoRouter-based API routing
│ │ └── plugins/ # 15 plugin directories
│ ├── src/ # ALL core classes: PSR-4, FOG\<Bucket>\<Class>
│ │ ├── Items/ # 67 models (Host, Image, Snapin, ...)
│ │ ├── Managers/ # 66 collection/query classes
│ │ ├── Base/ # 18 FOGBase, FOGController, FOGPage, managers
│ │ ├── Pages/ # 28 UI page classes
│ │ ├── Reports/ # 13 report classes
│ │ ├── Hooks/ # 10 hook classes
│ │ ├── Events/ # 1 event class
│ │ └── Audit,Auth,Boot,Client,Db,Exception,Net,Router,Service,
│ │ # TaskHandling,Util
│ ├── lib/ # only two things live here now
│ │ ├── router/ # altorouter/altotransformer — a FORK, kept
│ │ │ # under upstream's name and MIT license
│ │ │ # (ADR 0013 §3); everything else moved to src/
│ │ └── plugins/ # installed plugin artifacts, NOT repo source
│ │ # (ADR 0009) — gitignored, root-owned
│ └── management/ # Apache/Nginx document root
│ ├── index.php # Main UI entry point
│ ├── js/fog/ # FOG-specific JS (fog.js, fog.common.js, entity subdirs)
Expand Down Expand Up @@ -531,8 +538,8 @@ badly — `ou` and `windowskey` become "Ou" and "Windowskey" without it.
| `packages/web/commons/init.php` | Autoloader, session config, `Initiator` class |
| `packages/web/commons/base.inc.php` | Security headers, output buffering setup |
| `packages/web/commons/schema.php` | DB schema definitions |
| `packages/web/lib/fog/fogbase.class.php` | Root of the class hierarchy (~2900 lines) |
| `packages/web/lib/fog/loadglobals.class.php` | Bootstraps all global singletons |
| `packages/web/src/Base/FOGBase.php` | Root of the class hierarchy (~2900 lines) |
| `packages/web/src/Base/LoadGlobals.php` | Bootstraps all global singletons |
| `packages/web/commons/config.class.php` | **Not in git** — generated by the installer, beside `fogpaths.php`. Was `lib/fog/config.class.php`; that directory is gone |

---
Expand Down
10 changes: 10 additions & 0 deletions bin/psr4-scan.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@
'FOGController' => 'Items',
'FOGService' => 'Service',
'FOGClient' => 'Client',
// The four discovery kinds. Ancestry rather than 52 TABLE rows because
// the parent IS the definition of the kind -- a class extending FOGPage
// is a page, and there is no way to write one that belongs elsewhere.
// ReportManagement before FOGPage: a report extends ReportManagement,
// which extends FOGPage, so the more specific ancestor has to be asked
// first or every report buckets as a page.
'ReportManagement' => 'Reports',
'FOGPage' => 'Pages',
'Hook' => 'Hooks',
'Event' => 'Events',
];

const SRC = 'packages/web/src/';
Expand Down
66 changes: 66 additions & 0 deletions docs/adr/0013-flat-fog-namespace-and-the-reverse-alias-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,72 @@

accepted

## Amended 2026-08-30 — the last flat classes are bucketed, and the flat namespace is gone

**There is no `namespace FOG;` in core any more.** The 52 discovery-named
classes — 28 pages, 10 hooks, 13 reports, 1 event — that the 2026-08-27
amendment below kept flat under `lib/` are now PSR-4 files under
`src/{Pages,Hooks,Reports,Events}`, declaring `FOG\Pages\HostManagement`,
`FOG\Hooks\BootItem`, `FOG\Reports\Audit_Report`, `FOG\Events\HostList`.
Their `class_alias` trailers are deleted with them. `lib/` now holds only the
two AltoRouter files and the plugin roots.

**The reason they stayed is gone, and it was never the reason it looked
like.** The amendment below says PSR-4 "does not do discovery", which is true
and is 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 an alias is what the rest
of the migration was retiring. Discovery had to learn a second file shape
before the classes could move; it had not been taught one. That is a property
of the loader, not of PSR-4.

**What discovery does now.** Each site reads two sources and merges them:

| Site | Core | Plugins |
|---|---|---|
| `FOGPageManager::loadPageClasses()` | `FOGBase::coreitems('Pages')` | `fileitems('.page.php', 'pages')` |
| `EventManager::load()` (and `HookManager`) | `coreitems($this->fileBucket)` | `fileitems($ext, $dir)` |
| `ReportManagement::loadCustomReports()` | `coreitems('Reports')` | `fileitems('.report.php', 'reports')` |

`coreitems()` filters `Initiator::srcFileList()`, which is already built and
already cached, so this costs no extra walk. The name is then derived by
`FOGBase::classFromDiscoveredFile()`, which strips the discovery extension if
the file carries one and `.php` otherwise, then hands the result to
`qualify()`. That one call spans both shapes without branching on provenance:
`qualify()` maps a name `src/` declares onto its FQCN and passes anything else
through, so a core class resolves under the only name it now has and a
plugin's global-namespace class resolves exactly as it always did.

**Nothing changes for plugins.** They keep the
`<plugin>/<dir>/<name>.<type>.php` shape, keep the global namespace (ADR
0009), and a namespaced plugin page still requires its own `class_alias` —
`fileitems()` and the bare-name derivation are still what find it. The one
edit a plugin needs is to any reference to a class that moved, and in
`fog-plugins` that was a single name in eight files: `\FOG\ReportManagement`
became `\FOG\Pages\ReportManagement`.

**The bridge's job is now empty, and it is kept anyway.**
`Initiator::_bridgeNamespaced()` existed to answer the flat `FOG\<Name>` for
the 52. With them bucketed, `srcClassMap()` holds every one, so the bridge's
first arm refuses the flat spelling with an error naming the correct FQCN
instead of resolving it. That refusal is the whole remaining value — a plugin
still spelling `\FOG\ReportManagement` gets a log line telling it what to
write, rather than a bare class-not-found at the call site.

**One user-visible contract had to be preserved by hand.** A report's filename
was lowercase (`audit_report.report.php`) 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 — a silent
authorization change, not a cosmetic one.

**What is gated.** `bin/psr4-scan.php` gained four `RULES` entries deriving the
bucket from ancestry — `ReportManagement => Reports` before `FOGPage => Pages`,
since a report's chain reaches both — so `tests/psr4-layout.test.php` now
covers all 272 classes rather than 220 with 52 excluded by extension.

## Amended 2026-08-27 — decisions 1 and 2 are both superseded

**Decision 1 (a flat `FOG\` namespace) no longer holds.** Every class under
Expand Down
13 changes: 12 additions & 1 deletion docs/composer-psr4-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ re-litigate:

### The 46 discovery-named files join by namespace only

> **Superseded 2026-08-30.** They moved. All 52 of them (the count grew after
> this was written) are PSR-4 files under `src/{Pages,Hooks,Reports,Events}`
> with bucketed namespaces and no aliases. The "HARD constraint" below is
> restated accurately in ADR 0013's 2026-08-30 amendment: the blocker was that
> discovery derived a **bare** name and resolved it globally, which is a
> property of the loader and was fixable, not a property of PSR-4. Discovery
> now reads core from `src/` and plugins from the old shape, and merges them.
> The paragraph below is kept because its reasoning about *why the filename
> was a contract* is still correct — and for reports it is still load-bearing,
> which is why `loadCustomReports()` now lowercases explicitly.

`FOG\Pages` (26), `FOG\Hooks` (10), `FOG\Reports` (9), `FOG\Events` (1).

`VERIFIED` — this is inside the HARD constraint, because **the discovery
Expand Down Expand Up @@ -414,7 +425,7 @@ Commit 1 does not preclude it.
|---|---|---|
| Move | **202** | `lib/{fog,db,client,service,reg-task,router}/*.class.php` → `src/<same>/<Class>.php`, `System` included |
| Stay — ADR 0013 exclusions | 2 | `lib/router/altorouter.class.php`, `altotransformer.class.php` — upstream name, authorship, MIT license |
| Stay — HARD, discovery-named | 46 | 26 `.page.php`, 10 `.hook.php`, 9 `.report.php`, 1 `.event.php` |
| ~~Stay — HARD, discovery-named~~ **moved 2026-08-30** | 46 → 52 | 28 `.page.php`, 10 `.hook.php`, 13 `.report.php`, 1 `.event.php`, now `src/{Pages,Hooks,Reports,Events}` |
| Stay — generated | 1 | `lib/fog/config.class.php` |

`VERIFIED` — the 46 discovery-named files are reached only through
Expand Down
7 changes: 5 additions & 2 deletions docs/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,11 @@ not imported — nothing ever writes their name in a `use` statement. So if you
put one in your own namespace, the class FOG looks for does not exist and your
page silently never registers.

If you want a namespace, end each such file the way core's own `lib/pages/`
files do:
This is a rule about **plugin** files, and core is no longer an example of it.
Core's pages, hooks, reports and events moved to `src/{Pages,Hooks,Reports,
Events}` and dropped their aliases: they are found by their bucketed namespace
now, not by basename. Your files keep the discovered shape, so they still need
the alias. If you want a namespace, end each such file like this:

```php
namespace Vendor\HelloWorld;
Expand Down
53 changes: 33 additions & 20 deletions packages/web/src/Base/EventManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ class EventManager extends FOGBase
* @var string
*/
protected $fileDirectory = 'events';
/**
* The src/ bucket core listeners of this kind live in.
*
* Distinct from $fileDirectory, which now names the PLUGIN directory
* only: core moved to src/Events and src/Hooks, where the directory is
* StudlyCaps because it is a namespace segment rather than a path
* fragment matched case-sensitively by fileitems().
*
* @var string
*/
protected $fileBucket = 'Events';
/**
* Items log level.
*
Expand Down Expand Up @@ -341,24 +352,18 @@ public function notify($event, $eventData = [])
* Truthiness, not identity, so this agrees with the check
* HookManager::processEvent() makes at dispatch. One notion of active.
*
* @param string $file Absolute path to the .hook.php/.event.php file.
* @param int $strlen Negative length of the extension, as load() has it.
* @param string $file Absolute path to the discovered file.
* @param string $extension The discovery extension, as load() has it.
*
* @return bool
*/
private static function _declaresActive($file, $strlen)
private static function _declaresActive($file, $extension)
{
$className = str_replace(
["\t","\n",' '],
'_',
substr(
basename($file),
0,
$strlen
)
);
// class-name consumer: handed to class_exists() and ReflectionClass,
// both of which resolve a namespaced name and a global one alike.
// classFromDiscoveredFile() is what decides which of the two this is
// -- a core hook under src/Hooks answers only to FOG\Hooks\<Class>.
$className = self::classFromDiscoveredFile($file, $extension);
if (!class_exists($className)) {
return false;
}
Expand All @@ -375,15 +380,23 @@ public function load()
// Each manager says what it loads; see $fileExtension.
$extension = $this->fileExtension;
$dirpath = $this->fileDirectory;
$strlen = -strlen($extension);
list(
$normalfiles,
$pluginfiles
) = self::fileitems(
// Core hooks and events are PSR-4 files under src/Hooks and
// src/Events; only the plugin roots still carry the *.hook.php /
// *.event.php shape, so fileitems() now returns the plugin half
// alone and coreitems() supplies the other. $normalfiles keeps its
// meaning -- the set that must opt in through $active -- and only
// its source changed.
$normalfiles = self::coreitems($this->fileBucket);
// Only the plugin half is taken; the non-plugin half fileitems()
// returns is empty now that no core listener carries the discovery
// extension. Indexed rather than destructured with a skipped element,
// which is both easier to read and not a shape formatters disagree
// about.
$pluginfiles = self::fileitems(
$extension,
$dirpath,
true
);
)[1];
// Non-plugin files opt in through $active. Ask the class, not the
// file: this used to be a line-by-line regex for the literal text
// `$active = true;`, which decided whether a hook ran on its
Expand All @@ -394,7 +407,7 @@ public function load()
// was active.
$startfiles = [];
foreach ($normalfiles as &$file) {
if (self::_declaresActive($file, $strlen)) {
if (self::_declaresActive($file, $extension)) {
$startfiles[] = $file;
}
unset($file);
Expand All @@ -405,6 +418,6 @@ public function load()
$startfiles
);
unset($pluginfiles);
self::startClassFromFiles($startfiles, $strlen);
self::startClassFromFiles($startfiles, $extension);
}
}
94 changes: 75 additions & 19 deletions packages/web/src/Base/FOGBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -4675,30 +4675,22 @@ public static function randWait()
/**
* Starts the class based on the filename passed.
*
* @param array $files The array of files.
* @param int $strlen How much of file to strip off end to get classname.
* @param array $files The array of files.
* @param string $extension The discovery extension, e.g. '.hook.php'.
*
* @return void
*/
public static function startClassFromFiles($files, $strlen)
public static function startClassFromFiles($files, $extension)
{
foreach ($files as &$file) {
$className = str_replace(
["\t","\n",' '],
'_',
substr(
basename($file),
0,
$strlen
)
);
// qualify()d: this is a "have we loaded this file's class
// already" short circuit, and a core class loaded under its
// namespaced name does not answer to its bare basename now that
// the global aliases are gone. Left bare it is merely wasteful
// rather than wrong -- the include below is include_once -- but
// it would stop short-circuiting for every core file in the list.
if (class_exists(self::qualify($className), false)) {
// Derives the FQCN for a core file under src/ and the bare name
// for a plugin's, which is what the two shapes respectively
// answer to. This is also the short circuit -- a core class
// loaded under its namespaced name does not answer to its bare
// basename now that the global aliases are gone, so deriving it
// bare here would stop short-circuiting for every core file.
$className = self::classFromDiscoveredFile($file, $extension);
if (class_exists($className, false)) {
continue;
}
// The file list is a TTL-cached snapshot (Initiator::
Expand Down Expand Up @@ -4798,6 +4790,70 @@ public static function checkauth()
}
}
}
/**
* Every core class file in one src/ bucket.
*
* The core half of discovery. Pages, hooks, reports and events used to be
* found by fileitems() like everything else, because they sat under
* lib/<dir>/ with a *.<type>.php name that both the scan regex in
* Initiator::_scanClassFiles() and fileitems()' own path regex could see.
* They are PSR-4 files under src/<Bucket>/<Class>.php now, so neither
* regex matches them and fileitems() cannot return them at all -- it is
* left serving the plugin roots, which is the only place that shape still
* exists (ADR 0009).
*
* Read off Initiator::srcFileList() rather than by walking the bucket:
* that map is already built, already cached on its own TTL and already
* invalidated by forgetClassFileList(), so this adds no stat to a request
* and cannot disagree with what qualify() will resolve.
*
* @param string $bucket The src/ subdirectory, e.g. 'Pages'.
*
* @return string[] Absolute file paths.
*/
public static function coreitems(string $bucket): array
{
$files = [];
foreach (\Initiator::srcFileList() as $path) {
if (basename(dirname($path)) === $bucket) {
$files[] = $path;
}
}
@natcasesort($files);
return $files;
}
/**
* The name of the class a discovered file declares.
*
* Two file shapes reach discovery now and they answer to different names.
* A core class is src/<Bucket>/<Class>.php and, since the global aliases
* were retired, answers ONLY to its namespaced name. A plugin class is
* <plugin>/<dir>/<name>.<type>.php, is global-namespace by design and
* answers to its bare basename.
*
* qualify() spans both without a branch on where the file came from: it
* maps a name src/ declares onto its FQCN and passes anything else
* through untouched. So a plugin keeps resolving exactly as it did, and
* core resolves under the only name it now has.
*
* @param string $file Absolute path to the discovered file.
* @param string $extension The discovery extension, e.g. '.page.php'.
*
* @return string FQCN for a core class, bare name for a plugin's.
*/
public static function classFromDiscoveredFile(
string $file,
string $extension
): string {
$base = basename($file);
$strlen = -strlen($extension);
$short = substr($base, $strlen) === $extension
? substr($base, 0, $strlen)
: basename($base, '.php');
return self::qualify(
str_replace(["\t","\n",' '], '_', $short)
);
}
/**
* Get the file items.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/Base/FOGPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use FOG\Items\Image;
use FOG\Items\Site;
use FOG\Items\Snapin;
use FOG\ReportManagement;
use FOG\Pages\ReportManagement;
use FOG\Router\HTTPResponseCodes;
use FOG\Router\Route;

Expand Down
Loading
Loading