From bd212a42c69c1bfe48bea0263f0364bd18f11d4e Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Sun, 6 Sep 2026 19:46:07 -0500 Subject: [PATCH] Document why getClass existed, and which of it and new is right where The plugin docs already tell a plugin author what to write. What was missing is the overarching version: what changed, why the factory existed in the first place, and the rule for choosing between the two. The "why it existed" half matters most, because without it the change reads as a style preference someone imposed. It was not. FOG's move into namespaces renamed 226 files, the tree had to keep working after every commit, and one function turning a bare name into whatever the namespace happened to be that week is exactly the right tool for that -- 459 literal call sites across 120 files went through the whole migration unedited. The project's own refactor brief called it the reason the migration was tractable. It stopped earning that keep when the migration finished. The "why it went" half is the evidence: a factory declared @return object|mixed erases the type, so converting the tree surfaced 90 PHPStan errors on a baseline that reported zero, every one pre-existing and previously unreachable. And it was never a substitution seam, which is the usual argument for keeping a factory -- qualify() consults core's map before the plugins', so a bare name could only ever resolve to one class. The rule, as a table: a literal gets new (imported inside packages/web/src, fully qualified in a file with no namespace and throughout fog-plugins); a variable gets getClass(), which is the one shape new cannot express and is what the function is now for. Plus the two literal forms that survive because they have no new equivalent at all. Numbers verified against the current tree rather than taken from the ADR: 43 variable-named sites remain in packages/web/src, and the gate reports 3 literals in 2 distinct names, which are the surviving forms. Also adds install-script-architecture and fos-release-workflows to the Development index, which had been listing six of its eight pages. Verified: docs:build clean, check-anchors 0 broken (including the path-qualified anchor into plugin-development), check-version-split consistent, node --test 63/63, show-nav places the page, no unparsed wikilink. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PabMCuXYVRKbye7RAHaBC --- docs/development/index.md | 3 + docs/development/naming-a-class.md | 138 +++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 docs/development/naming-a-class.md diff --git a/docs/development/index.md b/docs/development/index.md index 8de7e235..f77385d0 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -11,6 +11,9 @@ tags: These are pages related to fog development, including guidelines on contributing to fog and releasing new versions. - [[plugin-development|Building a FOG Plugin — Start to Finish]] +- [[naming-a-class|Naming a Class — new and getClass]] +- [[install-script-architecture|Install Script Architecture]] +- [[fos-release-workflows|FOS Release Workflows]] - [[plugin-schema-migrations|Plugin Schema Migrations]] - [[storage-node-selection-hooks|Storage Node Selection Hooks]] - [[version-sync-automation|Version Sync Automation]] diff --git a/docs/development/naming-a-class.md b/docs/development/naming-a-class.md new file mode 100644 index 00000000..cef1e5d2 --- /dev/null +++ b/docs/development/naming-a-class.md @@ -0,0 +1,138 @@ +--- +title: Naming a Class — new and getClass +aliases: + - Naming a Class — new and getClass + - getClass + - getClass to new + - Class Instantiation +description: Why FOG instantiated almost everything through getClass, why literal names moved to new, and which of the two is correct where +context_id: naming-a-class +tags: + - 1_6-changes + - development + - plugins + - architecture +--- + +# Naming a Class — new and getClass + +>[!info] FOG 1.6 and later +>This describes the 1.6 source tree. FOG 1.5 has no namespaces and no +>`qualify()` map, so `getClass()` there is simply how classes are made. + +For most of FOG's life, core built almost everything by handing a quoted +string to a factory: `FOGBase::getClass('Host')`. It no longer does. A class +named by a **literal** is now instantiated with `new`; `getClass()` stays for +the one shape `new` cannot express, a class named by a **variable**. + +The decision and its evidence are fogproject's ADR 0043, *A class is named at +the call site, not fetched by string*. This page is the summary, and the +reasoning, for anyone writing against the tree. + +## The short version + +| You are naming the class... | Write | Because | +|---|---|---| +| With a literal, inside `packages/web/src` | `use FOG\Items\Host;` then `new Host()` | House style — most files there already carry an import block | +| With a literal, in a file that declares no namespace, or anywhere in `fog-plugins` | `new \FOG\Items\Host()`, fully qualified | A plugin tree is fetched on its own and cannot assume core's class list is nearby | +| With a **variable** — a name that arrived in a URL, over the API, or out of a config row | `self::getClass($name)` | `new` cannot express it. This is what `getClass()` is now for | +| And you want its default properties, not an instance | `self::getClass($name, '', true)` | Returns `ReflectionClass::getDefaultProperties()`; there is no `new` equivalent | + +## Why `getClass()` existed + +It was not an accident, and it was not a style choice. FOG's move into +namespaces renamed 226 files into `FOG\\`, and the tree had to +keep working after every commit along the way. + +At the time there were **459 literal call sites across 120 files**, spelling +136 distinct class names. One function that turned a bare name into whatever +the namespace happened to be that week is exactly the right tool for that +job, and it did the job: the migration landed without editing any of those +sites. The project's own refactor brief called it "the whole reason the +migration is tractable". + +## Why the literal form went + +The migration finished. What the literal sites were left paying was a +standing cost with nothing on the other side of it. + +**The type was erased.** `getClass()` is declared `@return object|mixed`, so +static analysis cannot check anything you do with the result and no editor +can follow one to a definition. That is not theoretical. Converting the tree +surfaced **90 PHPStan errors on a baseline that reported zero** — every one +pre-existing, every one previously unreachable. They included `@return void` +on a method whose body returns an array, `@return object` on methods that +return `false` (so every `if (!$x->destroy())` guard in the tree read as dead +code), and `@return object` on a method that returns trimmed strings. + +None of those were new defects. They are what a decade of annotations +drifting behind their bodies looks like when something finally reads them. + +**It was never a substitution seam.** That is the usual argument for keeping +a factory, and it does not hold here: `qualify()` consults core's map +*before* the plugin map, and that ordering is load-bearing — it is what stops +a plugin answering a core name. Nothing could ever answer `getClass('Host')` +with a different class, so there was no behaviour to preserve beyond +resolving the name, which `use FOG\Items\Host;` already does at compile time +and checkably. + +>[!note] The exception that proves it +>The test harnesses *do* substitute through it — `tests/lib/bootmenu-harness.php` +>declares a stub `FOGBase` whose `getClass()` returns cut-down stand-ins. But +>that seam belongs to the harness replacing `FOGBase` wholesale, not to the +>factory, and `tests/lib/stub-buckets.php` re-exports flat stubs under their +>bucketed names precisely so a direct `new` finds them. + +## Where `getClass()` is still correct + +Roughly forty sites, and they are the ones that matter: `Route`, +`Authorization`, `OpenAPI`, `FOGPage::$childClass` and the page-post +machinery all hold a lowercase string that arrived over the API or in a URL +and turn it into a class through `qualify()`. Nothing else can do that, and +narrowing the function to that single job is what makes the job legible. + +Two literal forms survive because they have no `new` equivalent at all: + +- **`getClass('X', '', true)`** — returns the class's default properties + rather than an instance. `getClass('OUI', '', true)` is the live example. +- **`getClass('ReflectionClass', ...)`**, which the factory special-cases. + +## The one place a bare name still bites + +Core resolves a bare name wherever *core* does the resolving: `getClass()` +with a variable, `getManager()`, discovery, `$childClass`, +`Route::_newEntity()`, `Authorization`. A class name **your own code** holds +in a plain string gets no such mapping — so `new $someString` or +`is_subclass_of($x, 'SomeClass')` naming a bare plugin class will not +resolve. Spell those fully qualified. + +## How it is enforced + +`tests/getclass-literals.test.php` in `fogproject` already checked that a +literal names a class spelled exactly as declared. It now also refuses a +literal `getClass()` that is neither of the two surviving forms. Its +scan-sanity anchor counts *declarations* rather than literals — counting +literals would have made the gate weaker every time it succeeded. + +`fog-plugins` carries its own `tests/core-references-are-qualified.test.php`, +which refuses a bare core name outright. + +## Doing the sweep on your own tree + +`bin/getclass-to-new.php` in `fogproject` is the tool the conversion was done +with, and it is kept so it can be re-run against a plugin tree or a +long-lived branch. It resolves names through the same core-then-plugins order +`qualify()` uses, so it cannot silently repoint a call at a plugin class, and +it is token-based rather than regex-based — which is what keeps it off the +`getClass(` occurrences that live inside strings, docblocks and commented-out +code. + +>[!warning] What this does not do +>It does not make the tree type-safe. It makes those call sites *visible* to +>a checker that was previously blind to them. The 90 errors were the first +>instalment of that, not the last. + +## See also + +- [[development/plugin-development#Name the class, do not fetch it by string|Building a FOG Plugin]] — the plugin author's version, with the fully-qualified spelling rule +- [[plugin-schema-migrations|Plugin Schema Migrations]]